/** * contractService — orchestrates the lifecycle of `contracts`, their * `contract_block_inclusions` (which blocks from the library make it * onto a given contract), and the public `contract_action_tokens` used * by the customer's signing link. * * Contracts are an INDEPENDENT document type alongside quotes and * invoices. Composition model: * - Admin picks blocks from the `contract_blocks` library and toggles * them on/off per section (basics → scope → privacy → commercial → * nda → closing). Order within a section is admin-controlled. * - On send, every included block's body is FROZEN into * `body_text_snapshot` on the inclusion row, so future edits to * the source block don't mutate already-sent contracts. * * Signing: * 1. Customer opens /contract/:token and either: * a) Types name, optionally draws a signature on canvas, ticks * "I have read and agree", submits → recordCustomerSignature * stamps the signature into a re-rendered PDF and the system * emails the admin. * b) Uploads a wet-signed PDF → attachSignedPdfUpload sets the * signed_pdf_path as the authoritative copy. * 2. Admin counter-signs (in-browser or by re-uploading the * double-signed PDF) → status flips to `fully_signed`. * * Bodies support {{placeholders}} resolved at PDF/preview render time * using the same Handlebars-lite regex that emailProcessor.safeTemplateReplace * uses. We rebuild it inline here (not exported from emailProcessor) to * keep the dependency tree shallow and so contracts can render * client-side previews in the future without pulling the email * processor. */ const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); const { db, withRetry, logActivity } = require('../database/db'); const logger = require('../utils/logger'); const { getAppSetting } = require('../utils/appSettings'); const { AppError } = require('../utils/errors'); const { claimNextSequence } = require('../utils/documentSequences'); const { hasColumnCached } = require('../utils/schemaCache'); const { formatShortDate } = require('../utils/dateFormatter'); const businessProfileService = require('./businessProfileService'); const { buildIssuerBlock, buildRecipientBlock } = require('./_renderContext'); const pdfService = require('./pdfService'); const pdfStampService = require('./pdfStampService'); const emailProcessor = require('./emailProcessor'); const { ensureContractEmailTemplatesSeeded } = require('./contractEmailTemplates'); const { ensureSystemBlocksSeeded } = require('./contractBlocksService'); const { getFrontendBaseUrl } = require('../utils/frontendUrl'); const SECTIONS_ORDER = ['basics', 'scope', 'privacy', 'commercial', 'nda', 'closing']; /** * Build a proper {id, type, name} actor object for logActivity. The * db.js helper silently downgrades string actors (e.g. 'admin:1') to * actor_type='system' with null name, so the audit timeline showed * "system" for every admin-driven event. Fetching the admin's name * once per service call is a small read cost on a non-hot path. * * Pass `customerPublic()` for events triggered by the public token * (customer signing, customer wet-signed PDF upload). */ async function adminActor(adminId) { if (!adminId) return { type: 'system' }; try { // admin_users only carries username + email (no first/last/name // columns — confirmed from db.js:265). Prefer username for the // audit timeline because it's the operator-chosen identifier // shown elsewhere in the admin UI; fall back to email when an // older install seeded a row without a username. const row = await db('admin_users') .where({ id: adminId }) .select('id', 'username', 'email') .first(); if (!row) return { id: adminId, type: 'admin', name: `Admin #${adminId}` }; const displayName = row.username || row.email || `Admin #${adminId}`; return { id: adminId, type: 'admin', name: displayName }; } catch (_) { return { id: adminId, type: 'admin', name: `Admin #${adminId}` }; } } function customerPublicActor() { return { type: 'customer', name: 'Customer (public link)' }; } /** * Fire a contract lifecycle event for the workflow engine. Best-effort: * resolves the customer email (so send_email actions have a recipient) and * never throws into the caller. No-op when the workflows flag is off (emit * fails closed). Mirrors quoteService.emitQuoteEvent. */ async function emitContractEvent(contract, status) { try { let customerEmail = null; if (contract.customer_account_id) { const c = await db('customer_accounts').where({ id: contract.customer_account_id }).first(); customerEmail = c?.email || null; } await require('./workflows').emitWorkflowEvent(`contract.${status}`, { entityType: 'contract', entityId: contract.id, payload: { contractId: contract.id, contractNumber: contract.contract_number, customerAccountId: contract.customer_account_id || null, customerEmail, eventName: contract.event_name || null, title: contract.title || null, }, }); } catch (err) { logger.warn('Failed to emit contract workflow event', { contractId: contract.id, status, error: err.message }); } } /** * Privacy gate for the customer/admin IP captured at signing time. * The `crm_contracts_store_ip` setting (default true) controls * whether the IP is persisted into the DB. When off, this helper * returns null regardless of what the route passed in — same shape * the rest of the code expects, just with no IP data. * * Default-true means upgrades preserve current behaviour. Operators * with strict data-minimisation requirements opt out in Settings → * CRM-Settings → Contracts. */ async function maybeStoreIp(ip) { if (!ip) return null; const enabled = await getAppSetting('crm_contracts_store_ip'); // Default true: only block when EXPLICITLY opted out. The audit // flagged that `enabled === false` missed legacy installs where // app_settings stored the toggle as a string ('false', '0') — those // would slip through and the IP would still get persisted despite // the operator's intent. Cover string/number/bool variants // defensively. Anything else (null, undefined, true) preserves // the default-on behavior. if (enabled === false) return null; if (enabled === 0 || enabled === '0') return null; if (typeof enabled === 'string' && enabled.toLowerCase() === 'false') return null; return ip; } // --------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------- // D.2 — `ensureInt` consolidated into utils/numericHelpers. const { ensureInt } = require('../utils/numericHelpers'); function formatNumberInTemplate(format, year, seq) { return format .replace(/\{YEAR\}/g, String(year)) .replace(/\{MONTH\}/g, String(new Date().getMonth() + 1).padStart(2, '0')) .replace(/\{SEQ:(\d+)d\}/g, (_, pad) => String(seq).padStart(parseInt(pad, 10), '0')) .replace(/\{SEQ\}/g, String(seq)); } /** * Gap-free per-year contract number sequence. See * utils/documentSequences.js for the locking story; migration 132 * created the underlying table. Atomic against concurrent admin * creates — the previous SELECT-MAX-then-INSERT raced and could * emit `C-2026-AB12C3` after 5 retries. */ async function nextContractNumber(trx) { // Read through `trx` when present — getAppSetting on the global db inside an // open transaction deadlocks the single-connection SQLite pool (the booking // flow's prepare_contract action runs createFromQuote unattended). const format = (await getAppSetting('crm_contracts_number_format', null, trx || db)) || 'C-{YEAR}-{SEQ:04d}'; const year = new Date().getFullYear(); const seq = await claimNextSequence('contract', year, trx); return formatNumberInTemplate(format, year, seq); } /** * Handlebars-lite renderer: * - `{{#if var}}…{{/if}}` blocks resolved by truthiness of variables[var]. * - `{{var}}` substituted with the matching variable. Missing * placeholders are left literally as `{{var}}` so the admin * notices the unresolved field in preview. * * Mirrors safeTemplateReplace in emailProcessor.js (lines 424-461) but * without HTML escaping — contract bodies are rendered into PDF via * pdfService.drawText, which doesn't need HTML safety. */ function renderTemplatedBody(template, variables) { if (typeof template !== 'string' || template.length === 0) return template; const conditionalsResolved = template.replace( /\{\{#if\s+(\w+)\s*\}\}([\s\S]*?)\{\{\/if\}\}/g, (_match, key, inner) => { const v = variables ? variables[key] : undefined; const truthy = v !== undefined && v !== null && v !== '' && v !== false && v !== 0; return truthy ? inner : ''; } ); return conditionalsResolved.replace(/\{\{(\w+)\}\}/g, (match, key) => { if (!variables || !Object.prototype.hasOwnProperty.call(variables, key)) return match; return String(variables[key]); }); } /** * Build the variable bag used by renderTemplatedBody. Reads the * customer record, business profile, and (when available) the * customer's active payment-term defaults so block placeholders for * net_days / skonto_percent / etc. resolve. Returns plain strings — * dates formatted DD.MM.YYYY in DE-CH style, numbers as-is. */ async function buildPlaceholderContext(contract, customer) { const profile = (await businessProfileService.getProfile()).profile || {}; const issuerCompany = profile.company_name || ''; const issuerAddress = [profile.address_line1, profile.postal_code, profile.city] .filter(Boolean) .join(', '); // Resolve net_days + skonto from app_settings defaults so the // payment_terms_reference block has sensible numbers to substitute // when the admin hasn't tied the contract to a specific quote. const netDaysDefault = ensureInt(await getAppSetting('crm_payment_default_net_days')) || 30; const skontoPercentDefault = await getAppSetting('crm_invoices_skonto_percent_default'); const skontoWithinDaysDefault = ensureInt(await getAppSetting('crm_invoices_skonto_business_days')) || 5; // {{source_quote_number}} placeholder — substituted into the body of // the `quote_line_items_table` system block (and any admin-authored // block that wants to reference the quote). Empty string when the // contract wasn't generated from a quote. let sourceQuoteNumber = ''; if (contract.source_quote_id) { const srcQuote = await db('quotes').where({ id: contract.source_quote_id }) .select('quote_number').first(); if (srcQuote) sourceQuoteNumber = srcQuote.quote_number || ''; } const customerName = customer ? (customer.company_name || [customer.first_name, customer.last_name].filter(Boolean).join(' ') || customer.display_name || customer.email || '') : ''; const customerAddress = customer ? [customer.address_line1, customer.address_line2, customer.postal_code, customer.city] .filter(Boolean) .join(', ') : ''; return { customer_name: customerName, customer_address: customerAddress, event_name: contract.event_name || '', event_date: formatShortDate(contract.event_date), issue_date: formatShortDate(contract.issue_date), contract_number: contract.contract_number || '', title: contract.title || '', net_days: String(netDaysDefault), skonto_percent: skontoPercentDefault == null ? '0' : String(skontoPercentDefault), skonto_within_days: String(skontoWithinDaysDefault), cancellation_30d_percent: '25', currency: (profile.default_currency || 'CHF').toUpperCase(), issuer_company_name: issuerCompany, issuer_address: issuerAddress, source_quote_number: sourceQuoteNumber, }; } /** * SHA-256 hex digest of a Buffer or file path. Used at every PDF * write so we can persist a content hash alongside the path — * either party can later re-hash the PDF they hold and prove (or * disprove) it matches what we issued. */ function sha256OfBuffer(buffer) { return crypto.createHash('sha256').update(buffer).digest('hex'); } function sha256OfFile(filePath) { try { return sha256OfBuffer(fs.readFileSync(filePath)); } catch (_) { return null; } } /** * Write a contract PDF to disk and return both the path AND the * SHA-256 hash of the buffer we just wrote. Callers persist BOTH on * the contracts row so audit defence is single-query: SELECT * pdf_path, pdf_sha256 FROM contracts WHERE id = ? then re-hash the * file on disk and compare. * * History-preserving (per requirement #6): every write appends a * deterministic suffix so old versions stay on disk. The contract * row's `pdf_path` / `signed_pdf_path` always points at the most * recent one; earlier versions remain available for forensic * comparison. */ async function persistContractPdf(contract, buffer, suffix = '') { if (!contract.contract_number) return { filePath: null, sha256: null }; const year = (contract.issue_date ? new Date(contract.issue_date) : new Date()).getFullYear(); const root = path.join(process.cwd(), 'storage', 'business-docs', 'contract', String(year)); fs.mkdirSync(root, { recursive: true }); // Always append a millisecond timestamp to the filename so writes // never overwrite an earlier version on disk. Forensic preservation. // Example filenames: // C-2026-0001_2026-05-19T1830-22-413.pdf (unsigned) // C-2026-0001_signed-by-customer_2026-05-19T1845-10-002.pdf // C-2026-0001_fully-signed_2026-05-19T1912-44-877.pdf const stamp = new Date().toISOString().replace(/[:.]/g, '-'); const fileName = suffix ? `${contract.contract_number}_${suffix}_${stamp}.pdf` : `${contract.contract_number}_${stamp}.pdf`; const filePath = path.join(root, fileName); fs.writeFileSync(filePath, buffer); return { filePath, sha256: sha256OfBuffer(buffer) }; } // Maximum decoded signature image size. Defends against a customer // (or attacker holding a captured signing token) POSTing a multi-MB // signature data URL to fill the disk. A typical signature_pad PNG // is 10–80 KB; even with retina upscaling we don't expect to see // 1 MB. The cap is enforced on the BASE64 length before decoding so // we never allocate the full Buffer for an oversized payload. // // The frontend (ContractResponsePage) downscales the canvas to a // fixed max width before exporting via `toDataURL`, so well-behaved // clients land well under this cap. This server-side check is the // authoritative guard. const MAX_SIGNATURE_BASE64_BYTES = 1024 * 1024; // 1 MB of base64 → ~750 KB decoded async function persistSignatureImage(contract, role, dataUrl) { if (!dataUrl || typeof dataUrl !== 'string') return null; if (dataUrl.length > MAX_SIGNATURE_BASE64_BYTES + 100 /* prefix slack */) { throw new AppError( `Signature image exceeds the ${Math.round(MAX_SIGNATURE_BASE64_BYTES / 1024)} KB cap`, 413, 'SIGNATURE_TOO_LARGE', ); } const match = dataUrl.match(/^data:image\/(png|jpeg);base64,(.+)$/); if (!match) { throw new AppError('Signature must be a base64-encoded PNG or JPEG data URL', 400, 'BAD_SIGNATURE_FORMAT'); } if (match[2].length > MAX_SIGNATURE_BASE64_BYTES) { throw new AppError( `Signature image exceeds the ${Math.round(MAX_SIGNATURE_BASE64_BYTES / 1024)} KB cap`, 413, 'SIGNATURE_TOO_LARGE', ); } const ext = match[1] === 'jpeg' ? 'jpg' : 'png'; const root = path.join( process.cwd(), 'storage', 'business-docs', 'contract', 'signatures', String(contract.id), ); fs.mkdirSync(root, { recursive: true }); // Filename already carries Date.now() so re-stamping a signature // never overwrites an earlier capture — forensic preservation. // Per role, the contract row's signed_*_signature_path always // points at the most recent; older files stay alongside. const filePath = path.join(root, `${role}-${Date.now()}.${ext}`); fs.writeFileSync(filePath, Buffer.from(match[2], 'base64')); return filePath; } /** * Build the stamp sequence the pdf-lib stamp service expects from a * single contract row. Customer first, admin second — provenance * order matches the visual order on the signature page. * * Used by the recovery paths (rerenderAndResend, restampSignatures). * The hot path (recordCustomerSignature / recordAdminCountersignature) * stamps incrementally so it constructs the stamp inline. */ function buildSignatureStamps(contract) { const locale = contract.language || 'de'; const nameLabel = 'Name'; const dateLabel = locale === 'de' ? 'Datum' : 'Date'; const stamps = []; if (contract.signed_customer_signature_path) { stamps.push({ signaturePngPath: contract.signed_customer_signature_path, role: 'customer', caption: { name: contract.signed_customer_name || '', signedAt: contract.signed_by_customer_at, nameLabel, dateLabel, }, }); } if (contract.signed_admin_signature_path) { stamps.push({ signaturePngPath: contract.signed_admin_signature_path, role: 'admin', caption: { name: contract.signed_admin_name || '', signedAt: contract.signed_by_admin_at, nameLabel, dateLabel, }, }); } return stamps; } /** * Build the audit-certificate context expected by * pdfStampService.renderAuditCertificate from a fully-signed * contract row. Returns null when the contract isn't signed enough * to warrant a certificate (no customer + no admin signature data). */ function buildAuditCertContext(contract) { const hasCustomerSig = contract.signed_by_customer_at || contract.signed_customer_name; const hasAdminSig = contract.signed_by_admin_at || contract.signed_admin_name; if (!hasCustomerSig && !hasAdminSig) return null; return { contract: { contract_number: contract.contract_number, sent_at: contract.sent_at, pdf_sha256: contract.pdf_sha256 || null, signed_pdf_sha256: contract.signed_pdf_sha256 || null, }, customer: hasCustomerSig ? { name: contract.signed_customer_name, signedAt: contract.signed_by_customer_at, ip: contract.signed_customer_ip, } : null, admin: hasAdminSig ? { name: contract.signed_admin_name, signedAt: contract.signed_by_admin_at, ip: contract.signed_admin_ip, } : null, locale: contract.language || 'de', }; } /** * Generate the audit certificate PDF, write it to disk under the same * year directory as the contract PDFs (suffix `audit`), and return * its file path. Returns null when there's nothing to certify or when * rendering fails (the email still goes out without the cert — the * stamped PDF alone remains delivered). */ async function persistAuditCertificate(contract) { const ctx = buildAuditCertContext(contract); if (!ctx) return null; try { const { buffer } = await pdfStampService.renderAuditCertificate(ctx); const year = (contract.issue_date ? new Date(contract.issue_date) : new Date()).getFullYear(); const root = path.join(process.cwd(), 'storage', 'business-docs', 'contract', String(year)); fs.mkdirSync(root, { recursive: true }); const stamp = new Date().toISOString().replace(/[:.]/g, '-'); const filePath = path.join(root, `${contract.contract_number}_audit_${stamp}.pdf`); fs.writeFileSync(filePath, buffer); return filePath; } catch (err) { logger.error('Failed to render audit certificate', { contractId: contract.id, contractNumber: contract.contract_number, message: err.message, }); return null; } } function ensureCustomerActive(customer) { if (!customer) throw new AppError('Customer not found', 404); if (customer.is_active === false || customer.is_active === 0) { throw new AppError('Customer is deactivated', 409); } } // --------------------------------------------------------------------- // Render-context builder + PDF helpers // --------------------------------------------------------------------- /** * Build the data shape pdfService.renderContractToBuffer expects. * Sections are emitted in canonical SECTIONS_ORDER; blocks within a * section are emitted in `position` order. Bodies are run through * renderTemplatedBody so {{placeholders}} are substituted. * * When the contract has been sent, `body_text_snapshot` is used (so * later edits to the source block don't mutate the rendered document). * Before send (preview from editor) the live `contract_blocks.body_text` * is used so the admin can iterate on block bodies and see the result. */ async function buildRenderContext(contract, inclusions) { const customer = await db('customer_accounts').where({ id: contract.customer_account_id }).first(); const profile = (await businessProfileService.getProfile()).profile || {}; const placeholders = await buildPlaceholderContext(contract, customer); // Pull source-quote line items when this contract was generated from a // quote. Surfaced on the render context so the renderer can draw a real // table at the location of the `quote_line_items_table` system block. // Sub-items keep their parent's position via the LEFT JOIN so the // renderer can indent them with a `↳` prefix. let quoteLineItems = []; let quoteCurrency = null; let quoteNumber = null; if (contract.source_quote_id) { const srcQuote = await db('quotes').where({ id: contract.source_quote_id }) .select('quote_number', 'currency').first(); if (srcQuote) { quoteCurrency = srcQuote.currency; quoteNumber = srcQuote.quote_number; quoteLineItems = await db('quote_line_items as li') .leftJoin('quote_line_items as parent', 'parent.id', 'li.parent_line_item_id') .where('li.quote_id', contract.source_quote_id) .orderBy('li.position', 'asc') .select('li.*', 'parent.position as parent_position'); } } const locale = contract.language || customer?.preferred_language || profile.default_locale || 'de'; // Group inclusions by section + render each block body. const blocksBySection = {}; for (const section of SECTIONS_ORDER) blocksBySection[section] = []; const sortedInclusions = [...inclusions] .filter((row) => row.included === true || row.included === 1 || row.included === '1') .sort((a, b) => { const sa = SECTIONS_ORDER.indexOf(a.section); const sb = SECTIONS_ORDER.indexOf(b.section); if (sa !== sb) return sa - sb; return (a.position || 0) - (b.position || 0); }); for (const row of sortedInclusions) { if (!blocksBySection[row.section]) continue; // The inclusion row carries the JOINED block columns aliased with // a `block_` prefix (see getContractById). Pre-send drafts have // null snapshots, so fall through to the live block body. // Migration 131 added ru/pt/nl/fr columns. The body resolver // picks the locale-matching column first, falls back through // DE → EN, so an admin can stage translations one locale at a // time without breaking contracts in other languages. const bodyEn = row.body_text_snapshot || row.block_body_text || ''; const bodyDe = row.body_text_de_snapshot || row.block_body_text_de || ''; const bodyRu = row.block_body_text_ru || ''; const bodyPt = row.block_body_text_pt || ''; const bodyNl = row.block_body_text_nl || ''; const bodyFr = row.block_body_text_fr || ''; const localeBody = ({ de: bodyDe, ru: bodyRu, pt: bodyPt, nl: bodyNl, fr: bodyFr, })[locale] || ''; const sourceBody = localeBody || bodyEn || bodyDe; // Substitute placeholders, then strip any leading `**Title**\n` // line — the block's `name` field is already rendered as a bold // sub-heading by the PDF/public layouts, so a bold first line in // the body produces a duplicated title. Inline `**bold**` markers // elsewhere in the body are preserved (the PDF renders them as // actual bold via renderBodyMarkdown; the public route strips // them since the React page has no inline-bold UI). const rendered = renderTemplatedBody(sourceBody, placeholders) .replace(/^\s*\*\*[^*\n]+\*\*\s*\n+/, ''); blocksBySection[row.section].push({ slug: row.block_slug || null, name: row.block_name, section: row.section, body: rendered, }); } // Use the same robust logo resolver quote/invoice use — checks // business_profile.logo_path → app_settings.branding_logo_path → // app_settings.branding_logo_url, with ~7 disk-location candidates // before giving up. const { resolveLogoFile } = require('../utils/resolveLogoFile'); const resolvedLogoPath = await resolveLogoFile(profile); // Global date format from Settings → General (general_date_format). let dateFormat = null; try { const raw = await getAppSetting('general_date_format'); if (raw && typeof raw === 'object' && raw.format) dateFormat = raw; else if (typeof raw === 'string' && raw.trim()) dateFormat = { format: raw.trim() }; } catch (_) { /* fall back to default */ } return { locale, dateFormat, // Mirror the quote/invoice issuer shape EXACTLY so drawIssuerBlock // honours the same business-profile toggles (pdf_show_logo, // pdf_show_company_name, pdf_logo_height, pdf_company_name_inline, // pdf_folding_marks) across all three document types. Per maintainer: // contracts reuse the same toggles — no contract-specific knobs. // Shared issuer + recipient builders. Contracts use the base toggle // set (no quote-only payment-block fields). The renderer-aware // recipient gating means contractService's previously-drifted // local attentionLine logic now matches quote + invoice exactly. issuer: buildIssuerBlock(profile, resolvedLogoPath), recipient: buildRecipientBlock(profile, customer), doc: { contractNumber: contract.contract_number, title: contract.title || '', issueDate: contract.issue_date, validUntil: contract.valid_until, introText: contract.intro_text ? renderTemplatedBody(contract.intro_text, placeholders) : null, outroText: contract.outro_text ? renderTemplatedBody(contract.outro_text, placeholders) : null, }, // Blocks grouped + ordered by canonical section order. sections: SECTIONS_ORDER .map((section) => ({ section, blocks: blocksBySection[section] })) .filter((s) => s.blocks.length > 0), // Source-quote line items, surfaced at the top level so the PDF // renderer can draw a formatted table where the // `quote_line_items_table` system block is included. Empty array // when the contract has no source quote. quoteLineItems, quoteCurrency, quoteSourceNumber: quoteNumber, // Signature evidence (used by the PDF renderer to stamp signatures // into the closing section when present). signatures: { customer: contract.signed_customer_name ? { name: contract.signed_customer_name, signedAt: contract.signed_by_customer_at, ip: contract.signed_customer_ip, signaturePath: contract.signed_customer_signature_path, } : null, admin: contract.signed_admin_name ? { name: contract.signed_admin_name, signedAt: contract.signed_by_admin_at, ip: contract.signed_admin_ip, signaturePath: contract.signed_admin_signature_path, } : null, }, // Audit-trail evidence appended to the rendered PDF as a final // page (issue #3). The renderer skips the page when this is null // OR when the contract isn't signed yet, so unsigned PDFs stay // unchanged. Hashes are best-effort: pdfSha256 may be null on // installs that haven't migrated to the new schema column yet — // the page still renders the rest of the evidence. audit: (contract.signed_customer_name || contract.signed_admin_name) ? { contractNumber: contract.contract_number, issuedAt: contract.sent_at, pdfSha256: contract.pdf_sha256 || null, signedPdfSha256: contract.signed_pdf_sha256 || null, } : null, }; } // --------------------------------------------------------------------- // Public API // --------------------------------------------------------------------- async function listContracts({ filters = {}, sort = 'issue_desc', page = 1, pageSize = 25 } = {}) { return await withRetry(async () => { let query = db('contracts') .leftJoin('customer_accounts', 'contracts.customer_account_id', 'customer_accounts.id') .select( 'contracts.*', 'customer_accounts.email as customer_email', 'customer_accounts.display_name as customer_display_name', 'customer_accounts.first_name as customer_first_name', 'customer_accounts.last_name as customer_last_name', 'customer_accounts.company_name as customer_company_name', ); if (Array.isArray(filters.status) && filters.status.length > 0) { query = query.whereIn('contracts.status', filters.status); } if (filters.customerAccountId) { query = query.where('contracts.customer_account_id', filters.customerAccountId); } if (filters.q && String(filters.q).trim()) { const term = `%${String(filters.q).trim()}%`; query = query.andWhere(function() { this.where('contracts.contract_number', 'like', term) .orWhere('contracts.title', 'like', term) .orWhere('customer_accounts.email', 'like', term) .orWhere('customer_accounts.company_name', 'like', term); }); } const countQuery = query.clone().clearSelect().clearOrder().count('contracts.id as total').first(); const totalRow = await countQuery; const total = ensureInt(totalRow?.total || 0); switch (sort) { case 'oldest': query = query.orderBy('contracts.created_at', 'asc').orderBy('contracts.id', 'asc'); break; case 'issue_asc': query = query.orderBy('contracts.issue_date', 'asc').orderBy('contracts.id', 'asc'); break; case 'issue_desc': query = query.orderBy('contracts.issue_date', 'desc').orderBy('contracts.id', 'desc'); break; case 'customer_asc': query = query .orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) asc') .orderBy('contracts.id', 'desc'); break; case 'customer_desc': query = query .orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) desc') .orderBy('contracts.id', 'desc'); break; case 'newest': default: query = query.orderBy('contracts.created_at', 'desc').orderBy('contracts.id', 'desc'); break; } const offset = Math.max(0, (page - 1) * pageSize); query = query.offset(offset).limit(pageSize); const rows = await query; return { rows, total, page, pageSize }; }); } async function getContractById(id) { return await withRetry(async () => { const contract = await db('contracts') .leftJoin('customer_accounts', 'contracts.customer_account_id', 'customer_accounts.id') .where('contracts.id', id) .select( 'contracts.*', 'customer_accounts.email as customer_email', 'customer_accounts.display_name as customer_display_name', 'customer_accounts.first_name as customer_first_name', 'customer_accounts.last_name as customer_last_name', 'customer_accounts.company_name as customer_company_name', 'customer_accounts.preferred_language as customer_preferred_language', ) .first(); if (!contract) return null; const inclusions = await db('contract_block_inclusions as inc') .leftJoin('contract_blocks as blk', 'blk.id', 'inc.block_id') .where('inc.contract_id', id) .orderByRaw(` CASE inc.section WHEN 'basics' THEN 1 WHEN 'scope' THEN 2 WHEN 'privacy' THEN 3 WHEN 'commercial' THEN 4 WHEN 'nda' THEN 5 WHEN 'closing' THEN 6 ELSE 99 END `) .orderBy('inc.position', 'asc') .select( 'inc.*', 'blk.slug as block_slug', 'blk.name as block_name', 'blk.description as block_description', 'blk.body_text as block_body_text', 'blk.body_text_de as block_body_text_de', // Migration 131 — locale variants. Pulled with column-existence // guard so installs that haven't run migration 131 still load // contracts (just without the new columns). ...(await hasColumnCached('contract_blocks', 'body_text_ru') ? ['blk.body_text_ru as block_body_text_ru'] : []), ...(await hasColumnCached('contract_blocks', 'body_text_pt') ? ['blk.body_text_pt as block_body_text_pt'] : []), ...(await hasColumnCached('contract_blocks', 'body_text_nl') ? ['blk.body_text_nl as block_body_text_nl'] : []), ...(await hasColumnCached('contract_blocks', 'body_text_fr') ? ['blk.body_text_fr as block_body_text_fr'] : []), 'blk.is_system as block_is_system', ); return { contract, inclusions }; }); } /** * Create a draft contract. Pre-populates `contract_block_inclusions` * with every active system block toggled ON so the admin sees a * sensible starting point and just toggles off what they don't need. * * Custom (non-system) blocks are NOT auto-included — admin opts in to * those explicitly so a runaway block library doesn't pollute every * new contract. */ async function createContract(payload, adminId) { // Self-heal: ensure runtime-seeded system blocks (e.g. the // quote_line_items_table added after migration 131 was deployed) // exist before we copy active system blocks into the new contract's // inclusion list. Idempotent — only fires if rows are missing. await ensureSystemBlocksSeeded(); const customer = await db('customer_accounts').where({ id: payload.customerAccountId }).first(); ensureCustomerActive(customer); const profile = (await businessProfileService.getProfile()).profile; const language = payload.language || customer.preferred_language || profile?.default_locale || 'de'; const validDays = ensureInt(await getAppSetting('crm_contracts_default_valid_days')) || 30; const issueDate = payload.issueDate || new Date().toISOString().slice(0, 10); const validUntil = payload.validUntil || new Date(Date.now() + validDays * 24 * 60 * 60 * 1000) .toISOString().slice(0, 10); // Schema-drift guard for the event-snapshot columns added as // in-place migration 130 edits. We only write them when the DB // actually has them; older dev installs that haven't re-migrated // simply skip these fields (contract still saves successfully). const hasEventCols = await hasColumnCached('contracts', 'event_name'); return await db.transaction(async (trx) => { // Pass trx so the sequence claim joins our outer transaction — // SQLite deadlocks otherwise (1-connection default). const contractNumber = await nextContractNumber(trx); const row = { contract_number: contractNumber, customer_account_id: payload.customerAccountId, status: 'draft', language, issue_date: issueDate, valid_until: validUntil, title: payload.title || null, intro_text: payload.introText || null, outro_text: payload.outroText || null, // Migration 140 — standalone contract is a deal root; mint a // fresh UUID. The createFromQuote path (line ~1557) sets this // from the source quote's deal_uuid instead. deal_uuid: crypto.randomUUID(), created_by_admin_id: adminId, created_at: new Date(), updated_at: new Date(), }; if (hasEventCols) { row.event_name = payload.eventName || null; row.event_date = payload.eventDate || null; row.event_time_start = payload.eventTimeStart || null; row.event_time_end = payload.eventTimeEnd || null; } // Migration 121 — optional link to a Project Overview project. if (payload.projectId !== undefined && await hasColumnCached('contracts', 'project_id')) { row.project_id = payload.projectId || null; } const inserted = await trx('contracts').insert(row).returning('id'); if (row.project_id && row.deal_uuid) { await require('./projectService').linkDealToProject(row.deal_uuid, row.project_id, trx); } const contractId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0]; // Seed with every active system block, toggled on. Per-section // position = display_order from the source block. // // D.3 — batched insert. Previously this loop fired one INSERT per // block (12+ round-trips inside the transaction on a fresh contract). // Batched into a single `.insert(rows)` since the row count is // bounded (system block count) and the inserts are independent. const systemBlocks = await trx('contract_blocks') .where({ is_system: true, is_active: true }) .orderBy(['section', 'display_order']); const sectionCounters = {}; const inclusionRows = systemBlocks.map((block) => { sectionCounters[block.section] = (sectionCounters[block.section] || 0) + 1; return { contract_id: contractId, block_id: block.id, section: block.section, position: sectionCounters[block.section], body_text_snapshot: null, body_text_de_snapshot: null, included: true, created_at: new Date(), updated_at: new Date(), }; }); if (inclusionRows.length > 0) { await trx('contract_block_inclusions').insert(inclusionRows); } try { await logActivity('contract_created', { contractId, contractNumber, customerAccountId: payload.customerAccountId }, null, await adminActor(adminId)); } catch (_) { /* logging is best-effort */ } logger.info('Contract created', { adminId, contractId, contractNumber }); return contractId; }); } /** * Update a draft contract. Editing a sent contract is refused — admin * must cancel + create a fresh one (avoids invalidating the customer's * signed copy). * * payload.blocks is an array of `{ blockId, included, position }` * tuples; the service rewrites the contract_block_inclusions rows * accordingly. */ async function updateContract(id, payload, adminId) { const existing = await db('contracts').where({ id }).first(); if (!existing) throw new AppError('Contract not found', 404); if (existing.status !== 'draft') { throw new AppError( `Cannot edit a contract with status '${existing.status}'. Cancel and create a new contract for amendments.`, 409, 'CONTRACT_LOCKED', ); } const hasEventCols = await hasColumnCached('contracts', 'event_name'); return await db.transaction(async (trx) => { const updates = { updated_at: new Date() }; const map = { title: 'title', introText: 'intro_text', outroText: 'outro_text', language: 'language', validUntil: 'valid_until', issueDate: 'issue_date', }; // Event-snapshot fields only flow through when the DB has them // (in-place migration 130 edit). Guarded so dev installs that // haven't re-migrated don't crash the update. if (hasEventCols) { Object.assign(map, { eventName: 'event_name', eventDate: 'event_date', eventTimeStart: 'event_time_start', eventTimeEnd: 'event_time_end', }); } for (const [api, col] of Object.entries(map)) { if (api in payload) updates[col] = payload[api] || null; } // Migration 121 — optional Project Overview link. if ('projectId' in payload && await hasColumnCached('contracts', 'project_id')) { updates.project_id = payload.projectId || null; } await trx('contracts').where({ id }).update(updates); // Cascade across the deal lineage (linked quote / event / invoices). if (updates.project_id) { const dealRow = await trx('contracts').where({ id }).select('deal_uuid').first(); await require('./projectService').linkDealToProject(dealRow && dealRow.deal_uuid, updates.project_id, trx); } // Replace inclusions only when the caller sent an explicit list. // (Editor's "save" sends every row; an inline "toggle" save could // send a partial update — current frontend always sends full list.) if (Array.isArray(payload.blocks)) { await trx('contract_block_inclusions').where({ contract_id: id }).del(); // Recompute per-section position so we don't trust caller order // for ordering integrity; caller controls only the section // sequence via the order of items in payload.blocks. // // Previously this loop did one SELECT per block to look up its // section. On a contract with 12 included blocks that's 12 // round-trips inside the transaction — pure N+1. Batch the // lookup into a single WHERE…IN, build a Map, and read it in // the loop. The insert itself stays sequential because the // editor's payload size is bounded (<30 blocks in practice) and // a single batch insert would lose row-by-row insert ordering // guarantees we don't actually need. const blockIds = [ ...new Set(payload.blocks.map((e) => e.blockId).filter((id) => Number.isFinite(id))), ]; const blocksFound = blockIds.length > 0 ? await trx('contract_blocks').whereIn('id', blockIds).select('id', 'section') : []; const sectionByBlockId = new Map(blocksFound.map((b) => [b.id, b.section])); const sectionCounters = {}; for (const entry of payload.blocks) { const section = sectionByBlockId.get(entry.blockId); if (!section) continue; sectionCounters[section] = (sectionCounters[section] || 0) + 1; await trx('contract_block_inclusions').insert({ contract_id: id, block_id: entry.blockId, section, position: ensureInt(entry.position) || sectionCounters[section], body_text_snapshot: null, body_text_de_snapshot: null, included: entry.included === false ? false : true, created_at: new Date(), updated_at: new Date(), }); } } try { await logActivity('contract_updated', { contractId: id }, null, await adminActor(adminId)); } catch (_) { /* logging is best-effort */ } return id; }); } /** * Render PDF for a saved contract (preview before send, or re-render * after signing). */ async function renderContractPdfBuffer(contractId) { const data = await getContractById(contractId); if (!data) throw new AppError('Contract not found', 404); const ctx = await buildRenderContext(data.contract, data.inclusions); return await pdfService.renderContractToBuffer(ctx); } /** * Send the contract: snapshot every included block's body, render PDF, * persist, mint a signing token, queue the customer email. */ async function sendContract(id, adminId) { // Self-heal: dev installs that ran migration 130 BEFORE we added // contract_fully_signed to the seed list won't have all three // contract templates in email_templates. Insert any missing rows // before we queue the email. Idempotent + module-cached. await ensureContractEmailTemplatesSeeded(db, logger); const data = await getContractById(id); if (!data) throw new AppError('Contract not found', 404); const { contract, inclusions } = data; if (!['draft'].includes(contract.status)) { throw new AppError(`Cannot send a contract with status '${contract.status}'`, 409); } const customer = await db('customer_accounts').where({ id: contract.customer_account_id }).first(); ensureCustomerActive(customer); // Snapshot every included block's body into the inclusion row so // future block edits don't mutate the sent contract. await db.transaction(async (trx) => { for (const inc of inclusions) { if (!(inc.included === true || inc.included === 1 || inc.included === '1')) continue; await trx('contract_block_inclusions').where({ id: inc.id }).update({ body_text_snapshot: inc.block_body_text || null, body_text_de_snapshot: inc.block_body_text_de || null, updated_at: new Date(), }); } }); // Re-fetch with snapshots populated so the renderer uses the frozen // bodies (matches post-send reads). const refreshed = await getContractById(id); const ctx = await buildRenderContext(refreshed.contract, refreshed.inclusions); const buffer = await pdfService.renderContractToBuffer(ctx); const { filePath: pdfPath, sha256: pdfSha256 } = await persistContractPdf(refreshed.contract, buffer); const token = crypto.randomBytes(32).toString('hex'); const expiresAt = contract.valid_until ? new Date(new Date(contract.valid_until).getTime() + 14 * 24 * 60 * 60 * 1000) : new Date(Date.now() + 60 * 24 * 60 * 60 * 1000); // Schema-drift guard for the new pdf_sha256 column (migration 130 // in-place edit). Dev installs that haven't re-migrated skip the // hash write; the send still succeeds. const hasPdfSha = await hasColumnCached('contracts', 'pdf_sha256'); await db.transaction(async (trx) => { await trx('contract_action_tokens').insert({ contract_id: id, token, expires_at: expiresAt, created_at: new Date(), }); const updates = { status: 'sent', sent_at: new Date(), pdf_path: pdfPath, updated_at: new Date(), }; if (hasPdfSha) updates.pdf_sha256 = pdfSha256; await trx('contracts').where({ id }).update(updates); }); const frontendUrl = (await getFrontendBaseUrl()) || 'http://localhost:3000'; const responseUrl = `${frontendUrl}/contract/${token}`; // Honour the admin's "Attach contract PDF to email" toggle. Default // ON; an admin who prefers a link-only email turns it off and the // customer reaches the PDF via the public sign page instead. const attachPdf = await getAppSetting('crm_contracts_pdf_attachment_enabled'); await emailProcessor.queueEmail(null, customer.email, 'contract_sent', { contract_number: contract.contract_number, customer_name: customer.display_name || [customer.first_name, customer.last_name].filter(Boolean).join(' ') || customer.email.split('@')[0], response_url: responseUrl, title: contract.title || '', event_name: contract.event_name || '', valid_until: formatShortDate(contract.valid_until), attachments: (attachPdf !== false && pdfPath) ? [{ filename: `${contract.contract_number}.pdf`, contentPath: pdfPath, contentType: 'application/pdf', }] : undefined, }); try { await logActivity('contract_sent', { contractId: id, token }, null, await adminActor(adminId)); } catch (_) { /* logging is best-effort */ } await emitContractEvent(contract, 'sent'); logger.info('Contract sent', { adminId, contractId: id }); return { token, pdfPath }; } /** * Record a customer's in-browser signature (canvas + typed name + * "I accept" checkbox). Validates the token, persists the signature * PNG, re-renders the PDF with the signature stamped, flips status * to `signed_by_customer`, and queues the admin notification email. */ async function recordCustomerSignature({ token, name, ip, signatureDataUrl, accepted }) { // Self-heal contract email templates. The contract_signed_admin_notification // email fires from this function — if its row is missing, the admin // never learns the customer signed. await ensureContractEmailTemplatesSeeded(db, logger); if (accepted !== true) { throw new AppError('You must confirm that you have read and agree to the terms.', 400, 'TOS_REQUIRED'); } if (!name || !String(name).trim()) { throw new AppError('Your name is required.', 400, 'NAME_REQUIRED'); } // Server-side guard for the "require drawn signature" admin toggle. // The public sign page also enforces this client-side, but the // server is the source of truth — a malicious caller posting // directly to /sign with a blank signatureDataUrl would otherwise // bypass the requirement. const requireDrawn = await getAppSetting('crm_contracts_require_drawn_signature'); if (requireDrawn === true && (!signatureDataUrl || !String(signatureDataUrl).trim())) { throw new AppError( 'A drawn signature is required for this contract — typing your name alone is not sufficient.', 400, 'SIGNATURE_REQUIRED', ); } const tokenRow = await db('contract_action_tokens').where({ token }).first(); if (!tokenRow) throw new AppError('Token not found', 404); if (tokenRow.expires_at && new Date(tokenRow.expires_at).getTime() < Date.now()) { throw new AppError('This signing link has expired', 410); } if (tokenRow.used_at) { throw new AppError('This contract has already been signed', 410, 'TOKEN_ALREADY_USED'); } const contract = await db('contracts').where({ id: tokenRow.contract_id }).first(); if (!contract) throw new AppError('Contract not found', 404); if (!['sent'].includes(contract.status)) { throw new AppError(`Contract cannot be signed in status '${contract.status}'`, 409); } const signaturePath = signatureDataUrl ? await persistSignatureImage(contract, 'customer', signatureDataUrl) : null; const now = new Date(); // Resolve the IP gate ONCE before the transaction so both writes // (contracts row + tokens row) agree. Setting flip mid-transaction // can't happen anyway, but doing it upfront keeps the data // consistent and saves a redundant read. const persistedIp = await maybeStoreIp(ip); try { await db.transaction(async (trx) => { await trx('contracts').where({ id: contract.id }).update({ status: 'signed_by_customer', signed_by_customer_at: now, signed_customer_name: String(name).trim(), signed_customer_ip: persistedIp, signed_customer_signature_path: signaturePath, updated_at: now, }); await trx('contract_action_tokens').where({ id: tokenRow.id }).update({ used_at: now, used_action: 'signed_by_customer', used_ip: persistedIp, }); }); } catch (txErr) { // C.7 — clean up the orphan signature PNG we wrote before the // transaction. The DB rollback already undid the contract + // token writes; the file would otherwise sit forever in // storage/business-docs/contract/.../signatures/. Best-effort // unlink — if the cleanup itself fails, log and re-throw the // original transaction error so the caller still sees the real // failure cause. if (signaturePath) { try { if (fs.existsSync(signaturePath)) fs.unlinkSync(signaturePath); } catch (cleanupErr) { logger.warn('Orphan signature PNG cleanup failed', { path: signaturePath, message: cleanupErr.message, }); } } throw txErr; } // Stamp the customer's signature onto the UNSIGNED PDF on disk. // Byte-immutable approach (see pdfStampService): we read pdf_path // (the immutable as-sent PDF), stamp the customer's signature PNG // at the fixed coordinates on the signature page, save as a new // timestamped file, and update signed_pdf_path. Original file // stays untouched on disk. const refreshed = await getContractById(contract.id); try { if (!refreshed.contract.pdf_path || !fs.existsSync(refreshed.contract.pdf_path)) { throw new Error(`Unsigned PDF missing on disk at ${refreshed.contract.pdf_path}`); } const originalPdfBuffer = fs.readFileSync(refreshed.contract.pdf_path); const stampedBuffer = await pdfStampService.stampSignature({ pdfBuffer: originalPdfBuffer, signaturePngPath: signaturePath, role: 'customer', caption: { name: String(name).trim(), signedAt: now, nameLabel: refreshed.contract.language === 'de' ? 'Name' : 'Name', dateLabel: refreshed.contract.language === 'de' ? 'Datum' : 'Date', }, }); const { filePath: signedPath, sha256: signedSha256 } = await persistContractPdf( refreshed.contract, stampedBuffer, 'signed-by-customer', ); const hasSignedPdfSha = await hasColumnCached('contracts', 'signed_pdf_sha256'); const updates = { signed_pdf_path: signedPath, updated_at: new Date(), }; if (hasSignedPdfSha) updates.signed_pdf_sha256 = signedSha256; // Migration 136 — clear any pre-existing render-failed marker; the // most recent stamp attempt just succeeded. if (await hasColumnCached('contracts', 'signed_pdf_render_failed_at')) { updates.signed_pdf_render_failed_at = null; updates.signed_pdf_render_error = null; } await db('contracts').where({ id: contract.id }).update(updates); } catch (err) { // Signature recorded; PDF re-render is best-effort. The admin can // re-render manually from the detail page if this fails. Logged as // error (not warn) so persistent failures surface in monitoring. logger.error('Failed to re-render contract PDF after customer signature', { contractId: contract.id, message: err.message, stack: err.stack, }); // Migration 136 — surface the failure on the contract row so the // admin detail page can render a recovery banner instead of the // admin only discovering this through monitoring. err.message is // truncated to 2 KB; the full stack stays in server logs. try { if (await hasColumnCached('contracts', 'signed_pdf_render_failed_at')) { await db('contracts').where({ id: contract.id }).update({ signed_pdf_render_failed_at: new Date(), signed_pdf_render_error: String(err.message || 'Unknown error').slice(0, 2048), updated_at: new Date(), }); } } catch (markErr) { // Marker write itself failed — log + swallow so the customer // sign response still succeeds. The orphan stays orphan but // we've at least surfaced both errors. logger.error('Failed to record signed_pdf_render_failed marker', { contractId: contract.id, message: markErr.message, }); } } // Notify admin. const customer = await db('customer_accounts').where({ id: contract.customer_account_id }).first(); const frontendUrl = (await getFrontendBaseUrl()) || 'http://localhost:3000'; try { await emailProcessor.queueEmail(null, null, 'contract_signed_admin_notification', { contract_number: contract.contract_number, customer_email: customer?.email || '', signed_customer_name: String(name).trim(), admin_dashboard_url: `${frontendUrl}/admin/clients/contracts/${contract.id}`, }); } catch (err) { logger.warn('Failed to queue admin notification after customer signature', { contractId: contract.id, error: err.message, }); } try { await logActivity('contract_signed_by_customer', { contractId: contract.id, token }, null, customerPublicActor()); } catch (_) { /* logging is best-effort */ } return { status: 'signed_by_customer', signedAt: now }; } /** * Admin counter-signature. Bumps status to `fully_signed` (or * `signed_by_admin` if the customer hasn't signed yet — edge case * where admin signs first, e.g. issuer-side framework agreement). */ async function recordAdminCountersignature(contractId, { name, ip, signatureDataUrl }, adminId) { // Self-heal: ensure the contract_fully_signed template exists // before we counter-sign. The dual-party send fires from this // function on the fully_signed transition; without the template // it silently fails and the customer never receives the PDF. await ensureContractEmailTemplatesSeeded(db, logger); if (!name || !String(name).trim()) { throw new AppError('Your name is required.', 400, 'NAME_REQUIRED'); } const contract = await db('contracts').where({ id: contractId }).first(); if (!contract) throw new AppError('Contract not found', 404); if (!['signed_by_customer', 'sent'].includes(contract.status)) { throw new AppError(`Cannot counter-sign a contract with status '${contract.status}'`, 409); } const signaturePath = signatureDataUrl ? await persistSignatureImage(contract, 'admin', signatureDataUrl) : null; const now = new Date(); const newStatus = contract.status === 'signed_by_customer' ? 'fully_signed' : 'signed_by_admin'; const persistedAdminIp = await maybeStoreIp(ip); try { await db('contracts').where({ id: contract.id }).update({ status: newStatus, signed_by_admin_at: now, signed_admin_name: String(name).trim(), signed_admin_ip: persistedAdminIp, signed_admin_signature_path: signaturePath, updated_at: now, }); } catch (updateErr) { // C.7 — clean up the orphan signature PNG if the contract row // update threw. Best-effort; log on cleanup failure and re-throw // the original update error. if (signaturePath) { try { if (fs.existsSync(signaturePath)) fs.unlinkSync(signaturePath); } catch (cleanupErr) { logger.warn('Orphan admin signature PNG cleanup failed', { path: signaturePath, message: cleanupErr.message, }); } } throw updateErr; } // Stamp the admin's signature ON TOP of whatever signed_pdf_path // currently holds (the customer-stamped PDF, in the normal flow) // — or directly onto the unsigned pdf_path if the admin is the // first to sign (edge case). Byte-immutable: each prior PDF stays // on disk; the new file is a fresh timestamped version. const refreshed = await getContractById(contract.id); let signedPath = null; let signedSha256 = null; try { const baseFile = (refreshed.contract.signed_pdf_path && fs.existsSync(refreshed.contract.signed_pdf_path)) ? refreshed.contract.signed_pdf_path : refreshed.contract.pdf_path; if (!baseFile || !fs.existsSync(baseFile)) { throw new Error(`Contract base PDF missing on disk for stamping (signed_pdf_path=${refreshed.contract.signed_pdf_path}, pdf_path=${refreshed.contract.pdf_path})`); } const baseBuffer = fs.readFileSync(baseFile); const stampedBuffer = await pdfStampService.stampSignature({ pdfBuffer: baseBuffer, signaturePngPath: signaturePath, role: 'admin', caption: { name: String(name).trim(), signedAt: now, nameLabel: refreshed.contract.language === 'de' ? 'Name' : 'Name', dateLabel: refreshed.contract.language === 'de' ? 'Datum' : 'Date', }, }); const suffix = newStatus === 'fully_signed' ? 'fully-signed' : 'signed-by-admin'; const persisted = await persistContractPdf(refreshed.contract, stampedBuffer, suffix); signedPath = persisted.filePath; signedSha256 = persisted.sha256; const hasSignedPdfSha = await hasColumnCached('contracts', 'signed_pdf_sha256'); const updates = { signed_pdf_path: signedPath, updated_at: new Date(), }; if (hasSignedPdfSha) updates.signed_pdf_sha256 = signedSha256; if (await hasColumnCached('contracts', 'signed_pdf_render_failed_at')) { updates.signed_pdf_render_failed_at = null; updates.signed_pdf_render_error = null; } await db('contracts').where({ id: contract.id }).update(updates); } catch (err) { logger.error('Failed to stamp contract PDF after admin signature', { contractId: contract.id, newStatus, message: err.message, stack: err.stack, }); // Migration 136 — mirror the customer-sign branch: persist a // recovery marker so the admin detail page can surface a banner. try { if (await hasColumnCached('contracts', 'signed_pdf_render_failed_at')) { await db('contracts').where({ id: contract.id }).update({ signed_pdf_render_failed_at: new Date(), signed_pdf_render_error: String(err.message || 'Unknown error').slice(0, 2048), updated_at: new Date(), }); } } catch (markErr) { logger.error('Failed to record signed_pdf_render_failed marker (admin sign)', { contractId: contract.id, message: markErr.message, }); } } // When the admin's signature is what FINALISED the contract (i.e. // status flipped to fully_signed), email a copy of the freshly // re-rendered PDF to both parties. We send two separate queueEmail // calls so each recipient gets the email rendered with their own // greeting + name. The admin BCC is delivered as "to the issuer" // so it lands in the same inbox the contract_sent email originated // from. if (newStatus === 'fully_signed') { try { // Pick the best available PDF as the attachment, in priority // order: this counter-sign's freshly-rendered signed copy → // the customer-only signed copy we wrote earlier → the // original unsigned PDF. Falling all the way through to no // attachment is acceptable; the email still goes out with the // contract number so the customer knows it's binding. const refetched = await db('contracts').where({ id: contract.id }).first(); const attachmentPath = signedPath || refetched?.signed_pdf_path || refetched?.pdf_path || null; const customer = await db('customer_accounts').where({ id: contract.customer_account_id }).first(); const profile = (await businessProfileService.getProfile()).profile || {}; const adminRow = await db('admin_users').where({ id: adminId }).first(); const customerName = customer?.display_name || [customer?.first_name, customer?.last_name].filter(Boolean).join(' ') || customer?.email?.split('@')[0] || ''; // Generate the audit certificate as a SIBLING document (separate // PDF) and attach it alongside the stamped contract. Audit cert // captures timestamps, IPs, names, and SHA-256 hashes — the legal // provenance record. Reproducible from contract data so safe to // regenerate on demand; we still persist a copy to disk for the // forensic trail. const auditCertPath = await persistAuditCertificate(refetched || refreshed.contract); const attachments = []; if (attachmentPath) { attachments.push({ filename: `${refreshed.contract.contract_number}-signed.pdf`, contentPath: attachmentPath, contentType: 'application/pdf', }); } if (auditCertPath) { attachments.push({ filename: `${refreshed.contract.contract_number}-audit.pdf`, contentPath: auditCertPath, contentType: 'application/pdf', }); } const attachmentsArg = attachments.length > 0 ? attachments : undefined; // 1. Customer copy if (customer?.email) { await emailProcessor.queueEmail(null, customer.email, 'contract_fully_signed', { contract_number: refreshed.contract.contract_number, customer_name: customerName, title: refreshed.contract.title || '', attachments: attachmentsArg, }); } // 2. Admin copy. Prefer business_profile.email (the inbox the // contract was sent FROM); fall back to the counter-signing // admin's account email so the audit trail still reaches a // human even on installs where business_profile.email is blank. const adminEmail = profile.email || adminRow?.email; if (adminEmail && adminEmail !== customer?.email) { await emailProcessor.queueEmail(null, adminEmail, 'contract_fully_signed', { contract_number: refreshed.contract.contract_number, customer_name: profile.company_name || adminRow?.first_name || 'Team', title: refreshed.contract.title || '', attachments: attachmentsArg, }); } } catch (err) { logger.error('Failed to send contract_fully_signed emails', { contractId: contract.id, message: err.message, stack: err.stack, }); } } try { await logActivity(`contract_${newStatus}`, { contractId: contract.id }, null, await adminActor(adminId)); } catch (_) { /* logging is best-effort */ } // The binding moment — fire contract.signed once the contract is fully signed // (matches the editor's trigger). Best-effort / fail-closed. if (newStatus === 'fully_signed') await emitContractEvent(contract, 'signed'); return { status: newStatus, signedAt: now }; } /** * Attach a wet-signed PDF as the authoritative signed copy. Either * party can upload (admin via admin route, customer via public token * route). When the customer uploads, status flips to `fully_signed` * because the wet signature is treated as a full agreement (admin * would normally also sign the wet copy before sending it to the * customer). */ async function attachSignedPdfUpload(contractId, filePath, uploaderRole) { // Self-heal contract email templates — same reason as the // sendContract + recordAdminCountersignature paths. await ensureContractEmailTemplatesSeeded(db, logger); if (!filePath) throw new AppError('No file uploaded', 400); const contract = await db('contracts').where({ id: contractId }).first(); if (!contract) throw new AppError('Contract not found', 404); if (['cancelled', 'draft'].includes(contract.status)) { throw new AppError(`Cannot attach a signed PDF to a contract in status '${contract.status}'`, 409); } const now = new Date(); const updates = { signed_pdf_path: filePath, status: 'fully_signed', updated_at: now, }; // Migration 135 — durable wet-upload discriminator. Persists the // "this row holds an authoritative wet upload, do not auto-overwrite" // signal as a column rather than inferring from the file path. See // the migration body for the full rationale. if (await hasColumnCached('contracts', 'signed_pdf_is_wet_upload')) { updates.signed_pdf_is_wet_upload = true; } // Hash the uploaded PDF on disk so we can later prove it wasn't // tampered with after upload. Multer wrote the file synchronously // before this handler runs, so reading it here is safe. if (await hasColumnCached('contracts', 'signed_pdf_sha256')) { updates.signed_pdf_sha256 = sha256OfFile(filePath); } if (uploaderRole === 'customer' && !contract.signed_by_customer_at) { updates.signed_by_customer_at = now; } if (uploaderRole === 'admin' && !contract.signed_by_admin_at) { updates.signed_by_admin_at = now; } await db('contracts').where({ id: contractId }).update(updates); // attachSignedPdfUpload always transitions to fully_signed (see // updates.status above), so the dual-party send fires here too — // same pattern as recordAdminCountersignature. The uploaded PDF // IS the authoritative copy so we attach it directly. try { const refreshedContract = await db('contracts').where({ id: contractId }).first(); const customer = await db('customer_accounts').where({ id: refreshedContract.customer_account_id }).first(); const profile = (await businessProfileService.getProfile()).profile || {}; const customerName = customer?.display_name || [customer?.first_name, customer?.last_name].filter(Boolean).join(' ') || customer?.email?.split('@')[0] || ''; const attachments = [{ filename: `${refreshedContract.contract_number}-signed.pdf`, contentPath: filePath, contentType: 'application/pdf', }]; // Sibling audit certificate — same legal-provenance record as the // in-browser sign path. Best-effort; missing cert doesn't block the // wet-signed PDF from reaching the parties. const auditCertPath = await persistAuditCertificate(refreshedContract); if (auditCertPath) { attachments.push({ filename: `${refreshedContract.contract_number}-audit.pdf`, contentPath: auditCertPath, contentType: 'application/pdf', }); } if (customer?.email) { await emailProcessor.queueEmail(null, customer.email, 'contract_fully_signed', { contract_number: refreshedContract.contract_number, customer_name: customerName, title: refreshedContract.title || '', attachments, }); } if (profile.email && profile.email !== customer?.email) { await emailProcessor.queueEmail(null, profile.email, 'contract_fully_signed', { contract_number: refreshedContract.contract_number, customer_name: profile.company_name || 'Team', title: refreshedContract.title || '', attachments, }); } } catch (err) { logger.warn('Failed to send contract_fully_signed emails after PDF upload', { contractId, error: err.message, }); } try { await logActivity('contract_signed_pdf_uploaded', { contractId, uploaderRole }, null, uploaderRole === 'admin' ? { type: 'admin', name: 'Admin (PDF upload)' } : customerPublicActor()); } catch (_) { /* logging is best-effort */ } await emitContractEvent(contract, 'signed'); return { status: 'fully_signed', signedPdfPath: filePath }; } /** * Convert an accepted quote into a fresh draft contract, pre-populating * the customer, language, title, valid-until window, and source_quote_id * back-pointer. Idempotent — if the quote already has a linked contract * (quote.converted_contract_id set), returns that contract's id without * creating a duplicate. * * Does NOT flip quote.status — the quote stays 'accepted' while the * contract is the active deliverable. The quote→event / quote→invoice * paths are gated against the converted_contract_id back-pointer so an * admin can't accidentally double-spend the quote. */ async function createFromQuote(quoteId, adminId) { // Same self-heal as createContract — the quote-conversion path seeds // the contract with every active system block, and the new // quote_line_items_table block needs to be present for it to land // in the default inclusion list. await ensureSystemBlocksSeeded(); const quote = await db('quotes').where({ id: quoteId }).first(); if (!quote) throw new AppError('Quote not found', 404); if (quote.status !== 'accepted') { throw new AppError(`Cannot convert a quote with status '${quote.status}'`, 409, 'QUOTE_NOT_ACCEPTED'); } if (quote.converted_contract_id) { return { contractId: quote.converted_contract_id, alreadyConverted: true }; } if (quote.converted_event_id) { throw new AppError( 'This quote was already converted to an event. Create the contract from the event instead.', 409, 'ALREADY_CONVERTED_TO_EVENT', ); } const customer = await db('customer_accounts').where({ id: quote.customer_account_id }).first(); ensureCustomerActive(customer); const profile = (await businessProfileService.getProfile()).profile; const validDays = ensureInt(await getAppSetting('crm_contracts_default_valid_days')) || 30; const issueDate = new Date().toISOString().slice(0, 10); const validUntil = new Date(Date.now() + validDays * 24 * 60 * 60 * 1000) .toISOString().slice(0, 10); const title = quote.event_name ? `Contract — ${quote.event_name}` : `Contract from quote ${quote.quote_number}`; // Schema-drift safety: the lineage columns landed in migration 130 // as in-place edits. Dev installs that ran 130 BEFORE that edit // won't have these columns yet. hasColumn() lets us skip the // affected writes instead of crashing with a generic 500. const hasContractSourceQuote = await hasColumnCached('contracts', 'source_quote_id'); const hasQuoteContractBackPointer = await hasColumnCached('quotes', 'converted_contract_id'); const hasContractEventCols = await hasColumnCached('contracts', 'event_name'); // Resolve the actor BEFORE opening the transaction — adminActor reads // admin_users via the global db, which deadlocks the single-connection // SQLite pool if evaluated inside the trx (prepare_contract runs unattended). const actor = await adminActor(adminId); return await db.transaction(async (trx) => { // Pass trx so the sequence claim joins our outer transaction — // SQLite deadlocks otherwise (1-connection default). const contractNumber = await nextContractNumber(trx); const contractRow = { contract_number: contractNumber, customer_account_id: quote.customer_account_id, status: 'draft', language: quote.language || customer.preferred_language || profile?.default_locale || 'de', issue_date: issueDate, valid_until: validUntil, title, intro_text: quote.intro_text || null, outro_text: quote.outro_text || null, created_by_admin_id: adminId, created_at: new Date(), updated_at: new Date(), }; if (hasContractSourceQuote) contractRow.source_quote_id = quote.id; // Migration 140 — contract from quote inherits the quote's // deal_uuid so both documents belong to the same deal chain. // Falls back to a fresh UUID only if the source quote predates the // backfill (shouldn't happen on a migrated install, but defensive). contractRow.deal_uuid = quote.deal_uuid || crypto.randomUUID(); // Propagate the quote's event snapshot — same fields the quote // already carries (set by createQuote). Means contract-from-quote // chains preserve "this contract is for the Wedding Doe / Müller" // labelling all the way through to the resulting invoice's // event_name field. if (hasContractEventCols) { contractRow.event_name = quote.event_name || null; contractRow.event_date = quote.event_date || null; contractRow.event_time_start = quote.event_time_start || null; contractRow.event_time_end = quote.event_time_end || null; } const inserted = await trx('contracts').insert(contractRow).returning('id'); const contractId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0]; // Seed every active system block. Same shape as createContract. // D.3 — batched insert (one DB round-trip vs N). const systemBlocks = await trx('contract_blocks') .where({ is_system: true, is_active: true }) .orderBy(['section', 'display_order']); const sectionCounters = {}; const inclusionRows = systemBlocks.map((block) => { sectionCounters[block.section] = (sectionCounters[block.section] || 0) + 1; return { contract_id: contractId, block_id: block.id, section: block.section, position: sectionCounters[block.section], body_text_snapshot: null, body_text_de_snapshot: null, included: true, created_at: new Date(), updated_at: new Date(), }; }); if (inclusionRows.length > 0) { await trx('contract_block_inclusions').insert(inclusionRows); } // Back-pointer so the quote detail page can deep-link to its // resulting contract and the convert-to-event/invoice paths know // to refuse double conversion. Skipped silently when the column // hasn't migrated — the contract is still created cleanly. if (hasQuoteContractBackPointer) { await trx('quotes').where({ id: quote.id }).update({ converted_contract_id: contractId, updated_at: new Date(), }); } try { // Pass `trx` so the audit insert rides the transaction's connection; // the global db here deadlocks the single-connection SQLite pool. await logActivity('contract_created_from_quote', { contractId, contractNumber, quoteId: quote.id, quoteNumber: quote.quote_number }, null, actor, trx); } catch (_) { /* logging is best-effort */ } logger.info('Contract created from quote', { adminId, contractId, contractNumber, quoteId: quote.id }); return { contractId, alreadyConverted: false }; }); } /** * Convert a fully-signed contract into an event + scheduled invoices. * Delegates to quoteService.convertToEvent using the contract's * source_quote_id so the line items + payment plan come from the * original quote. The quote MUST still be in 'accepted' status (i.e. * not previously converted) — createFromQuote keeps it that way. * * On success the contract's converted_event_id is set (back-pointer) * and the source quote flips to 'converted'. */ async function convertToEvent(contractId, adminId) { const contract = await db('contracts').where({ id: contractId }).first(); if (!contract) throw new AppError('Contract not found', 404); if (contract.status !== 'fully_signed') { throw new AppError( `Cannot convert a contract with status '${contract.status}'. The contract must be fully signed by both parties first.`, 409, 'CONTRACT_NOT_FULLY_SIGNED', ); } if (contract.converted_event_id) { return { eventId: contract.converted_event_id, alreadyConverted: true }; } const hasContractConvertedEvent = await hasColumnCached('contracts', 'converted_event_id'); // Path A: source quote present → delegate to quoteService which // replays the full installment schedule into invoices alongside // the event row. if (contract.source_quote_id) { const quoteService = require('./quoteService'); const result = await quoteService.convertToEvent(contract.source_quote_id, adminId, { fromContract: true }); if (hasContractConvertedEvent) { await db('contracts').where({ id: contractId }).update({ converted_event_id: result.eventId, updated_at: new Date(), }); } try { await logActivity('contract_converted_to_event', { contractId, eventId: result.eventId, quoteId: contract.source_quote_id }, result.eventId, await adminActor(adminId)); } catch (_) { /* logging is best-effort */ } return result; } // Path B: standalone contract → mint an empty placeholder event // row the admin fleshes out from the events admin page. Same // column-introspection trick quoteService uses so installs with // old/new host_*/customer_* column variants both work. const customer = await db('customer_accounts').where({ id: contract.customer_account_id }).first(); ensureCustomerActive(customer); const adminRow = await db('admin_users').where({ id: adminId }).first(); const today = new Date(); const oneYearFromNow = new Date(today.getTime()); oneYearFromNow.setFullYear(today.getFullYear() + 1); const fullName = [customer.first_name, customer.last_name].filter(Boolean).join(' ') || customer.display_name || customer.company_name || contract.contract_number; const customerEmail = customer.email || `${contract.contract_number.toLowerCase()}@picpeak.local`; const adminEmail = adminRow?.email || customer.email || 'admin@picpeak.local'; const placeholderHash = crypto.randomBytes(32).toString('hex'); const shareToken = crypto.randomBytes(32).toString('hex'); const eventCols = await db('events').columnInfo(); const candidate = { slug: `contract-${contract.contract_number.toLowerCase()}-${crypto.randomBytes(3).toString('hex')}`, // Prefer the contract's event_name snapshot (set on the contract // editor or inherited from the source quote) over the contract // title. Falls back to a deterministic placeholder so the event // row never has a blank name. event_name: contract.event_name || contract.title || `Event ${contract.contract_number}`, event_date: contract.event_date || contract.issue_date, host_name: fullName, host_email: customerEmail, customer_name: fullName, customer_email: customerEmail, customer_phone: customer.phone, admin_email: adminEmail, event_type: 'wedding', password_hash: placeholderHash, share_link: shareToken, share_token: shareToken, expires_at: oneYearFromNow, is_active: true, is_archived: false, is_draft: true, created_by: adminId, quote_id: null, created_at: new Date(), updated_at: new Date(), }; const eventRow = {}; for (const [k, v] of Object.entries(candidate)) { if (Object.prototype.hasOwnProperty.call(eventCols, k)) eventRow[k] = v; } const inserted = await db('events').insert(eventRow).returning('id'); const eventId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0]; // Link the customer so they see the event on their portal once // the admin activates it. Best-effort — older installs without // the junction table still get the event row. try { if (await db.schema.hasTable('event_customer_assignments')) { await db('event_customer_assignments').insert({ event_id: eventId, customer_account_id: customer.id, assigned_by_admin_id: adminId, assigned_at: new Date(), }); } } catch (_) { /* best-effort */ } if (hasContractConvertedEvent) { await db('contracts').where({ id: contractId }).update({ converted_event_id: eventId, updated_at: new Date(), }); } try { await logActivity('contract_converted_to_empty_event', { contractId, eventId }, eventId, await adminActor(adminId)); } catch (_) { /* logging is best-effort */ } return { eventId, alreadyConverted: false }; } /** * Convert a fully-signed contract directly into invoice(s) without * creating an event row. Same delegation pattern as convertToEvent. */ async function convertToInvoiceOnly(contractId, adminId) { const contract = await db('contracts').where({ id: contractId }).first(); if (!contract) throw new AppError('Contract not found', 404); if (contract.status !== 'fully_signed') { throw new AppError( `Cannot convert a contract with status '${contract.status}'. The contract must be fully signed by both parties first.`, 409, 'CONTRACT_NOT_FULLY_SIGNED', ); } // Schema-drift guard — the lineage columns are in-place edits to // migration 130. Skip the back-pointer update silently when the // column hasn't migrated yet. const hasInvoiceContractBackPointer = await hasColumnCached('invoices', 'source_contract_id'); // Path A: contract has a source quote → replay its line items + // payment plan via quoteService (full installment schedule). if (contract.source_quote_id) { const quoteService = require('./quoteService'); const result = await quoteService.convertToInvoiceOnly(contract.source_quote_id, adminId, { fromContract: true }); if (hasInvoiceContractBackPointer) { await db('invoices') .where({ source_quote_id: contract.source_quote_id }) .whereNull('source_contract_id') .update({ source_contract_id: contractId }); } try { await logActivity('contract_converted_to_invoices', { contractId, quoteId: contract.source_quote_id, installments: result.installmentsCreated }, null, await adminActor(adminId)); } catch (_) { /* logging is best-effort */ } return result; } // Path B: standalone contract (no source quote) → direct DB insert // of an empty draft. We deliberately bypass invoiceService.createInvoice // because that runs ensureCustomerCanBill, which throws if the // customer doesn't have feature_bills enabled. Admin clicking // "Convert to invoice" on the contract detail page IS the // authorisation; the admin will fill in line items manually before // sending. const customer = await db('customer_accounts').where({ id: contract.customer_account_id }).first(); ensureCustomerActive(customer); const invoiceService = require('./invoiceService'); const profile = (await businessProfileService.getProfile()).profile || {}; const currency = (profile.default_currency || 'CHF').toUpperCase(); const language = contract.language || customer.preferred_language || profile.default_locale || 'de'; const issueDate = new Date().toISOString().slice(0, 10); const netDays = ensureInt(await getAppSetting('crm_payment_default_net_days')) || 30; const dueDate = new Date(Date.now() + netDays * 24 * 60 * 60 * 1000).toISOString().slice(0, 10); // Pre-resolve which event-snapshot columns the invoices table has // (migration 123) so we can copy contract.event_name etc onto the // new invoice. Falls back to contract.title when event_name is // empty — gives standalone contracts a useful label even when // the admin didn't fill out the event field. const invoiceHasEventName = await hasColumnCached('invoices', 'event_name'); const eventNameSnapshot = (contract.event_name || contract.title || null); const invoiceNumber = await invoiceService.nextInvoiceNumber(); const invoiceRow = { invoice_number: invoiceNumber, customer_account_id: contract.customer_account_id, source_quote_id: null, event_id: null, language, currency, issue_date: issueDate, due_date: dueDate, installment_index: 0, installment_total: 1, status: 'scheduled', net_amount_minor: 0, vat_rate: 0, vat_amount_minor: 0, shipping_amount_minor: 0, total_amount_minor: 0, paid_amount_minor: 0, reminder_level: 0, late_fee_amount_minor: 0, created_by_admin_id: adminId, created_at: new Date(), updated_at: new Date(), }; if (hasInvoiceContractBackPointer) invoiceRow.source_contract_id = contractId; // Migration 140 — invoice inherits the contract's deal_uuid so the // contract + invoice belong to the same deal chain. Fresh UUID if // the contract predates the backfill (defensive). invoiceRow.deal_uuid = contract.deal_uuid || crypto.randomUUID(); // Snapshot the contract's event fields onto the invoice so the // BillDetailPage + customer portal show the same "Wedding Doe / // Müller" label that the contract carries. event_name is also the // field the dunning emails reference in their templates. if (invoiceHasEventName) { invoiceRow.event_name = eventNameSnapshot; invoiceRow.event_date = contract.event_date || null; invoiceRow.event_time_start = contract.event_time_start || null; invoiceRow.event_time_end = contract.event_time_end || null; } const inserted = await db('invoices').insert(invoiceRow).returning('id'); const invoiceId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0]; try { await logActivity('contract_converted_to_empty_invoice', { contractId, invoiceId, invoiceNumber }, null, await adminActor(adminId)); } catch (_) { /* logging is best-effort */ } // Match the result shape of the source-quote path so the frontend // toast can use the same translation key. `installmentsCreated` is // always 1 here (single empty invoice). return { installmentsCreated: 1, invoiceId }; } /** * Recovery helper: re-render the signed PDF + resend the * contract_fully_signed email to both parties. Used by the admin * detail page when: * - a previous render silently failed (signed_pdf_path is empty * on a fully_signed contract) * - the customer reports they didn't receive the email * - the bodies of the seeded blocks were updated post-signing and * the admin wants the latest text on file * * Only available on fully_signed contracts. The wet-signed PDF path * is preserved: when signed_pdf_path already points at an uploaded * file (not a re-render path) we DO NOT overwrite — the uploaded PDF * is the authoritative copy. We still resend the email with that * uploaded PDF as the attachment. */ async function rerenderAndResend(contractId, adminId) { // Self-heal contract email templates. This is the most likely // recovery path the admin reaches when a prior dual-party send // failed silently — including when the failure was caused by the // template being missing in the first place. const newlySeeded = await ensureContractEmailTemplatesSeeded(db, logger); if (newlySeeded.length > 0) { logger.warn('rerenderAndResend self-healed missing email templates', { contractId, seeded: newlySeeded, }); } const contract = await db('contracts').where({ id: contractId }).first(); if (!contract) throw new AppError('Contract not found', 404); if (contract.status !== 'fully_signed') { throw new AppError( `Re-send is only available on fully-signed contracts (status: ${contract.status})`, 409, 'NOT_FULLY_SIGNED', ); } let attachmentPath = contract.signed_pdf_path || null; // Migration 135 — `signed_pdf_is_wet_upload` is the durable // authoritative-source discriminator. It's set TRUE only by // attachSignedPdfUpload, so any non-wet path here is a system // stamp safe to replace. We still null-check the path so missing // (re-stamp recovery) cases trigger the re-stamp branch below. const hasWetFlagColumn = await hasColumnCached('contracts', 'signed_pdf_is_wet_upload'); const isWetSignedUpload = hasWetFlagColumn ? (contract.signed_pdf_is_wet_upload === true || contract.signed_pdf_is_wet_upload === 1) // Fallback ONLY for installs where the migration hasn't applied yet: // preserve the historical substring rule so we don't accidentally // overwrite uploads on an un-migrated DB. : !!(attachmentPath && attachmentPath.includes('uploads/contracts/signed')); if (!attachmentPath || !isWetSignedUpload) { // Stamp signatures onto the immutable unsigned pdf_path using // pdf-lib (NOT a full re-render). This preserves the exact bytes // the customer originally agreed to and side-steps the silent re- // render failure that left signed_pdf_path NULL on prior contracts. const refreshed = await getContractById(contract.id); if (!refreshed.contract.pdf_path || !fs.existsSync(refreshed.contract.pdf_path)) { throw new AppError( `Unsigned PDF missing on disk at ${refreshed.contract.pdf_path}; cannot re-stamp.`, 500, 'UNSIGNED_PDF_MISSING', ); } const originalBuffer = fs.readFileSync(refreshed.contract.pdf_path); const stamps = buildSignatureStamps(refreshed.contract); const { buffer: stampedBuffer, sha256: signedSha256 } = await pdfStampService.stampSignatures(originalBuffer, stamps); const persisted = await persistContractPdf(refreshed.contract, stampedBuffer, 'fully-signed'); attachmentPath = persisted.filePath; const hasSignedPdfSha = await hasColumnCached('contracts', 'signed_pdf_sha256'); const updates = { signed_pdf_path: attachmentPath, updated_at: new Date(), }; if (hasSignedPdfSha) updates.signed_pdf_sha256 = signedSha256; // Migration 136 — this branch is a recovery path; clear any // existing failed-render marker. if (await hasColumnCached('contracts', 'signed_pdf_render_failed_at')) { updates.signed_pdf_render_failed_at = null; updates.signed_pdf_render_error = null; } await db('contracts').where({ id: contract.id }).update(updates); } // Resend the dual-party email with the now-guaranteed attachment. const refetched = await db('contracts').where({ id: contract.id }).first(); const customer = await db('customer_accounts').where({ id: refetched.customer_account_id }).first(); const profile = (await businessProfileService.getProfile()).profile || {}; const adminRow = await db('admin_users').where({ id: adminId }).first(); const customerName = customer?.display_name || [customer?.first_name, customer?.last_name].filter(Boolean).join(' ') || customer?.email?.split('@')[0] || ''; // Sibling audit certificate (timestamps + IPs + hashes). Best-effort: // missing certificate doesn't block the email — the stamped contract // alone is the primary attachment. const auditCertPath = await persistAuditCertificate(refetched); const attachments = [{ filename: `${refetched.contract_number}-signed.pdf`, contentPath: attachmentPath, contentType: 'application/pdf', }]; if (auditCertPath) { attachments.push({ filename: `${refetched.contract_number}-audit.pdf`, contentPath: auditCertPath, contentType: 'application/pdf', }); } if (customer?.email) { await emailProcessor.queueEmail(null, customer.email, 'contract_fully_signed', { contract_number: refetched.contract_number, customer_name: customerName, title: refetched.title || '', attachments, }); } const adminEmail = profile.email || adminRow?.email; if (adminEmail && adminEmail !== customer?.email) { await emailProcessor.queueEmail(null, adminEmail, 'contract_fully_signed', { contract_number: refetched.contract_number, customer_name: profile.company_name || adminRow?.first_name || 'Team', title: refetched.title || '', attachments, }); } try { await logActivity('contract_resent_signed', { contractId }, null, await adminActor(adminId)); } catch (_) { /* logging is best-effort */ } return { signedPdfPath: attachmentPath, resent: true }; } /** * Recovery helper: admin re-stamps signatures (customer and/or admin) * on a contract whose signature_path columns are null/broken because * the original sign happened before the canvas worked correctly. * * The admin draws BOTH signatures on the detail page — the customer's * signature is admin-attested in this flow (the customer already * agreed via the original sign; this just makes the PDF show * something). Original signed_by_*_at + signed_*_name + signed_*_ip * stay untouched; only the *_signature_path columns + the rendered * PDF get refreshed. * * Available on contracts in status: * signed_by_customer (re-stamp customer, optionally admin too) * signed_by_admin (re-stamp admin, optionally customer too) * fully_signed (re-stamp either or both) */ async function restampSignatures(contractId, { customerSignatureDataUrl, adminSignatureDataUrl }, adminId) { const contract = await db('contracts').where({ id: contractId }).first(); if (!contract) throw new AppError('Contract not found', 404); if (!['signed_by_customer', 'signed_by_admin', 'fully_signed'].includes(contract.status)) { throw new AppError( `Cannot re-stamp signatures on a contract in status '${contract.status}'.`, 409, 'WRONG_STATUS', ); } if (!customerSignatureDataUrl && !adminSignatureDataUrl) { throw new AppError('At least one signature data URL must be provided.', 400, 'NO_SIGNATURE'); } const updates = { updated_at: new Date() }; if (customerSignatureDataUrl) { updates.signed_customer_signature_path = await persistSignatureImage(contract, 'customer', customerSignatureDataUrl); } if (adminSignatureDataUrl) { updates.signed_admin_signature_path = await persistSignatureImage(contract, 'admin', adminSignatureDataUrl); } await db('contracts').where({ id: contract.id }).update(updates); // Re-stamp signature images onto the immutable unsigned pdf_path // using pdf-lib (NOT a full re-render). This is the recovery path // for contracts where signature images existed on disk but the // earlier re-render approach failed silently and left signed_pdf_path // NULL or pointing at a stale file. We always rebuild the stamp from // pdf_path (the as-sent bytes) so the result is reproducible from // the audit record. // // Wet-signed PDF uploads remain authoritative — if signed_pdf_path // already points at an uploaded PDF we still produce a stamped copy // on disk for the audit trail, but signed_pdf_path is not updated. const refreshed = await getContractById(contract.id); if (!refreshed.contract.pdf_path || !fs.existsSync(refreshed.contract.pdf_path)) { throw new AppError( `Unsigned PDF missing on disk at ${refreshed.contract.pdf_path}; cannot re-stamp.`, 500, 'UNSIGNED_PDF_MISSING', ); } const originalBuffer = fs.readFileSync(refreshed.contract.pdf_path); const stamps = buildSignatureStamps(refreshed.contract); const { buffer: stampedBuffer, sha256: signedSha256 } = await pdfStampService.stampSignatures(originalBuffer, stamps); const { filePath: signedPath } = await persistContractPdf(refreshed.contract, stampedBuffer, contract.status === 'fully_signed' ? 'fully-signed' : 'partially-signed'); // Migration 135 — read the discriminator column. Fall back to the // historical substring rule only when the column is absent (un- // migrated install) so we never accidentally overwrite a wet upload. const hasWetFlagColumn = await hasColumnCached('contracts', 'signed_pdf_is_wet_upload'); const isWetSignedUpload = hasWetFlagColumn ? (contract.signed_pdf_is_wet_upload === true || contract.signed_pdf_is_wet_upload === 1) : !!(contract.signed_pdf_path && contract.signed_pdf_path.includes('uploads/contracts/signed')); if (!isWetSignedUpload) { const hasSignedPdfSha = await hasColumnCached('contracts', 'signed_pdf_sha256'); const updates = { signed_pdf_path: signedPath, updated_at: new Date(), }; if (hasSignedPdfSha) updates.signed_pdf_sha256 = signedSha256; // Migration 136 — restamp is a recovery path; clear the marker. if (await hasColumnCached('contracts', 'signed_pdf_render_failed_at')) { updates.signed_pdf_render_failed_at = null; updates.signed_pdf_render_error = null; } await db('contracts').where({ id: contract.id }).update(updates); } try { await logActivity('contract_signatures_restamped', { contractId, stamped: { customer: !!customerSignatureDataUrl, admin: !!adminSignatureDataUrl, }, }, null, await adminActor(adminId)); } catch (_) { /* logging is best-effort */ } return { signedPdfPath: isWetSignedUpload ? contract.signed_pdf_path : signedPath, stamped: { customer: !!customerSignatureDataUrl, admin: !!adminSignatureDataUrl, }, }; } /** * Read the chronological audit trail for a contract from activity_logs. * Matches every `contract_*` activity_type where metadata.contractId * equals this contract's id. Ordered oldest → newest so the UI can * render a vertical timeline. Read-only; used by the admin detail * page's AuditTrailCard. */ async function getAuditTrail(contractId) { if (!(await db.schema.hasTable('activity_logs'))) return []; // Push the metadata.contractId filter into SQL instead of fetching // every contract_* row and filtering in JS. The previous shape // scanned the entire history every time the detail page loaded — // O(rows-since-CRM-launch) per request. Both Postgres and SQLite // store metadata as a JSON-encoded string here, so we match on // a literal substring that covers either compact or whitespaced // JSON encodings — `"contractId":` or `"contractId": ` — // bounded by the activity_type prefix so the search hits the // contract_* slice of the index. // // The substring patterns intentionally don't anchor on word // boundaries; activity_logs.metadata never contains a contractId // key collision with another id-shaped value because logActivity // serialises only what callers pass. const id = Number(contractId); if (!Number.isFinite(id)) return []; const rows = await db('activity_logs') .where('activity_type', 'like', 'contract_%') .andWhere(function () { this.where('metadata', 'like', `%"contractId":${id}%`) .orWhere('metadata', 'like', `%"contractId": ${id}%`); }) .orderBy('created_at', 'asc') .select('id', 'activity_type', 'actor_type', 'actor_id', 'actor_name', 'metadata', 'created_at'); return rows.map((r) => { let meta = r.metadata; if (typeof meta === 'string') { try { meta = JSON.parse(meta); } catch { meta = {}; } } return { ...r, metadata: meta || {} }; }); } /** * Re-hash the two on-disk PDFs and compare against the stored hashes * (pdf_sha256 / signed_pdf_sha256 from migration 131). Lets the admin * confirm that backups, manual moves, or storage corruption haven't * silently altered the issued document. * * Each leg of the response carries: * - `path`: the stored path string (so the UI can show what was * checked even when it's missing) * - `present`: file exists on disk * - `expected`: the SHA-256 column value (null if never persisted) * - `actual`: the freshly-computed hash, or null when file missing * - `match`: true iff both hashes exist AND they're equal * * The customer already has both expected hashes via the audit * certificate the signing flow ships as a second email attachment, so * they can verify independently with `shasum -a 256`. This endpoint * is the admin-side equivalent — single click instead of dropping to * a shell. */ async function verifyIntegrity(id) { const contract = await db('contracts') .where({ id }) .select('id', 'pdf_path', 'pdf_sha256', 'signed_pdf_path', 'signed_pdf_sha256') .first(); if (!contract) throw new AppError('Contract not found', 404); const checkLeg = (filePath, expected) => { const present = !!filePath && fs.existsSync(filePath); const actual = present ? sha256OfFile(filePath) : null; return { path: filePath || null, present, expected: expected || null, actual, match: !!(expected && actual && expected === actual), }; }; return { unsigned: checkLeg(contract.pdf_path, contract.pdf_sha256), signed: checkLeg(contract.signed_pdf_path, contract.signed_pdf_sha256), }; } async function cancelContract(id, adminId) { const contract = await db('contracts').where({ id }).first(); if (!contract) throw new AppError('Contract not found', 404); if (!['draft', 'sent'].includes(contract.status)) { throw new AppError(`Cannot cancel a contract with status '${contract.status}'`, 409); } await db('contracts').where({ id }).update({ status: 'cancelled', updated_at: new Date(), }); // Invalidate any outstanding tokens. await db('contract_action_tokens').where({ contract_id: id, used_at: null }).update({ expires_at: new Date(), }); try { await logActivity('contract_cancelled', { contractId: id }, null, await adminActor(adminId)); } catch (_) { /* logging is best-effort */ } return { status: 'cancelled' }; } module.exports = { listContracts, getContractById, createContract, updateContract, sendContract, renderContractPdfBuffer, recordCustomerSignature, recordAdminCountersignature, attachSignedPdfUpload, cancelContract, createFromQuote, convertToEvent, convertToInvoiceOnly, rerenderAndResend, restampSignatures, getAuditTrail, verifyIntegrity, // Exported for tests + the public-route preview endpoint. _internal: { nextContractNumber, renderTemplatedBody, buildPlaceholderContext, buildRenderContext, SECTIONS_ORDER, }, };