433 lines
13 KiB
TypeScript

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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
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<string> {
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<CreateUserCampaignInput>, 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,
});
},
};