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]>
187 lines
12 KiB
JavaScript
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;
|