Add petition/action pages with signature collection, CRM integration, and campaign linking

New influence submodule for public petitions with configurable sign forms,
email verification, GeoIP tracking, dedup, CSV export, admin moderation,
and post-sign CTA linking to advocacy campaigns. Includes competitive
analysis document covering 30+ campaign tech platforms.

Bunker Admin
This commit is contained in:
2026-04-03 08:49:49 -06:00
parent 08bd1f92b0
commit 72622671a2
11 changed files with 2951 additions and 0 deletions

View File

@@ -0,0 +1,131 @@
-- CreateEnum
CREATE TYPE "PetitionStatus" AS ENUM ('DRAFT', 'ACTIVE', 'PAUSED', 'CLOSED', 'ARCHIVED');
-- CreateEnum
CREATE TYPE "PetitionSignatureStatus" AS ENUM ('PENDING_VERIFICATION', 'VERIFIED', 'UNVERIFIED', 'REJECTED');
-- AlterEnum
ALTER TYPE "ContactActivityType" ADD VALUE 'PETITION_SIGNED';
-- AlterEnum
ALTER TYPE "ContactSource" ADD VALUE 'PETITION_SIGNER';
-- AlterTable
ALTER TABLE "site_settings" ADD COLUMN "enable_petitions" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "notify_admin_petition_milestone" BOOLEAN NOT NULL DEFAULT false;
-- CreateTable
CREATE TABLE "petitions" (
"id" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"signatureGoal" INTEGER,
"showProgress" BOOLEAN NOT NULL DEFAULT true,
"showSignatureCount" BOOLEAN NOT NULL DEFAULT true,
"showSignerNames" BOOLEAN NOT NULL DEFAULT true,
"signatureCountOffset" INTEGER NOT NULL DEFAULT 0,
"requireName" BOOLEAN NOT NULL DEFAULT true,
"requireEmail" BOOLEAN NOT NULL DEFAULT true,
"requirePostalCode" BOOLEAN NOT NULL DEFAULT false,
"requirePhone" BOOLEAN NOT NULL DEFAULT false,
"allowComment" BOOLEAN NOT NULL DEFAULT true,
"commentLabel" TEXT,
"requireEmailConfirmation" BOOLEAN NOT NULL DEFAULT false,
"confirmationEmailSubject" TEXT,
"confirmationEmailBody" TEXT,
"coverPhoto" TEXT,
"callToAction" TEXT,
"thankYouMessage" TEXT,
"highlightPetition" BOOLEAN NOT NULL DEFAULT false,
"linkedCampaignId" TEXT,
"status" "PetitionStatus" NOT NULL DEFAULT 'DRAFT',
"isUserGenerated" BOOLEAN NOT NULL DEFAULT false,
"moderationStatus" "CampaignModerationStatus",
"rejectionReason" TEXT,
"moderationNotes" TEXT,
"createdByUserId" TEXT,
"createdByUserEmail" TEXT,
"createdByUserName" TEXT,
"reviewedByUserId" TEXT,
"reviewedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "petitions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "petition_signatures" (
"id" TEXT NOT NULL,
"petitionId" TEXT NOT NULL,
"signerName" TEXT,
"signerEmail" TEXT,
"signerPostalCode" TEXT,
"signerPhone" TEXT,
"signerComment" TEXT,
"isAnonymous" BOOLEAN NOT NULL DEFAULT false,
"displayName" TEXT,
"status" "PetitionSignatureStatus" NOT NULL DEFAULT 'UNVERIFIED',
"verificationToken" TEXT,
"verificationSentAt" TIMESTAMP(3),
"verifiedAt" TIMESTAMP(3),
"contactId" TEXT,
"signerIp" TEXT,
"geoCountry" TEXT,
"geoRegion" TEXT,
"geoCity" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "petition_signatures_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "petitions_slug_key" ON "petitions"("slug");
-- CreateIndex
CREATE INDEX "petitions_status_idx" ON "petitions"("status");
-- CreateIndex
CREATE INDEX "petitions_isUserGenerated_idx" ON "petitions"("isUserGenerated");
-- CreateIndex
CREATE INDEX "petitions_highlightPetition_idx" ON "petitions"("highlightPetition");
-- CreateIndex
CREATE INDEX "petitions_linkedCampaignId_idx" ON "petitions"("linkedCampaignId");
-- CreateIndex
CREATE UNIQUE INDEX "petition_signatures_verificationToken_key" ON "petition_signatures"("verificationToken");
-- CreateIndex
CREATE INDEX "petition_signatures_petitionId_idx" ON "petition_signatures"("petitionId");
-- CreateIndex
CREATE INDEX "petition_signatures_signerEmail_idx" ON "petition_signatures"("signerEmail");
-- CreateIndex
CREATE INDEX "petition_signatures_petitionId_status_idx" ON "petition_signatures"("petitionId", "status");
-- CreateIndex
CREATE INDEX "petition_signatures_contactId_idx" ON "petition_signatures"("contactId");
-- CreateIndex
CREATE UNIQUE INDEX "petition_signatures_petitionId_signerEmail_key" ON "petition_signatures"("petitionId", "signerEmail");
-- AddForeignKey
ALTER TABLE "petitions" ADD CONSTRAINT "petitions_linkedCampaignId_fkey" FOREIGN KEY ("linkedCampaignId") REFERENCES "campaigns"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "petitions" ADD CONSTRAINT "petitions_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "petitions" ADD CONSTRAINT "petitions_reviewedByUserId_fkey" FOREIGN KEY ("reviewedByUserId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "petition_signatures" ADD CONSTRAINT "petition_signatures_petitionId_fkey" FOREIGN KEY ("petitionId") REFERENCES "petitions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "petition_signatures" ADD CONSTRAINT "petition_signatures_contactId_fkey" FOREIGN KEY ("contactId") REFERENCES "contacts"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,100 @@
import { Router, Request, Response, NextFunction } from 'express';
import { petitionsService } from './petitions.service';
import { signPetitionSchema } from './petitions.schemas';
import { validate } from '../../../middleware/validate';
import { petitionSignRateLimit } from '../../../middleware/rate-limit';
const router = Router();
// GET /api/petitions/public — list active petitions
router.get(
'/public',
async (_req: Request, res: Response, next: NextFunction) => {
try {
const petitions = await petitionsService.findActivePetitions();
res.json(petitions);
} catch (err) { next(err); }
}
);
// GET /api/petitions/:slug/details — public petition data (ACTIVE only)
router.get(
'/:slug/details',
async (req: Request, res: Response, next: NextFunction) => {
try {
const slug = req.params.slug as string;
const petition = await petitionsService.findBySlugPublic(slug);
res.json(petition);
} catch (err) { next(err); }
}
);
// GET /api/petitions/:slug/signers — public signature list
router.get(
'/:slug/signers',
async (req: Request, res: Response, next: NextFunction) => {
try {
const slug = req.params.slug as string;
const page = parseInt(req.query.page as string) || 1;
const limit = Math.min(parseInt(req.query.limit as string) || 20, 50);
const result = await petitionsService.listSignaturesPublic(slug, page, limit);
res.json(result);
} catch (err) { next(err); }
}
);
// GET /api/petitions/:slug/public-stats — public signature counts + geo
router.get(
'/:slug/public-stats',
async (req: Request, res: Response, next: NextFunction) => {
try {
const slug = req.params.slug as string;
const petition = await petitionsService.findBySlugPublic(slug);
const stats = await petitionsService.getStats(petition.id);
res.json(stats);
} catch (err) { next(err); }
}
);
// POST /api/petitions/:slug/sign — sign petition
router.post(
'/:slug/sign',
petitionSignRateLimit,
validate(signPetitionSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const slug = req.params.slug as string;
const ip = (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() || req.ip || '';
const result = await petitionsService.signPetition(slug, req.body, ip);
res.status(result.alreadySigned ? 200 : 201).json(result);
} catch (err) { next(err); }
}
);
// Verification route
const verifyRouter = Router();
// GET /api/petitions/verify/:token — email verification
verifyRouter.get(
'/verify/:token',
async (req: Request, res: Response, next: NextFunction) => {
try {
const token = req.params.token as string;
const result = await petitionsService.verifySignature(token);
const title = result.alreadyVerified ? 'Already Confirmed' : 'Signature Confirmed';
const msg = result.alreadyVerified
? 'Your signature was already confirmed. Thank you for your support!'
: 'Your signature has been verified. Thank you for adding your voice!';
res.send(`<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>${title}</title>
<style>body{font-family:-apple-system,BlinkMacSystemFont,sans-serif;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#0d1b2a;color:#e0e0e0}
.card{background:#1b2838;padding:2rem;border-radius:12px;text-align:center;max-width:400px;box-shadow:0 4px 20px rgba(0,0,0,.3)}
h1{color:#3498db;margin-bottom:.5rem}p{line-height:1.6;color:#a0a0a0}</style>
</head><body><div class="card"><h1>${title}</h1><p>${msg}</p></div></body></html>`);
} catch (err) { next(err); }
}
);
export { router as petitionsPublicRouter, verifyRouter as petitionVerifyRouter };

View File

@@ -0,0 +1,203 @@
import { Router, Request, Response, NextFunction } from 'express';
import { petitionsService } from './petitions.service';
import {
createPetitionSchema, updatePetitionSchema, listPetitionsSchema,
listSignaturesSchema, updateSignatureStatusSchema,
moderatePetitionSchema, listModerationQueueSchema,
} from './petitions.schemas';
import { validate } from '../../../middleware/validate';
import { authenticate } from '../../../middleware/auth.middleware';
import { requireRole } from '../../../middleware/rbac.middleware';
import { INFLUENCE_ROLES } from '../../../utils/roles';
const router = Router();
router.use(authenticate);
router.use(requireRole(...INFLUENCE_ROLES));
// GET /api/petitions — list petitions
router.get(
'/',
validate(listPetitionsSchema, 'query'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await petitionsService.findAll(req.query as any, req.user!);
res.json(result);
} catch (err) { next(err); }
}
);
// GET /api/petitions/moderation/queue — moderation queue
router.get(
'/moderation/queue',
validate(listModerationQueueSchema, 'query'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await petitionsService.findModerationQueue(req.query as any);
res.json(result);
} catch (err) { next(err); }
}
);
// GET /api/petitions/moderation/stats — moderation stats
router.get(
'/moderation/stats',
async (_req: Request, res: Response, next: NextFunction) => {
try {
const stats = await petitionsService.getModerationStats();
res.json(stats);
} catch (err) { next(err); }
}
);
// PATCH /api/petitions/moderation/:id — moderate petition
router.patch(
'/moderation/:id',
validate(moderatePetitionSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const petition = await petitionsService.moderatePetition(id, req.body, req.user!.id);
res.json(petition);
} catch (err) { next(err); }
}
);
// GET /api/petitions/:id/admin — get single petition (admin)
router.get(
'/:id/admin',
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const petition = await petitionsService.findById(id);
res.json(petition);
} catch (err) { next(err); }
}
);
// POST /api/petitions — create petition
router.post(
'/',
validate(createPetitionSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const petition = await petitionsService.create(req.body, req.user!);
res.status(201).json(petition);
} catch (err) { next(err); }
}
);
// PUT /api/petitions/:id — update petition
router.put(
'/:id',
validate(updatePetitionSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const petition = await petitionsService.update(id, req.body);
res.json(petition);
} catch (err) { next(err); }
}
);
// DELETE /api/petitions/:id — delete petition
router.delete(
'/:id',
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
await petitionsService.delete(id);
res.status(204).send();
} catch (err) { next(err); }
}
);
// GET /api/petitions/:id/signatures — list all signatures (admin)
router.get(
'/:id/signatures',
validate(listSignaturesSchema, 'query'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const result = await petitionsService.listSignaturesAdmin(id, req.query as any);
res.json(result);
} catch (err) { next(err); }
}
);
// GET /api/petitions/:id/stats — signature stats (admin)
router.get(
'/:id/stats',
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const stats = await petitionsService.getStats(id);
res.json(stats);
} catch (err) { next(err); }
}
);
// PATCH /api/petitions/:id/signatures/:sigId/status — approve/reject signature
router.patch(
'/:id/signatures/:sigId/status',
validate(updateSignatureStatusSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const sigId = req.params.sigId as string;
const signature = await petitionsService.updateSignatureStatus(sigId, req.body);
res.json(signature);
} catch (err) { next(err); }
}
);
// POST /api/petitions/:id/signatures/bulk-status — bulk approve/reject
router.post(
'/:id/signatures/bulk-status',
async (req: Request, res: Response, next: NextFunction) => {
try {
const { ids, status } = req.body;
if (!Array.isArray(ids) || !status) {
res.status(400).json({ error: { message: 'ids (array) and status required', code: 'VALIDATION_ERROR' } });
return;
}
const result = await petitionsService.bulkUpdateSignatureStatus(ids, status);
res.json({ updated: result.count });
} catch (err) { next(err); }
}
);
// DELETE /api/petitions/:id/signatures/:sigId — delete signature
router.delete(
'/:id/signatures/:sigId',
async (req: Request, res: Response, next: NextFunction) => {
try {
const sigId = req.params.sigId as string;
await petitionsService.deleteSignature(sigId);
res.status(204).send();
} catch (err) { next(err); }
}
);
// GET /api/petitions/:id/export — export signatures (CSV/JSON)
router.get(
'/:id/export',
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const format = (req.query.format as string) === 'json' ? 'json' : 'csv';
const result = await petitionsService.exportSignatures(id, format);
if (format === 'json') {
res.setHeader('Content-Disposition', `attachment; filename="${result.filename}"`);
res.json(result.data);
} else {
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', `attachment; filename="${result.filename}"`);
res.setHeader('Cache-Control', 'no-store');
res.send(result.data);
}
} catch (err) { next(err); }
}
);
export { router as petitionsAdminRouter };

View File

@@ -0,0 +1,103 @@
import { z } from 'zod';
import { PetitionStatus, CampaignModerationStatus } from '@prisma/client';
export const createPetitionSchema = z.object({
title: z.string().min(1, 'Title is required').max(200),
description: z.string().max(5000).optional(),
signatureGoal: z.number().int().positive().nullable().optional(),
showProgress: z.boolean().optional().default(true),
showSignatureCount: z.boolean().optional().default(true),
showSignerNames: z.boolean().optional().default(true),
signatureCountOffset: z.number().int().min(0).optional().default(0),
requireName: z.boolean().optional().default(true),
requireEmail: z.boolean().optional().default(true),
requirePostalCode: z.boolean().optional().default(false),
requirePhone: z.boolean().optional().default(false),
allowComment: z.boolean().optional().default(true),
commentLabel: z.string().max(200).nullable().optional(),
requireEmailConfirmation: z.boolean().optional().default(false),
confirmationEmailSubject: z.string().max(200).nullable().optional(),
confirmationEmailBody: z.string().max(5000).nullable().optional(),
coverPhoto: z.string().url().max(500).nullable().optional(),
coverVideoId: z.number().int().positive().nullable().optional(),
callToAction: z.string().max(2000).nullable().optional(),
thankYouMessage: z.string().max(2000).nullable().optional(),
highlightPetition: z.boolean().optional().default(false),
linkedCampaignId: z.string().nullable().optional(),
status: z.nativeEnum(PetitionStatus).optional().default(PetitionStatus.DRAFT),
});
export const updatePetitionSchema = z.object({
title: z.string().min(1).max(200).optional(),
description: z.string().max(5000).nullable().optional(),
signatureGoal: z.number().int().positive().nullable().optional(),
showProgress: z.boolean().optional(),
showSignatureCount: z.boolean().optional(),
showSignerNames: z.boolean().optional(),
signatureCountOffset: z.number().int().min(0).optional(),
requireName: z.boolean().optional(),
requireEmail: z.boolean().optional(),
requirePostalCode: z.boolean().optional(),
requirePhone: z.boolean().optional(),
allowComment: z.boolean().optional(),
commentLabel: z.string().max(200).nullable().optional(),
requireEmailConfirmation: z.boolean().optional(),
confirmationEmailSubject: z.string().max(200).nullable().optional(),
confirmationEmailBody: z.string().max(5000).nullable().optional(),
coverPhoto: z.string().url().max(500).nullable().optional(),
coverVideoId: z.number().int().positive().nullable().optional(),
callToAction: z.string().max(2000).nullable().optional(),
thankYouMessage: z.string().max(2000).nullable().optional(),
highlightPetition: z.boolean().optional(),
linkedCampaignId: z.string().nullable().optional(),
status: z.nativeEnum(PetitionStatus).optional(),
});
export const listPetitionsSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(20),
search: z.string().optional(),
status: z.nativeEnum(PetitionStatus).optional(),
});
export const signPetitionSchema = z.object({
signerName: z.string().max(200).optional(),
signerEmail: z.string().email().max(255).optional(),
signerPostalCode: z.string().max(20).optional(),
signerPhone: z.string().max(30).optional(),
signerComment: z.string().max(2000).optional(),
isAnonymous: z.boolean().optional().default(false),
});
export const listSignaturesSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(20),
search: z.string().optional(),
status: z.string().optional(),
});
export const updateSignatureStatusSchema = z.object({
status: z.enum(['VERIFIED', 'REJECTED']),
});
export const moderatePetitionSchema = z.object({
action: z.enum(['approve', 'reject', 'request_changes']),
reason: z.string().max(2000).optional(),
notes: z.string().max(2000).optional(),
});
export const listModerationQueueSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(20),
search: z.string().optional(),
moderationStatus: z.nativeEnum(CampaignModerationStatus).optional(),
});
export type CreatePetitionInput = z.infer<typeof createPetitionSchema>;
export type UpdatePetitionInput = z.infer<typeof updatePetitionSchema>;
export type ListPetitionsInput = z.infer<typeof listPetitionsSchema>;
export type SignPetitionInput = z.infer<typeof signPetitionSchema>;
export type ListSignaturesInput = z.infer<typeof listSignaturesSchema>;
export type UpdateSignatureStatusInput = z.infer<typeof updateSignatureStatusSchema>;
export type ModeratePetitionInput = z.infer<typeof moderatePetitionSchema>;
export type ListModerationQueueInput = z.infer<typeof listModerationQueueSchema>;

View File

@@ -0,0 +1,777 @@
import crypto from 'crypto';
import { Prisma, UserRole, CampaignModerationStatus, PetitionSignatureStatus } from '@prisma/client';
import { prisma } from '../../../config/database';
import { AppError } from '../../../middleware/error-handler';
import { hasAnyRole, ADMIN_ROLES } from '../../../utils/roles';
import { geoipService } from '../../../services/geoip.service';
import { emailService } from '../../../services/email.service';
import { logger } from '../../../utils/logger';
import type {
CreatePetitionInput, UpdatePetitionInput, ListPetitionsInput,
SignPetitionInput, ListSignaturesInput, UpdateSignatureStatusInput,
ModeratePetitionInput, ListModerationQueueInput,
} from './petitions.schemas';
function escapeHtml(unsafe: string): string {
return unsafe
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
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.petition.findUnique({
where: { slug: candidate },
select: { id: true },
});
if (!existing || (excludeId && existing.id === excludeId)) {
return candidate;
}
candidate = `${slug}-${suffix}`;
suffix++;
}
}
function hashToken(token: string): string {
return crypto.createHash('sha256').update(token).digest('hex');
}
const petitionSelect = {
id: true,
slug: true,
title: true,
description: true,
signatureGoal: true,
showProgress: true,
showSignatureCount: true,
showSignerNames: true,
signatureCountOffset: true,
requireName: true,
requireEmail: true,
requirePostalCode: true,
requirePhone: true,
allowComment: true,
commentLabel: true,
requireEmailConfirmation: true,
coverPhoto: true,
coverVideoId: true,
callToAction: true,
thankYouMessage: true,
highlightPetition: true,
linkedCampaignId: true,
status: true,
isUserGenerated: true,
moderationStatus: true,
rejectionReason: true,
moderationNotes: true,
createdByUserId: true,
createdByUserEmail: true,
createdByUserName: true,
reviewedByUserId: true,
reviewedAt: true,
createdAt: true,
updatedAt: true,
_count: {
select: { signatures: true },
},
} satisfies Prisma.PetitionSelect;
const publicPetitionSelect = {
id: true,
slug: true,
title: true,
description: true,
signatureGoal: true,
showProgress: true,
showSignatureCount: true,
showSignerNames: true,
signatureCountOffset: true,
requireName: true,
requireEmail: true,
requirePostalCode: true,
requirePhone: true,
allowComment: true,
commentLabel: true,
requireEmailConfirmation: true,
coverPhoto: true,
coverVideoId: true,
callToAction: true,
thankYouMessage: true,
highlightPetition: true,
linkedCampaignId: true,
linkedCampaign: {
select: {
id: true,
slug: true,
title: true,
description: true,
status: true,
},
},
status: true,
createdByUserName: true,
createdAt: true,
_count: {
select: {
signatures: {
where: { status: { in: ['VERIFIED', 'UNVERIFIED'] } },
},
},
},
} satisfies Prisma.PetitionSelect;
interface AuthUser {
id: string;
email: string;
role: UserRole;
}
const MILESTONE_THRESHOLDS = [10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000, 25000, 50000, 100000];
export const petitionsService = {
// ─── Admin CRUD ──────────────────────────────────────────────────────
async findAll(filters: ListPetitionsInput, user?: AuthUser) {
const { page, limit, search, status } = filters;
const skip = (page - 1) * limit;
const where: Prisma.PetitionWhereInput = {};
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 petitions
if (user && !hasAnyRole(user, ADMIN_ROLES)) {
where.createdByUserId = user.id;
}
const [petitions, total] = await Promise.all([
prisma.petition.findMany({
where,
select: petitionSelect,
skip,
take: limit,
orderBy: { createdAt: 'desc' },
}),
prisma.petition.count({ where }),
]);
return {
petitions,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
};
},
async findById(id: string) {
const petition = await prisma.petition.findUnique({
where: { id },
select: petitionSelect,
});
if (!petition) throw new AppError(404, 'Petition not found', 'PETITION_NOT_FOUND');
return petition;
},
async create(data: CreatePetitionInput, user: AuthUser) {
const slug = await resolveSlugCollision(generateSlug(data.title));
return prisma.petition.create({
data: {
...data,
slug,
createdByUserId: user.id,
createdByUserEmail: user.email,
createdByUserName: (user as any).name || user.email,
},
select: petitionSelect,
});
},
async update(id: string, data: UpdatePetitionInput) {
const existing = await prisma.petition.findUnique({
where: { id },
select: { id: true, slug: true, title: true },
});
if (!existing) throw new AppError(404, 'Petition not found', 'PETITION_NOT_FOUND');
// Regenerate slug if title changed
let slug: string | undefined;
if (data.title && data.title !== existing.title) {
slug = await resolveSlugCollision(generateSlug(data.title), id);
}
return prisma.petition.update({
where: { id },
data: {
...data,
...(slug && { slug }),
},
select: petitionSelect,
});
},
async delete(id: string) {
const existing = await prisma.petition.findUnique({
where: { id },
select: { id: true },
});
if (!existing) throw new AppError(404, 'Petition not found', 'PETITION_NOT_FOUND');
await prisma.petition.delete({ where: { id } });
},
// ─── Public Routes ───────────────────────────────────────────────────
async findActivePetitions() {
return prisma.petition.findMany({
where: {
status: 'ACTIVE',
OR: [
{ isUserGenerated: false },
{ isUserGenerated: true, moderationStatus: 'APPROVED' },
],
},
select: publicPetitionSelect,
orderBy: [
{ highlightPetition: 'desc' },
{ createdAt: 'desc' },
],
});
},
async findBySlugPublic(slug: string) {
const petition = await prisma.petition.findFirst({
where: {
slug,
status: 'ACTIVE',
OR: [
{ isUserGenerated: false },
{ isUserGenerated: true, moderationStatus: 'APPROVED' },
],
},
select: publicPetitionSelect,
});
if (!petition) throw new AppError(404, 'Petition not found', 'PETITION_NOT_FOUND');
return petition;
},
async signPetition(slug: string, data: SignPetitionInput, ip: string) {
const petition = await prisma.petition.findFirst({
where: { slug, status: 'ACTIVE' },
select: {
id: true,
slug: true,
title: true,
requireName: true,
requireEmail: true,
requirePostalCode: true,
requirePhone: true,
requireEmailConfirmation: true,
confirmationEmailSubject: true,
confirmationEmailBody: true,
signatureCountOffset: true,
},
});
if (!petition) throw new AppError(404, 'Petition not found or not active', 'PETITION_NOT_FOUND');
// Validate required fields based on petition config
if (petition.requireName && !data.signerName) {
throw new AppError(400, 'Name is required', 'VALIDATION_ERROR');
}
if (petition.requireEmail && !data.signerEmail) {
throw new AppError(400, 'Email is required', 'VALIDATION_ERROR');
}
if (petition.requirePostalCode && !data.signerPostalCode) {
throw new AppError(400, 'Postal code is required', 'VALIDATION_ERROR');
}
if (petition.requirePhone && !data.signerPhone) {
throw new AppError(400, 'Phone number is required', 'VALIDATION_ERROR');
}
// Deduplicate by email (same response for duplicates to prevent email enumeration)
if (data.signerEmail) {
const existing = await prisma.petitionSignature.findFirst({
where: { petitionId: petition.id, signerEmail: data.signerEmail.toLowerCase() },
select: { id: true },
});
if (existing) {
return { id: existing.id, status: 'UNVERIFIED' as const, verificationSent: false, alreadySigned: true };
}
}
// GeoIP lookup
const geo = await geoipService.lookup(ip);
// Compute display name
let displayName: string | null = null;
if (data.isAnonymous) {
displayName = 'Anonymous';
} else if (data.signerName) {
displayName = escapeHtml(data.signerName);
}
// Verification token handling
let verificationToken: string | null = null;
let verificationTokenHash: string | null = null;
const needsVerification = petition.requireEmailConfirmation && data.signerEmail;
if (needsVerification) {
verificationToken = crypto.randomBytes(32).toString('hex');
verificationTokenHash = hashToken(verificationToken);
}
const signatureStatus: PetitionSignatureStatus = needsVerification ? 'PENDING_VERIFICATION' : 'UNVERIFIED';
// Create signature
const signature = await prisma.petitionSignature.create({
data: {
petitionId: petition.id,
signerName: data.signerName ? escapeHtml(data.signerName) : null,
signerEmail: data.signerEmail?.toLowerCase() || null,
signerPostalCode: data.signerPostalCode || null,
signerPhone: data.signerPhone || null,
signerComment: data.signerComment ? escapeHtml(data.signerComment) : null,
isAnonymous: data.isAnonymous ?? false,
displayName,
status: signatureStatus,
verificationToken: verificationTokenHash,
verificationSentAt: needsVerification ? new Date() : null,
signerIp: ip,
geoCountry: geo?.country || null,
geoRegion: geo?.region || null,
geoCity: geo?.city || null,
},
});
// CRM contact upsert
if (data.signerEmail) {
try {
let contact = await prisma.contact.findFirst({
where: { email: data.signerEmail.toLowerCase() },
select: { id: true },
});
if (!contact) {
contact = await prisma.contact.create({
data: {
displayName: data.signerName ? escapeHtml(data.signerName) : data.signerEmail,
email: data.signerEmail.toLowerCase(),
primarySource: 'PETITION_SIGNER',
},
});
}
// Link signature to contact
await prisma.petitionSignature.update({
where: { id: signature.id },
data: { contactId: contact.id },
});
// Record activity
await prisma.contactActivity.create({
data: {
contactId: contact.id,
type: 'PETITION_SIGNED',
title: `Signed petition: ${petition.title}`,
metadata: { petitionId: petition.id, petitionSlug: petition.slug } as unknown as Prisma.InputJsonValue,
},
});
} catch (err) {
logger.warn('Failed to upsert CRM contact for petition signature', { error: (err as Error).message });
}
}
// Send confirmation email if required
let verificationSent = false;
if (needsVerification && verificationToken && data.signerEmail) {
try {
const verificationUrl = `${process.env.APP_URL || 'http://localhost:4000'}/api/petitions/verify/${verificationToken}`;
await emailService.sendEmail({
to: data.signerEmail,
subject: petition.confirmationEmailSubject || `Confirm your signature — ${petition.title}`,
html: petition.confirmationEmailBody
? petition.confirmationEmailBody
.replace(/\{\{NAME\}\}/g, data.signerName || 'Supporter')
.replace(/\{\{PETITION_TITLE\}\}/g, escapeHtml(petition.title))
.replace(/\{\{VERIFICATION_URL\}\}/g, verificationUrl)
: `<p>Hi ${escapeHtml(data.signerName || 'there')},</p>
<p>Please confirm your signature on "<strong>${escapeHtml(petition.title)}</strong>" by clicking the link below:</p>
<p><a href="${verificationUrl}">Confirm my signature</a></p>
<p>If you did not sign this petition, you can safely ignore this email.</p>`,
text: `Confirm your signature on "${petition.title}": ${verificationUrl}`,
});
verificationSent = true;
} catch (err) {
logger.warn('Failed to send petition verification email', { error: (err as Error).message });
}
}
// Check milestone thresholds
try {
const totalCount = await prisma.petitionSignature.count({
where: {
petitionId: petition.id,
status: { in: ['VERIFIED', 'UNVERIFIED'] },
},
});
const displayCount = totalCount + petition.signatureCountOffset;
for (const threshold of MILESTONE_THRESHOLDS) {
if (displayCount >= threshold && (displayCount - 1) < threshold) {
logger.info(`Petition "${petition.title}" reached ${threshold} signatures`);
break;
}
}
} catch (err) {
logger.warn('Failed to check petition milestones', { error: (err as Error).message });
}
return {
id: signature.id,
status: signatureStatus,
verificationSent,
alreadySigned: false,
};
},
async verifySignature(token: string) {
const tokenHash = hashToken(token);
const signature = await prisma.petitionSignature.findFirst({
where: { verificationToken: tokenHash },
select: { id: true, status: true, petitionId: true, verificationSentAt: true },
});
if (!signature) throw new AppError(404, 'Invalid or expired verification link', 'INVALID_TOKEN');
// Check 30-day expiry
if (signature.verificationSentAt) {
const expiryMs = 30 * 24 * 60 * 60 * 1000;
if (Date.now() - signature.verificationSentAt.getTime() > expiryMs) {
throw new AppError(410, 'Verification link has expired', 'TOKEN_EXPIRED');
}
}
if (signature.status === 'VERIFIED') {
return { alreadyVerified: true };
}
await prisma.petitionSignature.update({
where: { id: signature.id },
data: { status: 'VERIFIED', verifiedAt: new Date() },
});
return { alreadyVerified: false };
},
// ─── Signatures Admin ────────────────────────────────────────────────
async listSignaturesAdmin(petitionId: string, filters: ListSignaturesInput) {
const { page, limit, search, status } = filters;
const skip = (page - 1) * limit;
const where: Prisma.PetitionSignatureWhereInput = { petitionId };
if (search) {
where.OR = [
{ signerName: { contains: search, mode: 'insensitive' } },
{ signerEmail: { contains: search, mode: 'insensitive' } },
{ signerPostalCode: { contains: search, mode: 'insensitive' } },
];
}
if (status) where.status = status as PetitionSignatureStatus;
const [signatures, total] = await Promise.all([
prisma.petitionSignature.findMany({
where,
orderBy: { createdAt: 'desc' },
skip,
take: limit,
}),
prisma.petitionSignature.count({ where }),
]);
return {
signatures,
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
};
},
async listSignaturesPublic(slug: string, page: number = 1, limit: number = 20) {
const petition = await prisma.petition.findFirst({
where: { slug, status: 'ACTIVE' },
select: { id: true, showSignerNames: true },
});
if (!petition) throw new AppError(404, 'Petition not found', 'PETITION_NOT_FOUND');
const where: Prisma.PetitionSignatureWhereInput = {
petitionId: petition.id,
status: { in: ['VERIFIED', 'UNVERIFIED'] },
};
const [signatures, total] = await Promise.all([
prisma.petitionSignature.findMany({
where,
select: {
id: true,
displayName: petition.showSignerNames,
signerComment: true,
isAnonymous: true,
geoCity: true,
geoCountry: true,
createdAt: true,
},
orderBy: { createdAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
prisma.petitionSignature.count({ where }),
]);
return {
signatures: signatures.map(s => ({
...s,
displayName: petition.showSignerNames ? s.displayName : null,
})),
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
};
},
async updateSignatureStatus(id: string, data: UpdateSignatureStatusInput) {
const existing = await prisma.petitionSignature.findUnique({
where: { id },
select: { id: true },
});
if (!existing) throw new AppError(404, 'Signature not found', 'SIGNATURE_NOT_FOUND');
return prisma.petitionSignature.update({
where: { id },
data: { status: data.status as PetitionSignatureStatus },
});
},
async deleteSignature(id: string) {
const existing = await prisma.petitionSignature.findUnique({
where: { id },
select: { id: true },
});
if (!existing) throw new AppError(404, 'Signature not found', 'SIGNATURE_NOT_FOUND');
await prisma.petitionSignature.delete({ where: { id } });
},
async bulkUpdateSignatureStatus(ids: string[], status: PetitionSignatureStatus) {
return prisma.petitionSignature.updateMany({
where: { id: { in: ids } },
data: { status },
});
},
// ─── Stats ───────────────────────────────────────────────────────────
async getStats(petitionId: string) {
const petition = await prisma.petition.findUnique({
where: { id: petitionId },
select: { id: true, signatureGoal: true, signatureCountOffset: true },
});
if (!petition) throw new AppError(404, 'Petition not found', 'PETITION_NOT_FOUND');
const countWhere = {
petitionId,
status: { in: ['VERIFIED' as const, 'UNVERIFIED' as const] },
};
const [total, byCountry, byRegion, recentSigners] = await Promise.all([
prisma.petitionSignature.count({ where: countWhere }),
prisma.petitionSignature.groupBy({
by: ['geoCountry'],
where: { ...countWhere, geoCountry: { not: null } },
_count: true,
orderBy: { _count: { geoCountry: 'desc' } },
take: 20,
}),
prisma.petitionSignature.groupBy({
by: ['geoRegion'],
where: { ...countWhere, geoRegion: { not: null } },
_count: true,
orderBy: { _count: { geoRegion: 'desc' } },
take: 20,
}),
prisma.petitionSignature.findMany({
where: countWhere,
select: { displayName: true, geoCity: true, geoCountry: true, createdAt: true },
orderBy: { createdAt: 'desc' },
take: 10,
}),
]);
const displayTotal = total + petition.signatureCountOffset;
return {
total: displayTotal,
verified: total,
goal: petition.signatureGoal,
percentComplete: petition.signatureGoal
? Math.min(100, Math.round((displayTotal / petition.signatureGoal) * 100))
: null,
byCountry: Object.fromEntries(byCountry.map(c => [c.geoCountry, c._count])),
byRegion: Object.fromEntries(byRegion.map(r => [r.geoRegion, r._count])),
recentSigners,
};
},
// ─── Export ──────────────────────────────────────────────────────────
async exportSignatures(petitionId: string, format: 'csv' | 'json') {
const petition = await prisma.petition.findUnique({
where: { id: petitionId },
select: { slug: true },
});
if (!petition) throw new AppError(404, 'Petition not found', 'PETITION_NOT_FOUND');
const signatures = await prisma.petitionSignature.findMany({
where: { petitionId },
orderBy: { createdAt: 'desc' },
});
if (format === 'json') {
return { data: signatures, filename: `petition-${petition.slug}-signatures.json` };
}
// CSV format
const headers = ['Name', 'Email', 'Postal Code', 'Phone', 'Comment', 'Anonymous', 'Country', 'Region', 'City', 'Status', 'Date'];
const csvRows = [headers.join(',')];
for (const sig of signatures) {
const row = [
sig.signerName || '',
sig.signerEmail || '',
sig.signerPostalCode || '',
sig.signerPhone || '',
(sig.signerComment || '').replace(/"/g, '""'),
sig.isAnonymous ? 'Yes' : 'No',
sig.geoCountry || '',
sig.geoRegion || '',
sig.geoCity || '',
sig.status,
sig.createdAt.toISOString(),
].map(v => `"${v}"`);
csvRows.push(row.join(','));
}
return { data: csvRows.join('\n'), filename: `petition-${petition.slug}-signatures.csv` };
},
// ─── Moderation ──────────────────────────────────────────────────────
async findModerationQueue(filters: ListModerationQueueInput) {
const { page, limit, search, moderationStatus } = filters;
const skip = (page - 1) * limit;
const where: Prisma.PetitionWhereInput = { isUserGenerated: true };
if (moderationStatus) {
where.moderationStatus = moderationStatus;
} else {
where.moderationStatus = 'PENDING_REVIEW';
}
if (search) {
where.OR = [
{ title: { contains: search, mode: 'insensitive' } },
{ createdByUserName: { contains: search, mode: 'insensitive' } },
];
}
const [petitions, total] = await Promise.all([
prisma.petition.findMany({
where,
select: petitionSelect,
skip,
take: limit,
orderBy: { createdAt: 'desc' },
}),
prisma.petition.count({ where }),
]);
return {
petitions,
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
};
},
async getModerationStats() {
const [pending, approved, rejected, changesRequested] = await Promise.all([
prisma.petition.count({ where: { isUserGenerated: true, moderationStatus: 'PENDING_REVIEW' } }),
prisma.petition.count({ where: { isUserGenerated: true, moderationStatus: 'APPROVED' } }),
prisma.petition.count({ where: { isUserGenerated: true, moderationStatus: 'REJECTED' } }),
prisma.petition.count({ where: { isUserGenerated: true, moderationStatus: 'CHANGES_REQUESTED' } }),
]);
return { pending, approved, rejected, changesRequested };
},
async moderatePetition(id: string, data: ModeratePetitionInput, reviewerId: string) {
const petition = await prisma.petition.findUnique({
where: { id },
select: { id: true, isUserGenerated: true },
});
if (!petition) throw new AppError(404, 'Petition not found', 'PETITION_NOT_FOUND');
if (!petition.isUserGenerated) throw new AppError(400, 'Only user-generated petitions can be moderated', 'NOT_USER_GENERATED');
const statusMap: Record<string, CampaignModerationStatus> = {
approve: 'APPROVED',
reject: 'REJECTED',
request_changes: 'CHANGES_REQUESTED',
};
return prisma.petition.update({
where: { id },
data: {
moderationStatus: statusMap[data.action],
status: data.action === 'approve' ? 'ACTIVE' : undefined,
rejectionReason: data.reason || null,
moderationNotes: data.notes || null,
reviewedByUserId: reviewerId,
reviewedAt: new Date(),
},
select: petitionSelect,
});
},
};