384 lines
11 KiB
TypeScript

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 };