CI - Test & Build / Lint & Type Check (push) Canceled after 0s
CI - Test & Build / Backend Unit Tests (push) Canceled after 0s
CI - Test & Build / Frontend Build (push) Canceled after 0s
CI - Test & Build / Security Scan (push) Canceled after 0s
CI - Test & Build / E2E Tests (Playwright) (push) Canceled after 0s
CI - Test & Build / CI Summary (push) Canceled after 0s
* feat: move login credentials to a DB-backed users table with an admin management page Replaces the hardcoded AUTH_USERNAME/AUTH_PASSWORD login check with a new auth_accounts table (bcrypt-hashed passwords, admin/user roles). Adds admin-only /api/users CRUD routes and a "Brugere" admin page in the frontend for managing logins without redeploying. Removes the unused, unmounted duplicate login route in src/routes/auth.js. * docs: add architecture codemaps with diagrams for the whole system Adds codemaps/architecture.md, backend.md, frontend.md, and data.md — Mermaid-diagrammed design documentation verified against the live codebase and database rather than assumed from CLAUDE.md. Covers the unified-server.js request flow (mounted routers + ~183 inline routes), 68 backend services grouped by domain, the frontend's state-driven view-switch (no React Router in practice despite BrowserRouter being present), and the full 122-table DB schema with the auth_accounts vs unrelated users table naming trap flagged explicitly. Links added from the root README. Co-Authored-By: Claude Sonnet 5 <[email protected]> * feat: ship canonical roof quote workflow * fix: keep migration dry-run idempotent * [verified] feat: complete Smart Pakker management * [verified] fix: ignore blank task dependencies * [verified] fix: align package duplication with schema * [verified] fix: enforce Discord status limits * [verified] fix: link Smart Pakke materials safely * [verified] fix: harden material link review * [verified] feat: improve material matching * fix: scope pitch validation to roof packages * fix: support canonical snapshots on production schema * [verified] fix: hide internal package metadata from PDF * [verified] feat: deliver sales-ready customer PDF * [verified] feat: ship sales-ready PDF with AI overview * [verified] fix: authenticate project list requests * [verified] fix: refresh project-list authentication * [verified] fix: open existing project details * [verified] fix: keep roof components searchable in builder * [verified] fix: expose all Smart Package categories * [verified] fix: authenticate project creation * [verified] feat: make Smart Pakker the universal project flow * [verified] feat: preview Smart Package contents * [verified] test: keep generic release isolated from downpipe work * feat: add first-class Smart Pakke rentals * [verified] feat: add gutter and downpipe smart packages * [verified] fix: prepare six-house gutter quote flow * [verified] fix: open generic quotes without roof geometry * [verified] fix: review generic quotes with authenticated APIs * [verified] fix: calculate generic Smart Package quotes * [verified] fix: return generic calculation breakdown * feat: checkpoint generic signed snapshot validation with red-green tests * feat: complete fail-closed generic quote approval and customer PDF flow * feat: use generic signed snapshot in final review * feat: redesign generic quote final review * fix: harden generic review summaries * feat: add auditable six-house package basis * [verified] feat: finish auditable Smart Pakke UI * [verified] fix: bind auditable quantity and price bases * [verified] fix: keep six-house basis across package versions * [verified] fix: complete smart package discovery management * [verified] fix: simplify composition and generic scope * [verified] test: keep explicit roof contracts fail closed * [verified] fix: harden generic quote snapshots * fix: make generic quote delivery customer safe * [verified] fix: secure package catalog reads * [verified] fix: close workspace provenance blockers * fix: harden customer document language boundary * [verified] fix: secure smart package internal reads * fix: version package child mutations atomically * feat: add generic customer quote text flow * [verified] fix: allow manual customer numbers * [verified] fix: expose optional roof geometry * [verified] fix: rebase hydrated packages after geometry edits * [verified] feat: add free editable site area map * [verified] fix: harden map recovery and geocoding gate * fix: bind map quantities to authoritative geometry * fix: release geocoder lock before dispatch * fix: separate roof and site geometry provenance * fix: revoke stale admin authorization * fix: migrate task geometry basis * fix: make backend CI dependency-complete * ci: seed isolated e2e login account * fix: allow clean database bootstrap * fix: skip indexes for optional tables * test: use canonical mansard geometry in e2e * [verified] fix(auth): enforce live operator boundary * fix: fail close Ordrestyring offer transport * fix(frontend): authenticate customer project requests * fix: align canonical roof type contract * [verified] fix: reconcile legacy package labor safely * [verified] fix: audit site geometry deletion * docs: add PR 31 reviewer guide * docs: synchronize Obsidian vault * docs: sync integrated reviewer guide to Obsidian * ci: seed isolated auth account explicitly * fix: close offer bootstrap and service readiness gaps * fix: authenticate protected package callers * fix: provision initial admin and disable generic send * [verified] fix: close final quote release blockers * [verified] fix: seed gutter packages before deployment --------- Co-authored-by: alexpolo1 <[email protected]> Co-authored-by: Claude Sonnet 5 <[email protected]>
105 lines
6.7 KiB
JavaScript
105 lines
6.7 KiB
JavaScript
'use strict';
|
||
|
||
const PdfGenerationService = require('./pdfGenerationService');
|
||
const { assertCustomerDocumentLanguage } = require('./customerDocumentLanguage');
|
||
const { SCHEMA, GenericQuoteSnapshotService } = require('./genericQuoteSnapshotService');
|
||
const pick = (object, keys) => Object.fromEntries(keys.filter(key => object[key] !== undefined).map(key => [key, object[key]]));
|
||
const error = (code, message) => { throw Object.assign(new Error(message), { status: 422, code }); };
|
||
|
||
function customerDocument(snapshot) {
|
||
if (snapshot?.artifact?.schema !== SCHEMA || !snapshot.approved || snapshot.approval?.signature !== snapshot.signature) {
|
||
error('GENERIC_QUOTE_APPROVAL_REQUIRED', 'Approved generic snapshot required');
|
||
}
|
||
new GenericQuoteSnapshotService().assertExpectedSignature(snapshot, snapshot.signature);
|
||
const artifact = snapshot.artifact;
|
||
const lines = Object.fromEntries(Object.entries(artifact.lines).map(([kind, rows]) => [kind, rows.map(line =>
|
||
pick(line, kind === 'tasks' ? ['name', 'totalHours', 'rate', 'lineTotal'] : ['name', 'quantity', 'unit', 'unitPrice', 'lineTotal']))]));
|
||
const categoryTotal = kind => Math.round((lines[kind] || []).reduce((sum, line) => sum + Number(line.lineTotal || 0), 0) * 100) / 100;
|
||
const subtotals = {
|
||
materials: categoryTotal('materials'),
|
||
labor: categoryTotal('tasks'),
|
||
rentals: categoryTotal('rentals'),
|
||
references: categoryTotal('references')
|
||
};
|
||
subtotals.lineTotal = Math.round(Object.values(subtotals).reduce((sum, value) => sum + value, 0) * 100) / 100;
|
||
const totals = pick(artifact.economics, ['totalExclVat', 'vatPercentage', 'vatAmount', 'totalInclVat']);
|
||
const customerPriceAdjustment = {
|
||
label: 'Aftalt pristilpasning',
|
||
explanation: 'Forskellen mellem de viste linjer og den aftalte samlede pris for projektets levering og udførelse.',
|
||
amount: Math.round((totals.totalExclVat - subtotals.lineTotal) * 100) / 100
|
||
};
|
||
const document = {
|
||
project: pick(artifact.customerProject, ['id', 'project_name', 'customer_number', 'customer_name', 'customer_address', 'customer_email']),
|
||
quoteText: artifact.quoteText, reservations: artifact.reservations, lines, subtotals, customerPriceAdjustment, totals
|
||
};
|
||
// Do not silently rewrite signed customer text or expose internal labels through it.
|
||
const customerText = [document.quoteText, ...Object.values(document.project), ...document.reservations,
|
||
...Object.values(lines).flat().map(line => line.name)];
|
||
assertCustomerDocumentLanguage(customerText);
|
||
return document;
|
||
}
|
||
|
||
function ordrestyringPayload(snapshot) {
|
||
const document = customerDocument(snapshot);
|
||
return {
|
||
projectId: document.project.id, customerNumber: document.project.customer_number,
|
||
description: document.quoteText, reservations: document.reservations,
|
||
lines: Object.entries(document.lines).flatMap(([type, rows]) => rows.map(line => ({ type, ...line }))),
|
||
...document.totals, snapshotSignature: snapshot.signature,
|
||
snapshotSchema: SCHEMA, approval: snapshot.approval
|
||
};
|
||
}
|
||
|
||
const escapeHtml = value => String(value ?? '').replace(/[&<>"']/g, char => ({
|
||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||
}[char]));
|
||
|
||
const formatMoney = value => `${Number(value || 0).toLocaleString('da-DK', {
|
||
minimumFractionDigits: 2,
|
||
maximumFractionDigits: 2
|
||
})} kr.`;
|
||
|
||
class GenericQuotePdfService extends PdfGenerationService {
|
||
async generatePdfHtml(document) {
|
||
const { project, lines, totals, subtotals, customerPriceAdjustment, quoteText, reservations } = document;
|
||
assertCustomerDocumentLanguage([
|
||
quoteText,
|
||
...Object.values(project || {}),
|
||
...(Array.isArray(reservations) ? reservations : []),
|
||
...Object.values(lines || {}).flat().map(line => line?.name)
|
||
]);
|
||
const sections = { materials: 'Materialer', tasks: 'Arbejde', rentals: 'Leje', references: 'Øvrige ydelser' };
|
||
const rows = Object.entries(lines).map(([kind, items]) => {
|
||
if (!items.length) return '';
|
||
const labor = kind === 'tasks';
|
||
const heading = labor
|
||
? '<th>Beskrivelse</th><th>Timer</th><th>Timepris ekskl. moms</th><th>Linjetotal ekskl. moms</th>'
|
||
: '<th>Beskrivelse</th><th>Antal</th><th>Enhed</th><th>Enhedspris ekskl. moms</th><th>Linjetotal ekskl. moms</th>';
|
||
const body = items.map(line => labor
|
||
? `<tr><td>${escapeHtml(line.name)}</td><td>${escapeHtml(line.totalHours)} timer</td><td>${formatMoney(line.rate)}</td><td>${formatMoney(line.lineTotal)}</td></tr>`
|
||
: `<tr><td>${escapeHtml(line.name)}</td><td>${escapeHtml(line.quantity)}</td><td>${escapeHtml(line.unit)}</td><td>${formatMoney(line.unitPrice)}</td><td>${formatMoney(line.lineTotal)}</td></tr>`).join('');
|
||
return `<h2>${sections[kind]}</h2><table><thead><tr>${heading}</tr></thead><tbody>${body}</tbody></table>`;
|
||
}).join('');
|
||
const subtotalRows = [
|
||
['Materialer', subtotals.materials], ['Arbejde', subtotals.labor], ['Leje', subtotals.rentals],
|
||
['Øvrige ydelser', subtotals.references], ['Linjer i alt', subtotals.lineTotal]
|
||
].map(([label, value]) => `<tr><th>${label}</th><td>${formatMoney(value)}</td></tr>`).join('');
|
||
return `<!doctype html><html lang="da"><head><meta charset="utf-8"><title>Tilbud – ${escapeHtml(project.project_name)}</title>
|
||
<style>body{font:14px Arial;color:#182b36;line-height:1.45}h1{font-size:28px}h2{font-size:17px;margin:22px 0 6px}
|
||
table{width:100%;border-collapse:collapse}td,th{text-align:left;padding:7px;border-bottom:1px solid #ddd}thead th{font-size:11px}
|
||
td:last-child,th:last-child{text-align:right}.summary{margin-top:8px}.explanation{font-size:12px;color:#455a64;margin:3px 0 10px}
|
||
p{white-space:pre-wrap}.grand-total{font-size:16px;font-weight:bold}</style></head><body>
|
||
<h1>Tilbud: ${escapeHtml(project.project_name)}</h1><p>${escapeHtml(project.customer_name)}\n${escapeHtml(project.customer_address)}</p>
|
||
<p>${escapeHtml(quoteText)}</p>${rows}
|
||
<h2>Prisoversigt</h2><table class="summary"><tbody>${subtotalRows}
|
||
<tr><th>${escapeHtml(customerPriceAdjustment.label)}</th><td>${formatMoney(customerPriceAdjustment.amount)}</td></tr></tbody></table>
|
||
<p class="explanation">${escapeHtml(customerPriceAdjustment.explanation)}</p>
|
||
<table class="summary"><tbody><tr><th>Samlet pris ekskl. moms</th><td>${formatMoney(totals.totalExclVat)}</td></tr>
|
||
<tr><th>Moms (${escapeHtml(totals.vatPercentage)}%)</th><td>${formatMoney(totals.vatAmount)}</td></tr>
|
||
<tr class="grand-total"><th>I alt inkl. moms</th><td>${formatMoney(totals.totalInclVat)}</td></tr></tbody></table>
|
||
${reservations.length ? `<h2>Forbehold</h2><p>${reservations.map(escapeHtml).join('\n')}</p>` : ''}</body></html>`;
|
||
}
|
||
}
|
||
|
||
module.exports = { customerDocument, ordrestyringPayload, GenericQuotePdfService };
|