Files
tilbudgivern/backend/src/services/siteGeometryService.js
T
f37adae2cb
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: deliver auditable Smart Pakke quote flow and free site geometry (#31)
* 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]>
2026-09-26 22:39:18 +02:00

187 lines
12 KiB
JavaScript

const fail = (message, status = 400, code = 'SITE_GEOMETRY_INVALID') => Object.assign(new Error(message), { status, code });
const R = 6371008.8;
// Product scope is an ordinary property/work site in Denmark. These guards keep
// the planar topology check and spherical area calculation in their intended
// local domain: longitude 7.5-15.5°E, latitude 54-58°N, <=5 km per bbox
// axis, <=12 km perimeter and <=5 km² ground area. They deliberately reject
// country/continent/polar input and similarly small out-of-region polygons.
const LOCAL_WORK_BOUNDS = Object.freeze({
minLongitude: 7.5,
maxLongitude: 15.5,
minLatitude: 54,
maxLatitude: 58,
maxBboxWidthM: 5_000,
maxBboxHeightM: 5_000,
maxPerimeterM: 12_000,
maxGroundAreaM2: 5_000_000
});
const rad = n => n * Math.PI / 180;
const distanceM = (a, b) => {
const [lng1, lat1] = a.map(rad);
const [lng2, lat2] = b.map(rad);
const h = Math.sin((lat2-lat1)/2)**2 + Math.cos(lat1)*Math.cos(lat2)*Math.sin((lng2-lng1)/2)**2;
return 2*R*Math.asin(Math.sqrt(Math.min(1,h)));
};
const same = (a, b) => a[0] === b[0] && a[1] === b[1];
const cross = (a, b, c) => (b[0]-a[0])*(c[1]-a[1])-(b[1]-a[1])*(c[0]-a[0]);
const on = (a,b,c) => cross(a,b,c) === 0 && c[0] >= Math.min(a[0],b[0]) && c[0] <= Math.max(a[0],b[0]) && c[1] >= Math.min(a[1],b[1]) && c[1] <= Math.max(a[1],b[1]);
const intersects = (a,b,c,d) => (cross(a,b,c)*cross(a,b,d) < 0 && cross(c,d,a)*cross(c,d,b) < 0) || on(a,b,c) || on(a,b,d) || on(c,d,a) || on(c,d,b);
function normalizeGeometry(payload) {
const geometry = payload?.geometry;
const inputRing = geometry?.coordinates?.[0];
const invalid = () => { throw fail('Tegn en lukket polygon med mindst tre forskellige punkter uden selvskæring.'); };
if (geometry?.type !== 'Polygon' || geometry.coordinates.length !== 1 || !Array.isArray(inputRing) || inputRing.length < 4 || inputRing.length > 501) invalid();
if (inputRing.some(p => !Array.isArray(p) || p.length !== 2 || !p.every(Number.isFinite) || Math.abs(p[0]) > 180 || Math.abs(p[1]) > 90)) invalid();
if (!same(inputRing[0],inputRing[inputRing.length-1])) invalid();
let points = inputRing.slice(0,-1);
if (new Set(points.map(p => JSON.stringify(p))).size !== points.length) invalid();
// Midpoint handles create redundant collinear vertices; normalize those away.
let changed = true;
while (changed && points.length >= 3) {
changed = false;
const filtered = points.filter((point, index) => {
const previous = points[(index - 1 + points.length) % points.length];
const next = points[(index + 1) % points.length];
const redundant = on(previous, next, point);
if (redundant) changed = true;
return !redundant;
});
points = filtered;
}
if (points.length < 3) invalid();
const ring = [...points, [...points[0]]];
// This local work-area editor deliberately excludes antimeridian/polar polygons.
if (Math.max(...points.map(p => p[0])) - Math.min(...points.map(p => p[0])) >= 180) invalid();
const minLng = Math.min(...points.map(p => p[0]));
const maxLng = Math.max(...points.map(p => p[0]));
const minLat = Math.min(...points.map(p => p[1]));
const maxLat = Math.max(...points.map(p => p[1]));
const middleLat = (minLat + maxLat) / 2;
const bboxWidthM = distanceM([minLng, middleLat], [maxLng, middleLat]);
const bboxHeightM = distanceM([minLng, minLat], [minLng, maxLat]);
let perimeterM = 0;
for (let i=0;i<points.length;i++) perimeterM += distanceM(ring[i], ring[i+1]);
if (minLng < LOCAL_WORK_BOUNDS.minLongitude || maxLng > LOCAL_WORK_BOUNDS.maxLongitude
|| minLat < LOCAL_WORK_BOUNDS.minLatitude || maxLat > LOCAL_WORK_BOUNDS.maxLatitude
|| bboxWidthM > LOCAL_WORK_BOUNDS.maxBboxWidthM
|| bboxHeightM > LOCAL_WORK_BOUNDS.maxBboxHeightM
|| perimeterM > LOCAL_WORK_BOUNDS.maxPerimeterM) {
throw fail('Området skal være en lokal dansk arbejdsgrund inden for de understøttede størrelsesgrænser.', 400, 'SITE_GEOMETRY_OUT_OF_BOUNDS');
}
for (let i=0;i<points.length;i++) {
for (let j=i+2;j<points.length;j++) {
if (i === 0 && j === points.length-1) continue;
if (intersects(ring[i],ring[i+1],ring[j],ring[j+1])) invalid();
}
}
let area = 0;
for (let i=0;i<points.length;i++) {
const [lng1,lat1] = ring[i].map(rad);
const [lng2,lat2] = ring[i+1].map(rad);
area += (lng2-lng1)*(2+Math.sin(lat1)+Math.sin(lat2));
}
const groundAreaM2 = Math.abs(area)*R*R/2;
if (!Number.isFinite(groundAreaM2) || groundAreaM2 < 0.01) invalid();
if (groundAreaM2 > LOCAL_WORK_BOUNDS.maxGroundAreaM2) {
throw fail('Området skal være en lokal dansk arbejdsgrund inden for de understøttede størrelsesgrænser.', 400, 'SITE_GEOMETRY_OUT_OF_BOUNDS');
}
const normalized = points.map(p => [...p]);
if (area > 0) normalized.reverse();
const first = normalized.reduce((best,p,i) => p[0] < normalized[best][0] || (p[0] === normalized[best][0] && p[1] < normalized[best][1]) ? i : best,0);
const ordered = [...normalized.slice(first),...normalized.slice(0,first)];
ordered.push([...ordered[0]]);
return {
schema: 'site_geometry_v1', crs: 'EPSG:4326',
areas: [{ id: 'area-1', kind: 'work_area', label: 'Område 1', geometry: { type: 'Polygon', coordinates: [ordered] }, groundAreaM2, perimeterM,
source: { type: 'user_drawn', provider: 'openstreetmap' }, quality: { grade: 'unverified', reasonCodes: ['USER_DRAWN_NOT_FIELD_MEASURED'] } }],
totals: { groundAreaM2, perimeterM, roofSlopedAreaM2: null },
calculation: { engine: 'siteGeometry', version: 1, areaAlgorithm: 'server_geodesic_sphere_v1' }
};
}
module.exports = { normalizeGeometry, fail, LOCAL_WORK_BOUNDS };
const { createHash } = require('crypto');
const parse = row => row ? (typeof row.geometry_json === 'string' ? JSON.parse(row.geometry_json) : row.geometry_json) : null;
const validRevision = value => Number.isSafeInteger(value) && value >= 0 && value < 2147483647;
const isTombstone = value => value?.schema === 'site_geometry_tombstone_v1' && value.deleted === true;
class SiteGeometryService {
constructor(db) { this.db = db; }
async getState(projectId) {
const projects = await this.db.query('SELECT id FROM customer_projects WHERE id=?', [projectId]);
if (!projects.length) throw fail('Projekt ikke fundet', 404, 'PROJECT_NOT_FOUND');
const rows = await this.db.query('SELECT revision, geometry_json FROM project_site_geometry WHERE project_id=?', [projectId]);
const stored = parse(rows[0]);
if (!stored) return { geometry: null, revision: 0 };
if (isTombstone(stored)) return { geometry: null, revision: Number(rows[0].revision), deleted: true };
return { geometry: stored, revision: Number(rows[0].revision) };
}
async get(projectId) {
return (await this.getState(projectId)).geometry;
}
async save(projectId, payload, operator) {
if (!validRevision(payload?.expectedRevision) || !operator) throw fail('Revision og operatør skal angives');
const canonical = normalizeGeometry(payload);
const connection = await this.db.pool.getConnection();
try {
await connection.beginTransaction();
// Lock the parent even before the first geometry row exists.
const [projects] = await connection.execute('SELECT id FROM customer_projects WHERE id=? FOR UPDATE', [projectId]);
if (!projects.length) throw fail('Projekt ikke fundet', 404, 'PROJECT_NOT_FOUND');
const [rows] = await connection.execute('SELECT revision, geometry_json FROM project_site_geometry WHERE project_id=? FOR UPDATE', [projectId]);
if (Number(rows[0]?.revision || 0) !== payload.expectedRevision) throw fail('Området er ændret i en anden session. Genåbn kortet for at hente seneste version.', 409, 'GEOMETRY_REVISION_CONFLICT');
const previous = parse(rows[0]);
const now = new Date().toISOString();
canonical.revision = payload.expectedRevision + 1;
canonical.audit = { createdAt: previous?.audit?.createdAt || now, createdBy: previous?.audit?.createdBy || operator, updatedAt: now, updatedBy: operator, editMethod: 'polygon_handles' };
canonical.signature = createHash('sha256').update(JSON.stringify(canonical)).digest('hex');
const json = JSON.stringify(canonical);
await connection.execute('INSERT INTO project_site_geometry (project_id, revision, geometry_json) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE revision=VALUES(revision), geometry_json=VALUES(geometry_json)', [projectId, canonical.revision, json]);
await connection.execute('INSERT INTO project_site_geometry_audit (project_id, revision, actor_id, event_type, occurred_at, before_signature, after_signature, geometry_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', [projectId, canonical.revision, operator, isTombstone(previous) ? 'restored' : previous ? 'edited' : 'created', new Date(now), previous?.signature || null, canonical.signature, json]);
await connection.commit();
return canonical;
} catch (error) {
await connection.rollback();
throw error;
} finally { connection.release(); }
}
async delete(projectId, payload, operator) {
if (!validRevision(payload?.expectedRevision) || !operator) throw fail('Revision og operatør skal angives');
const connection = await this.db.pool.getConnection();
try {
await connection.beginTransaction();
const [projects] = await connection.execute('SELECT id FROM customer_projects WHERE id=? FOR UPDATE', [projectId]);
if (!projects.length) throw fail('Projekt ikke fundet', 404, 'PROJECT_NOT_FOUND');
const [rows] = await connection.execute('SELECT revision, geometry_json FROM project_site_geometry WHERE project_id=? FOR UPDATE', [projectId]);
const currentRevision = Number(rows[0]?.revision || 0);
const previous = parse(rows[0]);
if (!previous || isTombstone(previous)) {
if (currentRevision !== payload.expectedRevision) throw fail('Området er ændret i en anden session. Genåbn kortet for at hente seneste version.', 409, 'GEOMETRY_REVISION_CONFLICT');
throw fail('Der er ikke et gemt kortområde at slette.', 404, 'SITE_GEOMETRY_NOT_FOUND');
}
if (currentRevision !== payload.expectedRevision) throw fail('Området er ændret i en anden session. Genåbn kortet for at hente seneste version.', 409, 'GEOMETRY_REVISION_CONFLICT');
const now = new Date().toISOString();
const tombstone = {
schema: 'site_geometry_tombstone_v1',
revision: currentRevision + 1,
deleted: true,
audit: {
createdAt: previous.audit?.createdAt || now,
createdBy: previous.audit?.createdBy || operator,
deletedAt: now,
deletedBy: operator
}
};
tombstone.signature = createHash('sha256').update(JSON.stringify(tombstone)).digest('hex');
const json = JSON.stringify(tombstone);
await connection.execute('INSERT INTO project_site_geometry (project_id, revision, geometry_json) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE revision=VALUES(revision), geometry_json=VALUES(geometry_json)', [projectId, tombstone.revision, json]);
await connection.execute('INSERT INTO project_site_geometry_audit (project_id, revision, actor_id, event_type, occurred_at, before_signature, after_signature, geometry_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', [projectId, tombstone.revision, operator, 'deleted', new Date(now), previous.signature || null, tombstone.signature, json]);
await connection.commit();
return { geometry: null, revision: tombstone.revision, deleted: true };
} catch (error) {
await connection.rollback();
throw error;
} finally { connection.release(); }
}
}
module.exports.SiteGeometryService = SiteGeometryService;