"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.locationsService = void 0; const client_1 = require("@prisma/client"); const sync_1 = require("csv-parse/sync"); const sync_2 = require("csv-stringify/sync"); const proj4_1 = __importDefault(require("proj4")); const database_1 = require("../../../config/database"); const error_handler_1 = require("../../../middleware/error-handler"); const geocoding_service_1 = require("../geocoding/geocoding.service"); const logger_1 = require("../../../utils/logger"); const metrics_1 = require("../../../utils/metrics"); const spatial_1 = require("../../../utils/spatial"); const settings_service_1 = require("../settings/settings.service"); // Statistics Canada Lambert Conformal Conic projection (EPSG:3347) → WGS84 (EPSG:4326) proj4_1.default.defs('EPSG:3347', '+proj=lcc +lat_1=49 +lat_2=77 +lat_0=63.390675 +lon_0=-91.86666666666666 +x_0=6200000 +y_0=3000000 +ellps=GRS80 +units=m +no_defs'); // Map CSV header names to our fields (case-insensitive, flexible) const CSV_HEADER_MAP = { 'address': 'address', 'street': 'address', 'street address': 'address', 'first name': 'firstName', 'firstname': 'firstName', 'first': 'firstName', 'last name': 'lastName', 'lastname': 'lastName', 'last': 'lastName', 'email': 'email', 'e-mail': 'email', 'phone': 'phone', 'telephone': 'phone', 'tel': 'phone', 'phone number': 'phone', 'unit': 'unitNumber', 'unit number': 'unitNumber', 'unitnumber': 'unitNumber', 'apt': 'unitNumber', 'apartment': 'unitNumber', 'suite': 'unitNumber', 'support level': 'supportLevel', 'supportlevel': 'supportLevel', 'support': 'supportLevel', 'level': 'supportLevel', 'sign': 'sign', 'lawn sign': 'sign', 'sign size': 'signSize', 'signsize': 'signSize', 'notes': 'notes', 'note': 'notes', 'comments': 'notes', 'latitude': 'latitude', 'lat': 'latitude', 'longitude': 'longitude', 'lng': 'longitude', 'lon': 'longitude', }; // NAR (National Address Register) column name mapping → internal keys // Supports both 2025 NAR format (CIVIC_NO, OFFICIAL_STREET_NAME, BG_X/BG_Y) // and legacy format (STR_NBR, STR_NME, LAT/LNG) const NAR_HEADER_MAP = { // 2025 NAR Address file columns 'civic_no': 'CIVIC_NO', 'civic_no_suffix': 'CIVIC_NO_SUFFIX', 'official_street_name': 'STREET_NAME', 'official_street_type': 'STREET_TYPE', 'official_street_dir': 'STREET_DIR', 'apt_no_label': 'UNIT_NBR', 'bg_x': 'BG_X', 'bg_y': 'BG_Y', 'bg_latitude': 'LAT', 'bg_longitude': 'LNG', 'mail_mun_name': 'CITY', 'csd_eng_name': 'CSD_NAME', 'mail_prov_abvn': 'PROV', 'prov_code': 'PROV_CODE', 'mail_postal_code': 'POSTAL_CODE', 'fed_eng_name': 'FED_DISTRICT', 'fed_code': 'FED_CODE', 'bu_use': 'BU_USE', // Legacy column support (backward compatibility) 'str_nbr': 'CIVIC_NO', 'street_number': 'CIVIC_NO', 'house_number': 'CIVIC_NO', 'str_nme': 'STREET_NAME', 'street_name': 'STREET_NAME', 'str_typ': 'STREET_TYPE', 'street_type': 'STREET_TYPE', 'str_dir': 'STREET_DIR', 'street_direction': 'STREET_DIR', 'unit_nbr': 'UNIT_NBR', 'unit': 'UNIT_NBR', 'suite': 'UNIT_NBR', 'lat': 'LAT', 'latitude': 'LAT', 'lng': 'LNG', 'lon': 'LNG', 'longitude': 'LNG', 'long': 'LNG', 'city': 'CITY', 'mun_nme': 'CITY', 'municipality': 'CITY', 'prov': 'PROV', 'province': 'PROV', 'prv_nme': 'PROV', }; // Columns that indicate NAR format (match 3+ = NAR) const NAR_DETECT_COLUMNS = [ 'CIVIC_NO', 'OFFICIAL_STREET_NAME', 'OFFICIAL_STREET_TYPE', // 2025 NAR 'BG_X', 'BG_Y', 'MAIL_POSTAL_CODE', 'MAIL_PROV_ABVN', // 2025 NAR 'BG_LATITUDE', 'BG_LONGITUDE', // 2025 NAR Location files 'STR_NBR', 'STR_NME', 'STR_TYP', 'LAT', 'LNG', // Legacy ]; function detectNarFormat(headers) { const normalizedHeaders = headers.map((h) => h.trim().toUpperCase()); let matchCount = 0; const matched = new Set(); for (const col of NAR_DETECT_COLUMNS) { if (normalizedHeaders.includes(col) && !matched.has(col)) { matched.add(col); matchCount++; } } // Also check via aliases (lowercase header → uppercase target) for (const h of normalizedHeaders) { const alias = NAR_HEADER_MAP[h.toLowerCase()]; if (alias && !matched.has(h)) { matched.add(h); matchCount++; } } return matchCount >= 3; } /** Convert BG_X/BG_Y (EPSG:3347 Lambert) to [lat, lng] (WGS84) */ function lambertToLatLng(bgX, bgY) { const [lng, lat] = (0, proj4_1.default)('EPSG:3347', 'EPSG:4326', [bgX, bgY]); return [lat, lng]; } function roundCoord(val, decimals = 5) { const factor = Math.pow(10, decimals); return Math.round(val * factor) / factor; } function parseSupportLevel(value) { const v = value.trim(); if (v === '1' || v.toLowerCase() === 'level_1') return client_1.SupportLevel.LEVEL_1; if (v === '2' || v.toLowerCase() === 'level_2') return client_1.SupportLevel.LEVEL_2; if (v === '3' || v.toLowerCase() === 'level_3') return client_1.SupportLevel.LEVEL_3; if (v === '4' || v.toLowerCase() === 'level_4') return client_1.SupportLevel.LEVEL_4; return undefined; } function parseBoolean(value) { const v = value.trim().toLowerCase(); return v === 'true' || v === 'yes' || v === '1' || v === 'y'; } /** * Record location history for audit trail */ async function recordHistory(locationId, userId, action, changes, metadata) { const historyRecords = changes?.map(({ field, oldValue, newValue }) => ({ locationId, userId, action, field, oldValue: oldValue != null ? String(oldValue) : null, newValue: newValue != null ? String(newValue) : null, metadata: metadata ? metadata : undefined, })) ?? [{ locationId, userId, action, field: null, oldValue: null, newValue: null, metadata: metadata ? metadata : undefined, }]; await database_1.prisma.locationHistory.createMany({ data: historyRecords }); } exports.locationsService = { async findAll(filters) { const { page, limit, search, supportLevel, hasSign, confidenceLevel, sortBy, sortOrder } = filters; const skip = (page - 1) * limit; const where = {}; // Address-level filters (search contacts, supportLevel, sign) const addressWhere = {}; let hasAddressFilters = false; if (search) { // Search both location address and address contact info where.OR = [ { address: { contains: search, mode: 'insensitive' } }, { addresses: { some: { OR: [ { firstName: { contains: search, mode: 'insensitive' } }, { lastName: { contains: search, mode: 'insensitive' } }, { email: { contains: search, mode: 'insensitive' } }, ], }, }, }, ]; } if (supportLevel) { addressWhere.supportLevel = supportLevel; hasAddressFilters = true; } if (hasSign !== undefined) { addressWhere.sign = hasSign; hasAddressFilters = true; } if (hasAddressFilters) { where.addresses = { some: addressWhere }; } // Confidence level filter (Location-level) if (confidenceLevel) { if (confidenceLevel === 'high') { where.geocodeConfidence = { gte: 85 }; } else if (confidenceLevel === 'medium') { where.geocodeConfidence = { gte: 60, lt: 85 }; } else if (confidenceLevel === 'low') { where.geocodeConfidence = { lt: 60, gt: 0 }; } else if (confidenceLevel === 'none') { const noConfidenceConditions = [{ geocodeConfidence: null }, { geocodeConfidence: 0 }]; where.OR = where.OR ? [...where.OR, ...noConfidenceConditions] : noConfidenceConditions; } } const orderBy = { [sortBy]: sortOrder }; const [locations, total] = await Promise.all([ database_1.prisma.location.findMany({ where, skip, take: limit, orderBy, include: { addresses: { select: { id: true, unitNumber: true, firstName: true, lastName: true, supportLevel: true, sign: true, }, }, }, }), database_1.prisma.location.count({ where }), ]); return { locations, pagination: { page, limit, total, totalPages: Math.ceil(total / limit), }, }; }, async findById(id) { const location = await database_1.prisma.location.findUnique({ where: { id }, include: { addresses: { select: { id: true, unitNumber: true, firstName: true, lastName: true, email: true, phone: true, supportLevel: true, sign: true, signSize: true, notes: true, }, }, }, }); if (!location) { throw new error_handler_1.AppError(404, 'Location not found', 'LOCATION_NOT_FOUND'); } return location; }, async create(data, userId) { // Split data into Location (building) and Address (unit) fields const locationData = { address: data.address, latitude: data.latitude, longitude: data.longitude, createdByUserId: userId, }; const addressData = { unitNumber: data.unitNumber, firstName: data.firstName, lastName: data.lastName, email: data.email, phone: data.phone, supportLevel: data.supportLevel, sign: data.sign, signSize: data.signSize, notes: data.notes, }; // Auto-geocode if address provided and no lat/lng let geocodeMetadata; if (data.address && data.latitude == null && data.longitude == null) { const result = await geocoding_service_1.geocodingService.geocode(data.address); if (result) { locationData.latitude = result.latitude; locationData.longitude = result.longitude; locationData.geocodeConfidence = result.confidence; locationData.geocodeProvider = result.provider; geocodeMetadata = { provider: result.provider, confidence: result.confidence, geocoded: true, }; } } // Create location, address, and record history in transaction const location = await database_1.prisma.$transaction(async (tx) => { const newLocation = await tx.location.create({ data: locationData }); // Create default Address record (even if no occupant data) await tx.address.create({ data: { locationId: newLocation.id, unitNumber: addressData.unitNumber || null, firstName: addressData.firstName || null, lastName: addressData.lastName || null, email: addressData.email || null, phone: addressData.phone || null, supportLevel: addressData.supportLevel || null, sign: addressData.sign ?? false, signSize: addressData.signSize || null, notes: addressData.notes || null, createdByUserId: userId, }, }); // Record creation history await tx.locationHistory.create({ data: { locationId: newLocation.id, userId, action: geocodeMetadata ? client_1.LocationHistoryAction.GEOCODED : client_1.LocationHistoryAction.CREATED, metadata: geocodeMetadata ? geocodeMetadata : undefined, }, }); return newLocation; }); return location; }, async update(id, data, userId) { const existing = await database_1.prisma.location.findUnique({ where: { id }, include: { addresses: true }, }); if (!existing) { throw new error_handler_1.AppError(404, 'Location not found', 'LOCATION_NOT_FOUND'); } // Split data into Location and Address fields const locationUpdateData = { updatedByUserId: userId, }; const addressUpdateData = { updatedByUserId: userId, }; // Location fields if (data.address !== undefined && data.address) locationUpdateData.address = data.address; if (data.latitude !== undefined) locationUpdateData.latitude = data.latitude; if (data.longitude !== undefined) locationUpdateData.longitude = data.longitude; if (data.postalCode !== undefined) locationUpdateData.postalCode = data.postalCode ?? undefined; if (data.province !== undefined) locationUpdateData.province = data.province ?? undefined; if (data.buildingType !== undefined) locationUpdateData.buildingType = data.buildingType ?? undefined; if (data.buildingNotes !== undefined) locationUpdateData.buildingNotes = data.buildingNotes ?? undefined; // Address fields (will update first address or create one) const addressFields = ['unitNumber', 'firstName', 'lastName', 'email', 'phone', 'supportLevel', 'sign', 'signSize', 'notes']; let hasAddressUpdates = false; for (const field of addressFields) { if (data[field] !== undefined) { addressUpdateData[field] = data[field] === '' ? null : data[field]; hasAddressUpdates = true; } } // Track field changes for history const changes = []; let action = client_1.LocationHistoryAction.UPDATED; let metadata; // Check for explicit lat/lng changes (MOVED_ON_MAP) if (data.latitude !== undefined && Number(data.latitude) !== Number(existing.latitude)) { changes.push({ field: 'latitude', oldValue: existing.latitude, newValue: data.latitude }); action = client_1.LocationHistoryAction.MOVED_ON_MAP; } if (data.longitude !== undefined && Number(data.longitude) !== Number(existing.longitude)) { changes.push({ field: 'longitude', oldValue: existing.longitude, newValue: data.longitude }); action = client_1.LocationHistoryAction.MOVED_ON_MAP; } // Re-geocode if address changed and no explicit lat/lng provided if (data.address && data.address !== existing.address && data.latitude === undefined && data.longitude === undefined) { const result = await geocoding_service_1.geocodingService.geocode(data.address); if (result) { locationUpdateData.latitude = result.latitude; locationUpdateData.longitude = result.longitude; locationUpdateData.geocodeConfidence = result.confidence; locationUpdateData.geocodeProvider = result.provider; action = client_1.LocationHistoryAction.GEOCODED; metadata = { provider: result.provider, confidence: result.confidence, }; changes.push({ field: 'latitude', oldValue: existing.latitude, newValue: result.latitude }); changes.push({ field: 'longitude', oldValue: existing.longitude, newValue: result.longitude }); } } // Track location field changes if (data.address !== undefined && data.address !== existing.address) { changes.push({ field: 'address', oldValue: existing.address, newValue: data.address }); } // Update location, address, and record history in transaction const location = await database_1.prisma.$transaction(async (tx) => { const updated = await tx.location.update({ where: { id }, data: locationUpdateData, }); // Update address if there are address fields if (hasAddressUpdates) { if (existing.addresses.length > 0) { // Update first address (default unit) await tx.address.update({ where: { id: existing.addresses[0].id }, data: addressUpdateData, }); } else { // Create address if none exists const { updatedByUserId, ...addressFields } = addressUpdateData; await tx.address.create({ data: { locationId: id, ...addressFields, createdByUserId: userId, }, }); } } // Record history if there were changes if (changes.length > 0) { const historyRecords = changes.map(({ field, oldValue, newValue }) => ({ locationId: id, userId, action, field, oldValue: oldValue != null ? String(oldValue) : null, newValue: newValue != null ? String(newValue) : null, metadata: metadata ? metadata : null, })); await tx.locationHistory.createMany({ data: historyRecords }); } return updated; }); return location; }, async delete(id) { const existing = await database_1.prisma.location.findUnique({ where: { id } }); if (!existing) { throw new error_handler_1.AppError(404, 'Location not found', 'LOCATION_NOT_FOUND'); } await database_1.prisma.location.delete({ where: { id } }); }, async bulkDelete(ids) { const result = await database_1.prisma.location.deleteMany({ where: { id: { in: ids } }, }); return { deleted: result.count }; }, async getHistory(locationId, page = 1, limit = 20) { const skip = (page - 1) * limit; const [history, total] = await Promise.all([ database_1.prisma.locationHistory.findMany({ where: { locationId }, include: { user: { select: { id: true, email: true, name: true, role: true }, }, }, orderBy: { createdAt: 'desc' }, skip, take: limit, }), database_1.prisma.locationHistory.count({ where: { locationId } }), ]); return { history, pagination: { page, limit, total, totalPages: Math.ceil(total / limit), }, }; }, async geocodeAddress(address) { const result = await geocoding_service_1.geocodingService.geocode(address); if (!result) { throw new error_handler_1.AppError(404, 'Could not geocode address', 'GEOCODE_FAILED'); } return result; }, async geocodeMissing() { // NOTE: In V2 schema, latitude/longitude/address are all non-nullable, // so this will return empty results. Kept for API compatibility. const missing = await database_1.prisma.location.findMany({ where: { geocodeConfidence: null, // Find ungeocoded locations by confidence }, select: { id: true, address: true }, take: 100, // Limit batch size }); let geocoded = 0; let failed = 0; for (const loc of missing) { if (!loc.address) continue; try { const result = await geocoding_service_1.geocodingService.geocode(loc.address); if (result) { await database_1.prisma.location.update({ where: { id: loc.id }, data: { latitude: result.latitude, longitude: result.longitude, geocodeConfidence: result.confidence, geocodeProvider: result.provider, }, }); geocoded++; } else { failed++; } } catch (err) { logger_1.logger.warn(`Failed to geocode location ${loc.id}:`, err); failed++; } } return { total: missing.length, geocoded, failed }; }, async getStats() { try { const [total, singleFamily, multiUnit, mixedUse, commercial, geocoded, // Confidence distribution confidenceHigh, confidenceMedium, confidenceLow, confidenceNone, // Provider breakdown nominatim, mapbox, arcgis, photon, google, locationiq, manual,] = await Promise.all([ database_1.prisma.location.count(), database_1.prisma.location.count({ where: { buildingType: 'SINGLE_FAMILY' } }), database_1.prisma.location.count({ where: { buildingType: 'MULTI_UNIT' } }), database_1.prisma.location.count({ where: { buildingType: 'MIXED_USE' } }), database_1.prisma.location.count({ where: { buildingType: 'COMMERCIAL' } }), database_1.prisma.location.count(), // All locations (latitude/longitude are non-nullable) // Confidence queries database_1.prisma.location.count({ where: { geocodeConfidence: { gte: 85 } } }), database_1.prisma.location.count({ where: { geocodeConfidence: { gte: 60, lt: 85 } } }), database_1.prisma.location.count({ where: { geocodeConfidence: { lt: 60 } } }), database_1.prisma.location.count({ where: { OR: [{ geocodeConfidence: null }, { geocodeConfidence: 0 }] } }), // Provider queries database_1.prisma.location.count({ where: { geocodeProvider: client_1.GeocodeProvider.NOMINATIM } }), database_1.prisma.location.count({ where: { geocodeProvider: client_1.GeocodeProvider.MAPBOX } }), database_1.prisma.location.count({ where: { geocodeProvider: client_1.GeocodeProvider.ARCGIS } }), database_1.prisma.location.count({ where: { geocodeProvider: client_1.GeocodeProvider.PHOTON } }), database_1.prisma.location.count({ where: { geocodeProvider: client_1.GeocodeProvider.GOOGLE } }), database_1.prisma.location.count({ where: { geocodeProvider: client_1.GeocodeProvider.LOCATIONIQ } }), database_1.prisma.location.count({ where: { OR: [{ geocodeProvider: client_1.GeocodeProvider.UNKNOWN }, { geocodeProvider: null }] } }), ]); // Calculate average confidence const avgConfidenceResult = await database_1.prisma.location.aggregate({ _avg: { geocodeConfidence: true }, where: { geocodeConfidence: { gt: 0 } }, // gt: 0 already excludes null }); return { total, buildingTypes: { SINGLE_FAMILY: singleFamily, MULTI_UNIT: multiUnit, MIXED_USE: mixedUse, COMMERCIAL: commercial, }, geocoded, ungeocoded: total - geocoded, // Confidence stats confidence: { high: confidenceHigh, medium: confidenceMedium, low: confidenceLow, none: confidenceNone, average: avgConfidenceResult._avg.geocodeConfidence ? Math.round(avgConfidenceResult._avg.geocodeConfidence) : null, }, // Provider breakdown providers: { nominatim, mapbox, arcgis, photon, google, locationiq, manual, }, }; } catch (error) { logger_1.logger.error('Failed to fetch location stats:', error); throw error; // Re-throw to trigger 500 response } }, async getAllForMap(bounds, limit = 5000) { const startTime = Date.now(); const where = {}; if (bounds) { // Fix Decimal type handling - convert bounds to Prisma.Decimal where.latitude = { gte: new client_1.Prisma.Decimal(bounds.minLat.toString()), lte: new client_1.Prisma.Decimal(bounds.maxLat.toString()), }; where.longitude = { gte: new client_1.Prisma.Decimal(bounds.minLng.toString()), lte: new client_1.Prisma.Decimal(bounds.maxLng.toString()), }; } const locations = await database_1.prisma.location.findMany({ where, orderBy: { createdAt: 'desc' }, take: limit, // Zoom-aware limit from frontend include: { addresses: { select: { id: true, unitNumber: true, firstName: true, lastName: true, email: true, phone: true, supportLevel: true, sign: true, signSize: true, notes: true, }, }, }, }); const durationSeconds = (Date.now() - startTime) / 1000; (0, metrics_1.recordLocationQuery)('admin_all', !!bounds, locations.length, durationSeconds); return locations; }, async getPublicLocations(bounds) { const startTime = Date.now(); const where = {}; if (bounds) { // Fix Decimal type handling - convert bounds to Prisma.Decimal where.latitude = { gte: new client_1.Prisma.Decimal(bounds.minLat.toString()), lte: new client_1.Prisma.Decimal(bounds.maxLat.toString()), }; where.longitude = { gte: new client_1.Prisma.Decimal(bounds.minLng.toString()), lte: new client_1.Prisma.Decimal(bounds.maxLng.toString()), }; } const locations = await database_1.prisma.location.findMany({ where, select: { id: true, latitude: true, longitude: true, address: true, addresses: { select: { id: true, unitNumber: true, supportLevel: true, sign: true, signSize: true, }, }, }, take: 5000, // Safety limit }); // Server-side enforcement: strip sensitive fields based on map visibility settings const mapSettings = await settings_service_1.mapSettingsService.get(); if (!mapSettings.publicShowSupportLevels || !mapSettings.publicShowSignInfo) { for (const loc of locations) { for (const addr of loc.addresses) { if (!mapSettings.publicShowSupportLevels) { addr.supportLevel = null; } if (!mapSettings.publicShowSignInfo) { addr.sign = false; addr.signSize = null; } } } } const durationSeconds = (Date.now() - startTime) / 1000; (0, metrics_1.recordLocationQuery)('public', !!bounds, locations.length, durationSeconds); return locations; }, async importFromCsv(buffer, userId) { let records; try { records = (0, sync_1.parse)(buffer, { columns: true, skip_empty_lines: true, trim: true, bom: true, }); } catch { throw new error_handler_1.AppError(400, 'Invalid CSV file format', 'INVALID_CSV'); } if (!records.length) { throw new error_handler_1.AppError(400, 'CSV file is empty', 'EMPTY_CSV'); } // Detect column mapping from headers const headers = Object.keys(records[0]); const columnMap = {}; for (const header of headers) { const normalized = header.toLowerCase().trim(); const mapped = CSV_HEADER_MAP[normalized]; if (mapped) { columnMap[header] = mapped; } } let success = 0; let warnings = 0; let failed = 0; const errors = []; for (let i = 0; i < records.length; i++) { const record = records[i]; try { const row = {}; for (const [header, field] of Object.entries(columnMap)) { const value = record[header]; if (value !== undefined && value !== '') { row[field] = value; } } if (!row.address) { errors.push(`Row ${i + 2}: Missing address`); failed++; continue; } // Split into Location and Address data const locationData = { address: row.address, createdByUserId: userId, }; const addressData = { firstName: row.firstName || null, lastName: row.lastName || null, email: row.email || null, phone: row.phone || null, unitNumber: row.unitNumber || null, supportLevel: row.supportLevel ? parseSupportLevel(row.supportLevel) : null, sign: row.sign ? parseBoolean(row.sign) : false, signSize: row.signSize || null, notes: row.notes || null, }; // Use provided lat/lng or geocode if (row.latitude && row.longitude) { const lat = parseFloat(row.latitude); const lng = parseFloat(row.longitude); if (!isNaN(lat) && !isNaN(lng)) { locationData.latitude = lat; locationData.longitude = lng; } } else { const result = await geocoding_service_1.geocodingService.geocode(row.address); if (result) { locationData.latitude = result.latitude; locationData.longitude = result.longitude; locationData.geocodeConfidence = result.confidence; locationData.geocodeProvider = result.provider; } else { warnings++; } } // Create Location and Address in transaction const newLocation = await database_1.prisma.location.create({ data: locationData }); await database_1.prisma.address.create({ data: { locationId: newLocation.id, ...addressData, createdByUserId: userId, }, }); success++; } catch (err) { const msg = err instanceof Error ? err.message : 'Unknown error'; errors.push(`Row ${i + 2}: ${msg}`); failed++; } } return { total: records.length, success, warnings, failed, errors: errors.slice(0, 50) }; }, async reverseGeocode(latitude, longitude) { const result = await geocoding_service_1.geocodingService.reverseGeocode(latitude, longitude); if (!result) { throw new error_handler_1.AppError(404, 'Could not reverse geocode coordinates', 'REVERSE_GEOCODE_FAILED'); } return result; }, async importBulk(buffer, userId, options, filters) { let records; try { records = (0, sync_1.parse)(buffer, { columns: true, skip_empty_lines: true, trim: true, bom: true, }); } catch { throw new error_handler_1.AppError(400, 'Invalid CSV file format', 'INVALID_CSV'); } if (!records.length) { throw new error_handler_1.AppError(400, 'CSV file is empty', 'EMPTY_CSV'); } const headers = Object.keys(records[0]); const isNar = options.format === 'nar' || detectNarFormat(headers); // Build header mapping for NAR (map original CSV header → internal key) const headerMap = {}; if (isNar) { for (const header of headers) { const lower = header.trim().toLowerCase(); const alias = NAR_HEADER_MAP[lower]; if (alias) { headerMap[header] = alias; } } } // Load existing coordinates for deduplication const existingCoords = new Set(); if (options.deduplicateRadius > 0) { const existing = await database_1.prisma.location.findMany({ select: { latitude: true, longitude: true }, }); // No where clause needed - latitude/longitude are non-nullable for (const loc of existing) { const key = `${roundCoord(Number(loc.latitude))}:${roundCoord(Number(loc.longitude))}`; existingCoords.add(key); } } const inFileCoords = new Set(); let created = 0; let skippedDuplicate = 0; let skippedOutOfBounds = 0; let skippedInvalid = 0; const errors = []; const parsedRecords = []; const addressesToGeocode = []; const geocodeIndexMap = new Map(); // maps parsedRecord index → geocode result index for (let i = 0; i < records.length; i++) { const record = records[i]; try { let address; let unitNumber; let lat; let lng; let postalCode; let province; let federalDistrict; let buildingUse; if (isNar) { // Build address from NAR fields (supports 2025 + legacy format) const getValue = (narKey) => { for (const [header, mapped] of Object.entries(headerMap)) { if (mapped === narKey) return (record[header] ?? '').trim(); } return ''; }; // Build civic address: "123A Main ST W" const civicNo = getValue('CIVIC_NO'); const civicSuffix = getValue('CIVIC_NO_SUFFIX'); const streetName = getValue('STREET_NAME'); const streetType = getValue('STREET_TYPE'); const streetDir = getValue('STREET_DIR'); const streetParts = [ civicNo + (civicSuffix || ''), streetName, streetType, streetDir, ].filter(Boolean); address = streetParts.join(' '); const city = getValue('CITY') || getValue('CSD_NAME'); province = getValue('PROV') || undefined; // City/province filters (fast string check before coordinate work) if (filters?.city && city.toLowerCase() !== filters.city.toLowerCase()) { skippedOutOfBounds++; continue; } if (filters?.province && province && province.toLowerCase() !== filters.province.toLowerCase()) { skippedOutOfBounds++; continue; } if (city) address += `, ${city}`; if (province) address += `, ${province}`; unitNumber = getValue('UNIT_NBR') || undefined; postalCode = getValue('POSTAL_CODE') || undefined; federalDistrict = getValue('FED_DISTRICT') || undefined; const buUse = getValue('BU_USE'); if (buUse) { const parsed = parseInt(buUse, 10); if (!isNaN(parsed)) buildingUse = parsed; } // Residential filter: skip non-residential (BU_USE 3) if filtering if (options.residentialOnly && buildingUse === 3) { skippedOutOfBounds++; continue; } // Coordinates: prefer BG_LATITUDE/BG_LONGITUDE, fallback to BG_X/BG_Y (Lambert→WGS84), then LAT/LNG const bgLatStr = getValue('LAT'); const bgLngStr = getValue('LNG'); if (bgLatStr && bgLngStr) { lat = parseFloat(bgLatStr); lng = parseFloat(bgLngStr); if (isNaN(lat) || isNaN(lng)) { lat = undefined; lng = undefined; } } if (lat === undefined || lng === undefined) { const bgXStr = getValue('BG_X'); const bgYStr = getValue('BG_Y'); if (bgXStr && bgYStr) { const bgX = parseFloat(bgXStr); const bgY = parseFloat(bgYStr); if (!isNaN(bgX) && !isNaN(bgY)) { [lat, lng] = lambertToLatLng(bgX, bgY); } } } } else { // Standard CSV - use existing header mapping const row = {}; for (const [header, field] of Object.entries(CSV_HEADER_MAP)) { for (const h of headers) { if (h.toLowerCase().trim() === header) { const value = record[h]; if (value !== undefined && value !== '') { row[field] = value; } } } } address = row.address; unitNumber = row.unitNumber; if (row.latitude && row.longitude) { lat = parseFloat(row.latitude); lng = parseFloat(row.longitude); if (isNaN(lat) || isNaN(lng)) { lat = undefined; lng = undefined; } } } if (!address) { skippedInvalid++; if (i < 20) errors.push(`Row ${i + 2}: Missing address`); continue; } const needsGeocoding = lat === undefined && lng === undefined && !options.skipGeocoding; parsedRecords.push({ rowIndex: i, address, unitNumber, lat, lng, postalCode, province, federalDistrict, buildingUse, needsGeocoding, }); if (needsGeocoding) { geocodeIndexMap.set(parsedRecords.length - 1, addressesToGeocode.length); addressesToGeocode.push(address); } } catch (err) { skippedInvalid++; if (errors.length < 50) { const msg = err instanceof Error ? err.message : 'Unknown error'; errors.push(`Row ${i + 2}: ${msg}`); } } } // Phase 2: Batch geocode all addresses in parallel let geocodeResults = []; if (addressesToGeocode.length > 0 && !options.skipGeocoding) { logger_1.logger.info(`Batch geocoding ${addressesToGeocode.length} addresses...`); geocodeResults = await geocoding_service_1.geocodingService.geocodeBatch(addressesToGeocode); logger_1.logger.info(`Batch geocoding complete: ${geocodeResults.filter(r => r !== null).length} succeeded`); } // Phase 3: Apply geocoding results and create location records const batch = []; for (let i = 0; i < parsedRecords.length; i++) { const parsed = parsedRecords[i]; let { lat, lng } = parsed; try { // Apply geocoding result if needed if (parsed.needsGeocoding) { const geocodeIndex = geocodeIndexMap.get(i); if (geocodeIndex !== undefined) { const result = geocodeResults[geocodeIndex]; if (result) { lat = result.latitude; lng = result.longitude; } } } // Skip if no coordinates if (lat === undefined || lng === undefined) { skippedInvalid++; if (errors.length < 20) errors.push(`Row ${parsed.rowIndex + 2}: No coordinates`); continue; } // Cut boundary filter if (filters?.cutPolygon && filters.cutPolygon.length > 0) { const inside = filters.cutPolygon.some((ring) => (0, spatial_1.isPointInPolygon)(lat, lng, ring)); if (!inside) { skippedOutOfBounds++; continue; } } // Bounding box filter (map area) if (filters?.bounds) { const { north, south, east, west } = filters.bounds; if (lat < south || lat > north || lng < west || lng > east) { skippedOutOfBounds++; continue; } } // Deduplication if (options.deduplicateRadius > 0) { const coordKey = `${roundCoord(lat)}:${roundCoord(lng)}`; if (existingCoords.has(coordKey) || inFileCoords.has(coordKey)) { skippedDuplicate++; continue; } inFileCoords.add(coordKey); } batch.push({ address: parsed.address, latitude: lat, longitude: lng, postalCode: parsed.postalCode, province: parsed.province, federalDistrict: parsed.federalDistrict, buildingUse: parsed.buildingUse, createdByUserId: userId, }); // Flush batch if (batch.length >= options.batchSize) { const result = await database_1.prisma.location.createMany({ data: [...batch], skipDuplicates: true }); created += result.count; batch.length = 0; } } catch (err) { skippedInvalid++; if (errors.length < 50) { const msg = err instanceof Error ? err.message : 'Unknown error'; errors.push(`Row ${parsed.rowIndex + 2}: ${msg}`); } } } // Flush remaining if (batch.length > 0) { const result = await database_1.prisma.location.createMany({ data: batch, skipDuplicates: true }); created += result.count; } return { total: records.length, created, skippedDuplicate, skippedOutOfBounds, skippedInvalid, errors: errors.slice(0, 50), }; }, async exportToCsv(filters) { const where = {}; // Build address filters const addressWhere = {}; if (filters?.supportLevel) addressWhere.supportLevel = filters.supportLevel; if (filters?.hasSign !== undefined) addressWhere.sign = filters.hasSign; // If there are address filters, only include locations with matching addresses if (Object.keys(addressWhere).length > 0) { where.addresses = { some: addressWhere }; } const locations = await database_1.prisma.location.findMany({ where, include: { addresses: true }, orderBy: { createdAt: 'desc' }, }); // Sanitize a field value against CSV formula injection. // Spreadsheet apps treat cells starting with =, +, -, @, \t, \r as formulas. // Prefixing with an apostrophe causes Excel/Sheets to treat the value as plain text. function sanitizeCsvField(value) { if (/^[=+\-@\t\r]/.test(value)) return `'${value}`; return value; } // Flatten: one row per address const rows = []; for (const loc of locations) { for (const addr of loc.addresses) { // Skip addresses that don't match filters if (filters?.supportLevel && addr.supportLevel !== filters.supportLevel) continue; if (filters?.hasSign !== undefined && addr.sign !== filters.hasSign) continue; rows.push({ address: sanitizeCsvField(loc.address || ''), unitNumber: sanitizeCsvField(addr.unitNumber || ''), firstName: sanitizeCsvField(addr.firstName || ''), lastName: sanitizeCsvField(addr.lastName || ''), email: sanitizeCsvField(addr.email || ''), phone: sanitizeCsvField(addr.phone || ''), supportLevel: addr.supportLevel || '', sign: addr.sign ? 'Yes' : 'No', signSize: addr.signSize || '', notes: sanitizeCsvField(addr.notes || ''), latitude: loc.latitude?.toString() || '', longitude: loc.longitude?.toString() || '', geocodeConfidence: loc.geocodeConfidence?.toString() || '', geocodeProvider: loc.geocodeProvider || '', createdAt: loc.createdAt.toISOString(), }); } } return (0, sync_2.stringify)(rows, { header: true }); }, }; //# sourceMappingURL=locations.service.js.map