feat(crm): backend code — services + routes + utilities + tests

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
This commit is contained in:
Luca
2026-05-26 18:18:51 +02:00
parent 60abe8c76d
commit d543949188
91 changed files with 23578 additions and 123 deletions
+96
View File
@@ -0,0 +1,96 @@
/**
* schemaCache — process-local memoisation for `db.schema.hasColumn`.
*
* **Why this exists**
*
* The CRM services on `feat/crm` are riddled with `hasColumn` guards
* because the schema has been drifting fast (every doc-feature
* migration adds a column that older installs may not yet have).
* Each call hits information_schema (Postgres) or sqlite_master
* (SQLite). On hot paths — `recordCustomerSignature`,
* `recordAdminCountersignature`, `getContractById`, the monthly
* billing pass — we issue 48 hasColumn checks per request, all
* for columns whose presence cannot change at runtime.
*
* The audit flagged this as a perf medium. Caching is safe because:
*
* 1. Schema-changing operations (migrations, `ALTER TABLE`) only
* run at boot via `run-migrations-safe.js`, BEFORE any service
* module accepts traffic. The cache is populated lazily after
* boot finishes, so the entries reflect post-migration state.
*
* 2. The Node process is the only schema authority. There's no
* sibling process sneaking in `ALTER TABLE` while we serve
* requests.
*
* 3. If a future migration path needs to run mid-flight, it can
* call `invalidateSchemaCache()` after the schema change.
*
* **What we cache**
*
* Just the boolean answer to `(table, column)`. A miss means the
* column doesn't exist on this install; a hit means it does. The
* cache key is `${table}.${column}`. There's no TTL — the entry is
* valid for the lifetime of the Node process.
*
* **What we DON'T cache**
*
* Negative results from `hasTable` failures (the table simply isn't
* there) — those go through the underlying call each time. That
* scenario is exceptional (table truly missing during a half-applied
* migration window) and we want it to surface, not get masked by a
* stale cache.
*
* **API**
*
* const { hasColumnCached, invalidateSchemaCache } = require('../utils/schemaCache');
* if (await hasColumnCached('contracts', 'signed_pdf_render_failed_at')) {
* ...
* }
*
* Drop-in replacement for `db.schema.hasColumn(...)` calls. The
* existing helper signature returns a Promise<boolean> so async
* call-sites need no shape change.
*/
const { db } = require('../database/db');
const cache = new Map();
async function hasColumnCached(table, column) {
const key = `${table}.${column}`;
if (cache.has(key)) return cache.get(key);
// Resolve via the underlying schema API. We deliberately don't
// catch errors here — if the call throws (e.g. DB connection lost
// mid-boot), the error surfaces to the caller exactly as it would
// have without the cache.
const present = await db.schema.hasColumn(table, column);
cache.set(key, present);
return present;
}
/**
* Drop every cached entry. Call this after a runtime schema change
* (rare — only the dev tooling does this today). Safe to call any
* time; the next hasColumnCached lookup will re-resolve.
*/
function invalidateSchemaCache() {
cache.clear();
}
/**
* Drop entries for a single table. Useful when only one table was
* altered and other tables' caches are still valid.
*/
function invalidateSchemaCacheForTable(table) {
const prefix = `${table}.`;
for (const k of cache.keys()) {
if (k.startsWith(prefix)) cache.delete(k);
}
}
module.exports = {
hasColumnCached,
invalidateSchemaCache,
invalidateSchemaCacheForTable,
};