795 lines
32 KiB
JavaScript

"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.canvassService = void 0;
const client_1 = require("@prisma/client");
const library_1 = require("@prisma/client/runtime/library");
const database_1 = require("../../../config/database");
const error_handler_1 = require("../../../middleware/error-handler");
const logger_1 = require("../../../utils/logger");
const metrics_1 = require("../../../utils/metrics");
const spatial_1 = require("../../../utils/spatial");
const canvass_route_service_1 = require("./canvass-route.service");
const metrics_2 = require("../../../utils/metrics");
const ADDRESS_SELECT = {
id: true,
unitNumber: true,
firstName: true,
lastName: true,
email: true,
phone: true,
supportLevel: true,
sign: true,
signSize: true,
notes: true,
location: {
select: {
id: true,
latitude: true,
longitude: true,
address: true,
buildingNotes: true,
},
},
};
async function annotateAddressesWithVisits(addresses, userId) {
const addressIds = addresses.map((a) => a.id);
if (addressIds.length === 0)
return [];
const latestVisits = await database_1.prisma.canvassVisit.findMany({
where: { addressId: { in: addressIds } },
orderBy: { visitedAt: 'desc' },
distinct: ['addressId'],
select: {
addressId: true,
outcome: true,
visitedAt: true,
userId: true,
user: { select: { name: true } },
},
});
const visitMap = new Map(latestVisits.map((v) => [v.addressId, v]));
return addresses.map((addr) => {
const visit = visitMap.get(addr.id);
return {
...addr,
location: {
...addr.location,
latitude: Number(addr.location.latitude),
longitude: Number(addr.location.longitude),
},
lastVisit: visit
? {
outcome: visit.outcome,
visitedAt: visit.visitedAt,
visitorName: visit.user?.name ?? null,
isMyVisit: visit.userId === userId,
}
: null,
};
});
}
const ADMIN_ADDRESS_FIELDS = ['firstName', 'lastName', 'unitNumber', 'email', 'phone'];
const VOLUNTEER_ADDRESS_FIELDS = ['supportLevel', 'sign', 'signSize', 'notes'];
exports.canvassService = {
// ─── Volunteer Methods ─────────────────────────────────────────────
async getMyAssignments(userId) {
const signups = await database_1.prisma.shiftSignup.findMany({
where: {
userId,
status: client_1.SignupStatus.CONFIRMED,
shift: { cutId: { not: null } },
},
include: {
shift: {
include: {
cut: { select: { id: true, name: true, completionPercentage: true, geojson: true } },
},
},
},
orderBy: { shift: { date: 'asc' } },
});
return signups
.filter((s) => s.shift.cut)
.map((s) => ({
shiftId: s.shift.id,
shiftTitle: s.shift.title,
shiftDate: s.shift.date,
startTime: s.shift.startTime,
endTime: s.shift.endTime,
location: s.shift.location,
cutId: s.shift.cut.id,
cutName: s.shift.cut.name,
completionPercentage: s.shift.cut.completionPercentage,
}));
},
async getMyStats(userId) {
const today = new Date();
today.setHours(0, 0, 0, 0);
const [totalVisits, todayVisits, byOutcome, sessions] = await Promise.all([
database_1.prisma.canvassVisit.count({ where: { userId } }),
database_1.prisma.canvassVisit.count({ where: { userId, visitedAt: { gte: today } } }),
database_1.prisma.canvassVisit.groupBy({
by: ['outcome'],
where: { userId },
_count: true,
}),
database_1.prisma.canvassSession.count({ where: { userId } }),
]);
const outcomeMap = {};
for (const row of byOutcome) {
outcomeMap[row.outcome] = row._count;
}
return { totalVisits, todayVisits, byOutcome: outcomeMap, sessions };
},
async getMyVisits(userId, filters) {
const { page, limit } = filters;
const skip = (page - 1) * limit;
const [visits, total] = await Promise.all([
database_1.prisma.canvassVisit.findMany({
where: { userId },
skip,
take: limit,
orderBy: { visitedAt: 'desc' },
include: {
address: {
select: {
id: true,
unitNumber: true,
location: { select: { address: true } },
},
},
},
}),
database_1.prisma.canvassVisit.count({ where: { userId } }),
]);
return {
visits,
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
};
},
async getActiveSession(userId) {
return database_1.prisma.canvassSession.findFirst({
where: { userId, status: client_1.CanvassSessionStatus.ACTIVE },
include: {
cut: { select: { id: true, name: true } },
shift: { select: { id: true, title: true } },
},
});
},
async startSession(userId, data) {
// Check for existing active session
const existing = await database_1.prisma.canvassSession.findFirst({
where: { userId, status: client_1.CanvassSessionStatus.ACTIVE },
});
if (existing) {
throw new error_handler_1.AppError(409, 'You already have an active canvass session', 'SESSION_ACTIVE');
}
// Verify cut exists
const cut = await database_1.prisma.cut.findUnique({ where: { id: data.cutId } });
if (!cut) {
throw new error_handler_1.AppError(404, 'Cut not found', 'CUT_NOT_FOUND');
}
const session = await database_1.prisma.canvassSession.create({
data: {
userId,
cutId: data.cutId,
shiftId: data.shiftId,
startLatitude: data.startLatitude,
startLongitude: data.startLongitude,
},
include: {
cut: { select: { id: true, name: true } },
shift: { select: { id: true, title: true } },
},
});
return session;
},
async endSession(sessionId, userId) {
const session = await database_1.prisma.canvassSession.findUnique({ where: { id: sessionId } });
if (!session) {
throw new error_handler_1.AppError(404, 'Session not found', 'SESSION_NOT_FOUND');
}
if (session.userId !== userId) {
throw new error_handler_1.AppError(403, 'Not your session', 'FORBIDDEN');
}
if (session.status !== client_1.CanvassSessionStatus.ACTIVE) {
throw new error_handler_1.AppError(400, 'Session is not active', 'SESSION_NOT_ACTIVE');
}
const updated = await database_1.prisma.canvassSession.update({
where: { id: sessionId },
data: { status: client_1.CanvassSessionStatus.COMPLETED, endedAt: new Date() },
});
// Recalculate cut completion percentage
await this.recalculateCutCompletion(session.cutId);
return updated;
},
async getCutLocationsForCanvass(cutId, userId, bounds, limit) {
const startTime = Date.now();
const cut = await database_1.prisma.cut.findUnique({ where: { id: cutId } });
if (!cut) {
throw new error_handler_1.AppError(404, 'Cut not found', 'CUT_NOT_FOUND');
}
const polygons = (0, spatial_1.parseGeoJsonPolygon)(cut.geojson);
// Two-stage filtering: bounds first (fast DB query), then polygon (in-memory)
const where = {};
if (bounds) {
// Convert to Decimal for proper PostgreSQL type matching
where.latitude = {
gte: new library_1.Decimal(bounds.minLat.toString()),
lte: new library_1.Decimal(bounds.maxLat.toString())
};
where.longitude = {
gte: new library_1.Decimal(bounds.minLng.toString()),
lte: new library_1.Decimal(bounds.maxLng.toString())
};
}
// CRITICAL: Apply limit to ADDRESSES not locations
// Fetch more locations than needed to account for multi-unit buildings
const addressLimit = Math.min(limit || 5000, 5000);
const locationFetchLimit = Math.ceil(addressLimit * 1.5); // Fetch 50% more locations to ensure we get enough addresses
const allLocations = await database_1.prisma.location.findMany({
where,
select: {
id: true,
latitude: true,
longitude: true,
address: true,
buildingNotes: true,
addresses: {
select: {
id: true,
unitNumber: true,
firstName: true,
lastName: true,
email: true,
phone: true,
supportLevel: true,
sign: true,
signSize: true,
notes: true,
},
},
},
take: locationFetchLimit,
});
// Filter locations by polygon and flatten addresses, RESPECTING ADDRESS LIMIT
const addressesInCut = [];
let addressCount = 0;
for (const loc of allLocations) {
// Stop if we've reached the address limit
if (addressCount >= addressLimit) {
break;
}
const lat = Number(loc.latitude);
const lng = Number(loc.longitude);
if (polygons.some((poly) => (0, spatial_1.isPointInPolygon)(lat, lng, poly))) {
for (const addr of loc.addresses) {
// Check limit before adding each address
if (addressCount >= addressLimit) {
break;
}
addressesInCut.push({
...addr,
location: {
id: loc.id,
latitude: loc.latitude,
longitude: loc.longitude,
address: loc.address,
buildingNotes: loc.buildingNotes,
},
});
addressCount++;
}
}
}
const result = await annotateAddressesWithVisits(addressesInCut, userId);
const durationSeconds = (Date.now() - startTime) / 1000;
(0, metrics_1.recordLocationQuery)('canvass_cut', !!bounds, result.length, durationSeconds);
return result;
},
async getAllLocationsForCanvass(userId, bounds, limit) {
const startTime = Date.now();
const where = {};
if (bounds) {
// Convert to Decimal for proper PostgreSQL type matching
where.latitude = {
gte: new library_1.Decimal(bounds.minLat.toString()),
lte: new library_1.Decimal(bounds.maxLat.toString())
};
where.longitude = {
gte: new library_1.Decimal(bounds.minLng.toString()),
lte: new library_1.Decimal(bounds.maxLng.toString())
};
}
// CRITICAL: Apply limit to ADDRESSES not locations
// Fetch more locations than needed to account for multi-unit buildings
const addressLimit = Math.min(limit || 5000, 5000);
const locationFetchLimit = Math.ceil(addressLimit * 1.5); // Fetch 50% more locations to ensure we get enough addresses
const allLocations = await database_1.prisma.location.findMany({
where,
select: {
id: true,
latitude: true,
longitude: true,
address: true,
buildingNotes: true,
addresses: {
select: {
id: true,
unitNumber: true,
firstName: true,
lastName: true,
email: true,
phone: true,
supportLevel: true,
sign: true,
signSize: true,
notes: true,
},
},
},
take: locationFetchLimit,
});
// Flatten addresses with location embedded, RESPECTING ADDRESS LIMIT
const allAddresses = [];
let addressCount = 0;
for (const loc of allLocations) {
// Stop if we've reached the address limit
if (addressCount >= addressLimit) {
break;
}
for (const addr of loc.addresses) {
// Check limit before adding each address
if (addressCount >= addressLimit) {
break;
}
allAddresses.push({
...addr,
location: {
id: loc.id,
latitude: loc.latitude,
longitude: loc.longitude,
address: loc.address,
buildingNotes: loc.buildingNotes,
},
});
addressCount++;
}
}
const result = await annotateAddressesWithVisits(allAddresses, userId);
const durationSeconds = (Date.now() - startTime) / 1000;
(0, metrics_1.recordLocationQuery)('canvass_all', !!bounds, result.length, durationSeconds);
return result;
},
async updateAddressAsVolunteer(addressId, userId, role, data) {
const existing = await database_1.prisma.address.findUnique({ where: { id: addressId } });
if (!existing) {
throw new error_handler_1.AppError(404, 'Address not found', 'ADDRESS_NOT_FOUND');
}
const isAdmin = role === client_1.UserRole.SUPER_ADMIN || role === client_1.UserRole.MAP_ADMIN;
// Build update data, stripping admin-only fields for non-admins
const updateData = {
updatedByUserId: userId,
};
for (const field of VOLUNTEER_ADDRESS_FIELDS) {
if (data[field] !== undefined) {
updateData[field] = data[field];
}
}
if (isAdmin) {
for (const field of ADMIN_ADDRESS_FIELDS) {
if (data[field] !== undefined) {
updateData[field] = data[field];
}
}
}
return database_1.prisma.address.update({ where: { id: addressId }, data: updateData });
},
async getWalkingRoute(cutId, userId, filters) {
const addresses = await this.getCutLocationsForCanvass(cutId, userId);
let filtered = addresses;
if (filters.excludeVisited) {
filtered = addresses.filter((a) => !a.lastVisit);
}
const routeLocations = filtered.map((a) => ({
id: a.location.id, // Use location ID for routing
latitude: a.location.latitude,
longitude: a.location.longitude,
}));
const cut = await database_1.prisma.cut.findUnique({
where: { id: cutId },
select: { geojson: true },
});
return (0, canvass_route_service_1.calculateWalkingRoute)(routeLocations, filters.startLatitude, filters.startLongitude, cut?.geojson);
},
async recordVisit(userId, data) {
// Verify address exists
const address = await database_1.prisma.address.findUnique({ where: { id: data.addressId } });
if (!address) {
throw new error_handler_1.AppError(404, 'Address not found', 'ADDRESS_NOT_FOUND');
}
// Create visit record
const visit = await database_1.prisma.canvassVisit.create({
data: {
addressId: data.addressId,
userId,
shiftId: data.shiftId,
sessionId: data.sessionId,
outcome: data.outcome,
supportLevel: data.supportLevel,
signRequested: data.signRequested ?? false,
signSize: data.signSize,
notes: data.notes,
durationSeconds: data.durationSeconds,
},
include: {
address: {
select: {
id: true,
unitNumber: true,
location: { select: { address: true } },
},
},
},
});
// If SPOKE_WITH and updateLocation, push data to the address record
if (data.outcome === client_1.VisitOutcome.SPOKE_WITH && data.updateLocation) {
const updateData = {
updatedByUserId: userId,
};
if (data.supportLevel)
updateData.supportLevel = data.supportLevel;
if (data.signRequested !== undefined)
updateData.sign = data.signRequested;
if (data.signSize)
updateData.signSize = data.signSize;
if (data.notes) {
const timestamp = new Date().toISOString().split('T')[0];
const prefix = `[${timestamp}] ${data.notes}`;
updateData.notes = address.notes ? `${prefix}\n${address.notes}` : prefix;
}
await database_1.prisma.address.update({
where: { id: data.addressId },
data: updateData,
});
}
(0, metrics_2.recordCanvassVisit)(data.outcome);
return visit;
},
async recordBulkVisit(userId, data) {
// Verify session exists and belongs to user
if (data.sessionId) {
const session = await database_1.prisma.canvassSession.findUnique({
where: { id: data.sessionId },
include: { cut: true },
});
if (!session || session.userId !== userId) {
throw new error_handler_1.AppError(403, 'Invalid session', 'INVALID_SESSION');
}
if (session.status !== 'ACTIVE') {
throw new error_handler_1.AppError(400, 'Session is not active', 'INACTIVE_SESSION');
}
// Verify location is within cut boundary
if (session.cutId && session.cut) {
const location = await database_1.prisma.location.findUnique({
where: { id: data.locationId },
});
if (!location) {
throw new error_handler_1.AppError(404, 'Location not found', 'LOCATION_NOT_FOUND');
}
if (session.cut.geojson) {
try {
const polygons = (0, spatial_1.parseGeoJsonPolygon)(session.cut.geojson);
const isWithinBoundary = polygons.some((polygon) => (0, spatial_1.isPointInPolygon)(Number(location.latitude), Number(location.longitude), polygon));
if (!isWithinBoundary) {
throw new error_handler_1.AppError(403, 'Location is outside your assigned territory', 'LOCATION_OUT_OF_BOUNDS');
}
}
catch (err) {
logger_1.logger.error('Failed to validate location boundary for bulk visit', { error: err instanceof Error ? err.message : JSON.stringify(err) });
// Don't block the visit if polygon parsing fails, but log it
}
}
}
}
// Get all unvisited addresses at this location
const addresses = await database_1.prisma.address.findMany({
where: {
locationId: data.locationId,
canvassVisits: { none: {} },
},
});
if (addresses.length === 0) {
throw new error_handler_1.AppError(400, 'No unvisited addresses found at this location', 'NO_UNVISITED_ADDRESSES');
}
// Create visit record for each address
const visits = await Promise.all(addresses.map((addr) => database_1.prisma.canvassVisit.create({
data: {
addressId: addr.id,
userId,
sessionId: data.sessionId,
shiftId: data.shiftId,
outcome: data.outcome,
notes: data.notes ? `[BULK] ${data.notes}` : null,
visitedAt: new Date(),
},
})));
visits.forEach(() => (0, metrics_2.recordCanvassVisit)(data.outcome));
return { created: visits.length, visits };
},
// ─── Admin Methods ─────────────────────────────────────────────────
async getAdminStats() {
const today = new Date();
today.setHours(0, 0, 0, 0);
const [totalVisits, todayVisits, activeSessions, activeVolunteers, cuts] = await Promise.all([
database_1.prisma.canvassVisit.count(),
database_1.prisma.canvassVisit.count({ where: { visitedAt: { gte: today } } }),
database_1.prisma.canvassSession.count({ where: { status: client_1.CanvassSessionStatus.ACTIVE } }),
database_1.prisma.canvassVisit.findMany({
where: { visitedAt: { gte: today } },
distinct: ['userId'],
select: { userId: true },
}),
database_1.prisma.cut.findMany({
select: { id: true, name: true, completionPercentage: true },
}),
]);
(0, metrics_2.setActiveCanvassSessions)(activeSessions);
const avgCompletion = cuts.length > 0
? Math.round(cuts.reduce((sum, c) => sum + c.completionPercentage, 0) / cuts.length)
: 0;
return {
totalVisits,
todayVisits,
activeSessions,
activeVolunteers: activeVolunteers.length,
overallCompletion: avgCompletion,
};
},
async getCutStats(cutId) {
const cut = await database_1.prisma.cut.findUnique({ where: { id: cutId } });
if (!cut)
throw new error_handler_1.AppError(404, 'Cut not found', 'CUT_NOT_FOUND');
const polygons = (0, spatial_1.parseGeoJsonPolygon)(cut.geojson);
const allLocations = await database_1.prisma.location.findMany({
// latitude/longitude are non-nullable in schema
select: {
id: true,
latitude: true,
longitude: true,
addresses: { select: { id: true } },
},
});
// Filter locations within polygons and collect all address IDs
const addressIds = [];
for (const loc of allLocations) {
if (polygons.some((p) => (0, spatial_1.isPointInPolygon)(Number(loc.latitude), Number(loc.longitude), p))) {
addressIds.push(...loc.addresses.map((a) => a.id));
}
}
const [totalAddresses, visitedAddresses, totalVisits, byOutcome] = await Promise.all([
Promise.resolve(addressIds.length),
database_1.prisma.canvassVisit.findMany({
where: { addressId: { in: addressIds } },
distinct: ['addressId'],
select: { addressId: true },
}),
database_1.prisma.canvassVisit.count({ where: { addressId: { in: addressIds } } }),
database_1.prisma.canvassVisit.groupBy({
by: ['outcome'],
where: { addressId: { in: addressIds } },
_count: true,
}),
]);
const outcomeMap = {};
for (const row of byOutcome) {
outcomeMap[row.outcome] = row._count;
}
return {
cutId,
cutName: cut.name,
totalLocations: totalAddresses,
visitedLocations: visitedAddresses.length,
completionPercentage: cut.completionPercentage,
totalVisits,
byOutcome: outcomeMap,
};
},
async getAdminActivity(filters) {
const { page, limit, cutId, userId, outcome } = filters;
const skip = (page - 1) * limit;
const where = {};
if (userId)
where.userId = userId;
if (outcome)
where.outcome = outcome;
// If filtering by cut, we need to find addresses in that cut
if (cutId) {
const cut = await database_1.prisma.cut.findUnique({ where: { id: cutId } });
if (cut) {
const polygons = (0, spatial_1.parseGeoJsonPolygon)(cut.geojson);
const allLocations = await database_1.prisma.location.findMany({
// latitude/longitude are non-nullable in schema
select: {
id: true,
latitude: true,
longitude: true,
addresses: { select: { id: true } },
},
});
const addressIds = [];
for (const loc of allLocations) {
if (polygons.some((p) => (0, spatial_1.isPointInPolygon)(Number(loc.latitude), Number(loc.longitude), p))) {
addressIds.push(...loc.addresses.map((a) => a.id));
}
}
where.addressId = { in: addressIds };
}
}
const [visits, total] = await Promise.all([
database_1.prisma.canvassVisit.findMany({
where,
skip,
take: limit,
orderBy: { visitedAt: 'desc' },
include: {
address: {
select: {
id: true,
unitNumber: true,
location: { select: { address: true } },
},
},
user: { select: { id: true, name: true, email: true } },
},
}),
database_1.prisma.canvassVisit.count({ where }),
]);
return {
visits,
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
};
},
async getVolunteers() {
const volunteers = await database_1.prisma.canvassVisit.groupBy({
by: ['userId'],
_count: true,
_max: { visitedAt: true },
});
const userIds = volunteers.map((v) => v.userId);
const users = await database_1.prisma.user.findMany({
where: { id: { in: userIds } },
select: { id: true, name: true, email: true },
});
const userMap = new Map(users.map((u) => [u.id, u]));
const sessions = await database_1.prisma.canvassSession.groupBy({
by: ['userId'],
where: { userId: { in: userIds } },
_count: true,
});
const sessionMap = new Map(sessions.map((s) => [s.userId, s._count]));
return volunteers.map((v) => ({
userId: v.userId,
name: userMap.get(v.userId)?.name ?? null,
email: userMap.get(v.userId)?.email ?? '',
totalVisits: v._count,
sessions: sessionMap.get(v.userId) ?? 0,
lastActive: v._max.visitedAt,
}));
},
async getVolunteerStats(userId) {
return this.getMyStats(userId);
},
async getAdminVisits(filters) {
const { page, limit, cutId, userId, shiftId, outcome, sortBy, sortOrder } = filters;
const skip = (page - 1) * limit;
const where = {};
if (userId)
where.userId = userId;
if (shiftId)
where.shiftId = shiftId;
if (outcome)
where.outcome = outcome;
if (cutId) {
const cut = await database_1.prisma.cut.findUnique({ where: { id: cutId } });
if (cut) {
const polygons = (0, spatial_1.parseGeoJsonPolygon)(cut.geojson);
const allLocations = await database_1.prisma.location.findMany({
// latitude/longitude are non-nullable in schema
select: {
id: true,
latitude: true,
longitude: true,
addresses: { select: { id: true } },
},
});
const addressIds = [];
for (const loc of allLocations) {
if (polygons.some((p) => (0, spatial_1.isPointInPolygon)(Number(loc.latitude), Number(loc.longitude), p))) {
addressIds.push(...loc.addresses.map((a) => a.id));
}
}
where.addressId = { in: addressIds };
}
}
const [visits, total] = await Promise.all([
database_1.prisma.canvassVisit.findMany({
where,
skip,
take: limit,
orderBy: { [sortBy]: sortOrder },
include: {
address: {
select: {
id: true,
unitNumber: true,
location: { select: { address: true } },
},
},
user: { select: { id: true, name: true, email: true } },
},
}),
database_1.prisma.canvassVisit.count({ where }),
]);
return {
visits,
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
};
},
// ─── Helpers ───────────────────────────────────────────────────────
async recalculateCutCompletion(cutId) {
const cut = await database_1.prisma.cut.findUnique({ where: { id: cutId } });
if (!cut)
return;
const polygons = (0, spatial_1.parseGeoJsonPolygon)(cut.geojson);
const allLocations = await database_1.prisma.location.findMany({
// latitude/longitude are non-nullable in schema
select: {
id: true,
latitude: true,
longitude: true,
addresses: { select: { id: true } },
},
});
// Filter locations within polygons and collect all address IDs
const addressIds = [];
for (const loc of allLocations) {
if (polygons.some((p) => (0, spatial_1.isPointInPolygon)(Number(loc.latitude), Number(loc.longitude), p))) {
addressIds.push(...loc.addresses.map((a) => a.id));
}
}
if (addressIds.length === 0)
return;
const visitedAddresses = await database_1.prisma.canvassVisit.findMany({
where: { addressId: { in: addressIds } },
distinct: ['addressId'],
select: { addressId: true },
});
const pct = Math.round((visitedAddresses.length / addressIds.length) * 100);
await database_1.prisma.cut.update({
where: { id: cutId },
data: {
completionPercentage: pct,
lastCanvassed: new Date(),
},
});
},
async closeAbandonedSessions() {
const cutoff = new Date(Date.now() - 12 * 60 * 60 * 1000); // 12 hours ago
const result = await database_1.prisma.canvassSession.updateMany({
where: {
status: client_1.CanvassSessionStatus.ACTIVE,
startedAt: { lt: cutoff },
},
data: {
status: client_1.CanvassSessionStatus.ABANDONED,
endedAt: new Date(),
},
});
if (result.count > 0) {
logger_1.logger.info(`Closed ${result.count} abandoned canvass sessions`);
}
return result.count;
},
};
//# sourceMappingURL=canvass.service.js.map