Tonne of debugging - getting ready for the production builds
This commit is contained in:
16
api/dist/modules/map/canvass/canvass-route.service.d.ts
vendored
Normal file
16
api/dist/modules/map/canvass/canvass-route.service.d.ts
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
export interface RouteLocation {
|
||||
id: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
export interface RouteResult {
|
||||
orderedLocations: RouteLocation[];
|
||||
totalDistanceMeters: number;
|
||||
estimatedMinutes: number;
|
||||
}
|
||||
/**
|
||||
* Nearest-neighbor walking route algorithm.
|
||||
* Starts from volunteer GPS position or cut polygon centroid.
|
||||
*/
|
||||
export declare function calculateWalkingRoute(locations: RouteLocation[], startLat?: number, startLng?: number, cutGeojson?: string): RouteResult;
|
||||
//# sourceMappingURL=canvass-route.service.d.ts.map
|
||||
1
api/dist/modules/map/canvass/canvass-route.service.d.ts.map
vendored
Normal file
1
api/dist/modules/map/canvass/canvass-route.service.d.ts.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"canvass-route.service.d.ts","sourceRoot":"","sources":["../../../../src/modules/map/canvass/canvass-route.service.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,WAAW;IAC1B,gBAAgB,EAAE,aAAa,EAAE,CAAC;IAClC,mBAAmB,EAAE,MAAM,CAAC;IAC5B,gBAAgB,EAAE,MAAM,CAAC;CAC1B;AAKD;;;GAGG;AACH,wBAAgB,qBAAqB,CACnC,SAAS,EAAE,aAAa,EAAE,EAC1B,QAAQ,CAAC,EAAE,MAAM,EACjB,QAAQ,CAAC,EAAE,MAAM,EACjB,UAAU,CAAC,EAAE,MAAM,GAClB,WAAW,CAwDb"}
|
||||
62
api/dist/modules/map/canvass/canvass-route.service.js
vendored
Normal file
62
api/dist/modules/map/canvass/canvass-route.service.js
vendored
Normal file
@@ -0,0 +1,62 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.calculateWalkingRoute = calculateWalkingRoute;
|
||||
const spatial_1 = require("../../../utils/spatial");
|
||||
const WALKING_SPEED_MPS = 5000 / 60; // 5 km/h in meters per minute
|
||||
const MINUTES_PER_DOOR = 2;
|
||||
/**
|
||||
* Nearest-neighbor walking route algorithm.
|
||||
* Starts from volunteer GPS position or cut polygon centroid.
|
||||
*/
|
||||
function calculateWalkingRoute(locations, startLat, startLng, cutGeojson) {
|
||||
if (locations.length === 0) {
|
||||
return { orderedLocations: [], totalDistanceMeters: 0, estimatedMinutes: 0 };
|
||||
}
|
||||
// Determine starting point
|
||||
let currentLat;
|
||||
let currentLng;
|
||||
if (startLat !== undefined && startLng !== undefined) {
|
||||
currentLat = startLat;
|
||||
currentLng = startLng;
|
||||
}
|
||||
else if (cutGeojson) {
|
||||
const polygons = (0, spatial_1.parseGeoJsonPolygon)(cutGeojson);
|
||||
const centroid = (0, spatial_1.calculateCentroid)(polygons[0]);
|
||||
currentLat = centroid.lat;
|
||||
currentLng = centroid.lng;
|
||||
}
|
||||
else {
|
||||
// Use first location as starting point
|
||||
currentLat = locations[0].latitude;
|
||||
currentLng = locations[0].longitude;
|
||||
}
|
||||
const remaining = [...locations];
|
||||
const ordered = [];
|
||||
let totalDistance = 0;
|
||||
while (remaining.length > 0) {
|
||||
let nearestIdx = 0;
|
||||
let nearestDist = Infinity;
|
||||
for (let i = 0; i < remaining.length; i++) {
|
||||
const loc = remaining[i];
|
||||
const dist = (0, spatial_1.haversineDistance)(currentLat, currentLng, loc.latitude, loc.longitude);
|
||||
if (dist < nearestDist) {
|
||||
nearestDist = dist;
|
||||
nearestIdx = i;
|
||||
}
|
||||
}
|
||||
const nearest = remaining.splice(nearestIdx, 1)[0];
|
||||
ordered.push(nearest);
|
||||
totalDistance += nearestDist;
|
||||
currentLat = nearest.latitude;
|
||||
currentLng = nearest.longitude;
|
||||
}
|
||||
const walkingMinutes = totalDistance / WALKING_SPEED_MPS;
|
||||
const doorMinutes = ordered.length * MINUTES_PER_DOOR;
|
||||
const estimatedMinutes = Math.round(walkingMinutes + doorMinutes);
|
||||
return {
|
||||
orderedLocations: ordered,
|
||||
totalDistanceMeters: Math.round(totalDistance),
|
||||
estimatedMinutes,
|
||||
};
|
||||
}
|
||||
//# sourceMappingURL=canvass-route.service.js.map
|
||||
1
api/dist/modules/map/canvass/canvass-route.service.js.map
vendored
Normal file
1
api/dist/modules/map/canvass/canvass-route.service.js.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"canvass-route.service.js","sourceRoot":"","sources":["../../../../src/modules/map/canvass/canvass-route.service.ts"],"names":[],"mappings":";;AAqBA,sDA6DC;AAlFD,oDAAmG;AAcnG,MAAM,iBAAiB,GAAG,IAAI,GAAG,EAAE,CAAC,CAAC,8BAA8B;AACnE,MAAM,gBAAgB,GAAG,CAAC,CAAC;AAE3B;;;GAGG;AACH,SAAgB,qBAAqB,CACnC,SAA0B,EAC1B,QAAiB,EACjB,QAAiB,EACjB,UAAmB;IAEnB,IAAI,SAAS,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAC3B,OAAO,EAAE,gBAAgB,EAAE,EAAE,EAAE,mBAAmB,EAAE,CAAC,EAAE,gBAAgB,EAAE,CAAC,EAAE,CAAC;IAC/E,CAAC;IAED,2BAA2B;IAC3B,IAAI,UAAkB,CAAC;IACvB,IAAI,UAAkB,CAAC;IAEvB,IAAI,QAAQ,KAAK,SAAS,IAAI,QAAQ,KAAK,SAAS,EAAE,CAAC;QACrD,UAAU,GAAG,QAAQ,CAAC;QACtB,UAAU,GAAG,QAAQ,CAAC;IACxB,CAAC;SAAM,IAAI,UAAU,EAAE,CAAC;QACtB,MAAM,QAAQ,GAAG,IAAA,6BAAmB,EAAC,UAAU,CAAC,CAAC;QACjD,MAAM,QAAQ,GAAG,IAAA,2BAAiB,EAAC,QAAQ,CAAC,CAAC,CAAE,CAAC,CAAC;QACjD,UAAU,GAAG,QAAQ,CAAC,GAAG,CAAC;QAC1B,UAAU,GAAG,QAAQ,CAAC,GAAG,CAAC;IAC5B,CAAC;SAAM,CAAC;QACN,uCAAuC;QACvC,UAAU,GAAG,SAAS,CAAC,CAAC,CAAE,CAAC,QAAQ,CAAC;QACpC,UAAU,GAAG,SAAS,CAAC,CAAC,CAAE,CAAC,SAAS,CAAC;IACvC,CAAC;IAED,MAAM,SAAS,GAAG,CAAC,GAAG,SAAS,CAAC,CAAC;IACjC,MAAM,OAAO,GAAoB,EAAE,CAAC;IACpC,IAAI,aAAa,GAAG,CAAC,CAAC;IAEtB,OAAO,SAAS,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QAC5B,IAAI,UAAU,GAAG,CAAC,CAAC;QACnB,IAAI,WAAW,GAAG,QAAQ,CAAC;QAE3B,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,SAAS,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;YAC1C,MAAM,GAAG,GAAG,SAAS,CAAC,CAAC,CAAE,CAAC;YAC1B,MAAM,IAAI,GAAG,IAAA,2BAAiB,EAAC,UAAU,EAAE,UAAU,EAAE,GAAG,CAAC,QAAQ,EAAE,GAAG,CAAC,SAAS,CAAC,CAAC;YACpF,IAAI,IAAI,GAAG,WAAW,EAAE,CAAC;gBACvB,WAAW,GAAG,IAAI,CAAC;gBACnB,UAAU,GAAG,CAAC,CAAC;YACjB,CAAC;QACH,CAAC;QAED,MAAM,OAAO,GAAG,SAAS,CAAC,MAAM,CAAC,UAAU,EAAE,CAAC,CAAC,CAAC,CAAC,CAAE,CAAC;QACpD,OAAO,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;QACtB,aAAa,IAAI,WAAW,CAAC;QAC7B,UAAU,GAAG,OAAO,CAAC,QAAQ,CAAC;QAC9B,UAAU,GAAG,OAAO,CAAC,SAAS,CAAC;IACjC,CAAC;IAED,MAAM,cAAc,GAAG,aAAa,GAAG,iBAAiB,CAAC;IACzD,MAAM,WAAW,GAAG,OAAO,CAAC,MAAM,GAAG,gBAAgB,CAAC;IACtD,MAAM,gBAAgB,GAAG,IAAI,CAAC,KAAK,CAAC,cAAc,GAAG,WAAW,CAAC,CAAC;IAElE,OAAO;QACL,gBAAgB,EAAE,OAAO;QACzB,mBAAmB,EAAE,IAAI,CAAC,KAAK,CAAC,aAAa,CAAC;QAC9C,gBAAgB;KACjB,CAAC;AACJ,CAAC"}
|
||||
4
api/dist/modules/map/canvass/canvass.routes.d.ts
vendored
Normal file
4
api/dist/modules/map/canvass/canvass.routes.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
declare const volunteerRouter: import("express-serve-static-core").Router;
|
||||
declare const adminRouter: import("express-serve-static-core").Router;
|
||||
export { volunteerRouter as canvassVolunteerRouter, adminRouter as canvassAdminRouter };
|
||||
//# sourceMappingURL=canvass.routes.d.ts.map
|
||||
1
api/dist/modules/map/canvass/canvass.routes.d.ts.map
vendored
Normal file
1
api/dist/modules/map/canvass/canvass.routes.d.ts.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"canvass.routes.d.ts","sourceRoot":"","sources":["../../../../src/modules/map/canvass/canvass.routes.ts"],"names":[],"mappings":"AAyBA,QAAA,MAAM,eAAe,4CAAW,CAAC;AAgQjC,QAAA,MAAM,WAAW,4CAAW,CAAC;AAsF7B,OAAO,EAAE,eAAe,IAAI,sBAAsB,EAAE,WAAW,IAAI,kBAAkB,EAAE,CAAC"}
|
||||
276
api/dist/modules/map/canvass/canvass.routes.js
vendored
Normal file
276
api/dist/modules/map/canvass/canvass.routes.js
vendored
Normal file
@@ -0,0 +1,276 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.canvassAdminRouter = exports.canvassVolunteerRouter = void 0;
|
||||
const express_1 = require("express");
|
||||
const client_1 = require("@prisma/client");
|
||||
const canvass_service_1 = require("./canvass.service");
|
||||
const canvass_schemas_1 = require("./canvass.schemas");
|
||||
const locations_schemas_1 = require("../locations/locations.schemas");
|
||||
const locations_service_1 = require("../locations/locations.service");
|
||||
const geocoding_service_1 = require("../geocoding/geocoding.service");
|
||||
const validate_1 = require("../../../middleware/validate");
|
||||
const auth_middleware_1 = require("../../../middleware/auth.middleware");
|
||||
const rbac_middleware_1 = require("../../../middleware/rbac.middleware");
|
||||
const rate_limit_1 = require("../../../middleware/rate-limit");
|
||||
const MAP_ADMIN_ROLES = [client_1.UserRole.SUPER_ADMIN, client_1.UserRole.MAP_ADMIN];
|
||||
// ─── Volunteer Router ────────────────────────────────────────────────
|
||||
const volunteerRouter = (0, express_1.Router)();
|
||||
exports.canvassVolunteerRouter = volunteerRouter;
|
||||
volunteerRouter.use(auth_middleware_1.authenticate);
|
||||
// GET /api/map/canvass/my/assignments
|
||||
volunteerRouter.get('/my/assignments', async (req, res, next) => {
|
||||
try {
|
||||
const assignments = await canvass_service_1.canvassService.getMyAssignments(req.user.id);
|
||||
res.json(assignments);
|
||||
}
|
||||
catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
// GET /api/map/canvass/my/stats
|
||||
volunteerRouter.get('/my/stats', async (req, res, next) => {
|
||||
try {
|
||||
const stats = await canvass_service_1.canvassService.getMyStats(req.user.id);
|
||||
res.json(stats);
|
||||
}
|
||||
catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
// GET /api/map/canvass/my/visits
|
||||
volunteerRouter.get('/my/visits', (0, validate_1.validate)(canvass_schemas_1.listMyVisitsSchema, 'query'), async (req, res, next) => {
|
||||
try {
|
||||
const result = await canvass_service_1.canvassService.getMyVisits(req.user.id, req.query);
|
||||
res.json(result);
|
||||
}
|
||||
catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
// GET /api/map/canvass/my/session
|
||||
volunteerRouter.get('/my/session', async (req, res, next) => {
|
||||
try {
|
||||
const session = await canvass_service_1.canvassService.getActiveSession(req.user.id);
|
||||
res.json(session);
|
||||
}
|
||||
catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
// POST /api/map/canvass/sessions
|
||||
volunteerRouter.post('/sessions', (0, validate_1.validate)(canvass_schemas_1.startSessionSchema), async (req, res, next) => {
|
||||
try {
|
||||
const session = await canvass_service_1.canvassService.startSession(req.user.id, req.body);
|
||||
res.status(201).json(session);
|
||||
}
|
||||
catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
// POST /api/map/canvass/sessions/:id/end
|
||||
volunteerRouter.post('/sessions/:id/end', async (req, res, next) => {
|
||||
try {
|
||||
const id = req.params.id;
|
||||
const session = await canvass_service_1.canvassService.endSession(id, req.user.id);
|
||||
res.json(session);
|
||||
}
|
||||
catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
// GET /api/map/canvass/cuts/:cutId/locations
|
||||
volunteerRouter.get('/cuts/:cutId/locations', async (req, res, next) => {
|
||||
try {
|
||||
const cutId = req.params.cutId;
|
||||
const bounds = req.query.minLat ? {
|
||||
minLat: parseFloat(req.query.minLat),
|
||||
maxLat: parseFloat(req.query.maxLat),
|
||||
minLng: parseFloat(req.query.minLng),
|
||||
maxLng: parseFloat(req.query.maxLng),
|
||||
} : undefined;
|
||||
const limit = req.query.limit ? parseInt(req.query.limit) : undefined;
|
||||
const locations = await canvass_service_1.canvassService.getCutLocationsForCanvass(cutId, req.user.id, bounds, limit);
|
||||
res.json(locations);
|
||||
}
|
||||
catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
// GET /api/map/canvass/cuts/:cutId/route
|
||||
volunteerRouter.get('/cuts/:cutId/route', (0, validate_1.validate)(canvass_schemas_1.walkingRouteSchema, 'query'), async (req, res, next) => {
|
||||
try {
|
||||
const cutId = req.params.cutId;
|
||||
const route = await canvass_service_1.canvassService.getWalkingRoute(cutId, req.user.id, req.query);
|
||||
res.json(route);
|
||||
}
|
||||
catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
// GET /api/map/canvass/locations — all locations with visit annotations
|
||||
volunteerRouter.get('/locations', async (req, res, next) => {
|
||||
try {
|
||||
const bounds = req.query.minLat ? {
|
||||
minLat: parseFloat(req.query.minLat),
|
||||
maxLat: parseFloat(req.query.maxLat),
|
||||
minLng: parseFloat(req.query.minLng),
|
||||
maxLng: parseFloat(req.query.maxLng),
|
||||
} : undefined;
|
||||
const limit = req.query.limit ? parseInt(req.query.limit) : undefined;
|
||||
const locations = await canvass_service_1.canvassService.getAllLocationsForCanvass(req.user.id, bounds, limit);
|
||||
res.json(locations);
|
||||
}
|
||||
catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
// PUT /api/map/canvass/locations/:id — role-gated address editing (deprecated path, should be /addresses/:id)
|
||||
volunteerRouter.put('/locations/:id', (0, validate_1.validate)(canvass_schemas_1.volunteerUpdateLocationSchema), async (req, res, next) => {
|
||||
try {
|
||||
const id = req.params.id;
|
||||
const address = await canvass_service_1.canvassService.updateAddressAsVolunteer(id, req.user.id, req.user.role, req.body);
|
||||
res.json(address);
|
||||
}
|
||||
catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
// POST /api/map/canvass/locations — create a new location (role-gated fields)
|
||||
volunteerRouter.post('/locations', (0, validate_1.validate)(canvass_schemas_1.volunteerCreateLocationSchema), async (req, res, next) => {
|
||||
try {
|
||||
const role = req.user.role;
|
||||
const data = { ...req.body };
|
||||
// Strip fields based on role
|
||||
const isAdmin = role === client_1.UserRole.SUPER_ADMIN || role === client_1.UserRole.MAP_ADMIN;
|
||||
if (!isAdmin) {
|
||||
delete data.firstName;
|
||||
delete data.lastName;
|
||||
delete data.email;
|
||||
delete data.phone;
|
||||
}
|
||||
if (role === client_1.UserRole.TEMP) {
|
||||
delete data.supportLevel;
|
||||
delete data.sign;
|
||||
delete data.signSize;
|
||||
delete data.notes;
|
||||
}
|
||||
const location = await locations_service_1.locationsService.create(data, req.user.id);
|
||||
res.status(201).json(location);
|
||||
}
|
||||
catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
// POST /api/map/canvass/reverse-geocode — reverse geocode lat/lng
|
||||
volunteerRouter.post('/reverse-geocode', (0, validate_1.validate)(locations_schemas_1.reverseGeocodeSchema), async (req, res, next) => {
|
||||
try {
|
||||
const result = await locations_service_1.locationsService.reverseGeocode(req.body.latitude, req.body.longitude);
|
||||
res.json(result);
|
||||
}
|
||||
catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
// POST /api/map/canvass/geocode-search — geocode an address for map flyTo
|
||||
volunteerRouter.post('/geocode-search', rate_limit_1.canvassGeocodeRateLimit, (0, validate_1.validate)(locations_schemas_1.geocodeAddressSchema), async (req, res, next) => {
|
||||
try {
|
||||
const result = await geocoding_service_1.geocodingService.geocode(req.body.address);
|
||||
if (!result) {
|
||||
res.status(404).json({ error: { message: 'Address not found', code: 'GEOCODE_FAILED' } });
|
||||
return;
|
||||
}
|
||||
res.json(result);
|
||||
}
|
||||
catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
// POST /api/map/canvass/visits
|
||||
volunteerRouter.post('/visits', rate_limit_1.canvassVisitRateLimit, (0, validate_1.validate)(canvass_schemas_1.recordVisitSchema), async (req, res, next) => {
|
||||
try {
|
||||
const visit = await canvass_service_1.canvassService.recordVisit(req.user.id, req.body);
|
||||
res.status(201).json(visit);
|
||||
}
|
||||
catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
// POST /api/map/canvass/visits/bulk - Record visit to all unvisited units in building
|
||||
volunteerRouter.post('/visits/bulk', rate_limit_1.canvassBulkVisitRateLimit, // Stricter rate limit for bulk operations
|
||||
(0, validate_1.validate)(canvass_schemas_1.bulkRecordVisitSchema), async (req, res, next) => {
|
||||
try {
|
||||
const result = await canvass_service_1.canvassService.recordBulkVisit(req.user.id, req.body);
|
||||
res.status(201).json(result);
|
||||
}
|
||||
catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
// ─── Admin Router ────────────────────────────────────────────────────
|
||||
const adminRouter = (0, express_1.Router)();
|
||||
exports.canvassAdminRouter = adminRouter;
|
||||
adminRouter.use(auth_middleware_1.authenticate);
|
||||
adminRouter.use((0, rbac_middleware_1.requireRole)(...MAP_ADMIN_ROLES));
|
||||
// GET /api/map/canvass/stats
|
||||
adminRouter.get('/stats', async (_req, res, next) => {
|
||||
try {
|
||||
const stats = await canvass_service_1.canvassService.getAdminStats();
|
||||
res.json(stats);
|
||||
}
|
||||
catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
// GET /api/map/canvass/stats/cuts/:cutId
|
||||
adminRouter.get('/stats/cuts/:cutId', async (req, res, next) => {
|
||||
try {
|
||||
const cutId = req.params.cutId;
|
||||
const stats = await canvass_service_1.canvassService.getCutStats(cutId);
|
||||
res.json(stats);
|
||||
}
|
||||
catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
// GET /api/map/canvass/activity
|
||||
adminRouter.get('/activity', (0, validate_1.validate)(canvass_schemas_1.adminActivitySchema, 'query'), async (req, res, next) => {
|
||||
try {
|
||||
const result = await canvass_service_1.canvassService.getAdminActivity(req.query);
|
||||
res.json(result);
|
||||
}
|
||||
catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
// GET /api/map/canvass/volunteers
|
||||
adminRouter.get('/volunteers', async (_req, res, next) => {
|
||||
try {
|
||||
const volunteers = await canvass_service_1.canvassService.getVolunteers();
|
||||
res.json(volunteers);
|
||||
}
|
||||
catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
// GET /api/map/canvass/volunteers/:userId
|
||||
adminRouter.get('/volunteers/:userId', async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.params.userId;
|
||||
const stats = await canvass_service_1.canvassService.getVolunteerStats(userId);
|
||||
res.json(stats);
|
||||
}
|
||||
catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
// GET /api/map/canvass/visits
|
||||
adminRouter.get('/visits', (0, validate_1.validate)(canvass_schemas_1.adminVisitsSchema, 'query'), async (req, res, next) => {
|
||||
try {
|
||||
const result = await canvass_service_1.canvassService.getAdminVisits(req.query);
|
||||
res.json(result);
|
||||
}
|
||||
catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
//# sourceMappingURL=canvass.routes.js.map
|
||||
1
api/dist/modules/map/canvass/canvass.routes.js.map
vendored
Normal file
1
api/dist/modules/map/canvass/canvass.routes.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
264
api/dist/modules/map/canvass/canvass.schemas.d.ts
vendored
Normal file
264
api/dist/modules/map/canvass/canvass.schemas.d.ts
vendored
Normal file
@@ -0,0 +1,264 @@
|
||||
import { z } from 'zod';
|
||||
export declare const recordVisitSchema: z.ZodObject<{
|
||||
addressId: z.ZodString;
|
||||
outcome: z.ZodNativeEnum<{
|
||||
NOT_HOME: "NOT_HOME";
|
||||
REFUSED: "REFUSED";
|
||||
MOVED: "MOVED";
|
||||
ALREADY_VOTED: "ALREADY_VOTED";
|
||||
SPOKE_WITH: "SPOKE_WITH";
|
||||
LEFT_LITERATURE: "LEFT_LITERATURE";
|
||||
COME_BACK_LATER: "COME_BACK_LATER";
|
||||
}>;
|
||||
supportLevel: z.ZodOptional<z.ZodNativeEnum<{
|
||||
LEVEL_1: "LEVEL_1";
|
||||
LEVEL_2: "LEVEL_2";
|
||||
LEVEL_3: "LEVEL_3";
|
||||
LEVEL_4: "LEVEL_4";
|
||||
}>>;
|
||||
signRequested: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
|
||||
signSize: z.ZodOptional<z.ZodString>;
|
||||
notes: z.ZodOptional<z.ZodString>;
|
||||
durationSeconds: z.ZodOptional<z.ZodNumber>;
|
||||
sessionId: z.ZodOptional<z.ZodString>;
|
||||
shiftId: z.ZodOptional<z.ZodString>;
|
||||
updateLocation: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
|
||||
}, "strip", z.ZodTypeAny, {
|
||||
outcome: "NOT_HOME" | "REFUSED" | "MOVED" | "ALREADY_VOTED" | "SPOKE_WITH" | "LEFT_LITERATURE" | "COME_BACK_LATER";
|
||||
signRequested: boolean;
|
||||
addressId: string;
|
||||
updateLocation: boolean;
|
||||
durationSeconds?: number | undefined;
|
||||
sessionId?: string | undefined;
|
||||
shiftId?: string | undefined;
|
||||
supportLevel?: "LEVEL_1" | "LEVEL_2" | "LEVEL_3" | "LEVEL_4" | undefined;
|
||||
signSize?: string | undefined;
|
||||
notes?: string | undefined;
|
||||
}, {
|
||||
outcome: "NOT_HOME" | "REFUSED" | "MOVED" | "ALREADY_VOTED" | "SPOKE_WITH" | "LEFT_LITERATURE" | "COME_BACK_LATER";
|
||||
addressId: string;
|
||||
durationSeconds?: number | undefined;
|
||||
sessionId?: string | undefined;
|
||||
shiftId?: string | undefined;
|
||||
supportLevel?: "LEVEL_1" | "LEVEL_2" | "LEVEL_3" | "LEVEL_4" | undefined;
|
||||
signSize?: string | undefined;
|
||||
notes?: string | undefined;
|
||||
signRequested?: boolean | undefined;
|
||||
updateLocation?: boolean | undefined;
|
||||
}>;
|
||||
export declare const bulkRecordVisitSchema: z.ZodObject<{
|
||||
locationId: z.ZodString;
|
||||
outcome: z.ZodEnum<["NOT_HOME", "REFUSED", "MOVED"]>;
|
||||
notes: z.ZodOptional<z.ZodString>;
|
||||
sessionId: z.ZodOptional<z.ZodString>;
|
||||
shiftId: z.ZodOptional<z.ZodString>;
|
||||
}, "strip", z.ZodTypeAny, {
|
||||
outcome: "NOT_HOME" | "REFUSED" | "MOVED";
|
||||
locationId: string;
|
||||
sessionId?: string | undefined;
|
||||
shiftId?: string | undefined;
|
||||
notes?: string | undefined;
|
||||
}, {
|
||||
outcome: "NOT_HOME" | "REFUSED" | "MOVED";
|
||||
locationId: string;
|
||||
sessionId?: string | undefined;
|
||||
shiftId?: string | undefined;
|
||||
notes?: string | undefined;
|
||||
}>;
|
||||
export declare const startSessionSchema: z.ZodObject<{
|
||||
cutId: z.ZodString;
|
||||
shiftId: z.ZodOptional<z.ZodString>;
|
||||
startLatitude: z.ZodOptional<z.ZodNumber>;
|
||||
startLongitude: z.ZodOptional<z.ZodNumber>;
|
||||
}, "strip", z.ZodTypeAny, {
|
||||
cutId: string;
|
||||
shiftId?: string | undefined;
|
||||
startLatitude?: number | undefined;
|
||||
startLongitude?: number | undefined;
|
||||
}, {
|
||||
cutId: string;
|
||||
shiftId?: string | undefined;
|
||||
startLatitude?: number | undefined;
|
||||
startLongitude?: number | undefined;
|
||||
}>;
|
||||
export declare const endSessionSchema: z.ZodObject<{}, "strip", z.ZodTypeAny, {}, {}>;
|
||||
export declare const walkingRouteSchema: z.ZodObject<{
|
||||
excludeVisited: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
|
||||
startLatitude: z.ZodOptional<z.ZodNumber>;
|
||||
startLongitude: z.ZodOptional<z.ZodNumber>;
|
||||
}, "strip", z.ZodTypeAny, {
|
||||
excludeVisited: boolean;
|
||||
startLatitude?: number | undefined;
|
||||
startLongitude?: number | undefined;
|
||||
}, {
|
||||
startLatitude?: number | undefined;
|
||||
startLongitude?: number | undefined;
|
||||
excludeVisited?: boolean | undefined;
|
||||
}>;
|
||||
export declare const listMyVisitsSchema: z.ZodObject<{
|
||||
page: z.ZodDefault<z.ZodNumber>;
|
||||
limit: z.ZodDefault<z.ZodNumber>;
|
||||
}, "strip", z.ZodTypeAny, {
|
||||
limit: number;
|
||||
page: number;
|
||||
}, {
|
||||
limit?: number | undefined;
|
||||
page?: number | undefined;
|
||||
}>;
|
||||
export declare const adminActivitySchema: z.ZodObject<{
|
||||
page: z.ZodDefault<z.ZodNumber>;
|
||||
limit: z.ZodDefault<z.ZodNumber>;
|
||||
cutId: z.ZodOptional<z.ZodString>;
|
||||
userId: z.ZodOptional<z.ZodString>;
|
||||
outcome: z.ZodOptional<z.ZodNativeEnum<{
|
||||
NOT_HOME: "NOT_HOME";
|
||||
REFUSED: "REFUSED";
|
||||
MOVED: "MOVED";
|
||||
ALREADY_VOTED: "ALREADY_VOTED";
|
||||
SPOKE_WITH: "SPOKE_WITH";
|
||||
LEFT_LITERATURE: "LEFT_LITERATURE";
|
||||
COME_BACK_LATER: "COME_BACK_LATER";
|
||||
}>>;
|
||||
}, "strip", z.ZodTypeAny, {
|
||||
limit: number;
|
||||
page: number;
|
||||
userId?: string | undefined;
|
||||
outcome?: "NOT_HOME" | "REFUSED" | "MOVED" | "ALREADY_VOTED" | "SPOKE_WITH" | "LEFT_LITERATURE" | "COME_BACK_LATER" | undefined;
|
||||
cutId?: string | undefined;
|
||||
}, {
|
||||
limit?: number | undefined;
|
||||
userId?: string | undefined;
|
||||
page?: number | undefined;
|
||||
outcome?: "NOT_HOME" | "REFUSED" | "MOVED" | "ALREADY_VOTED" | "SPOKE_WITH" | "LEFT_LITERATURE" | "COME_BACK_LATER" | undefined;
|
||||
cutId?: string | undefined;
|
||||
}>;
|
||||
export declare const adminVisitsSchema: z.ZodObject<{
|
||||
page: z.ZodDefault<z.ZodNumber>;
|
||||
limit: z.ZodDefault<z.ZodNumber>;
|
||||
cutId: z.ZodOptional<z.ZodString>;
|
||||
userId: z.ZodOptional<z.ZodString>;
|
||||
shiftId: z.ZodOptional<z.ZodString>;
|
||||
outcome: z.ZodOptional<z.ZodNativeEnum<{
|
||||
NOT_HOME: "NOT_HOME";
|
||||
REFUSED: "REFUSED";
|
||||
MOVED: "MOVED";
|
||||
ALREADY_VOTED: "ALREADY_VOTED";
|
||||
SPOKE_WITH: "SPOKE_WITH";
|
||||
LEFT_LITERATURE: "LEFT_LITERATURE";
|
||||
COME_BACK_LATER: "COME_BACK_LATER";
|
||||
}>>;
|
||||
sortBy: z.ZodDefault<z.ZodOptional<z.ZodEnum<["visitedAt", "outcome"]>>>;
|
||||
sortOrder: z.ZodDefault<z.ZodOptional<z.ZodEnum<["asc", "desc"]>>>;
|
||||
}, "strip", z.ZodTypeAny, {
|
||||
limit: number;
|
||||
page: number;
|
||||
sortBy: "outcome" | "visitedAt";
|
||||
sortOrder: "asc" | "desc";
|
||||
userId?: string | undefined;
|
||||
outcome?: "NOT_HOME" | "REFUSED" | "MOVED" | "ALREADY_VOTED" | "SPOKE_WITH" | "LEFT_LITERATURE" | "COME_BACK_LATER" | undefined;
|
||||
shiftId?: string | undefined;
|
||||
cutId?: string | undefined;
|
||||
}, {
|
||||
limit?: number | undefined;
|
||||
userId?: string | undefined;
|
||||
page?: number | undefined;
|
||||
outcome?: "NOT_HOME" | "REFUSED" | "MOVED" | "ALREADY_VOTED" | "SPOKE_WITH" | "LEFT_LITERATURE" | "COME_BACK_LATER" | undefined;
|
||||
shiftId?: string | undefined;
|
||||
cutId?: string | undefined;
|
||||
sortBy?: "outcome" | "visitedAt" | undefined;
|
||||
sortOrder?: "asc" | "desc" | undefined;
|
||||
}>;
|
||||
export declare const volunteerUpdateLocationSchema: z.ZodObject<{
|
||||
supportLevel: z.ZodOptional<z.ZodNullable<z.ZodNativeEnum<{
|
||||
LEVEL_1: "LEVEL_1";
|
||||
LEVEL_2: "LEVEL_2";
|
||||
LEVEL_3: "LEVEL_3";
|
||||
LEVEL_4: "LEVEL_4";
|
||||
}>>>;
|
||||
sign: z.ZodOptional<z.ZodBoolean>;
|
||||
signSize: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
||||
notes: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
||||
firstName: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
||||
lastName: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
||||
address: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
||||
unitNumber: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
||||
email: z.ZodUnion<[z.ZodOptional<z.ZodNullable<z.ZodString>>, z.ZodLiteral<"">]>;
|
||||
phone: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
||||
}, "strip", z.ZodTypeAny, {
|
||||
email?: string | null | undefined;
|
||||
phone?: string | null | undefined;
|
||||
address?: string | null | undefined;
|
||||
unitNumber?: string | null | undefined;
|
||||
firstName?: string | null | undefined;
|
||||
lastName?: string | null | undefined;
|
||||
supportLevel?: "LEVEL_1" | "LEVEL_2" | "LEVEL_3" | "LEVEL_4" | null | undefined;
|
||||
sign?: boolean | undefined;
|
||||
signSize?: string | null | undefined;
|
||||
notes?: string | null | undefined;
|
||||
}, {
|
||||
email?: string | null | undefined;
|
||||
phone?: string | null | undefined;
|
||||
address?: string | null | undefined;
|
||||
unitNumber?: string | null | undefined;
|
||||
firstName?: string | null | undefined;
|
||||
lastName?: string | null | undefined;
|
||||
supportLevel?: "LEVEL_1" | "LEVEL_2" | "LEVEL_3" | "LEVEL_4" | null | undefined;
|
||||
sign?: boolean | undefined;
|
||||
signSize?: string | null | undefined;
|
||||
notes?: string | null | undefined;
|
||||
}>;
|
||||
export declare const volunteerCreateLocationSchema: z.ZodObject<{
|
||||
address: z.ZodString;
|
||||
latitude: z.ZodNumber;
|
||||
longitude: z.ZodNumber;
|
||||
unitNumber: z.ZodOptional<z.ZodString>;
|
||||
supportLevel: z.ZodOptional<z.ZodNativeEnum<{
|
||||
LEVEL_1: "LEVEL_1";
|
||||
LEVEL_2: "LEVEL_2";
|
||||
LEVEL_3: "LEVEL_3";
|
||||
LEVEL_4: "LEVEL_4";
|
||||
}>>;
|
||||
sign: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
|
||||
signSize: z.ZodOptional<z.ZodString>;
|
||||
notes: z.ZodOptional<z.ZodString>;
|
||||
firstName: z.ZodOptional<z.ZodString>;
|
||||
lastName: z.ZodOptional<z.ZodString>;
|
||||
email: z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>;
|
||||
phone: z.ZodOptional<z.ZodString>;
|
||||
}, "strip", z.ZodTypeAny, {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
address: string;
|
||||
sign: boolean;
|
||||
email?: string | undefined;
|
||||
phone?: string | undefined;
|
||||
unitNumber?: string | undefined;
|
||||
firstName?: string | undefined;
|
||||
lastName?: string | undefined;
|
||||
supportLevel?: "LEVEL_1" | "LEVEL_2" | "LEVEL_3" | "LEVEL_4" | undefined;
|
||||
signSize?: string | undefined;
|
||||
notes?: string | undefined;
|
||||
}, {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
address: string;
|
||||
email?: string | undefined;
|
||||
phone?: string | undefined;
|
||||
unitNumber?: string | undefined;
|
||||
firstName?: string | undefined;
|
||||
lastName?: string | undefined;
|
||||
supportLevel?: "LEVEL_1" | "LEVEL_2" | "LEVEL_3" | "LEVEL_4" | undefined;
|
||||
sign?: boolean | undefined;
|
||||
signSize?: string | undefined;
|
||||
notes?: string | undefined;
|
||||
}>;
|
||||
export type RecordVisitInput = z.infer<typeof recordVisitSchema>;
|
||||
export type BulkRecordVisitInput = z.infer<typeof bulkRecordVisitSchema>;
|
||||
export type StartSessionInput = z.infer<typeof startSessionSchema>;
|
||||
export type WalkingRouteInput = z.infer<typeof walkingRouteSchema>;
|
||||
export type ListMyVisitsInput = z.infer<typeof listMyVisitsSchema>;
|
||||
export type AdminActivityInput = z.infer<typeof adminActivitySchema>;
|
||||
export type AdminVisitsInput = z.infer<typeof adminVisitsSchema>;
|
||||
export type VolunteerUpdateLocationInput = z.infer<typeof volunteerUpdateLocationSchema>;
|
||||
export type VolunteerCreateLocationInput = z.infer<typeof volunteerCreateLocationSchema>;
|
||||
//# sourceMappingURL=canvass.schemas.d.ts.map
|
||||
1
api/dist/modules/map/canvass/canvass.schemas.d.ts.map
vendored
Normal file
1
api/dist/modules/map/canvass/canvass.schemas.d.ts.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"canvass.schemas.d.ts","sourceRoot":"","sources":["../../../../src/modules/map/canvass/canvass.schemas.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAGxB,eAAO,MAAM,iBAAiB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAW5B,CAAC;AAEH,eAAO,MAAM,qBAAqB;;;;;;;;;;;;;;;;;;EAMhC,CAAC;AAEH,eAAO,MAAM,kBAAkB;;;;;;;;;;;;;;;EAK7B,CAAC;AAEH,eAAO,MAAM,gBAAgB,gDAE3B,CAAC;AAEH,eAAO,MAAM,kBAAkB;;;;;;;;;;;;EAI7B,CAAC;AAEH,eAAO,MAAM,kBAAkB;;;;;;;;;EAG7B,CAAC;AAEH,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;;;;;;;;;;;;;;EAM9B,CAAC;AAEH,eAAO,MAAM,iBAAiB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAS5B,CAAC;AAEH,eAAO,MAAM,6BAA6B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAYxC,CAAC;AAEH,eAAO,MAAM,6BAA6B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAexC,CAAC;AAEH,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iBAAiB,CAAC,CAAC;AACjE,MAAM,MAAM,oBAAoB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,qBAAqB,CAAC,CAAC;AACzE,MAAM,MAAM,iBAAiB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAC;AACnE,MAAM,MAAM,iBAAiB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAC;AACnE,MAAM,MAAM,iBAAiB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAC;AACnE,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AACrE,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iBAAiB,CAAC,CAAC;AACjE,MAAM,MAAM,4BAA4B,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,6BAA6B,CAAC,CAAC;AACzF,MAAM,MAAM,4BAA4B,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,6BAA6B,CAAC,CAAC"}
|
||||
89
api/dist/modules/map/canvass/canvass.schemas.js
vendored
Normal file
89
api/dist/modules/map/canvass/canvass.schemas.js
vendored
Normal file
@@ -0,0 +1,89 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.volunteerCreateLocationSchema = exports.volunteerUpdateLocationSchema = exports.adminVisitsSchema = exports.adminActivitySchema = exports.listMyVisitsSchema = exports.walkingRouteSchema = exports.endSessionSchema = exports.startSessionSchema = exports.bulkRecordVisitSchema = exports.recordVisitSchema = void 0;
|
||||
const zod_1 = require("zod");
|
||||
const client_1 = require("@prisma/client");
|
||||
exports.recordVisitSchema = zod_1.z.object({
|
||||
addressId: zod_1.z.string().min(1), // Changed from locationId
|
||||
outcome: zod_1.z.nativeEnum(client_1.VisitOutcome),
|
||||
supportLevel: zod_1.z.nativeEnum(client_1.SupportLevel).optional(),
|
||||
signRequested: zod_1.z.boolean().optional().default(false),
|
||||
signSize: zod_1.z.string().optional(),
|
||||
notes: zod_1.z.string().optional(),
|
||||
durationSeconds: zod_1.z.number().int().optional(),
|
||||
sessionId: zod_1.z.string().optional(),
|
||||
shiftId: zod_1.z.string().optional(),
|
||||
updateLocation: zod_1.z.boolean().optional().default(true),
|
||||
});
|
||||
exports.bulkRecordVisitSchema = zod_1.z.object({
|
||||
locationId: zod_1.z.string().min(1), // Building ID
|
||||
outcome: zod_1.z.enum(['NOT_HOME', 'REFUSED', 'MOVED']), // Only non-contact outcomes
|
||||
notes: zod_1.z.string().optional(),
|
||||
sessionId: zod_1.z.string().optional(),
|
||||
shiftId: zod_1.z.string().optional(),
|
||||
});
|
||||
exports.startSessionSchema = zod_1.z.object({
|
||||
cutId: zod_1.z.string().min(1),
|
||||
shiftId: zod_1.z.string().optional(),
|
||||
startLatitude: zod_1.z.number().optional(),
|
||||
startLongitude: zod_1.z.number().optional(),
|
||||
});
|
||||
exports.endSessionSchema = zod_1.z.object({
|
||||
// no body required — session id from URL
|
||||
});
|
||||
exports.walkingRouteSchema = zod_1.z.object({
|
||||
excludeVisited: zod_1.z.coerce.boolean().optional().default(false),
|
||||
startLatitude: zod_1.z.coerce.number().optional(),
|
||||
startLongitude: zod_1.z.coerce.number().optional(),
|
||||
});
|
||||
exports.listMyVisitsSchema = zod_1.z.object({
|
||||
page: zod_1.z.coerce.number().int().positive().default(1),
|
||||
limit: zod_1.z.coerce.number().int().positive().max(100).default(20),
|
||||
});
|
||||
exports.adminActivitySchema = zod_1.z.object({
|
||||
page: zod_1.z.coerce.number().int().positive().default(1),
|
||||
limit: zod_1.z.coerce.number().int().positive().max(100).default(20),
|
||||
cutId: zod_1.z.string().optional(),
|
||||
userId: zod_1.z.string().optional(),
|
||||
outcome: zod_1.z.nativeEnum(client_1.VisitOutcome).optional(),
|
||||
});
|
||||
exports.adminVisitsSchema = zod_1.z.object({
|
||||
page: zod_1.z.coerce.number().int().positive().default(1),
|
||||
limit: zod_1.z.coerce.number().int().positive().max(100).default(50),
|
||||
cutId: zod_1.z.string().optional(),
|
||||
userId: zod_1.z.string().optional(),
|
||||
shiftId: zod_1.z.string().optional(),
|
||||
outcome: zod_1.z.nativeEnum(client_1.VisitOutcome).optional(),
|
||||
sortBy: zod_1.z.enum(['visitedAt', 'outcome']).optional().default('visitedAt'),
|
||||
sortOrder: zod_1.z.enum(['asc', 'desc']).optional().default('desc'),
|
||||
});
|
||||
exports.volunteerUpdateLocationSchema = zod_1.z.object({
|
||||
supportLevel: zod_1.z.nativeEnum(client_1.SupportLevel).nullable().optional(),
|
||||
sign: zod_1.z.boolean().optional(),
|
||||
signSize: zod_1.z.string().nullable().optional(),
|
||||
notes: zod_1.z.string().nullable().optional(),
|
||||
// Admin-only fields (stripped by service for non-admins)
|
||||
firstName: zod_1.z.string().nullable().optional(),
|
||||
lastName: zod_1.z.string().nullable().optional(),
|
||||
address: zod_1.z.string().nullable().optional(),
|
||||
unitNumber: zod_1.z.string().nullable().optional(),
|
||||
email: zod_1.z.string().email().nullable().optional().or(zod_1.z.literal('')),
|
||||
phone: zod_1.z.string().nullable().optional(),
|
||||
});
|
||||
exports.volunteerCreateLocationSchema = zod_1.z.object({
|
||||
address: zod_1.z.string().min(1, 'Address is required'),
|
||||
latitude: zod_1.z.number().min(-90).max(90),
|
||||
longitude: zod_1.z.number().min(-180).max(180),
|
||||
unitNumber: zod_1.z.string().optional(),
|
||||
// USER+ fields (stripped by route for TEMPs)
|
||||
supportLevel: zod_1.z.nativeEnum(client_1.SupportLevel).optional(),
|
||||
sign: zod_1.z.boolean().optional().default(false),
|
||||
signSize: zod_1.z.string().optional(),
|
||||
notes: zod_1.z.string().optional(),
|
||||
// Admin-only fields
|
||||
firstName: zod_1.z.string().optional(),
|
||||
lastName: zod_1.z.string().optional(),
|
||||
email: zod_1.z.string().email().optional().or(zod_1.z.literal('')),
|
||||
phone: zod_1.z.string().optional(),
|
||||
});
|
||||
//# sourceMappingURL=canvass.schemas.js.map
|
||||
1
api/dist/modules/map/canvass/canvass.schemas.js.map
vendored
Normal file
1
api/dist/modules/map/canvass/canvass.schemas.js.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"canvass.schemas.js","sourceRoot":"","sources":["../../../../src/modules/map/canvass/canvass.schemas.ts"],"names":[],"mappings":"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"}
|
||||
323
api/dist/modules/map/canvass/canvass.service.d.ts
vendored
Normal file
323
api/dist/modules/map/canvass/canvass.service.d.ts
vendored
Normal file
@@ -0,0 +1,323 @@
|
||||
import { Prisma, UserRole } from '@prisma/client';
|
||||
import type { RecordVisitInput, BulkRecordVisitInput, StartSessionInput, WalkingRouteInput, ListMyVisitsInput, AdminActivityInput, AdminVisitsInput, VolunteerUpdateLocationInput } from './canvass.schemas';
|
||||
export declare const canvassService: {
|
||||
getMyAssignments(userId: string): Promise<{
|
||||
shiftId: string;
|
||||
shiftTitle: string;
|
||||
shiftDate: Date;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
location: string | null;
|
||||
cutId: string;
|
||||
cutName: string;
|
||||
completionPercentage: number;
|
||||
}[]>;
|
||||
getMyStats(userId: string): Promise<{
|
||||
totalVisits: number;
|
||||
todayVisits: number;
|
||||
byOutcome: Record<string, number>;
|
||||
sessions: number;
|
||||
}>;
|
||||
getMyVisits(userId: string, filters: ListMyVisitsInput): Promise<{
|
||||
visits: ({
|
||||
address: {
|
||||
id: string;
|
||||
location: {
|
||||
address: string;
|
||||
};
|
||||
unitNumber: string | null;
|
||||
};
|
||||
} & {
|
||||
id: string;
|
||||
durationSeconds: number | null;
|
||||
userId: string;
|
||||
sessionId: string | null;
|
||||
outcome: import(".prisma/client").$Enums.VisitOutcome;
|
||||
shiftId: string | null;
|
||||
supportLevel: import(".prisma/client").$Enums.SupportLevel | null;
|
||||
signSize: string | null;
|
||||
notes: string | null;
|
||||
signRequested: boolean;
|
||||
visitedAt: Date;
|
||||
addressId: string;
|
||||
})[];
|
||||
pagination: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}>;
|
||||
getActiveSession(userId: string): Promise<({
|
||||
shift: {
|
||||
id: string;
|
||||
title: string;
|
||||
} | null;
|
||||
cut: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
} & {
|
||||
status: import(".prisma/client").$Enums.CanvassSessionStatus;
|
||||
id: string;
|
||||
userId: string;
|
||||
shiftId: string | null;
|
||||
startedAt: Date;
|
||||
endedAt: Date | null;
|
||||
startLatitude: Prisma.Decimal | null;
|
||||
startLongitude: Prisma.Decimal | null;
|
||||
cutId: string;
|
||||
}) | null>;
|
||||
startSession(userId: string, data: StartSessionInput): Promise<{
|
||||
shift: {
|
||||
id: string;
|
||||
title: string;
|
||||
} | null;
|
||||
cut: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
} & {
|
||||
status: import(".prisma/client").$Enums.CanvassSessionStatus;
|
||||
id: string;
|
||||
userId: string;
|
||||
shiftId: string | null;
|
||||
startedAt: Date;
|
||||
endedAt: Date | null;
|
||||
startLatitude: Prisma.Decimal | null;
|
||||
startLongitude: Prisma.Decimal | null;
|
||||
cutId: string;
|
||||
}>;
|
||||
endSession(sessionId: string, userId: string): Promise<{
|
||||
status: import(".prisma/client").$Enums.CanvassSessionStatus;
|
||||
id: string;
|
||||
userId: string;
|
||||
shiftId: string | null;
|
||||
startedAt: Date;
|
||||
endedAt: Date | null;
|
||||
startLatitude: Prisma.Decimal | null;
|
||||
startLongitude: Prisma.Decimal | null;
|
||||
cutId: string;
|
||||
}>;
|
||||
getCutLocationsForCanvass(cutId: string, userId: string, bounds?: {
|
||||
minLat: number;
|
||||
maxLat: number;
|
||||
minLng: number;
|
||||
maxLng: number;
|
||||
}, limit?: number): Promise<{
|
||||
location: {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
id: string;
|
||||
address: string;
|
||||
buildingNotes: string | null;
|
||||
};
|
||||
lastVisit: {
|
||||
outcome: import(".prisma/client").$Enums.VisitOutcome;
|
||||
visitedAt: Date;
|
||||
visitorName: string | null;
|
||||
isMyVisit: boolean;
|
||||
} | null;
|
||||
id: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
unitNumber: string | null;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
supportLevel: import(".prisma/client").$Enums.SupportLevel | null;
|
||||
sign: boolean;
|
||||
signSize: string | null;
|
||||
notes: string | null;
|
||||
}[]>;
|
||||
getAllLocationsForCanvass(userId: string, bounds?: {
|
||||
minLat: number;
|
||||
maxLat: number;
|
||||
minLng: number;
|
||||
maxLng: number;
|
||||
}, limit?: number): Promise<{
|
||||
location: {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
id: string;
|
||||
address: string;
|
||||
buildingNotes: string | null;
|
||||
};
|
||||
lastVisit: {
|
||||
outcome: import(".prisma/client").$Enums.VisitOutcome;
|
||||
visitedAt: Date;
|
||||
visitorName: string | null;
|
||||
isMyVisit: boolean;
|
||||
} | null;
|
||||
id: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
unitNumber: string | null;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
supportLevel: import(".prisma/client").$Enums.SupportLevel | null;
|
||||
sign: boolean;
|
||||
signSize: string | null;
|
||||
notes: string | null;
|
||||
}[]>;
|
||||
updateAddressAsVolunteer(addressId: string, userId: string, role: UserRole, data: VolunteerUpdateLocationInput): Promise<{
|
||||
id: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
createdByUserId: string | null;
|
||||
updatedByUserId: string | null;
|
||||
unitNumber: string | null;
|
||||
addrGuid: string | null;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
supportLevel: import(".prisma/client").$Enums.SupportLevel | null;
|
||||
sign: boolean;
|
||||
signSize: string | null;
|
||||
notes: string | null;
|
||||
locationId: string;
|
||||
}>;
|
||||
getWalkingRoute(cutId: string, userId: string, filters: WalkingRouteInput): Promise<import("./canvass-route.service").RouteResult>;
|
||||
recordVisit(userId: string, data: RecordVisitInput): Promise<{
|
||||
address: {
|
||||
id: string;
|
||||
location: {
|
||||
address: string;
|
||||
};
|
||||
unitNumber: string | null;
|
||||
};
|
||||
} & {
|
||||
id: string;
|
||||
durationSeconds: number | null;
|
||||
userId: string;
|
||||
sessionId: string | null;
|
||||
outcome: import(".prisma/client").$Enums.VisitOutcome;
|
||||
shiftId: string | null;
|
||||
supportLevel: import(".prisma/client").$Enums.SupportLevel | null;
|
||||
signSize: string | null;
|
||||
notes: string | null;
|
||||
signRequested: boolean;
|
||||
visitedAt: Date;
|
||||
addressId: string;
|
||||
}>;
|
||||
recordBulkVisit(userId: string, data: BulkRecordVisitInput): Promise<{
|
||||
created: number;
|
||||
visits: {
|
||||
id: string;
|
||||
durationSeconds: number | null;
|
||||
userId: string;
|
||||
sessionId: string | null;
|
||||
outcome: import(".prisma/client").$Enums.VisitOutcome;
|
||||
shiftId: string | null;
|
||||
supportLevel: import(".prisma/client").$Enums.SupportLevel | null;
|
||||
signSize: string | null;
|
||||
notes: string | null;
|
||||
signRequested: boolean;
|
||||
visitedAt: Date;
|
||||
addressId: string;
|
||||
}[];
|
||||
}>;
|
||||
getAdminStats(): Promise<{
|
||||
totalVisits: number;
|
||||
todayVisits: number;
|
||||
activeSessions: number;
|
||||
activeVolunteers: number;
|
||||
overallCompletion: number;
|
||||
}>;
|
||||
getCutStats(cutId: string): Promise<{
|
||||
cutId: string;
|
||||
cutName: string;
|
||||
totalLocations: number;
|
||||
visitedLocations: number;
|
||||
completionPercentage: number;
|
||||
totalVisits: number;
|
||||
byOutcome: Record<string, number>;
|
||||
}>;
|
||||
getAdminActivity(filters: AdminActivityInput): Promise<{
|
||||
visits: ({
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
};
|
||||
address: {
|
||||
id: string;
|
||||
location: {
|
||||
address: string;
|
||||
};
|
||||
unitNumber: string | null;
|
||||
};
|
||||
} & {
|
||||
id: string;
|
||||
durationSeconds: number | null;
|
||||
userId: string;
|
||||
sessionId: string | null;
|
||||
outcome: import(".prisma/client").$Enums.VisitOutcome;
|
||||
shiftId: string | null;
|
||||
supportLevel: import(".prisma/client").$Enums.SupportLevel | null;
|
||||
signSize: string | null;
|
||||
notes: string | null;
|
||||
signRequested: boolean;
|
||||
visitedAt: Date;
|
||||
addressId: string;
|
||||
})[];
|
||||
pagination: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}>;
|
||||
getVolunteers(): Promise<{
|
||||
userId: string;
|
||||
name: string | null;
|
||||
email: string;
|
||||
totalVisits: number;
|
||||
sessions: number;
|
||||
lastActive: Date | null;
|
||||
}[]>;
|
||||
getVolunteerStats(userId: string): Promise<{
|
||||
totalVisits: number;
|
||||
todayVisits: number;
|
||||
byOutcome: Record<string, number>;
|
||||
sessions: number;
|
||||
}>;
|
||||
getAdminVisits(filters: AdminVisitsInput): Promise<{
|
||||
visits: ({
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
};
|
||||
address: {
|
||||
id: string;
|
||||
location: {
|
||||
address: string;
|
||||
};
|
||||
unitNumber: string | null;
|
||||
};
|
||||
} & {
|
||||
id: string;
|
||||
durationSeconds: number | null;
|
||||
userId: string;
|
||||
sessionId: string | null;
|
||||
outcome: import(".prisma/client").$Enums.VisitOutcome;
|
||||
shiftId: string | null;
|
||||
supportLevel: import(".prisma/client").$Enums.SupportLevel | null;
|
||||
signSize: string | null;
|
||||
notes: string | null;
|
||||
signRequested: boolean;
|
||||
visitedAt: Date;
|
||||
addressId: string;
|
||||
})[];
|
||||
pagination: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}>;
|
||||
recalculateCutCompletion(cutId: string): Promise<void>;
|
||||
closeAbandonedSessions(): Promise<number>;
|
||||
};
|
||||
//# sourceMappingURL=canvass.service.d.ts.map
|
||||
1
api/dist/modules/map/canvass/canvass.service.d.ts.map
vendored
Normal file
1
api/dist/modules/map/canvass/canvass.service.d.ts.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"canvass.service.d.ts","sourceRoot":"","sources":["../../../../src/modules/map/canvass/canvass.service.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAoD,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AASpG,OAAO,KAAK,EACV,gBAAgB,EAChB,oBAAoB,EACpB,iBAAiB,EACjB,iBAAiB,EACjB,iBAAiB,EACjB,kBAAkB,EAClB,gBAAgB,EAChB,4BAA4B,EAC7B,MAAM,mBAAmB,CAAC;AAwE3B,eAAO,MAAM,cAAc;6BAGM,MAAM;;;;;;;;;;;uBAgCZ,MAAM;;;;;;wBAuBL,MAAM,WAAW,iBAAiB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;6BA6B7B,MAAM;;;;;;;;;;;;;;;;;;;;yBAUV,MAAM,QAAQ,iBAAiB;;;;;;;;;;;;;;;;;;;;0BAgC9B,MAAM,UAAU,MAAM;;;;;;;;;;;qCAwBzC,MAAM,UACL,MAAM,WACL;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,UACnE,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;sCAoGN,MAAM,WACL;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,UACnE,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;wCAuFH,MAAM,UACT,MAAM,QACR,QAAQ,QACR,4BAA4B;;;;;;;;;;;;;;;;;;2BA+BP,MAAM,UAAU,MAAM,WAAW,iBAAiB;wBA2BrD,MAAM,QAAQ,gBAAgB;;;;;;;;;;;;;;;;;;;;;;4BA0D1B,MAAM,QAAQ,oBAAoB;;;;;;;;;;;;;;;;;;;;;;;;uBAqHvC,MAAM;;;;;;;;;8BAuDC,kBAAkB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;8BA0FlB,MAAM;;;;;;4BAIR,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;oCA4DR,MAAM;;CA8D7C,CAAC"}
|
||||
795
api/dist/modules/map/canvass/canvass.service.js
vendored
Normal file
795
api/dist/modules/map/canvass/canvass.service.js
vendored
Normal file
@@ -0,0 +1,795 @@
|
||||
"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
|
||||
1
api/dist/modules/map/canvass/canvass.service.js.map
vendored
Normal file
1
api/dist/modules/map/canvass/canvass.service.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user