#!/usr/bin/env node 'use strict'; /** * Areas Geometry Audit Script * * Scans the `areas` collection for documents that would cause the geojson-rbush * spatial index to fail during duplicate checking (checkDupAreas), or that would * be silently filtered out by the defensive fixes added to that function. * * Issue classes detected: * 1. null_geometry — document has no `geometry` field * 2. null_coordinates — geometry exists but `coordinates` is null/missing/empty * 3. wrong_geo_type — geometry.type is not "Polygon" * 4. bad_nesting — coordinates nesting depth != 3 (not [[[lng,lat],...]]) * 5. nan_coordinates — coordinates contain null, undefined, or NaN values * 6. rbush_fail — tree.insert() throws even though structure looked valid * (mirrors the exact production code path) * * Documents matching any issue would be silently dropped from the rbush tree, * meaning areas belonging to those clients may not have full dup-checking coverage. * * Usage (run from the server/ root): * node scripts/audit_areas_geometry.js * node scripts/audit_areas_geometry.js --env ./environment_prod.env * node scripts/audit_areas_geometry.js --client= * node scripts/audit_areas_geometry.js --output=./audit_results.json * * Dry-run is the ONLY mode — this script never writes to the database. * Use the output JSON to plan follow-up remediation. * * Options: * --env Path to environment file (default: ./environment.env) * --env= Alternate form * --client= Limit scan to a single client's areas * --output= Write full findings as JSON to this path * --batch= Cursor batch size (default: 500) */ const fs = require('fs'); const path = require('path'); // --------------------------------------------------------------------------- // Arg parsing // --------------------------------------------------------------------------- const args = process.argv.slice(2); let envFile = './environment.env'; let clientFilter = null; let outputFile = null; let batchSize = 500; for (let i = 0; i < args.length; i++) { if (args[i] === '--env' && args[i + 1] && !args[i + 1].startsWith('--')) { envFile = args[i + 1]; i++; } else if (args[i].startsWith('--env=')) { envFile = args[i].split('=').slice(1).join('='); } else if (args[i].startsWith('--client=')) { clientFilter = args[i].split('=')[1]; } else if (args[i].startsWith('--output=')) { outputFile = args[i].split('=')[1]; } else if (args[i].startsWith('--batch=')) { batchSize = parseInt(args[i].split('=')[1], 10) || 500; } } // --------------------------------------------------------------------------- // Load environment // --------------------------------------------------------------------------- const envPath = path.resolve(process.cwd(), envFile); if (!fs.existsSync(envPath)) { console.error(`[audit_areas_geometry] Environment file not found: ${envPath}`); process.exit(1); } require('dotenv').config({ path: envPath }); console.log(`[audit_areas_geometry] Environment loaded from: ${envPath}`); if (clientFilter) console.log(`[audit_areas_geometry] Filtering to client: ${clientFilter}`); console.log(`[audit_areas_geometry] DRY-RUN — no writes will occur\n`); // --------------------------------------------------------------------------- // DB + model bootstrap // --------------------------------------------------------------------------- const mongoose = require('mongoose'); const { DBConnection } = require('../helpers/db/connect'); const GeojsonRbush = require('@mickeyjohn/geojson-rbush').default; const AreaSchema = new mongoose.Schema({ properties: mongoose.Schema.Types.Mixed, geometry: mongoose.Schema.Types.Mixed, client: mongoose.Schema.Types.ObjectId, }, { strict: false }); const Area = mongoose.models.Area || mongoose.model('Area', AreaSchema, 'areas'); // --------------------------------------------------------------------------- // Validation helpers // --------------------------------------------------------------------------- const ISSUE = { NULL_GEOMETRY: 'null_geometry', NULL_COORDINATES: 'null_coordinates', WRONG_GEO_TYPE: 'wrong_geo_type', BAD_NESTING: 'bad_nesting', NAN_COORDINATES: 'nan_coordinates', RBUSH_FAIL: 'rbush_fail', }; // Shared test tree — insert then clear to mirror the exact production code path // without accumulating state between documents. const _testTree = GeojsonRbush(); function hasNaNValues(coords) { if (!Array.isArray(coords)) return true; for (const item of coords) { if (Array.isArray(item)) { if (hasNaNValues(item)) return true; } else { if (item === null || item === undefined || isNaN(item)) return true; } } return false; } function coordinateDepth(coords) { if (!Array.isArray(coords)) return 0; if (!Array.isArray(coords[0])) return 1; if (!Array.isArray(coords[0][0])) return 2; return 3; } function auditDocument(doc) { const issues = []; const geo = doc.geometry; if (!geo || typeof geo !== 'object') { issues.push(ISSUE.NULL_GEOMETRY); return issues; // no point checking further } if (!geo.coordinates || !Array.isArray(geo.coordinates) || geo.coordinates.length === 0) { issues.push(ISSUE.NULL_COORDINATES); return issues; } if (geo.type !== 'Polygon') { issues.push(ISSUE.WRONG_GEO_TYPE); // still check coordinates } if (coordinateDepth(geo.coordinates) !== 3) { issues.push(ISSUE.BAD_NESTING); } if (hasNaNValues(geo.coordinates)) { issues.push(ISSUE.NAN_COORDINATES); } // If no structural issues found yet, mirror the exact production code path: // try inserting into a real rbush tree and clear it immediately after. if (issues.length === 0) { const feature = { type: 'Feature', geometry: geo, properties: {} }; try { _testTree.insert(feature); _testTree.clear(); } catch (e) { issues.push(ISSUE.RBUSH_FAIL); } } return issues; } // --------------------------------------------------------------------------- // Main audit // --------------------------------------------------------------------------- async function run() { const db = new DBConnection('audit_areas_geometry'); await db.connect({ setupExitHandlers: false, setupEventListeners: false, exitOnError: false }); const query = clientFilter ? { client: new mongoose.Types.ObjectId(clientFilter) } : {}; const cursor = Area.find(query, { _id: 1, client: 1, geometry: 1 }).lean().cursor({ batchSize }); const findings = []; // { _id, client, issues[] } const countByIssue = {}; Object.values(ISSUE).forEach(k => { countByIssue[k] = 0; }); let scanned = 0; let invalidCount = 0; process.stdout.write('[audit_areas_geometry] Scanning'); for await (const doc of cursor) { scanned++; if (scanned % 5000 === 0) process.stdout.write('.'); const issues = auditDocument(doc); if (issues.length === 0) continue; invalidCount++; issues.forEach(k => { countByIssue[k]++; }); findings.push({ _id: doc._id.toString(), client: doc.client ? doc.client.toString() : null, issues, }); } console.log(` done.\n`); // --------------------------------------------------------------------------- // Report // --------------------------------------------------------------------------- console.log('='.repeat(60)); console.log('AUDIT SUMMARY'); console.log('='.repeat(60)); console.log(` Total scanned : ${scanned.toLocaleString()}`); console.log(` Invalid docs : ${invalidCount.toLocaleString()} (${scanned ? ((invalidCount / scanned) * 100).toFixed(2) : 0}%)`); console.log(''); console.log(' Breakdown by issue type:'); Object.entries(countByIssue).forEach(([key, n]) => { if (n > 0) console.log(` ${key.padEnd(20)} ${n.toLocaleString()}`); }); console.log('='.repeat(60)); if (invalidCount > 0) { // Group by client for a quick "which clients are affected" view const byClient = {}; findings.forEach(f => { const k = f.client || '(no client)'; if (!byClient[k]) byClient[k] = { count: 0, issues: new Set() }; byClient[k].count++; f.issues.forEach(i => byClient[k].issues.add(i)); }); console.log('\nAFFECTED CLIENTS:'); Object.entries(byClient) .sort((a, b) => b[1].count - a[1].count) .forEach(([clientId, info]) => { console.log(` ${clientId} → ${info.count} doc(s) [${[...info.issues].join(', ')}]`); }); // Print first 20 IDs inline for quick reference console.log('\nFIRST 20 INVALID DOCUMENT IDs:'); findings.slice(0, 20).forEach(f => { console.log(` ${f._id} client=${f.client} issues=${f.issues.join(',')}`); }); if (findings.length > 20) { console.log(` ... and ${findings.length - 20} more (use --output to capture all)`); } } // --------------------------------------------------------------------------- // Optional JSON output // --------------------------------------------------------------------------- if (outputFile) { const outPath = path.resolve(process.cwd(), outputFile); const payload = { auditDate: new Date().toISOString(), envFile, clientFilter: clientFilter || null, totalScanned: scanned, totalInvalid: invalidCount, countByIssue, findings, }; fs.writeFileSync(outPath, JSON.stringify(payload, null, 2)); console.log(`\n[audit_areas_geometry] Full findings written to: ${outPath}`); } else if (invalidCount > 0) { console.log('\nTip: re-run with --output=./audit_results.json to capture all IDs.'); } await db.close(); process.exit(0); } run().catch(err => { console.error('[audit_areas_geometry] Fatal error:', err); process.exit(1); });