384 lines
11 KiB
TypeScript
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 };
|