import { Router, Request, Response, NextFunction } from 'express'; import { UserRole } from '@prisma/client'; import { canvassService } from './canvass.service'; import { recordVisitSchema, bulkRecordVisitSchema, startSessionSchema, walkingRouteSchema, listMyVisitsSchema, adminActivitySchema, adminVisitsSchema, volunteerUpdateLocationSchema, volunteerCreateLocationSchema, outcomeTrendsQuerySchema, } from './canvass.schemas'; import { reverseGeocodeSchema, geocodeAddressSchema } from '../locations/locations.schemas'; import { locationsService } from '../locations/locations.service'; import { geocodingService } from '../geocoding/geocoding.service'; import { validate } from '../../../middleware/validate'; import { authenticate } from '../../../middleware/auth.middleware'; import { requireRole } from '../../../middleware/rbac.middleware'; import { canvassVisitRateLimit, canvassBulkVisitRateLimit, canvassGeocodeRateLimit } from '../../../middleware/rate-limit'; const MAP_ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN]; // ─── Volunteer Router ──────────────────────────────────────────────── const volunteerRouter = Router(); volunteerRouter.use(authenticate); // GET /api/map/canvass/my/assignments volunteerRouter.get( '/my/assignments', async (req: Request, res: Response, next: NextFunction) => { try { const assignments = await canvassService.getMyAssignments(req.user!.id); res.json(assignments); } catch (err) { next(err); } }, ); // GET /api/map/canvass/my/stats volunteerRouter.get( '/my/stats', async (req: Request, res: Response, next: NextFunction) => { try { const stats = await canvassService.getMyStats(req.user!.id); res.json(stats); } catch (err) { next(err); } }, ); // GET /api/map/canvass/my/visits volunteerRouter.get( '/my/visits', validate(listMyVisitsSchema, 'query'), async (req: Request, res: Response, next: NextFunction) => { try { const result = await canvassService.getMyVisits(req.user!.id, req.query as any); res.json(result); } catch (err) { next(err); } }, ); // GET /api/map/canvass/my/session volunteerRouter.get( '/my/session', async (req: Request, res: Response, next: NextFunction) => { try { const session = await canvassService.getActiveSession(req.user!.id); res.json(session); } catch (err) { next(err); } }, ); // POST /api/map/canvass/sessions volunteerRouter.post( '/sessions', validate(startSessionSchema), async (req: Request, res: Response, next: NextFunction) => { try { const session = await 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: Request, res: Response, next: NextFunction) => { try { const id = req.params.id as string; const session = await 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: Request, res: Response, next: NextFunction) => { try { const cutId = req.params.cutId as string; const bounds = req.query.minLat ? { minLat: parseFloat(req.query.minLat as string), maxLat: parseFloat(req.query.maxLat as string), minLng: parseFloat(req.query.minLng as string), maxLng: parseFloat(req.query.maxLng as string), } : undefined; const limit = req.query.limit ? parseInt(req.query.limit as string) : undefined; const locations = await 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', validate(walkingRouteSchema, 'query'), async (req: Request, res: Response, next: NextFunction) => { try { const cutId = req.params.cutId as string; const route = await canvassService.getWalkingRoute(cutId, req.user!.id, req.query as any); res.json(route); } catch (err) { next(err); } }, ); // GET /api/map/canvass/locations — all locations with visit annotations volunteerRouter.get( '/locations', async (req: Request, res: Response, next: NextFunction) => { try { const bounds = req.query.minLat ? { minLat: parseFloat(req.query.minLat as string), maxLat: parseFloat(req.query.maxLat as string), minLng: parseFloat(req.query.minLng as string), maxLng: parseFloat(req.query.maxLng as string), } : undefined; const limit = req.query.limit ? parseInt(req.query.limit as string) : undefined; const locations = await 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', validate(volunteerUpdateLocationSchema), async (req: Request, res: Response, next: NextFunction) => { try { const id = req.params.id as string; const address = await 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', validate(volunteerCreateLocationSchema), async (req: Request, res: Response, next: NextFunction) => { try { const data = { ...req.body }; // Strip fields based on role const userRoles = req.user!.roles || [req.user!.role]; const isAdmin = userRoles.some((r: string) => r === UserRole.SUPER_ADMIN || r === UserRole.MAP_ADMIN); if (!isAdmin) { delete data.firstName; delete data.lastName; delete data.email; delete data.phone; } if (userRoles.length === 1 && userRoles[0] === UserRole.TEMP) { delete data.supportLevel; delete data.sign; delete data.signSize; delete data.notes; } const location = await 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', validate(reverseGeocodeSchema), async (req: Request, res: Response, next: NextFunction) => { try { const result = await 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', canvassGeocodeRateLimit, validate(geocodeAddressSchema), async (req: Request, res: Response, next: NextFunction) => { try { const result = await 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', canvassVisitRateLimit, validate(recordVisitSchema), async (req: Request, res: Response, next: NextFunction) => { try { const visit = await 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', canvassBulkVisitRateLimit, // Stricter rate limit for bulk operations validate(bulkRecordVisitSchema), async (req: Request, res: Response, next: NextFunction) => { try { const result = await canvassService.recordBulkVisit(req.user!.id, req.body); res.status(201).json(result); } catch (err) { next(err); } }, ); // ─── Admin Router ──────────────────────────────────────────────────── const adminRouter = Router(); adminRouter.use(authenticate); adminRouter.use(requireRole(...MAP_ADMIN_ROLES)); // GET /api/map/canvass/stats adminRouter.get( '/stats', async (_req: Request, res: Response, next: NextFunction) => { try { const stats = await canvassService.getAdminStats(); res.json(stats); } catch (err) { next(err); } }, ); // GET /api/map/canvass/stats/cuts/:cutId adminRouter.get( '/stats/cuts/:cutId', async (req: Request, res: Response, next: NextFunction) => { try { const cutId = req.params.cutId as string; const stats = await canvassService.getCutStats(cutId); res.json(stats); } catch (err) { next(err); } }, ); // GET /api/map/canvass/activity adminRouter.get( '/activity', validate(adminActivitySchema, 'query'), async (req: Request, res: Response, next: NextFunction) => { try { const result = await canvassService.getAdminActivity(req.query as any); res.json(result); } catch (err) { next(err); } }, ); // GET /api/map/canvass/volunteers adminRouter.get( '/volunteers', async (_req: Request, res: Response, next: NextFunction) => { try { const volunteers = await canvassService.getVolunteers(); res.json(volunteers); } catch (err) { next(err); } }, ); // GET /api/map/canvass/volunteers/:userId adminRouter.get( '/volunteers/:userId', async (req: Request, res: Response, next: NextFunction) => { try { const userId = req.params.userId as string; const stats = await canvassService.getVolunteerStats(userId); res.json(stats); } catch (err) { next(err); } }, ); // GET /api/map/canvass/visits adminRouter.get( '/visits', validate(adminVisitsSchema, 'query'), async (req: Request, res: Response, next: NextFunction) => { try { const result = await canvassService.getAdminVisits(req.query as any); res.json(result); } catch (err) { next(err); } }, ); // GET /api/map/canvass/trends adminRouter.get( '/trends', validate(outcomeTrendsQuerySchema, 'query'), async (req: Request, res: Response, next: NextFunction) => { try { const result = await canvassService.getOutcomeTrends(req.query as any); res.json(result); } catch (err) { next(err); } }, ); export { volunteerRouter as canvassVolunteerRouter, adminRouter as canvassAdminRouter };