changemaker.lite/api/src/modules/action-campaigns/action-campaigns.routes.ts
bunker-admin ed011a762b Add action-campaigns module + volunteer-dashboard aggregator
ActionCampaign service exposes admin CRUD plus a per-user composer
(getActiveForUser) that fans out a per-step completion check against
the existing per-user model for each ActionStepKind: VideoView for
WATCH_VIDEO, CampaignEmail for SUBMIT_INFLUENCE, PetitionSignature
for SIGN_PETITION (matched by signer email), Ticket for RSVP_EVENT,
ShiftSignup for SIGNUP_SHIFT, ChallengeTeamMember for JOIN_CHALLENGE.
CUSTOM and VISIT_LINK steps complete only via explicit self-report.
An existing ActionStepCompletion row also short-circuits the check
so manual marking and idempotency both work.

Volunteer dashboard aggregator at GET /api/volunteer/dashboard
composes the active campaign with the user's profile, referral info,
upcoming featured event, training shifts (Shift.kind='TRAINING'),
ticketed events the user holds, an engagement-counter point total
(placeholder until the Redis engagement score is wired in), and
resources tagged 'volunteer-resource' across Document/Video/Photo.

Bunker Admin
2026-04-11 10:20:26 -06:00

179 lines
4.8 KiB
TypeScript

import { Router, Request, Response, NextFunction } from 'express';
import { actionCampaignsService } from './action-campaigns.service';
import {
createActionCampaignSchema,
updateActionCampaignSchema,
createActionStepSchema,
updateActionStepSchema,
reorderStepsSchema,
markCompleteSchema,
} from './action-campaigns.schemas';
import { validate } from '../../middleware/validate';
import { authenticate, optionalAuth } from '../../middleware/auth.middleware';
import { requireRole } from '../../middleware/rbac.middleware';
import { INFLUENCE_ROLES } from '../../utils/roles';
const publicRouter = Router();
publicRouter.get(
'/active',
optionalAuth,
async (req: Request, res: Response, next: NextFunction) => {
try {
if (req.user?.id) {
const data = await actionCampaignsService.getActiveForUser(req.user.id);
res.json(data);
return;
}
const campaign = await actionCampaignsService.getActiveCampaign();
res.json(campaign);
} catch (err) {
next(err);
}
},
);
publicRouter.post(
'/:slug/steps/:stepId/complete',
authenticate,
validate(markCompleteSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const stepId = req.params.stepId as string;
const completion = await actionCampaignsService.markStepComplete(
req.user!.id,
stepId,
(req.body as { source?: import('@prisma/client').ActionStepCompletionSource }).source,
);
res.status(201).json({ ok: true, completion });
} catch (err) {
next(err);
}
},
);
const adminRouter = Router();
adminRouter.use(authenticate);
adminRouter.use(requireRole(...INFLUENCE_ROLES));
adminRouter.get('/', async (_req: Request, res: Response, next: NextFunction) => {
try {
const campaigns = await actionCampaignsService.listCampaigns();
res.json(campaigns);
} catch (err) {
next(err);
}
});
adminRouter.post(
'/',
validate(createActionCampaignSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const campaign = await actionCampaignsService.createCampaign(req.body, req.user!.id);
res.status(201).json(campaign);
} catch (err) {
next(err);
}
},
);
adminRouter.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const campaign = await actionCampaignsService.getCampaign(id);
res.json(campaign);
} catch (err) {
next(err);
}
});
adminRouter.put(
'/:id',
validate(updateActionCampaignSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const campaign = await actionCampaignsService.updateCampaign(id, req.body);
res.json(campaign);
} catch (err) {
next(err);
}
},
);
adminRouter.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
await actionCampaignsService.deleteCampaign(id);
res.status(204).send();
} catch (err) {
next(err);
}
});
adminRouter.post('/:id/activate', async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const campaign = await actionCampaignsService.activateCampaign(id);
res.json(campaign);
} catch (err) {
next(err);
}
});
adminRouter.post(
'/:id/steps',
validate(createActionStepSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const step = await actionCampaignsService.addStep(id, req.body);
res.status(201).json(step);
} catch (err) {
next(err);
}
},
);
adminRouter.put(
'/:id/steps/:stepId',
validate(updateActionStepSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const stepId = req.params.stepId as string;
const step = await actionCampaignsService.updateStep(stepId, req.body);
res.json(step);
} catch (err) {
next(err);
}
},
);
adminRouter.delete('/:id/steps/:stepId', async (req: Request, res: Response, next: NextFunction) => {
try {
const stepId = req.params.stepId as string;
await actionCampaignsService.deleteStep(stepId);
res.status(204).send();
} catch (err) {
next(err);
}
});
adminRouter.post(
'/:id/steps/reorder',
validate(reorderStepsSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const { stepIds } = req.body as { stepIds: string[] };
const steps = await actionCampaignsService.reorderSteps(id, stepIds);
res.json(steps);
} catch (err) {
next(err);
}
},
);
export { publicRouter as actionCampaignsRouter, adminRouter as actionCampaignsAdminRouter };