feat: deliver auditable Smart Pakke quote flow and free site geometry (#31)
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]>
This commit is contained in:
Alex
2026-09-26 22:39:18 +02:00
committed by GitHub
co-authored by Claude Sonnet 5 alexpolo1
parent 8b16d35287
commit f37adae2cb
242 changed files with 35912 additions and 7286 deletions
@@ -0,0 +1,9 @@
-- Explicit package-level geometry contract used by the Lego builder.
ALTER TABLE material_packages
ADD COLUMN IF NOT EXISTS geometry_basis VARCHAR(40) NULL AFTER unit,
ADD COLUMN IF NOT EXISTS geometry_factor DECIMAL(10,3) NULL AFTER geometry_basis,
ADD COLUMN IF NOT EXISTS default_count DECIMAL(10,3) NULL AFTER geometry_factor,
ADD COLUMN IF NOT EXISTS default_quantity DECIMAL(10,3) NULL AFTER default_count;
CREATE INDEX IF NOT EXISTS idx_material_packages_geometry_basis
ON material_packages (geometry_basis);
@@ -0,0 +1,387 @@
const crypto = require('crypto');
const loadCliEnvironment = () => {
const path = require('path');
require('dotenv').config({ path: path.join(__dirname, '../../backend/.env') });
};
const roundMoney = value => Math.round(Number(value || 0) * 100) / 100;
const APPLY_LOCK_NAME = 'tilbudgivern:gutter-meter-packages:v1';
const materialProvenanceNote = line => [
line.sourceNote,
`Kilde: ${line.supplier || 'ukendt'} (${line.sourceUrl || line.sku || 'ingen reference'})`,
`SKU: ${line.sku || 'mangler'}`,
`Pris observeret: ${line.sourceDate || 'ukendt dato'}`,
line.vatStatus === 'converted_incl_to_excl_vat'
? 'Momsstatus: konverteret fra inkl. moms til ekskl. moms'
: 'Momsstatus: ekskl. moms'
].filter(Boolean).join(' · ');
const gutterTasks = [
['Opmåling', 0.06],
['Montage af rendejern', 0.10],
['Montage af tagrender og fittings', 0.21],
['Kontrol af fald', 0.03]
].map(([name, timePerUnit], index) => ({ name, timePerUnit, rate: 600, order: index + 1 }));
const downpipeTasks = [
['Opmåling', 0.09],
['Tilpasning af 3 m nedløbsrør', 0.09],
['Montage af 3 nedløbsholdere', 0.12],
['Montage af 3 m nedløbsrør', 0.12],
['Montage af tudstykke og tilslutning', 0.09],
['Kontrol', 0.03]
].map(([name, timePerUnit], index) => ({ name, timePerUnit, rate: 600, order: index + 1 }));
const finalize = definition => {
const materialTotal = definition.materials.reduce((sum, line) => sum + line.quantity * line.unitPrice, 0);
const laborHours = definition.tasks.reduce((sum, task) => sum + task.timePerUnit, 0);
const laborTotal = definition.tasks.reduce((sum, task) => sum + task.timePerUnit * task.rate, 0);
const roundedMaterialTotal = roundMoney(materialTotal);
const roundedLaborTotal = roundMoney(laborTotal);
const hourlyRate = definition.tasks[0]?.rate || 0;
const unit = definition.unit || 'løbende m';
const unitLabel = unit === 'stk' ? 'stk' : 'løbende m';
const pipeBasis = definition.pipeLengthMetersPerPiece ? ` · ${definition.pipeLengthMetersPerPiece} m rør pr. stk` : '';
const validationStatus = definition.validationStatus || 'needs_review';
return {
...definition,
unit,
unitPrice: roundMoney(materialTotal + laborTotal),
materialTotal: roundedMaterialTotal,
laborTotal: roundedLaborTotal,
vatStatus: 'excl_vat',
validationStatus,
isActive: validationStatus === 'verified' ? 1 : 0,
priceBasisNote: `Materialer ${roundedMaterialTotal} kr./${unitLabel} ekskl. moms + montage ${roundedLaborTotal} kr./${unitLabel} ekskl. moms (${roundMoney(laborHours)} t/${unitLabel} × ${hourlyRate} kr./time ekskl. moms)${pipeBasis}`
};
};
const buildGutterPackageDefinitions = () => [
finalize({
key: 'gutter-zinc', legacyName: 'Tagrende med rendejern', name: 'Tagrender i zink – levering og montering',
unit: 'løbende m', timeUnit: 'per_meter',
geometryBasis: 'fixed', defaultQuantity: 1,
priceSource: 'Materialedatabase + Ordrestyring',
description: 'Indtast den samlede opmålte længde tagrende for alle bygninger. Zinktagrende og rendejern monteres med fald. Montagetid for standardfittings er inkluderet; fittingsmaterialer, nedløbsrør og specialhjørner er tilvalg. Pris pr. løbende meter.',
materials: [
{ sku: 'RENDE-150Z', name: 'Tagrender halvrund zink 150 mm', category: 'Tagmaterialer', quantity: 1, unit: 'løbende m', unitPrice: 245, supplier: 'Bygma', sourceUrl: 'material-database://materials/RENDE-150Z', sourceDate: '2025-10-15', vatStatus: 'excl_vat' },
{ sku: '5253218', name: 'RHEINZINK rendejern galvaniseret 280 mm', category: 'Tagmaterialer', quantity: 2, unit: 'stk', unitPrice: 29.09, supplier: 'Stark A/S', sourceUrl: 'material-database://stark/5253218', sourceDate: '2025-11-26', vatStatus: 'excl_vat' }
], tasks: gutterTasks
}),
finalize({
key: 'gutter-aluminium', name: 'Tagrender i aluminium – levering og montering',
validationStatus: 'verified',
unit: 'løbende m', timeUnit: 'per_meter',
geometryBasis: 'fixed', defaultQuantity: 1,
priceSource: 'Wattoo-reference + Ordrestyring',
description: 'Indtast den samlede opmålte længde tagrende for alle bygninger. PREFA aluminiumstagrende og aluminium-rendejern monteres med fald. Montagetid for standardfittings er inkluderet; fittingsmaterialer, nedløbsrør og specialhjørner er tilvalg. Pris pr. løbende meter.',
materials: [
{ sku: 'MANUAL-ALU-GUTTER-280', name: 'PREFA aluminiumstagrende 280 mm', category: 'Tagmaterialer', quantity: 1, unit: 'løbende m', unitPrice: 100, supplier: 'Wattoo reference', sourceUrl: 'https://www.wattoo.dk/vvs/tag-tagrender/tagrender/aluminium-tagrender?p=2', sourceDate: '2026-09-04', vatStatus: 'converted_incl_to_excl_vat', sourceNote: '375 kr inkl. moms for 3 m = 100 kr/m ekskl. moms' },
{ sku: 'MANUAL-ALU-HANGER-280', name: 'PREFA aluminium-rendejern 280 mm', category: 'Tagmaterialer', quantity: 2, unit: 'stk', unitPrice: 43.20, supplier: 'Wattoo reference', sourceUrl: 'https://www.wattoo.dk/vvs/tag-tagrender/tagrender/aluminium-tagrender?p=2', sourceDate: '2026-09-04', vatStatus: 'converted_incl_to_excl_vat', sourceNote: '54 kr inkl. moms pr. stk. = 43,20 kr ekskl. moms' }
], tasks: gutterTasks
}),
finalize({
key: 'gutter-steel', name: 'Tagrender i stål – levering og montering',
unit: 'løbende m', timeUnit: 'per_meter',
geometryBasis: 'fixed', defaultQuantity: 1,
priceSource: 'Materialedatabase + Ordrestyring',
description: 'Indtast den samlede opmålte længde tagrende for alle bygninger. Stålmetallic tagrende og stål-rendejern monteres med fald. Montagetid for standardfittings er inkluderet; fittingsmaterialer, nedløbsrør og specialhjørner er tilvalg. Pris pr. løbende meter.',
materials: [
{ sku: '5842085', name: 'LP tagrende stålmetallic 125 mm', category: 'Tagmaterialer', quantity: 1, unit: 'løbende m', unitPrice: 96.78, supplier: 'Stark A/S', sourceUrl: 'material-database://stark/5842085', sourceDate: '2025-11-26', vatStatus: 'excl_vat', sourceNote: '290,33 kr ekskl. moms for 3 m = 96,78 kr/m' },
{ sku: '5842056', name: 'LP rendejern stålmetallic 125 mm', category: 'Tagmaterialer', quantity: 2, unit: 'stk', unitPrice: 53.90, supplier: 'Stark A/S', sourceUrl: 'material-database://stark/5842056', sourceDate: '2025-11-26', vatStatus: 'excl_vat' }
], tasks: gutterTasks
}),
finalize({
key: 'gutter-plastic', name: 'Tagrender i plastik – levering og montering',
unit: 'løbende m', timeUnit: 'per_meter',
geometryBasis: 'fixed', defaultQuantity: 1,
priceSource: 'Materialedatabase + Ordrestyring',
description: 'Indtast den samlede opmålte længde tagrende for alle bygninger. Grå plasttagrende og galvaniserede rendejern monteres med fald. Montagetid for standardfittings er inkluderet; fittingsmaterialer, nedløbsrør og specialhjørner er tilvalg. Pris pr. løbende meter.',
materials: [
{ sku: '8621252', name: 'PLASTMO tagrende nr. 11 grå', category: 'Tagmaterialer', quantity: 1, unit: 'løbende m', unitPrice: 101.76, supplier: 'Stark A/S', sourceUrl: 'material-database://stark/8621252', sourceDate: '2025-11-26', vatStatus: 'excl_vat', sourceNote: '305,28 kr ekskl. moms for 3 m = 101,76 kr/m' },
{ sku: '1093335', name: 'PLASTMO rendejern kort galvaniseret', category: 'Tagmaterialer', quantity: 2, unit: 'stk', unitPrice: 35.68, supplier: 'Stark A/S', sourceUrl: 'material-database://stark/1093335', sourceDate: '2025-11-26', vatStatus: 'excl_vat' }
], tasks: gutterTasks
}),
finalize({
key: 'downpipe-zinc', legacyName: 'Nedløb i zink', name: 'Nedløbsrør i zink – 3 m med tudstykke og 3 holdere',
unit: 'stk', timeUnit: 'per_piece', geometryBasis: 'fixed', defaultQuantity: 1, pipeLengthMetersPerPiece: 3,
priceSource: 'STARK materialedatabase + Ordrestyring',
description: 'Én klods er ét 3 m RHEINZINK-nedløbsrør med ét tudstykke, tre rørholdere og montage. Antal 3 giver 3 × 3 m = 9 m rør. Bøjninger samt brønd- og sokkeltilslutning er tilvalg.',
materials: [
{ componentType: 'pipe', lengthPerPieceMeters: 3, sku: '5252831', name: 'RHEINZINK nedløbsrør blank Ø76 × 3000 mm', category: 'Tagmaterialer', quantity: 1, unit: 'stk', unitPrice: 351.12, supplier: 'Stark A/S', sourceUrl: 'material-database://stark/5252831', sourceDate: '2025-11-26', vatStatus: 'excl_vat' },
{ componentType: 'outlet', sku: '5252910', name: 'RHEINZINK tudstykke blank 280/76 mm', category: 'Tagmaterialer', quantity: 1, unit: 'stk', unitPrice: 24.05, supplier: 'Stark A/S', sourceUrl: 'material-database://stark/5252910', sourceDate: '2025-11-26', vatStatus: 'excl_vat' },
{ componentType: 'holder', sku: '2142336', name: 'RHEINZINK rørholder Ø76 mm', category: 'Tagmaterialer', quantity: 3, unit: 'stk', unitPrice: 34.13, supplier: 'Stark A/S', sourceUrl: 'material-database://stark/2142336', sourceDate: '2025-11-26', vatStatus: 'excl_vat' }
], tasks: downpipeTasks
}),
finalize({
key: 'downpipe-aluminium', name: 'Nedløbsrør i aluminium – 3 m med tudstykke og 3 holdere',
validationStatus: 'verified',
unit: 'stk', timeUnit: 'per_piece', geometryBasis: 'fixed', defaultQuantity: 1, pipeLengthMetersPerPiece: 3,
priceSource: 'Wattoo reference + Ordrestyring',
description: 'Én klods er ét 3 m sort PREFA-aluminiumsnedløbsrør Ø100 med ét tudstykke, tre rørbøjler og montage. Antal 3 giver 3 × 3 m = 9 m rør. Dorn/stift, bøjninger samt brønd- og sokkeltilslutning er tilvalg.',
materials: [
{ componentType: 'pipe', lengthPerPieceMeters: 3, sku: '275850460', name: 'PREFA aluminiumsnedløbsrør sort Ø100 × 3000 mm', category: 'Tagmaterialer', quantity: 1, unit: 'stk', unitPrice: 384, supplier: 'Wattoo reference', sourceUrl: 'https://www.wattoo.dk/nedloebsroer-alu-100mm-sort-3m-3-meter-275850460', sourceDate: '2026-09-16', vatStatus: 'converted_incl_to_excl_vat', sourceNote: '480,00 kr inkl. moms pr. 3 m stk. = 384,00 kr ekskl. moms' },
{ componentType: 'outlet', sku: '275806450', name: 'PREFA aluminiumtudstykke sort 280/100 mm', category: 'Tagmaterialer', quantity: 1, unit: 'stk', unitPrice: 196.80, supplier: 'Wattoo reference', sourceUrl: 'https://www.wattoo.dk/tudstykke-alu-280-100-sort-275806450', sourceDate: '2026-09-16', vatStatus: 'converted_incl_to_excl_vat', sourceNote: '246,00 kr inkl. moms pr. stk. = 196,80 kr ekskl. moms' },
{ componentType: 'holder', sku: '277871460', name: 'PREFA aluminiumrørbøjle sort Ø100 mm uden dorn', category: 'Tagmaterialer', quantity: 3, unit: 'stk', unitPrice: 67.20, supplier: 'Wattoo reference', sourceUrl: 'https://www.wattoo.dk/roerboejle-alu-100mm-sort-t-nedloeb-u-dorn-277871460', sourceDate: '2026-09-16', vatStatus: 'converted_incl_to_excl_vat', sourceNote: '84,00 kr inkl. moms pr. stk. = 67,20 kr ekskl. moms' }
], tasks: downpipeTasks
}),
finalize({
key: 'downpipe-steel', name: 'Nedløbsrør i stål – 3 m med tudstykke og 3 holdere',
unit: 'stk', timeUnit: 'per_piece', geometryBasis: 'fixed', defaultQuantity: 1, pipeLengthMetersPerPiece: 3,
priceSource: 'STARK materialedatabase + Ordrestyring',
description: 'Én klods er ét 3 m LP Rainline stålmetallic nedløbsrør Ø75 med ét tudstykke, tre rørholdere og montage. Antal 3 giver 3 × 3 m = 9 m rør. Bøjninger samt brønd- og sokkeltilslutning er tilvalg.',
materials: [
{ componentType: 'pipe', lengthPerPieceMeters: 3, sku: '5842106', name: 'LP nedløbsrør stålmetallic Ø75 × 3000 mm', category: 'Tagmaterialer', quantity: 1, unit: 'stk', unitPrice: 344.92, supplier: 'Stark A/S', sourceUrl: 'material-database://stark/5842106', sourceDate: '2025-11-26', vatStatus: 'excl_vat' },
{ componentType: 'outlet', sku: '1533565', name: 'LP Rainline tudstykke stålmetallic Ø75/125 mm', category: 'Tagmaterialer', quantity: 1, unit: 'stk', unitPrice: 110.77, supplier: 'Stark A/S', sourceUrl: 'material-database://stark/1533565', sourceDate: '2025-11-26', vatStatus: 'excl_vat' },
{ componentType: 'holder', sku: '1886362', name: 'LP Rainline rørholder stålmetallic Ø75 mm', category: 'Tagmaterialer', quantity: 3, unit: 'stk', unitPrice: 41.30, supplier: 'Stark A/S', sourceUrl: 'material-database://stark/1886362', sourceDate: '2025-11-26', vatStatus: 'excl_vat' }
], tasks: downpipeTasks
}),
finalize({
key: 'downpipe-plastic', name: 'Nedløbsrør i plastik – 3 m med tudstykke og 3 holdere',
unit: 'stk', timeUnit: 'per_piece', geometryBasis: 'fixed', defaultQuantity: 1, pipeLengthMetersPerPiece: 3,
priceSource: 'STARK materialedatabase + Ordrestyring',
description: 'Én klods er ét 3 m gråt PLASTMO plastnedløbsrør Ø75 med ét tudstykke, tre plastrørholdere og montage. Antal 3 giver 3 × 3 m = 9 m rør. Holderprisen er omregnet fra 2 stk./sæt; indkøb afrundes særskilt. Bøjninger samt brønd- og sokkeltilslutning er tilvalg.',
materials: [
{ componentType: 'pipe', lengthPerPieceMeters: 3, sku: '8621575', name: 'PLASTMO nedløbsrør grå Ø75 × 3000 mm', category: 'Tagmaterialer', quantity: 1, unit: 'stk', unitPrice: 213.12, supplier: 'Stark A/S', sourceUrl: 'material-database://stark/8621575', sourceDate: '2025-11-26', vatStatus: 'excl_vat' },
{ componentType: 'outlet', sku: '2231769', name: 'PLASTMO tudstykke grå Ø75 nr. 11', category: 'Tagmaterialer', quantity: 1, unit: 'stk', unitPrice: 114.62, supplier: 'Stark A/S', sourceUrl: 'material-database://stark/2231769', sourceDate: '2025-11-26', vatStatus: 'excl_vat' },
{ componentType: 'holder', physicalQuantityPerPrimary: 3, piecesPerPurchaseUnit: 2, physicalUnit: 'stk', sku: '2400968', name: 'PLASTMO plastrørholder grå Ø75 mm – 2 stk./sæt', category: 'Tagmaterialer', quantity: 2, unit: 'sæt', unitPrice: 82.94, supplier: 'Stark A/S', sourceUrl: 'material-database://stark/2400968', sourceDate: '2025-11-26', vatStatus: 'excl_vat', sourceNote: 'Katalogenheden er ét sæt med 2 holdere. Fysisk forbrug er 3 holdere pr. nedløb; indkøb afrundes op til hele sæt på tværs af den valgte mængde.' }
], tasks: downpipeTasks
})
];
const definitionContentHash = definition => crypto.createHash('sha256').update(JSON.stringify({
key: definition.key,
name: definition.name,
description: definition.description,
unit: definition.unit,
unitPrice: definition.unitPrice,
geometryBasis: definition.geometryBasis,
geometryFactor: definition.geometryFactor ?? null,
defaultCount: definition.defaultCount ?? null,
defaultQuantity: definition.defaultQuantity ?? null,
timeUnit: definition.timeUnit,
pipeLengthMetersPerPiece: definition.pipeLengthMetersPerPiece ?? null,
priceSource: definition.priceSource,
validationStatus: definition.validationStatus,
isActive: definition.isActive,
materials: definition.materials.map(({ materialId, ...line }) => line),
tasks: definition.tasks
})).digest('hex');
const isUnchangedSeed = (row, definition) => Boolean(
row
&& Number(row.is_active) === Number(definition.isActive)
&& row.validation_status === definition.validationStatus
&& String(row.validation_notes || '').includes(`[CONTENT_HASH:${definitionContentHash(definition)}]`)
);
const findMaterial = async (connection, line) => {
const [rows] = await connection.execute('SELECT id FROM materials WHERE sku = ? LIMIT 1', [line.sku]);
return rows[0]?.id || null;
};
const ensureMaterial = async (connection, line) => {
let materialId = await findMaterial(connection, line);
if (materialId) return materialId;
const [insert] = await connection.execute(`
INSERT INTO materials (sku, name, description, unit, category, specifications)
VALUES (?, ?, ?, ?, ?, ?)
`, [line.sku, line.name, materialProvenanceNote(line), line.unit, line.category, JSON.stringify({ sourceUrl: line.sourceUrl, sourceDate: line.sourceDate, vatStatus: line.vatStatus, sourceNote: line.sourceNote })]);
materialId = insert.insertId;
await connection.execute(`
INSERT INTO material_prices (name,material_id,price,currency,valid_from,confidence_score,source_document,parse_log,is_active,category,unit,supplier_name,sku,confidence,document_date)
VALUES (?,?,?,'DKK',?,0.50,?,?,1,?,?,?,?,0.50,?)
`, [line.name, materialId, line.unitPrice, line.sourceDate, line.sourceUrl, materialProvenanceNote(line), line.category, line.unit, line.supplier, line.sku, line.sourceDate]);
return materialId;
};
const inspectDefinition = async (connection, definition) => {
const materials = [];
for (const line of definition.materials) {
const materialId = await findMaterial(connection, line);
materials.push({ line, materialId, action: materialId ? 'reuse' : 'insert' });
}
const marker = `[GUTTER_METER:${definition.key}]`;
const [existingRows] = await connection.execute(`
SELECT id,validation_notes,is_active,validation_status,version
FROM material_packages
WHERE validation_notes LIKE ?
ORDER BY version DESC,id DESC
`, [`%${marker}%`]);
let identityRows = existingRows;
if (definition.legacyName) {
const [legacyRows] = await connection.execute(`
SELECT id,validation_notes,is_active,validation_status,version
FROM material_packages
WHERE name=? AND created_by='excel-import' AND package_type='component'
AND unit IN ('lbm','løbende m')
ORDER BY version DESC,id DESC
`, [definition.legacyName]);
identityRows = [...new Map([...existingRows, ...legacyRows].map(row => [Number(row.id), row])).values()]
.sort((a, b) => Number(b.version || 0) - Number(a.version || 0) || Number(b.id || 0) - Number(a.id || 0));
}
const previous = identityRows[0] || null;
const activeRows = identityRows.filter(row => Number(row.is_active) === 1);
if (activeRows.length > 1) throw new Error(`Gutter package ${definition.key} has multiple active versions`);
const activePrevious = activeRows[0] || null;
const desiredPrevious = definition.isActive ? activePrevious : previous;
const unchanged = Boolean(desiredPrevious && isUnchangedSeed(desiredPrevious, definition));
return {
definition,
materials,
previous,
activePrevious,
unchanged,
version: unchanged ? Number(desiredPrevious.version || 1) : Number(previous?.version || 0) + 1
};
};
const packageSummary = (inspection, packageId = inspection.previous?.id || null) => ({
key: inspection.definition.key,
packageId,
previousPackageId: inspection.previous?.id || null,
name: inspection.definition.name,
unit: inspection.definition.unit,
unitPrice: inspection.definition.unitPrice,
vatStatus: inspection.definition.vatStatus,
priceBasisNote: inspection.definition.priceBasisNote,
materials: inspection.definition.materials.length,
tasks: inspection.definition.tasks.length,
version: inspection.version,
unchanged: inspection.unchanged
});
const seed = async ({ apply = false, connection: suppliedConnection, definitions: suppliedDefinitions } = {}) => {
// Keep the migration's pure planning helpers importable in backend-only
// installs. The CLI database driver is needed only when no connection is
// injected (tests and callers supply one explicitly).
if (!suppliedConnection) loadCliEnvironment();
const pool = suppliedConnection ? null : require('mysql2/promise').createPool({ host: process.env.DB_HOST, port: Number(process.env.DB_PORT || 3306), user: process.env.DB_USER, password: process.env.DB_PASSWORD, database: process.env.DB_NAME, connectionLimit: 1 });
const connection = suppliedConnection || await pool.getConnection();
const definitions = suppliedDefinitions || buildGutterPackageDefinitions();
const planned = { materials: [], materialPrices: [], packages: [], packageVersions: [] };
const result = [];
let transactionStarted = false;
let advisoryLockAcquired = false;
try {
if (apply) {
const [lockRows] = await connection.execute('SELECT GET_LOCK(?, 10) AS acquired', [APPLY_LOCK_NAME]);
if (Number(lockRows[0]?.acquired) !== 1) throw new Error('Could not acquire gutter package apply lock');
advisoryLockAcquired = true;
}
const inspections = [];
for (const definition of definitions) {
const inspection = await inspectDefinition(connection, definition);
inspections.push(inspection);
if (!inspection.unchanged) {
for (const material of inspection.materials.filter(item => item.action === 'insert')) {
planned.materials.push({ sku: material.line.sku, name: material.line.name, action: 'insert' });
planned.materialPrices.push({ sku: material.line.sku, price: material.line.unitPrice, action: 'insert' });
}
const versionPlan = { key: definition.key, previousPackageId: inspection.previous?.id || null, version: inspection.version, action: 'insert' };
planned.packages.push({ ...versionPlan, name: definition.name });
planned.packageVersions.push(versionPlan);
}
}
if (!apply) {
return {
mode: 'dry-run',
packages: inspections.map(inspection => packageSummary(inspection)),
planned
};
}
if (inspections.every(inspection => inspection.unchanged)) {
return {
mode: 'apply',
packages: inspections.map(inspection => packageSummary(inspection)),
planned
};
}
await connection.beginTransaction();
transactionStarted = true;
for (const inspection of inspections) {
const { definition } = inspection;
if (inspection.unchanged) {
result.push(packageSummary(inspection));
continue;
}
const materialIds = new Map();
for (const material of inspection.materials) {
const materialId = material.materialId || await ensureMaterial(connection, material.line);
materialIds.set(material.line.sku, materialId);
}
const marker = `[GUTTER_METER:${definition.key}]`;
const contentMarker = `[CONTENT_HASH:${definitionContentHash(definition)}]`;
const catalogIdentity = `[CATALOG_ID:${definition.key}:v${inspection.version}]`;
const validationNotes = `${marker} ${catalogIdentity} ${contentMarker}`;
const laborHours = definition.tasks.reduce((sum, task) => sum + task.timePerUnit, 0);
if (inspection.activePrevious) {
const [archive] = await connection.execute(`
UPDATE material_packages
SET is_active=0, validation_status='archived'
WHERE id=? AND is_active=1
`, [inspection.activePrevious.id]);
if (Number(archive.affectedRows) !== 1) {
throw new Error(`Gutter package version conflict for ${definition.key}`);
}
}
const [insert] = await connection.execute(`
INSERT INTO material_packages (name,description,category,estimated_hours,hourly_rate,total_estimated_price,is_active,area_based,time_per_sqm,hours_unit,package_type,validation_status,validation_notes,validated_at,version,unit,geometry_basis,geometry_factor,default_count,default_quantity,time_per_unit,unit_price,price_per_unit,standard_price,price_basis_note,price_source,price_source_value,created_by)
VALUES (?,?,?, ?,600,?,?,0,0,?,'component',?,?,NOW(),?,?,?,?,?,?,?,?,?,?,?,?,?,'system-gutter-meter-packages')
`, [definition.name, definition.description, definition.category || (definition.unit === 'stk' ? 'Nedløbsrør' : 'Tagrender'), laborHours, definition.unitPrice, definition.isActive, definition.timeUnit, definition.validationStatus, validationNotes, inspection.version, definition.unit, definition.geometryBasis, definition.geometryFactor ?? null, definition.defaultCount ?? null, definition.defaultQuantity ?? null, laborHours, definition.unitPrice, definition.unitPrice, definition.unitPrice, definition.priceBasisNote, definition.priceSource, definition.key]);
const packageId = insert.insertId;
for (const line of definition.materials) {
await connection.execute(`
INSERT INTO package_materials (package_id,material_id,material_match_status,material_match_score,material_name,material_category,quantity,unit,unit_price,total_price,supplier,notes,geometry_multiplier,base_quantity,waste_factor,item_code,price_note,excel_raw_data)
VALUES (?,?,'manual_verified',1,?,?,?,?,?,?,?, ?, ?,?,1,?,?,?)
`, [packageId, materialIds.get(line.sku), line.name, line.category, line.quantity, line.unit, line.unitPrice, roundMoney(line.quantity * line.unitPrice), line.supplier, materialProvenanceNote(line), line.geometryMultiplier ?? null, line.quantity, line.sku, materialProvenanceNote(line), JSON.stringify({ componentType: line.componentType || null, lengthPerPieceMeters: line.lengthPerPieceMeters || null, physicalQuantityPerPrimary: line.physicalQuantityPerPrimary || null, piecesPerPurchaseUnit: line.piecesPerPurchaseUnit || null, physicalUnit: line.physicalUnit || null })]);
}
for (const task of definition.tasks) {
await connection.execute(`
INSERT INTO smart_package_tasks (package_id,name,description,hours,rate,task_order,time_unit,time_per_unit,price_basis_note)
VALUES (?,?,?,?,?,?,?,?,?)
`, [packageId, task.name, `${task.name} pr. ${definition.unit}`, task.timePerUnit, task.rate, task.order, definition.timeUnit, task.timePerUnit, `Prisgrundlag pr. ${definition.unit}`]);
await connection.execute(`
INSERT INTO package_tasks (package_id,task_name,task_description,estimated_hours,task_order,task_phase)
VALUES (?,?,?,?,?,?)
`, [packageId, task.name, `${task.name} pr. ${definition.unit}`, task.timePerUnit, task.order, task.order === 1 ? 'forberedelse' : (task.order === definition.tasks.length ? 'inspektion' : 'hovedarbejde')]);
}
result.push(packageSummary(inspection, packageId));
}
await connection.commit();
return { mode: 'apply', packages: result, planned };
} catch (error) {
if (transactionStarted) await connection.rollback();
throw error;
} finally {
if (advisoryLockAcquired) await connection.execute('SELECT RELEASE_LOCK(?) AS released', [APPLY_LOCK_NAME]);
if (pool) {
connection.release();
await pool.end();
}
}
};
const resolveApplyMode = (argv = process.argv.slice(2), env = process.env) => {
if (argv.includes('--dry-run')) return false;
return argv.includes('--apply') || env.APPLY === '1';
};
module.exports = { buildGutterPackageDefinitions, definitionContentHash, isUnchangedSeed, ensureMaterial, materialProvenanceNote, resolveApplyMode, seed };
if (require.main === module) {
seed({ apply: resolveApplyMode() })
.then(result => console.log(JSON.stringify(result, null, 2)))
.catch(error => { console.error(error); process.exit(1); });
}
@@ -0,0 +1,46 @@
-- Versioned Smart Package workspace. The JSON preserves Lego block instances,
-- ordering, geometry formulas and manual overrides; flattened project rows remain
-- the authority used by Final Review, PDF and exports.
ALTER TABLE project_materials
ADD COLUMN IF NOT EXISTS material_id INT NULL AFTER project_id,
ADD COLUMN IF NOT EXISTS package_instance_id VARCHAR(100) NULL AFTER project_id,
ADD COLUMN IF NOT EXISTS source_package_id INT NULL AFTER package_instance_id,
ADD COLUMN IF NOT EXISTS price_source VARCHAR(120) NULL AFTER material_source,
ADD COLUMN IF NOT EXISTS price_source_updated_at DATETIME NULL AFTER price_source,
MODIFY COLUMN material_source ENUM('manual','database','bygma_api','package') DEFAULT 'manual';
CREATE INDEX IF NOT EXISTS idx_project_materials_material_id
ON project_materials (material_id);
CREATE INDEX IF NOT EXISTS idx_project_materials_package_instance
ON project_materials (project_id, package_instance_id);
ALTER TABLE project_rentals
ADD COLUMN IF NOT EXISTS package_instance_id VARCHAR(100) NULL AFTER project_id,
ADD COLUMN IF NOT EXISTS source_package_id INT NULL AFTER package_instance_id;
CREATE INDEX IF NOT EXISTS idx_project_rentals_package_instance
ON project_rentals (project_id, package_instance_id);
CREATE TABLE IF NOT EXISTS project_smart_package_workspaces (
id INT AUTO_INCREMENT PRIMARY KEY,
project_id INT NOT NULL,
version INT NOT NULL DEFAULT 0,
workspace_json JSON NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY unique_project_workspace (project_id),
CONSTRAINT fk_smart_package_workspace_project
FOREIGN KEY (project_id) REFERENCES customer_projects(id) ON DELETE CASCADE
);
-- project_labor is an upsert target and must contain exactly one row per project.
-- Retain the newest legacy row before adding the idempotent unique constraint.
DELETE stale
FROM project_labor stale
JOIN project_labor newest
ON newest.project_id = stale.project_id AND newest.id > stale.id;
SELECT project_id, COUNT(*) AS remaining_rows
FROM project_labor
GROUP BY project_id
HAVING COUNT(*) > 1;
CREATE UNIQUE INDEX IF NOT EXISTS unique_project_labor ON project_labor (project_id);
@@ -0,0 +1,27 @@
const path = require('path');
const mysql = require('mysql2/promise');
require('dotenv').config({ path: path.join(__dirname, '../../backend/.env') });
const { ensureAuthAccountsTable } = require('../../backend/src/services/authAccountSchema');
async function run() {
const connection = await mysql.createConnection({
host: process.env.DB_HOST || 'localhost',
port: process.env.DB_PORT || 3306,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME
});
try {
await ensureAuthAccountsTable(connection);
console.log('auth_accounts table migration complete');
} finally {
await connection.end();
}
}
run().catch(error => {
console.error('Migration failed:', error);
process.exit(1);
});
@@ -0,0 +1,426 @@
const path = require('path');
const { createRequire } = require('module');
const backendRequire = createRequire(path.join(__dirname, '../../backend/package.json'));
const loadCliEnvironment = () => {
backendRequire('dotenv').config({ path: path.join(__dirname, '../../backend/.env') });
};
const SUPPORTED_ROOF_FORMS = Object.freeze([
'gable', 'pult', 'flat', 'fladt_tag', 'skraat_tag', 'mansard', 'hip'
]);
const PITCHED_ROOF_FORMS = Object.freeze(['gable', 'pult', 'mansard', 'hip']);
const ALL_ROOF_FORMS = Object.freeze([...PITCHED_ROOF_FORMS, 'flat']);
const contract = (catalogKey, exactName, material, allowedRoofForms) => Object.freeze({
catalogKey,
exactName,
replacementScope: 'complete_roof_replacement',
compatibleRoofMaterials: Object.freeze([material]),
allowedRoofForms,
// No manufacturer/source-backed pitch limits are available for these seeds.
minPitchDegrees: null,
maxPitchDegrees: null,
pitchVerificationStatus: 'unverified',
pitchReviewRequired: true,
geometryBasis: 'roof_area'
});
const COMPLETE_ROOF_PACKAGE_CONTRACTS = Object.freeze([
contract('betontag_renovering', 'Komplet tagskift — betontag', 'betontag', PITCHED_ROOF_FORMS),
contract('tegl_tag_renovering', 'Komplet tagskift — vingefalstegl', 'tegl', PITCHED_ROOF_FORMS),
contract('b7_tag_udskiftning', 'Komplet tagskift — Swisspearl B7', 'eternit', PITCHED_ROOF_FORMS),
contract('staaltag_renovering', 'Komplet tagskift — ståltag', 'staal', PITCHED_ROOF_FORMS),
contract('tagpap_renovering', 'Komplet tagrenovering — 2-lags tagpap', 'tagpap', ALL_ROOF_FORMS)
]);
const PACKAGE_COLUMNS = Object.freeze([
['catalog_key', 'VARCHAR(191) NULL AFTER id'],
['geometry_basis', 'VARCHAR(40) NULL AFTER unit'],
['replacement_scope', 'VARCHAR(40) NULL AFTER default_quantity'],
['compatible_roof_materials', 'JSON NULL AFTER replacement_scope'],
['allowed_roof_forms', 'JSON NULL AFTER compatible_roof_materials'],
['min_pitch_degrees', 'DECIMAL(5,2) NULL AFTER allowed_roof_forms'],
['max_pitch_degrees', 'DECIMAL(5,2) NULL AFTER min_pitch_degrees'],
['pitch_verification_status', "VARCHAR(30) NOT NULL DEFAULT 'unverified' AFTER max_pitch_degrees"],
['pitch_review_required', 'TINYINT(1) NOT NULL DEFAULT 1 AFTER pitch_verification_status']
]);
const ROOF_GEOMETRY_COLUMNS = Object.freeze([
['roof_covering_area', "DECIMAL(10,2) NULL COMMENT 'Faktisk tagbeklædningsareal i m²' AFTER total_area"],
['wall_height', 'DECIMAL(8,2) NULL AFTER roof_height'],
['geometry_json', 'JSON NULL'],
['edges_json', 'JSON NULL'],
['planes_json', 'JSON NULL'],
['openings_json', 'JSON NULL'],
['provenance_json', 'JSON NULL'],
['replacement_scope', 'VARCHAR(80) NULL']
]);
const CATALOG_KEY_INDEX = 'uq_material_packages_catalog_key';
const PACKAGE_READ_COLUMNS = Object.freeze([
'id', 'name', 'catalog_key', 'replacement_scope', 'compatible_roof_materials',
'allowed_roof_forms', 'min_pitch_degrees', 'max_pitch_degrees',
'pitch_verification_status', 'pitch_review_required', 'geometry_basis'
]);
const readSchema = async connection => {
const [columns] = await connection.execute(`
SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME IN ('material_packages', 'roof_geometry')
`);
const [indexes] = await connection.execute(`
SELECT INDEX_NAME, NON_UNIQUE
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'material_packages'
AND INDEX_NAME = ?
`, [CATALOG_KEY_INDEX]);
return { columns, indexes };
};
const columnsFor = (schema, tableName) => new Set(
schema.columns.filter(row => row.TABLE_NAME === tableName).map(row => row.COLUMN_NAME)
);
const assertInspectablePackageSchema = packageColumns => {
['id', 'name', 'validation_status', 'is_active'].forEach(column => {
if (!packageColumns.has(column)) {
throw new Error(`missing required pre-schema column: material_packages.${column}`);
}
});
};
const packageSelectList = packageColumns => PACKAGE_READ_COLUMNS
.map(column => packageColumns.has(column) ? column : `NULL AS ${column}`)
.join(', ');
const readActiveVerifiedPackages = async (connection, packageColumns, { lock = false, catalogKeys = null } = {}) => {
assertInspectablePackageSchema(packageColumns);
const params = [];
let keyClause = '';
if (catalogKeys) {
keyClause = ` AND catalog_key IN (${catalogKeys.map(() => '?').join(', ')})`;
params.push(...catalogKeys);
}
const [rows] = await connection.execute(`
SELECT ${packageSelectList(packageColumns)}
FROM material_packages
WHERE validation_status = 'verified' AND is_active = 1${keyClause}
${lock ? 'FOR UPDATE' : ''}
`, params);
return rows;
};
const readPackagesByCatalogKeys = async (connection, packageColumns, { lock = false } = {}) => {
assertInspectablePackageSchema(packageColumns);
if (!packageColumns.has('catalog_key')) return [];
const keys = COMPLETE_ROOF_PACKAGE_CONTRACTS.map(item => item.catalogKey);
const [rows] = await connection.execute(`
SELECT ${packageSelectList(packageColumns)}
FROM material_packages
WHERE catalog_key IN (${keys.map(() => '?').join(', ')})
${lock ? 'FOR UPDATE' : ''}
`, keys);
return rows;
};
const migrationCandidates = async (connection, packageColumns, { lock = false } = {}) => {
const activeRows = await readActiveVerifiedPackages(connection, packageColumns, { lock });
const keyedRows = await readPackagesByCatalogKeys(connection, packageColumns, { lock });
return [...new Map([...activeRows, ...keyedRows].map(row => [Number(row.id), row])).values()];
};
const ensureDistinctResolvedRows = resolved => {
if (new Set(resolved.map(({ row }) => row.id)).size !== resolved.length) {
throw new Error('ambiguous complete-roof identity collision');
}
return resolved;
};
const resolveSeedIdentities = rows => ensureDistinctResolvedRows(
COMPLETE_ROOF_PACKAGE_CONTRACTS.map(item => {
const keyMatches = rows.filter(row => row.catalog_key === item.catalogKey);
if (keyMatches.length > 1) {
throw new Error(`ambiguous complete-roof catalog key: ${item.catalogKey}`);
}
if (keyMatches.length === 1) return { contract: item, row: keyMatches[0], needsCatalogKey: false };
const nameMatches = rows.filter(row => row.name === item.exactName && !row.catalog_key);
if (nameMatches.length > 1) {
throw new Error(`ambiguous complete-roof exact seed identity: ${item.exactName}`);
}
if (nameMatches.length === 0) {
throw new Error(`missing complete-roof package: ${item.catalogKey}`);
}
return { contract: item, row: nameMatches[0], needsCatalogKey: true };
})
);
const resolveByCatalogKey = rows => ensureDistinctResolvedRows(
COMPLETE_ROOF_PACKAGE_CONTRACTS.map(item => {
const matches = rows.filter(row => row.catalog_key === item.catalogKey);
if (matches.length > 1) throw new Error(`ambiguous complete-roof catalog key: ${item.catalogKey}`);
if (matches.length === 0) throw new Error(`missing complete-roof catalog key: ${item.catalogKey}`);
return { contract: item, row: matches[0] };
})
);
const enumNeedsUpgrade = schema => {
const roofType = schema.columns.find(row => row.TABLE_NAME === 'roof_geometry' && row.COLUMN_NAME === 'roof_type');
const currentType = String(roofType?.COLUMN_TYPE || '').toLowerCase();
return Boolean(currentType) && SUPPORTED_ROOF_FORMS.some(form => !currentType.includes(`'${form}'`));
};
const plannedSchemaChanges = schema => {
const packageColumns = columnsFor(schema, 'material_packages');
const roofColumns = columnsFor(schema, 'roof_geometry');
const changes = [];
PACKAGE_COLUMNS.forEach(([column, definition]) => {
if (!packageColumns.has(column)) changes.push({ table: 'material_packages', column, definition });
});
ROOF_GEOMETRY_COLUMNS.forEach(([column, definition]) => {
if (!roofColumns.has(column)) changes.push({ table: 'roof_geometry', column, definition });
});
if (enumNeedsUpgrade(schema)) changes.push({ table: 'roof_geometry', column: 'roof_type', action: 'replace-supported-enum' });
const index = schema.indexes.find(row => row.INDEX_NAME === CATALOG_KEY_INDEX);
if (!index) changes.push({ table: 'material_packages', index: CATALOG_KEY_INDEX, unique: true, column: 'catalog_key' });
else if (Number(index.NON_UNIQUE) !== 0) throw new Error(`${CATALOG_KEY_INDEX} exists but is not unique`);
return changes;
};
const parseJsonArray = value => {
if (Array.isArray(value)) return value;
if (typeof value !== 'string') return null;
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed : null;
} catch (_error) {
return null;
}
};
const arraysEqual = (left, right) => (
Array.isArray(left)
&& left.length === right.length
&& left.every((value, index) => value === right[index])
);
const nullableNumberEqual = (value, expected) => (
expected === null
? value === null || value === undefined || value === ''
: Number(value) === expected
);
const rowHasContract = (row, item) => (
row.replacement_scope === item.replacementScope
&& arraysEqual(parseJsonArray(row.compatible_roof_materials), item.compatibleRoofMaterials)
&& arraysEqual(parseJsonArray(row.allowed_roof_forms), item.allowedRoofForms)
&& nullableNumberEqual(row.min_pitch_degrees, item.minPitchDegrees)
&& nullableNumberEqual(row.max_pitch_degrees, item.maxPitchDegrees)
&& row.pitch_verification_status === item.pitchVerificationStatus
&& Number(row.pitch_review_required) === Number(item.pitchReviewRequired)
&& row.geometry_basis === item.geometryBasis
);
const inspectCompleteRoofMigration = async connection => {
const schema = await readSchema(connection);
const packageColumns = columnsFor(schema, 'material_packages');
const rows = await migrationCandidates(connection, packageColumns);
const resolved = resolveSeedIdentities(rows);
return {
dryRun: true,
plannedSchema: plannedSchemaChanges(schema),
backfills: resolved.map(({ contract: item, row, needsCatalogKey }) => ({
catalogKey: item.catalogKey,
packageId: row.id,
catalogKeyAction: needsCatalogKey ? 'backfill-exact-seed-identity' : 'already-persisted',
contractAction: rowHasContract(row, item) ? 'unchanged' : 'update',
pitchVerificationStatus: item.pitchVerificationStatus,
pitchReviewRequired: item.pitchReviewRequired
}))
};
};
const ensureColumn = async (connection, tableName, columnName, definition) => {
const [rows] = await connection.execute(
`SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?`,
[tableName, columnName]
);
if (rows.length === 0) {
await connection.execute(`ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${definition}`);
}
};
const ensureRoofTypeEnum = async connection => {
const [rows] = await connection.execute(
`SELECT COLUMN_TYPE FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'roof_geometry' AND COLUMN_NAME = 'roof_type'`
);
const currentType = String(rows[0]?.COLUMN_TYPE || '').toLowerCase();
if (currentType && SUPPORTED_ROOF_FORMS.some(form => !currentType.includes(`'${form}'`))) {
await connection.execute(`
ALTER TABLE roof_geometry
MODIFY COLUMN roof_type ENUM('gable','pult','flat','fladt_tag','skraat_tag','mansard','hip','komplekst') NOT NULL
`);
}
};
const ensureCatalogKeyIndex = async connection => {
const [rows] = await connection.execute(`
SELECT INDEX_NAME, NON_UNIQUE FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'material_packages' AND INDEX_NAME = ?
`, [CATALOG_KEY_INDEX]);
if (rows.length === 0) {
await connection.execute(`CREATE UNIQUE INDEX ${CATALOG_KEY_INDEX} ON material_packages (catalog_key)`);
} else if (rows.some(row => Number(row.NON_UNIQUE) !== 0)) {
throw new Error(`${CATALOG_KEY_INDEX} exists but is not unique`);
}
};
const ensureCompleteRoofSchema = async connection => {
await ensureRoofTypeEnum(connection);
for (const [column, definition] of PACKAGE_COLUMNS) {
await ensureColumn(connection, 'material_packages', column, definition);
}
for (const [column, definition] of ROOF_GEOMETRY_COLUMNS) {
await ensureColumn(connection, 'roof_geometry', column, definition);
}
await ensureCatalogKeyIndex(connection);
};
const completePackageColumnSet = () => new Set([
'id', 'name', 'validation_status', 'is_active', 'geometry_basis', ...PACKAGE_COLUMNS.map(([column]) => column)
]);
const applyCompleteRoofPackageBackfill = async connection => {
const packageColumns = completePackageColumnSet();
const rows = await migrationCandidates(connection, packageColumns, { lock: true });
const seedIdentities = resolveSeedIdentities(rows);
const catalogKeyWrites = seedIdentities.filter(item => item.needsCatalogKey);
// Resolve the complete exact-name seed set before the first write. Exact names
// are used only for this one-time identity assignment, never for contract updates.
for (const { contract: item, row } of catalogKeyWrites) {
const [result] = await connection.execute(`
UPDATE material_packages
SET catalog_key = ?
WHERE id = ? AND catalog_key IS NULL
AND validation_status = 'verified' AND is_active = 1
`, [item.catalogKey, row.id]);
if (Number(result.affectedRows || 0) !== 1) {
throw new Error(`missing complete-roof package during catalog key backfill: ${item.catalogKey}`);
}
}
const resolved = resolveByCatalogKey(
await readPackagesByCatalogKeys(connection, packageColumns, { lock: true })
);
let changedRows = 0;
for (const { contract: item, row } of resolved) {
if (rowHasContract(row, item)) continue;
const [result] = await connection.execute(`
UPDATE material_packages
SET replacement_scope = ?,
compatible_roof_materials = ?,
allowed_roof_forms = ?,
min_pitch_degrees = ?,
max_pitch_degrees = ?,
pitch_verification_status = ?,
pitch_review_required = ?,
geometry_basis = ?
WHERE catalog_key = ?
`, [
item.replacementScope,
JSON.stringify(item.compatibleRoofMaterials),
JSON.stringify(item.allowedRoofForms),
item.minPitchDegrees,
item.maxPitchDegrees,
item.pitchVerificationStatus,
item.pitchReviewRequired ? 1 : 0,
item.geometryBasis,
item.catalogKey
]);
if (Number(result.affectedRows || 0) !== 1) {
throw new Error(`missing complete-roof package during update: ${item.catalogKey}`);
}
changedRows += 1;
}
return {
matchedPackageIds: resolved.map(({ row }) => row.id),
changedRows,
catalogKeysBackfilled: catalogKeyWrites.length
};
};
const migrate = async connection => {
await ensureCompleteRoofSchema(connection);
return applyCompleteRoofPackageBackfill(connection);
};
// Defer the CLI-only database dependency so the exported inspection and
// migration helpers remain testable from a backend-only npm installation.
const createPool = () => {
loadCliEnvironment();
return backendRequire('mysql2/promise').createPool({
host: process.env.DB_HOST,
port: Number(process.env.DB_PORT || 3306),
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
connectionLimit: 1
});
};
const run = async ({ dryRun = false, pool = createPool(), log = console.log } = {}) => {
const connection = await pool.getConnection();
let transactionStarted = false;
try {
if (dryRun) {
const plan = await inspectCompleteRoofMigration(connection);
log(JSON.stringify(plan, null, 2));
return plan;
}
// MySQL DDL implicitly commits, so schema work completes before the
// rollback-safe identity and contract backfills.
await ensureCompleteRoofSchema(connection);
await connection.beginTransaction();
transactionStarted = true;
const result = await applyCompleteRoofPackageBackfill(connection);
await connection.commit();
transactionStarted = false;
log(JSON.stringify(result, null, 2));
return result;
} catch (error) {
if (transactionStarted) await connection.rollback();
throw error;
} finally {
connection.release();
await pool.end();
}
};
module.exports = {
COMPLETE_ROOF_PACKAGE_CONTRACTS,
PACKAGE_COLUMNS,
ROOF_GEOMETRY_COLUMNS,
inspectCompleteRoofMigration,
ensureColumn,
ensureRoofTypeEnum,
ensureCompleteRoofSchema,
applyCompleteRoofPackageBackfill,
migrate,
run
};
if (require.main === module) {
run({ dryRun: process.argv.includes('--dry-run') }).catch(error => {
console.error(error);
process.exitCode = 1;
});
}
@@ -0,0 +1,130 @@
const path = require('path');
const { createRequire } = require('module');
const backendRequire = createRequire(path.join(__dirname, '../../backend/package.json'));
backendRequire('dotenv').config({ path: path.join(__dirname, '../../backend/.env') });
const ROOF_LIFECYCLE_VIOLATION_WHERE = `
mp.is_active = 1
AND mp.validation_status = 'verified'
AND (
mp.package_type = 'complete_offer'
OR NULLIF(TRIM(mp.replacement_scope), '') IS NOT NULL
OR (JSON_TYPE(mp.compatible_roof_materials) = 'ARRAY' AND JSON_LENGTH(mp.compatible_roof_materials) > 0)
OR (JSON_TYPE(mp.allowed_roof_forms) = 'ARRAY' AND JSON_LENGTH(mp.allowed_roof_forms) > 0)
)
AND (
COALESCE(mp.pitch_verification_status, '') <> 'verified'
OR COALESCE(mp.pitch_review_required, 1) <> 0
OR COALESCE(JSON_TYPE(mp.compatible_roof_materials), '') <> 'ARRAY'
OR COALESCE(JSON_LENGTH(mp.compatible_roof_materials), 0) = 0
OR COALESCE(JSON_TYPE(mp.allowed_roof_forms), '') <> 'ARRAY'
OR COALESCE(JSON_LENGTH(mp.allowed_roof_forms), 0) = 0
OR mp.min_pitch_degrees IS NULL
OR mp.max_pitch_degrees IS NULL
OR mp.min_pitch_degrees < 0
OR mp.max_pitch_degrees > 90
OR mp.min_pitch_degrees > mp.max_pitch_degrees
)
`;
const readRoofLifecycleViolations = async (connection, { lock = false } = {}) => {
const [rows] = await connection.execute(`
SELECT mp.id
FROM material_packages mp
WHERE ${ROOF_LIFECYCLE_VIOLATION_WHERE}
ORDER BY mp.id
${lock ? 'FOR UPDATE' : ''}
`);
return rows.map(row => Number(row.id));
};
const inspectRoofLifecycleViolations = async connection => {
const violatingPackageIds = await readRoofLifecycleViolations(connection);
return {
dryRun: true,
violatingPackageIds,
violationCount: violatingPackageIds.length
};
};
const transitionRoofLifecycleViolations = async connection => {
const ids = await readRoofLifecycleViolations(connection, { lock: true });
if (ids.length > 0) {
const placeholders = ids.map(() => '?').join(', ');
const [result] = await connection.execute(`
UPDATE material_packages
SET is_active = 0,
validation_status = 'needs_review',
pitch_review_required = 1,
validated_at = NULL,
version = COALESCE(version, 0) + 1,
validation_notes = CONCAT_WS('\n', NULLIF(validation_notes, ''),
'[FAIL_CLOSED_ROOF_LIFECYCLE] Deactivated: verified pitch contract is incomplete or requires review.')
WHERE id IN (${placeholders})
AND is_active = 1
AND validation_status = 'verified'
`, ids);
if (Number(result.affectedRows || 0) !== ids.length) {
throw new Error(`roof lifecycle transition count mismatch: expected ${ids.length}, updated ${result.affectedRows || 0}`);
}
}
const remaining = await readRoofLifecycleViolations(connection);
if (remaining.length > 0) {
throw new Error(`active verified roof lifecycle violations remain: ${remaining.join(',')}`);
}
return {
dryRun: false,
transitionedPackageIds: ids,
transitionedCount: ids.length,
remainingViolationCount: 0
};
};
const createPool = () => backendRequire('mysql2/promise').createPool({
host: process.env.DB_HOST,
port: Number(process.env.DB_PORT || 3306),
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
connectionLimit: 1
});
const run = async ({ apply = false, pool = createPool(), log = console.log } = {}) => {
const connection = await pool.getConnection();
try {
if (!apply) {
const report = await inspectRoofLifecycleViolations(connection);
log(JSON.stringify(report, null, 2));
return report;
}
await connection.beginTransaction();
try {
const result = await transitionRoofLifecycleViolations(connection);
await connection.commit();
log(JSON.stringify(result, null, 2));
return result;
} catch (error) {
await connection.rollback();
throw error;
}
} finally {
connection.release();
if (pool.end && pool !== connection) await pool.end();
}
};
if (require.main === module) {
run({ apply: process.argv.includes('--apply') }).catch(error => {
console.error(error.message);
process.exitCode = 1;
});
}
module.exports = {
ROOF_LIFECYCLE_VIOLATION_WHERE,
inspectRoofLifecycleViolations,
readRoofLifecycleViolations,
run,
transitionRoofLifecycleViolations
};