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
This commit is contained in:
bunker-admin 2026-04-11 10:20:26 -06:00
parent 3fc67cd81a
commit ed011a762b
6 changed files with 1010 additions and 0 deletions

View File

@ -0,0 +1,178 @@
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 };

View File

@ -0,0 +1,57 @@
import { z } from 'zod';
import { ActionStepKind, ActionStepCompletionSource } from '@prisma/client';
export const createActionCampaignSchema = z.object({
slug: z.string().min(1).max(80).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).optional(),
title: z.string().min(1).max(200),
description: z.string().max(5000).nullable().optional(),
rewardText: z.string().max(500).nullable().optional(),
isActive: z.boolean().optional().default(false),
startsAt: z.coerce.date().nullable().optional(),
endsAt: z.coerce.date().nullable().optional(),
minStepsForReward: z.number().int().positive().nullable().optional(),
});
export const updateActionCampaignSchema = z.object({
slug: z.string().min(1).max(80).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).optional(),
title: z.string().min(1).max(200).optional(),
description: z.string().max(5000).nullable().optional(),
rewardText: z.string().max(500).nullable().optional(),
isActive: z.boolean().optional(),
startsAt: z.coerce.date().nullable().optional(),
endsAt: z.coerce.date().nullable().optional(),
minStepsForReward: z.number().int().positive().nullable().optional(),
});
export const createActionStepSchema = z.object({
kind: z.nativeEnum(ActionStepKind),
label: z.string().min(1).max(200),
description: z.string().max(2000).nullable().optional(),
targetId: z.string().max(200).nullable().optional(),
targetUrl: z.string().url().max(500).nullable().optional(),
autoComplete: z.boolean().optional().default(true),
});
export const updateActionStepSchema = z.object({
kind: z.nativeEnum(ActionStepKind).optional(),
label: z.string().min(1).max(200).optional(),
description: z.string().max(2000).nullable().optional(),
targetId: z.string().max(200).nullable().optional(),
targetUrl: z.string().url().max(500).nullable().optional(),
autoComplete: z.boolean().optional(),
});
export const reorderStepsSchema = z.object({
stepIds: z.array(z.string().min(1)).min(1),
});
export const markCompleteSchema = z.object({
source: z.nativeEnum(ActionStepCompletionSource).optional().default(ActionStepCompletionSource.SELF_REPORTED),
});
export type CreateActionCampaignInput = z.infer<typeof createActionCampaignSchema>;
export type UpdateActionCampaignInput = z.infer<typeof updateActionCampaignSchema>;
export type CreateActionStepInput = z.infer<typeof createActionStepSchema>;
export type UpdateActionStepInput = z.infer<typeof updateActionStepSchema>;
export type ReorderStepsInput = z.infer<typeof reorderStepsSchema>;
export type MarkCompleteInput = z.infer<typeof markCompleteSchema>;

View File

@ -0,0 +1,426 @@
import { Prisma, ActionStepKind, ActionStepCompletionSource } from '@prisma/client';
import { prisma } from '../../config/database';
import { AppError } from '../../middleware/error-handler';
import type {
CreateActionCampaignInput,
UpdateActionCampaignInput,
CreateActionStepInput,
UpdateActionStepInput,
} from './action-campaigns.schemas';
function generateSlug(title: string): string {
return title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80);
}
async function resolveSlugCollision(slug: string, excludeId?: string): Promise<string> {
let candidate = slug;
let suffix = 2;
while (true) {
const existing = await prisma.actionCampaign.findUnique({
where: { slug: candidate },
select: { id: true },
});
if (!existing || (excludeId && existing.id === excludeId)) return candidate;
candidate = `${slug}-${suffix}`;
suffix++;
}
}
const campaignWithSteps = {
include: {
steps: { orderBy: { order: 'asc' as const } },
},
} satisfies Prisma.ActionCampaignDefaultArgs;
type CampaignWithSteps = Prisma.ActionCampaignGetPayload<typeof campaignWithSteps>;
export interface ActiveCampaignStepForUser {
id: string;
order: number;
kind: ActionStepKind;
label: string;
description: string | null;
targetId: string | null;
targetUrl: string | null;
autoComplete: boolean;
completed: boolean;
completedAt: Date | null;
source: ActionStepCompletionSource | null;
}
export interface ActiveCampaignForUser {
id: string;
slug: string;
title: string;
description: string | null;
rewardText: string | null;
isActive: boolean;
startsAt: Date | null;
endsAt: Date | null;
minStepsForReward: number | null;
totalSteps: number;
completedSteps: number;
rewardEarned: boolean;
steps: ActiveCampaignStepForUser[];
}
async function isStepAutoCompleted(
userId: string,
userEmail: string | null,
step: CampaignWithSteps['steps'][number],
): Promise<boolean> {
if (!step.targetId) return false;
switch (step.kind) {
case ActionStepKind.WATCH_VIDEO: {
const videoId = Number.parseInt(step.targetId, 10);
if (!Number.isFinite(videoId)) return false;
const view = await prisma.videoView.findFirst({
where: { userId, videoId },
select: { id: true },
});
return !!view;
}
case ActionStepKind.SUBMIT_INFLUENCE: {
const email = await prisma.campaignEmail.findFirst({
where: { userId, campaignSlug: step.targetId },
select: { id: true },
});
return !!email;
}
case ActionStepKind.SIGN_PETITION: {
if (!userEmail) return false;
const signature = await prisma.petitionSignature.findFirst({
where: {
signerEmail: userEmail,
petition: { slug: step.targetId },
},
select: { id: true },
});
return !!signature;
}
case ActionStepKind.RSVP_EVENT: {
const ticket = await prisma.ticket.findFirst({
where: {
userId,
event: { slug: step.targetId },
},
select: { id: true },
});
return !!ticket;
}
case ActionStepKind.SIGNUP_SHIFT: {
const signup = await prisma.shiftSignup.findFirst({
where: { userId, shiftId: step.targetId },
select: { id: true },
});
return !!signup;
}
case ActionStepKind.JOIN_CHALLENGE: {
const member = await prisma.challengeTeamMember.findFirst({
where: {
userId,
team: { challengeId: step.targetId },
},
select: { id: true },
});
return !!member;
}
case ActionStepKind.VISIT_LINK:
case ActionStepKind.CUSTOM:
return false;
default:
return false;
}
}
export const actionCampaignsService = {
async listCampaigns() {
return prisma.actionCampaign.findMany({
orderBy: [{ isActive: 'desc' }, { createdAt: 'desc' }],
include: {
steps: { orderBy: { order: 'asc' } },
_count: { select: { steps: true } },
},
});
},
async getCampaign(idOrSlug: string): Promise<CampaignWithSteps> {
const campaign = await prisma.actionCampaign.findFirst({
where: { OR: [{ id: idOrSlug }, { slug: idOrSlug }] },
include: { steps: { orderBy: { order: 'asc' } } },
});
if (!campaign) throw new AppError(404, 'Action campaign not found', 'ACTION_CAMPAIGN_NOT_FOUND');
return campaign;
},
async createCampaign(data: CreateActionCampaignInput, createdByUserId: string) {
const slug = await resolveSlugCollision(data.slug ? generateSlug(data.slug) : generateSlug(data.title));
return prisma.actionCampaign.create({
data: {
slug,
title: data.title,
description: data.description ?? null,
rewardText: data.rewardText ?? null,
isActive: data.isActive ?? false,
startsAt: data.startsAt ?? null,
endsAt: data.endsAt ?? null,
minStepsForReward: data.minStepsForReward ?? null,
createdByUserId,
},
include: { steps: { orderBy: { order: 'asc' } } },
});
},
async updateCampaign(id: string, data: UpdateActionCampaignInput) {
const existing = await prisma.actionCampaign.findUnique({ where: { id }, select: { id: true, slug: true } });
if (!existing) throw new AppError(404, 'Action campaign not found', 'ACTION_CAMPAIGN_NOT_FOUND');
let slug: string | undefined;
if (data.slug && data.slug !== existing.slug) {
slug = await resolveSlugCollision(generateSlug(data.slug), id);
}
return prisma.actionCampaign.update({
where: { id },
data: {
...(data.title !== undefined && { title: data.title }),
...(data.description !== undefined && { description: data.description }),
...(data.rewardText !== undefined && { rewardText: data.rewardText }),
...(data.isActive !== undefined && { isActive: data.isActive }),
...(data.startsAt !== undefined && { startsAt: data.startsAt }),
...(data.endsAt !== undefined && { endsAt: data.endsAt }),
...(data.minStepsForReward !== undefined && { minStepsForReward: data.minStepsForReward }),
...(slug && { slug }),
},
include: { steps: { orderBy: { order: 'asc' } } },
});
},
async deleteCampaign(id: string) {
const existing = await prisma.actionCampaign.findUnique({ where: { id }, select: { id: true } });
if (!existing) throw new AppError(404, 'Action campaign not found', 'ACTION_CAMPAIGN_NOT_FOUND');
await prisma.actionCampaign.delete({ where: { id } });
},
async activateCampaign(id: string) {
const existing = await prisma.actionCampaign.findUnique({ where: { id }, select: { id: true } });
if (!existing) throw new AppError(404, 'Action campaign not found', 'ACTION_CAMPAIGN_NOT_FOUND');
return prisma.$transaction(async (tx) => {
await tx.actionCampaign.updateMany({
where: { id: { not: id }, isActive: true },
data: { isActive: false },
});
return tx.actionCampaign.update({
where: { id },
data: { isActive: true },
include: { steps: { orderBy: { order: 'asc' } } },
});
});
},
async addStep(campaignId: string, data: CreateActionStepInput) {
const campaign = await prisma.actionCampaign.findUnique({
where: { id: campaignId },
select: { id: true },
});
if (!campaign) throw new AppError(404, 'Action campaign not found', 'ACTION_CAMPAIGN_NOT_FOUND');
const last = await prisma.actionStep.findFirst({
where: { campaignId },
orderBy: { order: 'desc' },
select: { order: true },
});
const nextOrder = (last?.order ?? -1) + 1;
return prisma.actionStep.create({
data: {
campaignId,
order: nextOrder,
kind: data.kind,
label: data.label,
description: data.description ?? null,
targetId: data.targetId ?? null,
targetUrl: data.targetUrl ?? null,
autoComplete: data.autoComplete ?? true,
},
});
},
async updateStep(stepId: string, data: UpdateActionStepInput) {
const existing = await prisma.actionStep.findUnique({ where: { id: stepId }, select: { id: true } });
if (!existing) throw new AppError(404, 'Action step not found', 'ACTION_STEP_NOT_FOUND');
return prisma.actionStep.update({
where: { id: stepId },
data: {
...(data.kind !== undefined && { kind: data.kind }),
...(data.label !== undefined && { label: data.label }),
...(data.description !== undefined && { description: data.description }),
...(data.targetId !== undefined && { targetId: data.targetId }),
...(data.targetUrl !== undefined && { targetUrl: data.targetUrl }),
...(data.autoComplete !== undefined && { autoComplete: data.autoComplete }),
},
});
},
async deleteStep(stepId: string) {
const existing = await prisma.actionStep.findUnique({ where: { id: stepId }, select: { id: true } });
if (!existing) throw new AppError(404, 'Action step not found', 'ACTION_STEP_NOT_FOUND');
await prisma.actionStep.delete({ where: { id: stepId } });
},
async reorderSteps(campaignId: string, stepIdOrder: string[]) {
const existing = await prisma.actionStep.findMany({
where: { campaignId },
select: { id: true },
});
const existingIds = new Set(existing.map((s) => s.id));
if (stepIdOrder.length !== existing.length || !stepIdOrder.every((id) => existingIds.has(id))) {
throw new AppError(400, 'stepIds must match existing steps for this campaign', 'INVALID_REORDER');
}
// Two-phase update to avoid @@unique([campaignId, order]) collisions
await prisma.$transaction(async (tx) => {
const offset = existing.length;
for (let i = 0; i < stepIdOrder.length; i++) {
await tx.actionStep.update({
where: { id: stepIdOrder[i]! },
data: { order: i + offset },
});
}
for (let i = 0; i < stepIdOrder.length; i++) {
await tx.actionStep.update({
where: { id: stepIdOrder[i]! },
data: { order: i },
});
}
});
return prisma.actionStep.findMany({
where: { campaignId },
orderBy: { order: 'asc' },
});
},
async getActiveCampaign(): Promise<CampaignWithSteps | null> {
const campaign = await prisma.actionCampaign.findFirst({
where: { isActive: true },
orderBy: { createdAt: 'desc' },
include: { steps: { orderBy: { order: 'asc' } } },
});
return campaign;
},
async getActiveForUser(userId: string): Promise<ActiveCampaignForUser | null> {
const campaign = await this.getActiveCampaign();
if (!campaign) return null;
const user = await prisma.user.findUnique({
where: { id: userId },
select: { email: true },
});
const userEmail = user?.email ?? null;
const completions = await prisma.actionStepCompletion.findMany({
where: { userId, stepId: { in: campaign.steps.map((s) => s.id) } },
select: { stepId: true, completedAt: true, source: true },
});
const completionByStepId = new Map(completions.map((c) => [c.stepId, c]));
const stepResults: ActiveCampaignStepForUser[] = [];
for (const step of campaign.steps) {
const existing = completionByStepId.get(step.id);
let completed = !!existing;
let completedAt: Date | null = existing?.completedAt ?? null;
let source: ActionStepCompletionSource | null = existing?.source ?? null;
if (!completed && step.autoComplete) {
const auto = await isStepAutoCompleted(userId, userEmail, step);
if (auto) {
completed = true;
completedAt = new Date();
source = ActionStepCompletionSource.AUTO;
}
}
stepResults.push({
id: step.id,
order: step.order,
kind: step.kind,
label: step.label,
description: step.description,
targetId: step.targetId,
targetUrl: step.targetUrl,
autoComplete: step.autoComplete,
completed,
completedAt,
source,
});
}
const totalSteps = stepResults.length;
const completedSteps = stepResults.filter((s) => s.completed).length;
const requiredSteps = campaign.minStepsForReward ?? totalSteps;
const rewardEarned = totalSteps > 0 && completedSteps >= requiredSteps;
return {
id: campaign.id,
slug: campaign.slug,
title: campaign.title,
description: campaign.description,
rewardText: campaign.rewardText,
isActive: campaign.isActive,
startsAt: campaign.startsAt,
endsAt: campaign.endsAt,
minStepsForReward: campaign.minStepsForReward,
totalSteps,
completedSteps,
rewardEarned,
steps: stepResults,
};
},
async markStepComplete(
userId: string,
stepId: string,
source: ActionStepCompletionSource = ActionStepCompletionSource.SELF_REPORTED,
) {
const step = await prisma.actionStep.findUnique({
where: { id: stepId },
include: { campaign: { select: { id: true, isActive: true } } },
});
if (!step) throw new AppError(404, 'Action step not found', 'ACTION_STEP_NOT_FOUND');
const existing = await prisma.actionStepCompletion.findUnique({
where: { userId_stepId: { userId, stepId } },
});
if (existing) return existing;
const isSelfReportable =
step.kind === ActionStepKind.CUSTOM || step.kind === ActionStepKind.VISIT_LINK;
if (!isSelfReportable) {
const user = await prisma.user.findUnique({ where: { id: userId }, select: { email: true } });
const autoOk = await isStepAutoCompleted(userId, user?.email ?? null, step);
if (!autoOk) {
throw new AppError(
400,
'Step cannot be self-reported without completing the required action',
'ACTION_STEP_NOT_COMPLETED',
);
}
return prisma.actionStepCompletion.create({
data: { userId, stepId, source: ActionStepCompletionSource.AUTO },
});
}
return prisma.actionStepCompletion.create({
data: { userId, stepId, source },
});
},
};

View File

@ -0,0 +1,22 @@
import { Router, Request, Response, NextFunction } from 'express';
import { volunteerDashboardService } from './volunteer-dashboard.service';
import { authenticate } from '../../middleware/auth.middleware';
import { AppError } from '../../middleware/error-handler';
const router = Router();
router.get(
'/dashboard',
authenticate,
async (req: Request, res: Response, next: NextFunction) => {
try {
const data = await volunteerDashboardService.getDashboard(req.user!.id);
if (!data) throw new AppError(404, 'User not found', 'USER_NOT_FOUND');
res.json(data);
} catch (err) {
next(err);
}
},
);
export { router as volunteerDashboardRouter };

View File

@ -0,0 +1,322 @@
import { Prisma, TicketedEventStatus, ShiftKind, ShiftStatus, TicketStatus } from '@prisma/client';
import { prisma } from '../../config/database';
import { env } from '../../config/env';
import { actionCampaignsService, type ActiveCampaignForUser } from '../action-campaigns/action-campaigns.service';
import { referralService } from '../social/referral.service';
export interface DashboardProfile {
id: string;
email: string;
name: string | null;
avatar: string | null;
role: string;
}
export interface DashboardReferral {
code: string | null;
link: string | null;
totalReferrals: number;
}
export interface DashboardFeaturedEvent {
slug: string;
title: string;
date: Date;
startTime: string;
venueName: string | null;
coverImageUrl: string | null;
ticketsSold: number;
maxAttendees: number | null;
}
export interface DashboardTraining {
id: string;
title: string;
date: Date;
startTime: string;
endTime: string;
location: string | null;
currentVolunteers: number;
maxVolunteers: number;
isSignedUp: boolean;
signupId: string | null;
}
export interface DashboardMyEvent {
ticketId: string;
eventSlug: string;
eventTitle: string;
eventDate: Date;
status: TicketStatus;
tierName: string;
}
export interface DashboardPoints {
total: number;
achievementCount: number;
}
export type DashboardResourceKind = 'document' | 'video' | 'photo';
export interface DashboardResource {
id: string;
kind: DashboardResourceKind;
title: string;
thumbnailUrl: string | null;
downloadPath: string | null;
viewPath: string | null;
}
export interface VolunteerDashboardPayload {
profile: DashboardProfile;
referral: DashboardReferral;
actionCampaign: ActiveCampaignForUser | null;
featuredEvent: DashboardFeaturedEvent | null;
trainings: DashboardTraining[];
myEvents: DashboardMyEvent[];
points: DashboardPoints;
resources: DashboardResource[];
}
const RESOURCE_TAG = 'volunteer-resource';
async function getProfile(userId: string): Promise<DashboardProfile | null> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { id: true, email: true, name: true, role: true },
});
if (!user) return null;
return {
id: user.id,
email: user.email,
name: user.name,
avatar: null,
role: user.role,
};
}
async function getReferral(userId: string): Promise<DashboardReferral> {
const stats = await referralService.getReferralStats(userId);
const firstCode = await prisma.inviteCode.findFirst({
where: { createdByUserId: userId, isActive: true },
orderBy: { createdAt: 'desc' },
select: { code: true },
});
const code = firstCode?.code ?? null;
const link = code ? `${env.ADMIN_URL}/register?ref=${code}` : null;
return { code, link, totalReferrals: stats.totalReferrals };
}
async function getFeaturedEvent(): Promise<DashboardFeaturedEvent | null> {
const today = new Date();
today.setHours(0, 0, 0, 0);
const event = await prisma.ticketedEvent.findFirst({
where: {
featured: true,
status: TicketedEventStatus.PUBLISHED,
date: { gte: today },
},
orderBy: { date: 'asc' },
select: {
slug: true,
title: true,
date: true,
startTime: true,
venueName: true,
coverImageUrl: true,
currentAttendees: true,
maxAttendees: true,
},
});
if (!event) return null;
return {
slug: event.slug,
title: event.title,
date: event.date,
startTime: event.startTime,
venueName: event.venueName,
coverImageUrl: event.coverImageUrl,
ticketsSold: event.currentAttendees,
maxAttendees: event.maxAttendees,
};
}
async function getTrainings(userId: string): Promise<DashboardTraining[]> {
const today = new Date();
today.setHours(0, 0, 0, 0);
const shifts = await prisma.shift.findMany({
where: {
kind: ShiftKind.TRAINING,
status: ShiftStatus.OPEN,
date: { gte: today },
},
orderBy: { date: 'asc' },
take: 5,
include: {
signups: {
where: { userId },
select: { id: true },
},
},
});
return shifts.map((shift) => ({
id: shift.id,
title: shift.title,
date: shift.date,
startTime: shift.startTime,
endTime: shift.endTime,
location: shift.location,
currentVolunteers: shift.currentVolunteers,
maxVolunteers: shift.maxVolunteers,
isSignedUp: shift.signups.length > 0,
signupId: shift.signups[0]?.id ?? null,
}));
}
async function getMyEvents(userId: string): Promise<DashboardMyEvent[]> {
const tickets = await prisma.ticket.findMany({
where: { userId, status: TicketStatus.VALID },
take: 5,
orderBy: { event: { date: 'asc' } },
include: {
event: { select: { slug: true, title: true, date: true } },
tier: { select: { name: true } },
},
});
return tickets.map((ticket) => ({
ticketId: ticket.id,
eventSlug: ticket.event.slug,
eventTitle: ticket.event.title,
eventDate: ticket.event.date,
status: ticket.status,
tierName: ticket.tier.name,
}));
}
async function getPoints(userId: string): Promise<DashboardPoints> {
const [stats, achievementCount] = await Promise.all([
prisma.userStats.findUnique({
where: { userId },
select: { totalVideosWatched: true, totalUpvotesGiven: true, totalCommentsMade: true, totalFinishes: true },
}),
prisma.userAchievement.count({ where: { userId } }),
]);
const total =
(stats?.totalVideosWatched ?? 0) +
(stats?.totalUpvotesGiven ?? 0) +
(stats?.totalCommentsMade ?? 0) +
(stats?.totalFinishes ?? 0);
return { total, achievementCount };
}
async function getResources(): Promise<DashboardResource[]> {
const tagFilter = { tags: { array_contains: RESOURCE_TAG } as Prisma.JsonFilter };
const [documents, videos, photos] = await Promise.all([
prisma.document.findMany({
where: { isPublished: true, ...tagFilter },
orderBy: { createdAt: 'desc' },
take: 8,
select: {
id: true,
title: true,
originalFilename: true,
filename: true,
thumbnailPath: true,
},
}),
prisma.video.findMany({
where: { isPublished: true, ...tagFilter },
orderBy: { createdAt: 'desc' },
take: 8,
select: {
id: true,
title: true,
filename: true,
thumbnailPath: true,
},
}),
prisma.photo.findMany({
where: { isPublished: true, ...tagFilter },
orderBy: { createdAt: 'desc' },
take: 8,
select: {
id: true,
title: true,
filename: true,
thumbnailPath: true,
},
}),
]);
const items: Array<DashboardResource & { sortKey: number }> = [];
for (const doc of documents) {
items.push({
id: doc.id,
kind: 'document',
title: doc.title ?? doc.originalFilename ?? doc.filename,
thumbnailUrl: doc.thumbnailPath,
downloadPath: `/api/documents/${doc.id}/download`,
viewPath: null,
sortKey: documents.indexOf(doc),
});
}
for (const video of videos) {
items.push({
id: String(video.id),
kind: 'video',
title: video.title ?? video.filename,
thumbnailUrl: video.thumbnailPath,
downloadPath: null,
viewPath: `/gallery/watch/${video.id}`,
sortKey: videos.indexOf(video),
});
}
for (const photo of photos) {
items.push({
id: String(photo.id),
kind: 'photo',
title: photo.title ?? photo.filename,
thumbnailUrl: photo.thumbnailPath,
downloadPath: null,
viewPath: `/gallery/photo/${photo.id}`,
sortKey: photos.indexOf(photo),
});
}
items.sort((a, b) => a.sortKey - b.sortKey);
return items.slice(0, 8).map(({ sortKey: _sortKey, ...rest }) => rest);
}
export const volunteerDashboardService = {
async getDashboard(userId: string): Promise<VolunteerDashboardPayload | null> {
const profile = await getProfile(userId);
if (!profile) return null;
const [referral, actionCampaign, featuredEvent, trainings, myEvents, points, resources] = await Promise.all([
getReferral(userId),
actionCampaignsService.getActiveForUser(userId),
getFeaturedEvent(),
getTrainings(userId),
getMyEvents(userId),
getPoints(userId),
getResources(),
]);
return {
profile,
referral,
actionCampaign,
featuredEvent,
trainings,
myEvents,
points,
resources,
};
},
};

View File

@ -87,6 +87,8 @@ import { galleryAdsAdminRouter } from './modules/gallery-ads/gallery-ads-admin.r
import { petitionsPublicRouter, petitionVerifyRouter } from './modules/influence/petitions/petitions-public.routes';
import { petitionsAdminRouter } from './modules/influence/petitions/petitions.routes';
import { effectivenessRouter } from './modules/influence/effectiveness/effectiveness.routes';
import { actionCampaignsRouter, actionCampaignsAdminRouter } from './modules/action-campaigns/action-campaigns.routes';
import { volunteerDashboardRouter } from './modules/volunteer-dashboard/volunteer-dashboard.routes';
import { docsAnalyticsPublicRouter, docsAnalyticsAdminRouter } from './modules/docs-analytics/docs-analytics.routes';
import { analyticsUserRouter } from './modules/analytics/analytics-user.routes';
import { analyticsAdminRouter } from './modules/analytics/analytics.routes';
@ -353,6 +355,9 @@ app.use('/api/petitions', petitionsPublicRouter); // Public petit
app.use('/api/petitions', petitionVerifyRouter); // Petition email verification (no auth)
app.use('/api/petitions', petitionsAdminRouter); // Admin petition CRUD (INFLUENCE_ROLES)
app.use('/api/influence/effectiveness', effectivenessRouter); // Campaign effectiveness analytics (ADMIN)
app.use('/api/action-campaigns', actionCampaignsRouter); // Public action campaign (optional auth) + step complete (auth)
app.use('/api/admin/action-campaigns', actionCampaignsAdminRouter); // Admin action campaign CRUD (INFLUENCE_ROLES)
app.use('/api/volunteer', volunteerDashboardRouter); // Volunteer dashboard aggregator (auth required)
app.use('/api/gallery-ads', galleryAdsPublicRouter); // Public gallery ads (optional auth)
app.use('/api/gallery-ads/admin', galleryAdsAdminRouter); // Admin gallery ad CRUD (SUPER_ADMIN)
app.use('/api/docs-analytics', docsAnalyticsPublicRouter); // Public docs page view tracking (no auth)