1157 lines
48 KiB
JavaScript
1157 lines
48 KiB
JavaScript
"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
|