import { Prisma, UserRole, CampaignModerationStatus } from '@prisma/client'; import { prisma } from '../../../config/database'; import { AppError } from '../../../middleware/error-handler'; import { hasAnyRole, ADMIN_ROLES } from '../../../utils/roles'; import type { CreateCampaignInput, UpdateCampaignInput, ListCampaignsInput, CreateUserCampaignInput, UpdateUserCampaignInput, ModerateCampaignInput, ListModerationQueueInput, } from './campaigns.schemas'; function escapeHtml(unsafe: string): string { return unsafe .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } const campaignSelect = { id: true, slug: true, title: true, description: true, emailSubject: true, emailBody: true, callToAction: true, coverPhoto: true, status: true, allowSmtpEmail: true, allowMailtoLink: true, collectUserInfo: true, showEmailCount: true, showCallCount: true, allowEmailEditing: true, allowCustomRecipients: true, showResponseWall: true, highlightCampaign: true, targetGovernmentLevels: true, createdByUserId: true, createdByUserEmail: true, createdByUserName: true, isUserGenerated: true, moderationStatus: true, reviewedByUserId: true, reviewedAt: true, rejectionReason: true, moderationNotes: true, createdAt: true, updatedAt: true, _count: { select: { emails: true, responses: true, }, }, } satisfies Prisma.CampaignSelect; 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.campaign.findUnique({ where: { slug: candidate }, select: { id: true }, }); if (!existing || (excludeId && existing.id === excludeId)) { return candidate; } candidate = `${slug}-${suffix}`; suffix++; } } interface AuthUser { id: string; email: string; role: UserRole; } export const campaignsService = { async findAll(filters: ListCampaignsInput, user?: AuthUser) { const { page, limit, search, status } = filters; const skip = (page - 1) * limit; const where: Prisma.CampaignWhereInput = {}; if (search) { where.OR = [ { title: { contains: search, mode: 'insensitive' } }, { description: { contains: search, mode: 'insensitive' } }, ]; } if (status) where.status = status; // Non-admin users only see their own campaigns if (user && !hasAnyRole(user, ADMIN_ROLES)) { where.createdByUserId = user.id; } const [campaigns, total] = await Promise.all([ prisma.campaign.findMany({ where, select: campaignSelect, skip, take: limit, orderBy: { createdAt: 'desc' }, }), prisma.campaign.count({ where }), ]); return { campaigns, pagination: { page, limit, total, totalPages: Math.ceil(total / limit), }, }; }, async findById(id: string) { const campaign = await prisma.campaign.findUnique({ where: { id }, select: campaignSelect, }); if (!campaign) { throw new AppError(404, 'Campaign not found', 'CAMPAIGN_NOT_FOUND'); } return campaign; }, async findBySlug(slug: string) { const campaign = await prisma.campaign.findUnique({ where: { slug }, select: campaignSelect, }); if (!campaign) { throw new AppError(404, 'Campaign not found', 'CAMPAIGN_NOT_FOUND'); } return campaign; }, async create(data: CreateCampaignInput, user: AuthUser) { const baseSlug = generateSlug(data.title); const slug = await resolveSlugCollision(baseSlug); // Look up user name from DB const dbUser = await prisma.user.findUnique({ where: { id: user.id }, select: { name: true }, }); // If highlighting this campaign, unset any other highlighted campaign if (data.highlightCampaign) { await prisma.campaign.updateMany({ where: { highlightCampaign: true }, data: { highlightCampaign: false }, }); } const campaign = await prisma.campaign.create({ data: { ...data, slug, createdByUserId: user.id, createdByUserEmail: user.email, createdByUserName: dbUser?.name ?? null, }, select: campaignSelect, }); return campaign; }, async update(id: string, data: UpdateCampaignInput) { const existing = await prisma.campaign.findUnique({ where: { id } }); if (!existing) { throw new AppError(404, 'Campaign not found', 'CAMPAIGN_NOT_FOUND'); } const updateData: Prisma.CampaignUpdateInput = { ...data }; // Regenerate slug if title changes if (data.title && data.title !== existing.title) { const baseSlug = generateSlug(data.title); updateData.slug = await resolveSlugCollision(baseSlug, id); } // If highlighting this campaign, unset any other highlighted campaign if (data.highlightCampaign) { await prisma.campaign.updateMany({ where: { highlightCampaign: true, id: { not: id } }, data: { highlightCampaign: false }, }); } const campaign = await prisma.campaign.update({ where: { id }, data: updateData, select: campaignSelect, }); return campaign; }, async findActiveCampaigns() { return prisma.campaign.findMany({ where: { status: 'ACTIVE' }, select: campaignSelect, orderBy: [ { highlightCampaign: 'desc' }, { createdAt: 'desc' }, ], }); }, async findBySlugPublic(slug: string) { const campaign = await prisma.campaign.findUnique({ where: { slug }, select: campaignSelect, }); if (!campaign) { throw new AppError(404, 'Campaign not found', 'CAMPAIGN_NOT_FOUND'); } if (campaign.status !== 'ACTIVE') { throw new AppError(404, 'Campaign not found', 'CAMPAIGN_NOT_FOUND'); } return campaign; }, async delete(id: string) { const existing = await prisma.campaign.findUnique({ where: { id } }); if (!existing) { throw new AppError(404, 'Campaign not found', 'CAMPAIGN_NOT_FOUND'); } await prisma.campaign.delete({ where: { id } }); }, // --- User-Generated Campaign Methods --- async createUserCampaign(data: CreateUserCampaignInput, user: AuthUser) { const baseSlug = generateSlug(data.title); const slug = await resolveSlugCollision(baseSlug); const dbUser = await prisma.user.findUnique({ where: { id: user.id }, select: { name: true }, }); const campaign = await prisma.campaign.create({ data: { slug, title: escapeHtml(data.title), description: data.description ? escapeHtml(data.description) : null, emailSubject: escapeHtml(data.emailSubject), emailBody: escapeHtml(data.emailBody), callToAction: data.callToAction ? escapeHtml(data.callToAction) : null, targetGovernmentLevels: data.targetGovernmentLevels, status: 'DRAFT', isUserGenerated: true, moderationStatus: CampaignModerationStatus.PENDING_REVIEW, allowSmtpEmail: false, allowMailtoLink: true, collectUserInfo: true, showEmailCount: true, showCallCount: false, allowEmailEditing: false, allowCustomRecipients: false, showResponseWall: false, highlightCampaign: false, createdByUserId: user.id, createdByUserEmail: user.email, createdByUserName: dbUser?.name ?? null, }, select: campaignSelect, }); return campaign; }, async findUserCampaigns(userId: string) { return prisma.campaign.findMany({ where: { createdByUserId: userId, isUserGenerated: true }, select: campaignSelect, orderBy: { createdAt: 'desc' }, }); }, async updateUserCampaign(id: string, data: Partial, user: AuthUser) { const existing = await prisma.campaign.findUnique({ where: { id } }); if (!existing) { throw new AppError(404, 'Campaign not found', 'CAMPAIGN_NOT_FOUND'); } if (existing.createdByUserId !== user.id) { throw new AppError(403, 'You can only edit your own campaigns', 'FORBIDDEN'); } if (!existing.isUserGenerated) { throw new AppError(403, 'Cannot edit admin-created campaigns', 'FORBIDDEN'); } if ( existing.moderationStatus !== CampaignModerationStatus.CHANGES_REQUESTED && existing.moderationStatus !== CampaignModerationStatus.PENDING_REVIEW ) { throw new AppError(400, 'Campaign cannot be edited in its current state', 'INVALID_STATE'); } const updateData: Prisma.CampaignUncheckedUpdateInput = {}; if (data.title) { updateData.title = escapeHtml(data.title); const baseSlug = generateSlug(data.title); updateData.slug = await resolveSlugCollision(baseSlug, id); } if (data.description !== undefined) updateData.description = data.description ? escapeHtml(data.description) : null; if (data.emailSubject) updateData.emailSubject = escapeHtml(data.emailSubject); if (data.emailBody) updateData.emailBody = escapeHtml(data.emailBody); if (data.callToAction !== undefined) updateData.callToAction = data.callToAction ? escapeHtml(data.callToAction) : null; if (data.targetGovernmentLevels) updateData.targetGovernmentLevels = data.targetGovernmentLevels; // Reset to pending review on edit updateData.moderationStatus = CampaignModerationStatus.PENDING_REVIEW; updateData.rejectionReason = null; return prisma.campaign.update({ where: { id }, data: updateData, select: campaignSelect, }); }, // --- Moderation Methods --- async findModerationQueue(filters: ListModerationQueueInput) { const { page, limit, search, moderationStatus } = filters; const skip = (page - 1) * limit; const where: Prisma.CampaignWhereInput = { isUserGenerated: true }; if (moderationStatus) where.moderationStatus = moderationStatus; if (search) { where.OR = [ { title: { contains: search, mode: 'insensitive' } }, { createdByUserName: { contains: search, mode: 'insensitive' } }, { createdByUserEmail: { contains: search, mode: 'insensitive' } }, ]; } const [campaigns, total] = await Promise.all([ prisma.campaign.findMany({ where, select: campaignSelect, skip, take: limit, orderBy: { createdAt: 'desc' }, }), prisma.campaign.count({ where }), ]); return { campaigns, pagination: { page, limit, total, totalPages: Math.ceil(total / limit) }, }; }, async getModerationStats() { const [total, pending, approved, rejected, changesRequested] = await Promise.all([ prisma.campaign.count({ where: { isUserGenerated: true } }), prisma.campaign.count({ where: { moderationStatus: CampaignModerationStatus.PENDING_REVIEW } }), prisma.campaign.count({ where: { moderationStatus: CampaignModerationStatus.APPROVED } }), prisma.campaign.count({ where: { moderationStatus: CampaignModerationStatus.REJECTED } }), prisma.campaign.count({ where: { moderationStatus: CampaignModerationStatus.CHANGES_REQUESTED } }), ]); return { total, pending, approved, rejected, changesRequested }; }, async moderateCampaign(id: string, input: ModerateCampaignInput, reviewer: AuthUser) { const existing = await prisma.campaign.findUnique({ where: { id } }); if (!existing) { throw new AppError(404, 'Campaign not found', 'CAMPAIGN_NOT_FOUND'); } if (!existing.isUserGenerated) { throw new AppError(400, 'Only user-generated campaigns can be moderated', 'INVALID_STATE'); } const updateData: Prisma.CampaignUncheckedUpdateInput = { reviewedByUserId: reviewer.id, reviewedAt: new Date(), moderationNotes: input.notes ?? null, }; switch (input.action) { case 'approve': updateData.moderationStatus = CampaignModerationStatus.APPROVED; updateData.status = 'ACTIVE'; updateData.rejectionReason = null; break; case 'reject': updateData.moderationStatus = CampaignModerationStatus.REJECTED; updateData.rejectionReason = input.reason ?? null; break; case 'request_changes': updateData.moderationStatus = CampaignModerationStatus.CHANGES_REQUESTED; updateData.rejectionReason = input.reason ?? null; break; } return prisma.campaign.update({ where: { id }, data: updateData, select: campaignSelect, }); }, };