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 { petitionsPublicRouter, petitionVerifyRouter } from './modules/influence/petitions/petitions-public.routes';
|
||||||
import { petitionsAdminRouter } from './modules/influence/petitions/petitions.routes';
|
import { petitionsAdminRouter } from './modules/influence/petitions/petitions.routes';
|
||||||
import { effectivenessRouter } from './modules/influence/effectiveness/effectiveness.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 { docsAnalyticsPublicRouter, docsAnalyticsAdminRouter } from './modules/docs-analytics/docs-analytics.routes';
|
||||||
import { analyticsUserRouter } from './modules/analytics/analytics-user.routes';
|
import { analyticsUserRouter } from './modules/analytics/analytics-user.routes';
|
||||||
import { analyticsAdminRouter } from './modules/analytics/analytics.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', petitionVerifyRouter); // Petition email verification (no auth)
|
||||||
app.use('/api/petitions', petitionsAdminRouter); // Admin petition CRUD (INFLUENCE_ROLES)
|
app.use('/api/petitions', petitionsAdminRouter); // Admin petition CRUD (INFLUENCE_ROLES)
|
||||||
app.use('/api/influence/effectiveness', effectivenessRouter); // Campaign effectiveness analytics (ADMIN)
|
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', galleryAdsPublicRouter); // Public gallery ads (optional auth)
|
||||||
app.use('/api/gallery-ads/admin', galleryAdsAdminRouter); // Admin gallery ad CRUD (SUPER_ADMIN)
|
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)
|
app.use('/api/docs-analytics', docsAnalyticsPublicRouter); // Public docs page view tracking (no auth)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user