Brings in the full backend CRM stack on top of the consolidated
migration (60abe8c).
Services (CRM)
- quoteService — full lifecycle (draft → sent → accepted → converted
to event/invoice), Skonto + Storno + reissue paths
- invoiceService — spawnInstallmentInvoices, updateInstallmentPlan,
monthly-billing accumulator, payment-check tokens, dunning ladder
- contractService — block-composable contract editor, in-browser
signature flow, wet-PDF upload path, integrity check, audit trail
- customerHoursService — per-entry locking, billing integration
- dealsService — cross-document lineage (deal_uuid)
- taxReportService — quarterly aggregates + CSV/PDF export
- eventReminderService — pre-event customer reminder cron pass
- _renderContext — shared issuer/recipient blocks across PDF types
- pdfService extensions — custom-font registration, font picker
Routes (admin + public)
- adminQuotes, adminInvoices, adminContracts, adminCalendar,
adminDeals, adminTaxReport, adminDev, adminBusinessProfile
- publicQuotes (accept/decline), publicContracts (sign),
publicPaymentCheck
- Extensions on adminEvents, adminCustomers, adminSettings,
adminEmail, adminFeatureFlags, adminThumbnails, adminPhotos,
adminCategories, adminUsers, adminArchives, adminDashboard
- server.js wires the new mounts (kept upstream's noStoreCache on
customer routes per 3-way merge)
Utilities
- schemaCache (cached hasColumn lookups across services)
- documentSequences (atomic gap-free numbering — §14 UStG)
- safePath (path-containment guards at fs stream boundaries)
- clientIp (sanctioned XFF reader for audit logs)
- publicTokenGuards (pre-multer token validation + attempt counters)
- numericHelpers (ensureInt / ensureNumber consolidation)
- dateFormatter (formatShortDate + dateInputLang)
- dbCompat extensions, iban + pdfFilename helpers, resolveLogoFile
Infrastructure
- Bundled PDF fonts (Comic-Neue / IBM-Plex-Sans / Inter / Jost /
Montserrat / Noto-Sans / Playfair-Display / Poppins)
- Backend package.json + lock updates (pdfkit, signature_pad,
qrcode, et al.)
- Sample storage layout under storage/business-docs/quote/
Tests
- 14 new test files covering quote/invoice/contract lifecycle,
installment plan reshape, line-item hierarchy, customer hours,
payment check, tax report PDF, IBAN parsing, filename sanitiser
80 lines
2.9 KiB
JavaScript
80 lines
2.9 KiB
JavaScript
/**
|
|
* Build a consistent filesystem-safe filename for quote / invoice
|
|
* PDFs. Format:
|
|
*
|
|
* <docNumber>_<customerLabel>.pdf
|
|
*
|
|
* - docNumber: the invoice/quote number as printed
|
|
* - customerLabel: customer.company_name || full person name ||
|
|
* display_name || email-local-part || 'customer'
|
|
*
|
|
* Both segments are sanitised: spaces → '-', non-ASCII letters
|
|
* preserved, slashes/colons/quotes stripped, length capped so the
|
|
* combined filename stays under the typical 255-byte filesystem
|
|
* limit (we cap each side at 80 chars, which is generous for both
|
|
* pieces).
|
|
*
|
|
* Used by:
|
|
* - Content-Disposition headers on every admin + customer PDF
|
|
* endpoint
|
|
* - The PDF's internal `Title` metadata (Chrome's PDF viewer
|
|
* uses this as the default name when saving from a blob URL,
|
|
* where Content-Disposition can't reach)
|
|
*/
|
|
|
|
function sanitiseSegment(input, maxLen = 80) {
|
|
if (!input) return '';
|
|
let s = String(input).trim();
|
|
// Replace OS-hostile characters with '-'.
|
|
s = s.replace(/[/\\:*?"<>|]+/g, '-');
|
|
// Collapse whitespace runs into a single '-'.
|
|
s = s.replace(/\s+/g, '-');
|
|
// Collapse repeat dashes.
|
|
s = s.replace(/-+/g, '-');
|
|
// Trim leading/trailing dashes + dots.
|
|
s = s.replace(/^[-.]+|[-.]+$/g, '');
|
|
if (s.length > maxLen) s = s.slice(0, maxLen);
|
|
return s;
|
|
}
|
|
|
|
/**
|
|
* Resolve a label representing the customer for the filename. Tries
|
|
* company name first (most useful for filing), then full person
|
|
* name, then display name, then the email's local part, finally
|
|
* 'customer' as a generic fallback.
|
|
*
|
|
* @param {object} customer customer_accounts row (snake_case)
|
|
* @returns {string} sanitised label segment
|
|
*/
|
|
function customerLabel(customer) {
|
|
if (!customer) return 'customer';
|
|
const company = (customer.company_name || '').trim();
|
|
if (company) return sanitiseSegment(company);
|
|
const fullName = [customer.first_name, customer.last_name]
|
|
.map((v) => (v || '').trim()).filter(Boolean).join(' ');
|
|
if (fullName) return sanitiseSegment(fullName);
|
|
const display = (customer.display_name || '').trim();
|
|
if (display) return sanitiseSegment(display);
|
|
const email = (customer.email || '').trim();
|
|
if (email) return sanitiseSegment(email.split('@')[0]);
|
|
return 'customer';
|
|
}
|
|
|
|
/**
|
|
* Build the final filename. Always ends with `.pdf`. When the
|
|
* document number is missing (e.g. preview of an unsaved row),
|
|
* substitutes a sensible fallback.
|
|
*
|
|
* @param {object} args
|
|
* - docNumber: 'R-2026-0001' / 'Q-2026-0042' / null for previews
|
|
* - customer: customer_accounts row
|
|
* - fallback: prefix when docNumber is null ('invoice-preview' etc.)
|
|
*/
|
|
function buildPdfFilename({ docNumber, customer, fallback = 'document' }) {
|
|
const numberSeg = sanitiseSegment(docNumber) || sanitiseSegment(fallback) || 'document';
|
|
const custSeg = customerLabel(customer);
|
|
return `${numberSeg}_${custSeg}.pdf`;
|
|
}
|
|
|
|
module.exports = { buildPdfFilename, sanitiseSegment, customerLabel };
|