980 lines
42 KiB
JavaScript
980 lines
42 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 crm_activity_1 = require("../../../utils/crm-activity");
|
|
const canvass_route_service_1 = require("./canvass-route.service");
|
|
const metrics_2 = require("../../../utils/metrics");
|
|
const notification_queue_service_1 = require("../../../services/notification-queue.service");
|
|
const notification_helper_1 = require("../../../services/notification.helper");
|
|
const env_1 = require("../../../config/env");
|
|
const rocketchat_webhook_service_1 = require("../../../services/rocketchat-webhook.service");
|
|
const listmonk_event_sync_service_1 = require("../../../services/listmonk-event-sync.service");
|
|
const achievements_service_1 = require("../../social/achievements.service");
|
|
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);
|
|
// Notify Rocket.Chat
|
|
try {
|
|
const [rcUser, rcCut, rcVisitCount] = await Promise.all([
|
|
database_1.prisma.user.findUnique({ where: { id: userId }, select: { name: true, email: true } }),
|
|
database_1.prisma.cut.findUnique({ where: { id: session.cutId }, select: { name: true } }),
|
|
database_1.prisma.canvassVisit.count({ where: { sessionId } }),
|
|
]);
|
|
rocketchat_webhook_service_1.rocketchatWebhookService.onCanvassSessionCompleted({
|
|
userName: rcUser?.name || rcUser?.email || 'Unknown',
|
|
visitCount: rcVisitCount,
|
|
cutName: rcCut?.name || undefined,
|
|
}).catch(() => { });
|
|
}
|
|
catch { /* non-critical */ }
|
|
// Notification: volunteer session summary
|
|
try {
|
|
if (await (0, notification_helper_1.isNotificationEnabled)('notifyVolunteerSessionSummary')) {
|
|
const [user, cut, visitCount, outcomeGroups, trackingSession] = await Promise.all([
|
|
database_1.prisma.user.findUnique({ where: { id: userId }, select: { email: true, name: true } }),
|
|
database_1.prisma.cut.findUnique({ where: { id: session.cutId }, select: { name: true } }),
|
|
database_1.prisma.canvassVisit.count({ where: { sessionId } }),
|
|
database_1.prisma.canvassVisit.groupBy({ by: ['outcome'], where: { sessionId }, _count: true }),
|
|
database_1.prisma.trackingSession.findFirst({
|
|
where: { userId, canvassSessionId: sessionId },
|
|
select: { totalDistanceM: true },
|
|
}),
|
|
]);
|
|
if (user && visitCount > 0) {
|
|
const durationMs = updated.endedAt
|
|
? updated.endedAt.getTime() - session.startedAt.getTime()
|
|
: 0;
|
|
const durationMinutes = Math.round(durationMs / 60000);
|
|
const distanceKm = trackingSession?.totalDistanceM
|
|
? Number(trackingSession.totalDistanceM) / 1000
|
|
: 0;
|
|
const outcomeBreakdown = {};
|
|
for (const row of outcomeGroups) {
|
|
outcomeBreakdown[row.outcome] = row._count;
|
|
}
|
|
await notification_queue_service_1.notificationQueueService.enqueue({
|
|
type: 'volunteer-session-summary',
|
|
volunteerEmail: user.email,
|
|
volunteerName: user.name || user.email,
|
|
cutName: cut?.name || 'Unknown area',
|
|
sessionDate: session.startedAt.toLocaleDateString('en-CA', {
|
|
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
|
|
}),
|
|
visitCount,
|
|
durationMinutes,
|
|
distanceKm,
|
|
outcomeBreakdown,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
catch (err) {
|
|
logger_1.logger.error('Failed to enqueue session summary notification:', err);
|
|
}
|
|
// Listmonk event sync — add canvasser to subscribers
|
|
try {
|
|
const [syncUser, syncCut, syncVisitCount, syncOutcomes] = await Promise.all([
|
|
database_1.prisma.user.findUnique({ where: { id: userId }, select: { email: true, name: true } }),
|
|
database_1.prisma.cut.findUnique({ where: { id: session.cutId }, select: { name: true } }),
|
|
database_1.prisma.canvassVisit.count({ where: { sessionId } }),
|
|
database_1.prisma.canvassVisit.groupBy({ by: ['outcome'], where: { sessionId }, _count: true }),
|
|
]);
|
|
if (syncUser) {
|
|
const outcomes = {};
|
|
for (const row of syncOutcomes) {
|
|
outcomes[row.outcome] = row._count;
|
|
}
|
|
listmonk_event_sync_service_1.listmonkEventSyncService.onCanvassSessionCompleted({
|
|
email: syncUser.email,
|
|
name: syncUser.name || syncUser.email,
|
|
cutName: syncCut?.name || 'Unknown',
|
|
visitCount: syncVisitCount,
|
|
outcomes,
|
|
}).catch(() => { });
|
|
}
|
|
}
|
|
catch { /* non-critical */ }
|
|
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;
|
|
}
|
|
const updatedAddress = await database_1.prisma.address.update({
|
|
where: { id: data.addressId },
|
|
data: updateData,
|
|
include: { location: { select: { address: true } } },
|
|
});
|
|
// Sync support level change to Listmonk (fire-and-forget)
|
|
if (updatedAddress.email) {
|
|
const name = [updatedAddress.firstName, updatedAddress.lastName].filter(Boolean).join(' ');
|
|
listmonk_event_sync_service_1.listmonkEventSyncService.onAddressUpdated({
|
|
email: updatedAddress.email,
|
|
name,
|
|
supportLevel: updatedAddress.supportLevel,
|
|
sign: updatedAddress.sign,
|
|
address: updatedAddress.location.address,
|
|
}).catch(() => { });
|
|
}
|
|
}
|
|
(0, metrics_2.recordCanvassVisit)(data.outcome);
|
|
// CRM activity via ContactAddress lookup (fire-and-forget)
|
|
database_1.prisma.contactAddress.findFirst({
|
|
where: { addressId: data.addressId },
|
|
select: { contactId: true },
|
|
}).then((ca) => {
|
|
if (ca) {
|
|
(0, crm_activity_1.recordCrmActivity)({
|
|
contactId: ca.contactId,
|
|
activityType: 'CANVASS_VISIT',
|
|
title: `Canvass visit: ${data.outcome}`,
|
|
metadata: { addressId: data.addressId, outcome: data.outcome, visitId: visit.id },
|
|
}).catch(() => { });
|
|
}
|
|
}).catch(() => { });
|
|
// Achievement check (fire-and-forget)
|
|
achievements_service_1.achievementsService.checkAndUnlock(userId, ['canvass']).catch(() => { });
|
|
// Notification: sign request alert for admins
|
|
if (data.signRequested) {
|
|
try {
|
|
if (await (0, notification_helper_1.isNotificationEnabled)('notifyAdminSignRequested')) {
|
|
const adminEmails = await (0, notification_helper_1.getAdminEmailsByRole)([client_1.UserRole.SUPER_ADMIN, client_1.UserRole.MAP_ADMIN]);
|
|
if (adminEmails.length > 0) {
|
|
const [volunteer, shift] = await Promise.all([
|
|
database_1.prisma.user.findUnique({ where: { id: userId }, select: { name: true } }),
|
|
data.shiftId ? database_1.prisma.shift.findUnique({ where: { id: data.shiftId }, select: { title: true } }) : null,
|
|
]);
|
|
const addressStr = visit.address?.location?.address || 'Unknown address';
|
|
const adminUrl = `${env_1.env.ADMIN_URL || 'http://localhost:3000'}/app/canvass/dashboard`;
|
|
await notification_queue_service_1.notificationQueueService.enqueue({
|
|
type: 'admin-sign-requested',
|
|
adminEmails,
|
|
volunteerName: volunteer?.name || 'Unknown',
|
|
address: addressStr,
|
|
shiftTitle: shift?.title || 'No shift',
|
|
signSize: data.signSize || '',
|
|
adminUrl,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
catch (err) {
|
|
logger_1.logger.error('Failed to enqueue sign request notification:', err);
|
|
}
|
|
}
|
|
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) },
|
|
};
|
|
},
|
|
async getOutcomeTrends(filters) {
|
|
const { granularity } = filters;
|
|
const dateTo = filters.dateTo ? new Date(filters.dateTo) : new Date();
|
|
const dateFrom = filters.dateFrom
|
|
? new Date(filters.dateFrom)
|
|
: new Date(dateTo.getTime() - 30 * 24 * 60 * 60 * 1000);
|
|
// Ensure dateTo covers end of day
|
|
const dateToEnd = new Date(dateTo);
|
|
dateToEnd.setHours(23, 59, 59, 999);
|
|
const rows = await database_1.prisma.$queryRaw `
|
|
SELECT DATE_TRUNC(${granularity}, "visitedAt") as period,
|
|
outcome::text as outcome,
|
|
COUNT(*)::int as count
|
|
FROM canvass_visits
|
|
WHERE "visitedAt" >= ${dateFrom} AND "visitedAt" <= ${dateToEnd}
|
|
GROUP BY period, outcome
|
|
ORDER BY period ASC
|
|
`;
|
|
// Pivot rows into series: [{ date, NOT_HOME: n, SPOKE_WITH: n, ... }]
|
|
const pivotMap = new Map();
|
|
const totals = {};
|
|
for (const row of rows) {
|
|
const dateStr = row.period.toISOString().split('T')[0];
|
|
if (!pivotMap.has(dateStr)) {
|
|
pivotMap.set(dateStr, {});
|
|
}
|
|
pivotMap.get(dateStr)[row.outcome] = row.count;
|
|
totals[row.outcome] = (totals[row.outcome] || 0) + row.count;
|
|
}
|
|
const series = Array.from(pivotMap.entries()).map(([date, outcomes]) => ({
|
|
date,
|
|
...outcomes,
|
|
}));
|
|
return {
|
|
granularity,
|
|
dateFrom: dateFrom.toISOString().split('T')[0],
|
|
dateTo: dateTo.toISOString().split('T')[0],
|
|
series,
|
|
totals,
|
|
};
|
|
},
|
|
// ─── 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
|