"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