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:
131
api/prisma/migrations/20260402200000_add_petitions/migration.sql
Normal file
131
api/prisma/migrations/20260402200000_add_petitions/migration.sql
Normal 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;
|
||||
|
||||
100
api/src/modules/influence/petitions/petitions-public.routes.ts
Normal file
100
api/src/modules/influence/petitions/petitions-public.routes.ts
Normal 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 };
|
||||
203
api/src/modules/influence/petitions/petitions.routes.ts
Normal file
203
api/src/modules/influence/petitions/petitions.routes.ts
Normal 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 };
|
||||
103
api/src/modules/influence/petitions/petitions.schemas.ts
Normal file
103
api/src/modules/influence/petitions/petitions.schemas.ts
Normal 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>;
|
||||
777
api/src/modules/influence/petitions/petitions.service.ts
Normal file
777
api/src/modules/influence/petitions/petitions.service.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user