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 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 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;