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:
parent
3fc67cd81a
commit
ed011a762b
178
api/src/modules/action-campaigns/action-campaigns.routes.ts
Normal file
178
api/src/modules/action-campaigns/action-campaigns.routes.ts
Normal 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 };
|
||||
57
api/src/modules/action-campaigns/action-campaigns.schemas.ts
Normal file
57
api/src/modules/action-campaigns/action-campaigns.schemas.ts
Normal 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>;
|
||||
426
api/src/modules/action-campaigns/action-campaigns.service.ts
Normal file
426
api/src/modules/action-campaigns/action-campaigns.service.ts
Normal 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 },
|
||||
});
|
||||
},
|
||||
};
|
||||
@ -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 };
|
||||
@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user