2378 lines
101 KiB
JavaScript
2378 lines
101 KiB
JavaScript
/**
|
||
* 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":<n>` or `"contractId": <n>` —
|
||
// 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,
|
||
},
|
||
};
|