changemaker.lite/api/dist/modules/map/locations/locations.service.js

1133 lines
47 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");
// 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
});
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' },
});
// 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: loc.address || '',
unitNumber: addr.unitNumber || '',
firstName: addr.firstName || '',
lastName: addr.lastName || '',
email: addr.email || '',
phone: addr.phone || '',
supportLevel: addr.supportLevel || '',
sign: addr.sign ? 'Yes' : 'No',
signSize: addr.signSize || '',
notes: 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