diff --git a/api/src/modules/action-campaigns/action-campaigns.routes.ts b/api/src/modules/action-campaigns/action-campaigns.routes.ts new file mode 100644 index 00000000..cb2532c2 --- /dev/null +++ b/api/src/modules/action-campaigns/action-campaigns.routes.ts @@ -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 }; diff --git a/api/src/modules/action-campaigns/action-campaigns.schemas.ts b/api/src/modules/action-campaigns/action-campaigns.schemas.ts new file mode 100644 index 00000000..0a683bb8 --- /dev/null +++ b/api/src/modules/action-campaigns/action-campaigns.schemas.ts @@ -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; +export type UpdateActionCampaignInput = z.infer; +export type CreateActionStepInput = z.infer; +export type UpdateActionStepInput = z.infer; +export type ReorderStepsInput = z.infer; +export type MarkCompleteInput = z.infer; diff --git a/api/src/modules/action-campaigns/action-campaigns.service.ts b/api/src/modules/action-campaigns/action-campaigns.service.ts new file mode 100644 index 00000000..d5387d3d --- /dev/null +++ b/api/src/modules/action-campaigns/action-campaigns.service.ts @@ -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 { + 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; + +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 { + 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 { + 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 { + const campaign = await prisma.actionCampaign.findFirst({ + where: { isActive: true }, + orderBy: { createdAt: 'desc' }, + include: { steps: { orderBy: { order: 'asc' } } }, + }); + return campaign; + }, + + async getActiveForUser(userId: string): Promise { + 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 }, + }); + }, +}; diff --git a/api/src/modules/volunteer-dashboard/volunteer-dashboard.routes.ts b/api/src/modules/volunteer-dashboard/volunteer-dashboard.routes.ts new file mode 100644 index 00000000..100fc41c --- /dev/null +++ b/api/src/modules/volunteer-dashboard/volunteer-dashboard.routes.ts @@ -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 }; diff --git a/api/src/modules/volunteer-dashboard/volunteer-dashboard.service.ts b/api/src/modules/volunteer-dashboard/volunteer-dashboard.service.ts new file mode 100644 index 00000000..a32d9146 --- /dev/null +++ b/api/src/modules/volunteer-dashboard/volunteer-dashboard.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 = []; + + 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 { + 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, + }; + }, +}; diff --git a/api/src/server.ts b/api/src/server.ts index 9e063a17..03349223 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -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)