Brings in the full backend CRM stack on top of the consolidated
migration (60abe8c).
Services (CRM)
- quoteService — full lifecycle (draft → sent → accepted → converted
to event/invoice), Skonto + Storno + reissue paths
- invoiceService — spawnInstallmentInvoices, updateInstallmentPlan,
monthly-billing accumulator, payment-check tokens, dunning ladder
- contractService — block-composable contract editor, in-browser
signature flow, wet-PDF upload path, integrity check, audit trail
- customerHoursService — per-entry locking, billing integration
- dealsService — cross-document lineage (deal_uuid)
- taxReportService — quarterly aggregates + CSV/PDF export
- eventReminderService — pre-event customer reminder cron pass
- _renderContext — shared issuer/recipient blocks across PDF types
- pdfService extensions — custom-font registration, font picker
Routes (admin + public)
- adminQuotes, adminInvoices, adminContracts, adminCalendar,
adminDeals, adminTaxReport, adminDev, adminBusinessProfile
- publicQuotes (accept/decline), publicContracts (sign),
publicPaymentCheck
- Extensions on adminEvents, adminCustomers, adminSettings,
adminEmail, adminFeatureFlags, adminThumbnails, adminPhotos,
adminCategories, adminUsers, adminArchives, adminDashboard
- server.js wires the new mounts (kept upstream's noStoreCache on
customer routes per 3-way merge)
Utilities
- schemaCache (cached hasColumn lookups across services)
- documentSequences (atomic gap-free numbering — §14 UStG)
- safePath (path-containment guards at fs stream boundaries)
- clientIp (sanctioned XFF reader for audit logs)
- publicTokenGuards (pre-multer token validation + attempt counters)
- numericHelpers (ensureInt / ensureNumber consolidation)
- dateFormatter (formatShortDate + dateInputLang)
- dbCompat extensions, iban + pdfFilename helpers, resolveLogoFile
Infrastructure
- Bundled PDF fonts (Comic-Neue / IBM-Plex-Sans / Inter / Jost /
Montserrat / Noto-Sans / Playfair-Display / Poppins)
- Backend package.json + lock updates (pdfkit, signature_pad,
qrcode, et al.)
- Sample storage layout under storage/business-docs/quote/
Tests
- 14 new test files covering quote/invoice/contract lifecycle,
installment plan reshape, line-item hierarchy, customer hours,
payment check, tax report PDF, IBAN parsing, filename sanitiser
289 lines
10 KiB
JavaScript
289 lines
10 KiB
JavaScript
/**
|
|
* contractBlocksService — CRUD for the contract block library.
|
|
*
|
|
* The library is shared across all contracts. System blocks (12 seeded
|
|
* by migration 130) cannot be deleted but their body text remains
|
|
* editable so the admin's lawyer can rewrite them. Admin-authored
|
|
* (non-system) blocks can be freely created, edited, and removed.
|
|
*
|
|
* Sections are validated against a fixed enum mirroring
|
|
* contractService.SECTIONS_ORDER — keeping these in sync is the
|
|
* "data-driven all the way down" guarantee (no orphan sections in
|
|
* the DB that the renderer can't display).
|
|
*/
|
|
|
|
const { db, withRetry } = require('../database/db');
|
|
const logger = require('../utils/logger');
|
|
const { AppError } = require('../utils/errors');
|
|
const { hasColumnCached } = require('../utils/schemaCache');
|
|
|
|
const ALLOWED_SECTIONS = ['basics', 'scope', 'privacy', 'commercial', 'nda', 'closing'];
|
|
|
|
/**
|
|
* System blocks added to the seed AFTER migration 131 was already
|
|
* deployed to beta. knex won't re-run an already-applied migration,
|
|
* so the new system blocks listed here need a runtime self-heal —
|
|
* same pattern as `ensureContractEmailTemplatesSeeded` for templates.
|
|
*
|
|
* Each entry must carry every column the row needs. `slug` is the
|
|
* uniqueness key; the seeder is no-op when the row already exists.
|
|
* EN/DE bodies only — non-EN/DE locales stay null until the admin
|
|
* fills them in via the block library UI.
|
|
*/
|
|
const RUNTIME_SEEDED_BLOCKS = [
|
|
{
|
|
slug: 'quote_line_items_table',
|
|
section: 'scope',
|
|
name: 'Quote line items',
|
|
description: 'Auto-inserts the source quote\'s line items as a table. Body text appears above the table.',
|
|
body_text: 'Service items per quote {{source_quote_number}}:',
|
|
body_text_de: 'Leistungspositionen gemäß Angebot {{source_quote_number}}:',
|
|
is_system: true,
|
|
is_active: true,
|
|
},
|
|
];
|
|
|
|
// Module-scope flag so the seed check runs once per process. The
|
|
// underlying queries are still idempotent — this just saves the round
|
|
// trip on every listBlocks / createContract call.
|
|
let _systemBlocksSeeded = false;
|
|
|
|
/**
|
|
* Self-heal: insert any RUNTIME_SEEDED_BLOCKS entries that don't yet
|
|
* exist in contract_blocks. Called from listBlocks + createContract
|
|
* paths so new system blocks appear automatically on installs that
|
|
* applied an earlier version of migration 131. Idempotent.
|
|
*/
|
|
async function ensureSystemBlocksSeeded() {
|
|
if (_systemBlocksSeeded) return [];
|
|
if (!(await db.schema.hasTable('contract_blocks'))) return [];
|
|
const newlyInserted = [];
|
|
for (const def of RUNTIME_SEEDED_BLOCKS) {
|
|
try {
|
|
const existing = await db('contract_blocks').where({ slug: def.slug }).first();
|
|
if (existing) continue;
|
|
// display_order = current MAX in the target section + 1 so the
|
|
// new block sorts to the end. Matches the migration's behaviour.
|
|
const maxOrderRow = await db('contract_blocks')
|
|
.where({ section: def.section })
|
|
.max('display_order as max').first();
|
|
const nextOrder = (maxOrderRow?.max || 0) + 1;
|
|
await db('contract_blocks').insert({
|
|
...def,
|
|
display_order: nextOrder,
|
|
created_at: new Date(),
|
|
updated_at: new Date(),
|
|
});
|
|
newlyInserted.push(def.slug);
|
|
logger.info(`Self-healed missing system contract block at runtime: ${def.slug}`);
|
|
} catch (err) {
|
|
logger.error(`Failed to seed system contract block ${def.slug}`, { message: err.message });
|
|
// Keep _systemBlocksSeeded=false so the next call retries.
|
|
return newlyInserted;
|
|
}
|
|
}
|
|
_systemBlocksSeeded = true;
|
|
return newlyInserted;
|
|
}
|
|
|
|
function ensureSection(section) {
|
|
if (!ALLOWED_SECTIONS.includes(section)) {
|
|
throw new AppError(
|
|
`Invalid section '${section}'. Must be one of: ${ALLOWED_SECTIONS.join(', ')}`,
|
|
400,
|
|
'INVALID_SECTION',
|
|
);
|
|
}
|
|
}
|
|
|
|
function slugify(name) {
|
|
const base = String(name || 'block')
|
|
.toLowerCase()
|
|
.normalize('NFKD')
|
|
.replace(/[\u0300-\u036f]/g, '')
|
|
.replace(/[^a-z0-9]+/g, '_')
|
|
.replace(/^_+|_+$/g, '')
|
|
.slice(0, 48);
|
|
// Append a 6-hex suffix so admin-authored blocks don't collide with
|
|
// each other or with seeded slugs.
|
|
const suffix = require('crypto').randomBytes(3).toString('hex');
|
|
return `${base || 'block'}_${suffix}`;
|
|
}
|
|
|
|
async function listBlocks({ section, includeInactive = false } = {}) {
|
|
// Self-heal before reading so the new system block (added to the
|
|
// already-deployed migration 131 in feat/crm) appears in the
|
|
// library UI immediately on first GET, without needing a fresh
|
|
// install. Safe to call repeatedly — guarded by _systemBlocksSeeded.
|
|
await ensureSystemBlocksSeeded();
|
|
return await withRetry(async () => {
|
|
let q = db('contract_blocks').select('*');
|
|
if (section) q = q.where({ section });
|
|
if (!includeInactive) q = q.where({ is_active: true });
|
|
q = q.orderBy('section', 'asc').orderBy('display_order', 'asc').orderBy('id', 'asc');
|
|
return await q;
|
|
});
|
|
}
|
|
|
|
async function getBlockById(id) {
|
|
return await db('contract_blocks').where({ id }).first();
|
|
}
|
|
|
|
async function createBlock(payload) {
|
|
if (!payload.name || !String(payload.name).trim()) {
|
|
throw new AppError('Block name is required', 400);
|
|
}
|
|
ensureSection(payload.section);
|
|
if (!payload.bodyText || !String(payload.bodyText).trim()) {
|
|
throw new AppError('Block body (EN) is required', 400);
|
|
}
|
|
|
|
const slug = payload.slug && /^[a-z0-9_]+$/.test(payload.slug)
|
|
? payload.slug
|
|
: slugify(payload.name);
|
|
|
|
// Ensure slug uniqueness (regenerate on the rare collision).
|
|
let finalSlug = slug;
|
|
let attempt = 0;
|
|
while (await db('contract_blocks').where({ slug: finalSlug }).first()) {
|
|
attempt += 1;
|
|
finalSlug = slugify(payload.name);
|
|
if (attempt > 5) {
|
|
throw new AppError('Could not generate a unique block slug', 500);
|
|
}
|
|
}
|
|
|
|
const row = {
|
|
slug: finalSlug,
|
|
section: payload.section,
|
|
name: String(payload.name).trim().slice(0, 128),
|
|
description: payload.description ? String(payload.description).slice(0, 255) : null,
|
|
body_text: String(payload.bodyText),
|
|
body_text_de: payload.bodyTextDe ? String(payload.bodyTextDe) : null,
|
|
is_system: false,
|
|
is_active: payload.isActive !== false,
|
|
display_order: Number.isFinite(payload.displayOrder) ? Number(payload.displayOrder) : 100,
|
|
created_at: new Date(),
|
|
updated_at: new Date(),
|
|
};
|
|
// Schema-drift guard — migration 131 adds these columns. On installs
|
|
// that haven't migrated yet, only EN+DE bodies persist; the other
|
|
// four fields are accepted from the payload but silently dropped.
|
|
for (const [field, payloadKey] of [
|
|
['body_text_ru', 'bodyTextRu'],
|
|
['body_text_pt', 'bodyTextPt'],
|
|
['body_text_nl', 'bodyTextNl'],
|
|
['body_text_fr', 'bodyTextFr'],
|
|
]) {
|
|
if (payload[payloadKey] != null
|
|
&& await hasColumnCached('contract_blocks', field)) {
|
|
row[field] = payload[payloadKey] ? String(payload[payloadKey]) : null;
|
|
}
|
|
}
|
|
|
|
const inserted = await db('contract_blocks').insert(row).returning('id');
|
|
const id = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
|
|
return await getBlockById(id);
|
|
}
|
|
|
|
/**
|
|
* Update a block. System blocks: every field is editable so the
|
|
* admin's lawyer can rewrite the body text in place. The only
|
|
* protection on system blocks is that they can't be hard-deleted —
|
|
* an admin who wants to retire one toggles `is_active=false`.
|
|
*/
|
|
async function updateBlock(id, payload) {
|
|
const existing = await getBlockById(id);
|
|
if (!existing) throw new AppError('Block not found', 404);
|
|
|
|
const updates = { updated_at: new Date() };
|
|
if ('section' in payload) {
|
|
ensureSection(payload.section);
|
|
updates.section = payload.section;
|
|
}
|
|
if ('name' in payload) {
|
|
if (!payload.name || !String(payload.name).trim()) {
|
|
throw new AppError('Block name is required', 400);
|
|
}
|
|
updates.name = String(payload.name).trim().slice(0, 128);
|
|
}
|
|
if ('description' in payload) {
|
|
updates.description = payload.description ? String(payload.description).slice(0, 255) : null;
|
|
}
|
|
if ('bodyText' in payload) {
|
|
if (!payload.bodyText || !String(payload.bodyText).trim()) {
|
|
throw new AppError('Block body (EN) is required', 400);
|
|
}
|
|
updates.body_text = String(payload.bodyText);
|
|
}
|
|
if ('bodyTextDe' in payload) {
|
|
updates.body_text_de = payload.bodyTextDe ? String(payload.bodyTextDe) : null;
|
|
}
|
|
// Same schema-drift guard as createBlock — accept ru/pt/nl/fr only
|
|
// when the column actually exists, so beta installs running this
|
|
// service against a not-yet-migrated DB don't throw.
|
|
for (const [field, payloadKey] of [
|
|
['body_text_ru', 'bodyTextRu'],
|
|
['body_text_pt', 'bodyTextPt'],
|
|
['body_text_nl', 'bodyTextNl'],
|
|
['body_text_fr', 'bodyTextFr'],
|
|
]) {
|
|
if (payloadKey in payload
|
|
&& await hasColumnCached('contract_blocks', field)) {
|
|
updates[field] = payload[payloadKey] ? String(payload[payloadKey]) : null;
|
|
}
|
|
}
|
|
if ('isActive' in payload) {
|
|
updates.is_active = payload.isActive !== false;
|
|
}
|
|
if ('displayOrder' in payload && Number.isFinite(payload.displayOrder)) {
|
|
updates.display_order = Number(payload.displayOrder);
|
|
}
|
|
|
|
await db('contract_blocks').where({ id }).update(updates);
|
|
return await getBlockById(id);
|
|
}
|
|
|
|
/**
|
|
* Hard-delete an admin-authored block. System blocks refuse delete —
|
|
* the admin must `deactivate` (toggle `is_active=false`) instead.
|
|
*
|
|
* Active inclusions on existing contracts are protected by the FK
|
|
* ON DELETE RESTRICT — deleting a block that's still referenced will
|
|
* raise a DB error which we catch and surface as a clean 409.
|
|
*/
|
|
async function deleteBlock(id) {
|
|
const existing = await getBlockById(id);
|
|
if (!existing) throw new AppError('Block not found', 404);
|
|
if (existing.is_system) {
|
|
throw new AppError(
|
|
'System blocks cannot be deleted. Toggle them inactive instead so they remain available for audit on old contracts.',
|
|
409,
|
|
'SYSTEM_BLOCK_PROTECTED',
|
|
);
|
|
}
|
|
try {
|
|
await db('contract_blocks').where({ id }).del();
|
|
} catch (err) {
|
|
if (/foreign key|FOREIGN KEY|RESTRICT/i.test(err.message)) {
|
|
throw new AppError(
|
|
'This block is referenced by one or more contracts. Toggle it inactive instead of deleting.',
|
|
409,
|
|
'BLOCK_IN_USE',
|
|
);
|
|
}
|
|
throw err;
|
|
}
|
|
return { id };
|
|
}
|
|
|
|
module.exports = {
|
|
listBlocks,
|
|
getBlockById,
|
|
createBlock,
|
|
updateBlock,
|
|
deleteBlock,
|
|
ensureSystemBlocksSeeded,
|
|
ALLOWED_SECTIONS,
|
|
};
|