32 KiB
Responses Module
Overview
The Responses module manages the public response wall for advocacy campaigns, allowing users to share representative responses (emails, letters, phone calls, etc.) with email verification, upvoting, and admin moderation. It features a dual verification system (verify or report links), IP-based and user-based upvoting, and comprehensive moderation tools.
Key Features:
- Public response submission with representative verification emails
- Email verification flow (30-day expiry, verify or report links)
- Upvoting system (IP-based for anonymous, user-based for logged-in users)
- Admin moderation (PENDING → APPROVED/REJECTED workflow)
- Response statistics (total, verified, upvotes, level breakdown)
- Public response listing with sorting (recent, upvotes, verified)
- Rate limiting (prevents spam submissions)
- Anonymous submissions (submitter name/email hidden)
- Response types (email, letter, phone call, meeting, social media, other)
- HTML result pages for email verification links
File Paths
| File | Purpose |
|---|---|
api/src/modules/influence/responses/responses.routes.ts |
3 routers (campaign public, responses public, admin) with 12 endpoints |
api/src/modules/influence/responses/responses.service.ts |
Response business logic + email verification |
api/src/modules/influence/responses/responses.schemas.ts |
Zod validation schemas |
Database Models
RepresentativeResponse
model RepresentativeResponse {
id String @id @default(cuid())
campaignId String
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
campaignSlug String
representativeName String
representativeTitle String?
representativeLevel GovernmentLevel
representativeEmail String?
responseType ResponseType
responseText String @db.Text
userComment String? @db.Text
screenshotUrl String?
// Submitter info
submittedByUserId String?
submittedByUser User? @relation("ResponseSubmitter", fields: [submittedByUserId], references: [id], onDelete: SetNull)
submittedByName String?
submittedByEmail String?
isAnonymous Boolean @default(false)
submittedIp String?
// Moderation
status ResponseStatus @default(PENDING)
// Verification
isVerified Boolean @default(false)
verificationToken String?
verificationSentAt DateTime?
verifiedAt DateTime?
verifiedBy String?
// Upvoting
upvoteCount Int @default(0)
upvotes ResponseUpvote[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([campaignId])
@@index([campaignSlug])
@@index([status])
@@map("representative_responses")
}
enum ResponseType {
EMAIL
LETTER
PHONE_CALL
MEETING
SOCIAL_MEDIA
OTHER
}
enum ResponseStatus {
PENDING // Awaiting moderation
APPROVED // Visible on public wall
REJECTED // Removed/disputed
}
ResponseUpvote
model ResponseUpvote {
id String @id @default(cuid())
responseId String
response RepresentativeResponse @relation(fields: [responseId], references: [id], onDelete: Cascade)
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
userEmail String?
upvotedIp String?
@@unique([responseId, userId]) // Logged-in users: one upvote per response
@@unique([responseId, upvotedIp]) // Anonymous users: one upvote per IP per response
@@map("response_upvotes")
}
Upvoting Logic:
- Logged-in users: tracked by
userId(allows upvoting from multiple devices) - Anonymous users: tracked by
upvotedIp(prevents duplicate upvotes from same IP) - Unique constraints ensure users can't upvote same response multiple times
API Endpoints
Campaign-Scoped Public Endpoints (No Authentication)
| Method | Path | Description |
|---|---|---|
| GET | /api/campaigns/:slug/responses |
List approved responses for campaign |
| GET | /api/campaigns/:slug/response-stats |
Get response statistics for campaign |
| POST | /api/campaigns/:slug/responses |
Submit new response (rate-limited) |
Response-Scoped Public Endpoints (Optional Authentication)
| Method | Path | Description |
|---|---|---|
| POST | /api/responses/:id/upvote |
Upvote a response |
| DELETE | /api/responses/:id/upvote |
Remove upvote from response |
| GET | /api/responses/:id/verify/:token |
Verify response (returns HTML page) |
| GET | /api/responses/:id/report/:token |
Report response as invalid (returns HTML page) |
Admin Endpoints (Authentication Required)
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/responses |
Admin roles | List all responses (paginated, filtered) |
| PATCH | /api/responses/:id/status |
Admin roles | Update response status |
| POST | /api/responses/:id/resend-verification |
Admin roles | Resend verification email |
| DELETE | /api/responses/:id |
Admin roles | Delete response |
Admin Roles: SUPER_ADMIN, INFLUENCE_ADMIN, MAP_ADMIN
Public Endpoint Details
POST /api/campaigns/:slug/responses
Submit a new representative response to a campaign.
Rate Limiting: 10 requests per minute per IP
Path Parameters:
slug(string): Campaign slug
Request Body:
{
"representativeName": "Chrystia Freeland",
"representativeTitle": "Deputy Prime Minister",
"representativeLevel": "FEDERAL",
"representativeEmail": "chrystia.freeland@parl.gc.ca",
"responseType": "EMAIL",
"responseText": "Thank you for writing. I appreciate your concerns regarding climate change and am committed to...",
"userComment": "Received this response 2 days after sending my email!",
"submittedByName": "Jane Doe",
"submittedByEmail": "jane@example.com",
"isAnonymous": false,
"sendVerification": true
}
Field Descriptions:
representativeName(required): Representative's full namerepresentativeLevel(required): Government level (FEDERAL,PROVINCIAL,MUNICIPAL,SCHOOL_BOARD)responseType(required): Response type (EMAIL,LETTER,PHONE_CALL,MEETING,SOCIAL_MEDIA,OTHER)responseText(required): Full text of representative's responserepresentativeTitle(optional): Representative's title/positionrepresentativeEmail(optional): Representative's email (required ifsendVerification=true)userComment(optional): Submitter's comment about the responsesubmittedByName(optional): Submitter's name (not shown ifisAnonymous=true)submittedByEmail(optional): Submitter's email (not shown publicly)isAnonymous(optional, default: false): Hide submitter name on public wallsendVerification(optional, default: false): Send verification email to representative
Response (201 Created):
{
"id": "clx1234567890",
"status": "PENDING",
"verificationSent": true
}
Verification Email Flow:
If sendVerification=true and representativeEmail is provided, an email is sent to the representative with:
- Verify Link: Marks response as APPROVED and verified
- Report Link: Marks response as REJECTED (representative disputes it)
- 30-day expiry: Verification token expires after 30 days
Example Verification Email:
Subject: Please verify this response submission for "Climate Action Now" campaign
Dear Representative,
A constituent has submitted a response from you for the "Climate Action Now" campaign on Changemaker Lite.
Response Type: Email
Response Text: "Thank you for writing. I appreciate your concerns regarding..."
Submitted By: Jane Doe
If this is a genuine response from you, please verify it:
https://api.cmlite.org/api/responses/clx1234567890/verify/abc123...
If you did not send this response, or it is inaccurate, please report it:
https://api.cmlite.org/api/responses/clx1234567890/report/abc123...
This link expires in 30 days.
Error Responses:
400 Bad Request: Campaign not active, response wall disabled, or validation error404 Not Found: Campaign not found429 Too Many Requests: Rate limit exceeded (10/min)
Campaign Requirements:
- Campaign must have
status=ACTIVE - Campaign must have
showResponseWall=true
GET /api/campaigns/:slug/responses
List approved responses for a campaign.
Path Parameters:
slug(string): Campaign slug
Query Parameters:
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
| page | number | No | 1 | Page number |
| limit | number | No | 20 | Results per page (max 100) |
| sort | string | No | recent | Sort order: recent, upvotes, verified |
| level | GovernmentLevel | No | - | Filter by government level |
Example Request:
# Recent responses
curl "http://api.cmlite.org/api/campaigns/climate-action-now/responses?page=1&limit=10"
# Sort by upvotes
curl "http://api.cmlite.org/api/campaigns/climate-action-now/responses?sort=upvotes"
# Filter by federal only
curl "http://api.cmlite.org/api/campaigns/climate-action-now/responses?level=FEDERAL"
Response (200 OK):
{
"responses": [
{
"id": "clx1234567890",
"representativeName": "Chrystia Freeland",
"representativeTitle": "Deputy Prime Minister",
"representativeLevel": "FEDERAL",
"responseType": "EMAIL",
"responseText": "Thank you for writing. I appreciate your concerns...",
"userComment": "Received this response 2 days after sending!",
"submittedByName": "Jane Doe",
"isAnonymous": false,
"isVerified": true,
"verifiedAt": "2026-02-10T12:00:00.000Z",
"upvoteCount": 42,
"createdAt": "2026-02-08T12:00:00.000Z"
}
],
"pagination": {
"page": 1,
"limit": 10,
"total": 89,
"totalPages": 9
}
}
Response Fields:
- Only
APPROVEDresponses are returned submittedByNameis null ifisAnonymous=truesubmittedByEmailnever exposed on public routesrepresentativeEmailnever exposed on public routes
Sorting:
switch (sort) {
case 'upvotes':
orderBy = { upvoteCount: 'desc' };
break;
case 'verified':
orderBy = { isVerified: 'desc' };
break;
default: // 'recent'
orderBy = { createdAt: 'desc' };
}
GET /api/campaigns/:slug/response-stats
Get aggregate statistics for campaign responses.
Path Parameters:
slug(string): Campaign slug
Example Request:
curl "http://api.cmlite.org/api/campaigns/climate-action-now/response-stats"
Response (200 OK):
{
"total": 89,
"verified": 42,
"totalUpvotes": 347,
"byLevel": {
"FEDERAL": 32,
"PROVINCIAL": 28,
"MUNICIPAL": 21,
"SCHOOL_BOARD": 8
}
}
Field Descriptions:
total: Total APPROVED responses for campaignverified: Count of APPROVED responses withisVerified=truetotalUpvotes: Sum of allupvoteCountvaluesbyLevel: Breakdown by government level
POST /api/responses/:id/upvote
Upvote a response.
Authentication: Optional (supports both logged-in and anonymous users)
Path Parameters:
id(string): Response ID
Example Request:
# Anonymous upvote (tracked by IP)
curl -X POST "http://api.cmlite.org/api/responses/clx1234567890/upvote"
# Logged-in upvote (tracked by user ID)
curl -X POST -H "Authorization: Bearer <token>" \
"http://api.cmlite.org/api/responses/clx1234567890/upvote"
Response (200 OK):
{
"success": true
}
Response (200 OK, Already Upvoted):
{
"success": false,
"alreadyUpvoted": true
}
Upvoting Logic:
- Verify response exists and is APPROVED
- Create
ResponseUpvoterecord:- Logged-in:
userId+responseId(allows upvoting from multiple IPs) - Anonymous:
upvotedIp+responseId(prevents duplicate upvotes from same IP)
- Logged-in:
- Increment
upvoteCounton response - If duplicate (Prisma P2002 error), return
alreadyUpvoted: true
Error Responses:
400 Bad Request: Response is not approved404 Not Found: Response not found
DELETE /api/responses/:id/upvote
Remove upvote from a response.
Authentication: Optional (supports both logged-in and anonymous users)
Path Parameters:
id(string): Response ID
Example Request:
# Remove anonymous upvote
curl -X DELETE "http://api.cmlite.org/api/responses/clx1234567890/upvote"
# Remove logged-in upvote
curl -X DELETE -H "Authorization: Bearer <token>" \
"http://api.cmlite.org/api/responses/clx1234567890/upvote"
Response (200 OK):
{
"success": true
}
Response (200 OK, Not Upvoted):
{
"success": false
}
Logic:
- Delete
ResponseUpvoterecord matchingresponseId+userId(orupvotedIpif anonymous) - Decrement
upvoteCountif deleted - Return
success: falseif no upvote record found
GET /api/responses/:id/verify/:token
Verify a response (representative confirms authenticity). Returns HTML result page.
Path Parameters:
id(string): Response IDtoken(string): Verification token (64-char hex)
Example URL:
https://api.cmlite.org/api/responses/clx1234567890/verify/abc123...
Response (200 OK, Success):
Returns HTML page with success message:
<!DOCTYPE html>
<html>
<head>
<title>Response Verified - Changemaker Lite</title>
...
</head>
<body>
<div class="container">
<div class="card">
<div class="icon">✓</div>
<h1 style="color: #16a34a">Response Verified</h1>
<p>Thank you for verifying this response for the "Climate Action Now" campaign. The response has been approved and will now appear on the public response wall.</p>
</div>
<div class="brand">Powered by <strong>Changemaker Lite</strong></div>
</div>
</body>
</html>
Response (200 OK, Failed):
Returns HTML page with error message:
reason: "Invalid verification link"— Token doesn't matchreason: "Verification link has expired"— More than 30 days since sent
Database Changes on Success:
await prisma.representativeResponse.update({
where: { id: responseId },
data: {
isVerified: true,
verifiedAt: new Date(),
verifiedBy: response.representativeEmail || 'Representative',
status: ResponseStatus.APPROVED,
},
});
Expiry Logic:
const VERIFICATION_EXPIRY_DAYS = 30;
if (response.verificationSentAt) {
const daysSinceSent = (Date.now() - response.verificationSentAt.getTime()) / (1000 * 60 * 60 * 24);
if (daysSinceSent > VERIFICATION_EXPIRY_DAYS) {
return { success: false, reason: 'Verification link has expired' };
}
}
GET /api/responses/:id/report/:token
Report a response as invalid (representative disputes it). Returns HTML result page.
Path Parameters:
id(string): Response IDtoken(string): Verification token (same token as verify link)
Example URL:
https://api.cmlite.org/api/responses/clx1234567890/report/abc123...
Response (200 OK, Success):
Returns HTML page with confirmation:
<h1 style="color: #dc2626">Response Reported</h1>
<p>This response for the "Climate Action Now" campaign has been flagged as invalid and removed from the public response wall. Thank you for letting us know.</p>
Database Changes on Success:
await prisma.representativeResponse.update({
where: { id: responseId },
data: {
status: ResponseStatus.REJECTED,
isVerified: false,
verifiedBy: `Disputed by ${response.representativeEmail || 'representative'}`,
},
});
Use Cases:
- Representative never sent the response (fake submission)
- Response text is inaccurate or fabricated
- Response was sent by someone else impersonating the representative
Admin Endpoint Details
GET /api/responses
List all responses with admin filters.
Authentication: Required (Admin roles)
Query Parameters:
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
| page | number | No | 1 | Page number |
| limit | number | No | 20 | Results per page (max 100) |
| status | ResponseStatus | No | - | Filter by status (PENDING, APPROVED, REJECTED) |
| campaignId | string | No | - | Filter by campaign ID |
| search | string | No | - | Search name, response text, or submitter |
Example Request:
# Pending responses
curl -H "Authorization: Bearer <token>" \
"http://api.cmlite.org/api/responses?status=PENDING&page=1&limit=10"
# Search
curl -H "Authorization: Bearer <token>" \
"http://api.cmlite.org/api/responses?search=climate"
# Campaign-specific
curl -H "Authorization: Bearer <token>" \
"http://api.cmlite.org/api/responses?campaignId=clxCampaign123"
Response (200 OK):
{
"responses": [
{
"id": "clx1234567890",
"representativeName": "Chrystia Freeland",
"representativeTitle": "Deputy Prime Minister",
"representativeLevel": "FEDERAL",
"representativeEmail": "chrystia.freeland@parl.gc.ca",
"responseType": "EMAIL",
"responseText": "Thank you for writing...",
"userComment": "Received this response 2 days after sending!",
"submittedByName": "Jane Doe",
"submittedByEmail": "jane@example.com",
"isAnonymous": false,
"status": "PENDING",
"isVerified": false,
"verifiedAt": null,
"verifiedBy": null,
"upvoteCount": 0,
"createdAt": "2026-02-08T12:00:00.000Z",
"campaign": {
"id": "clxCampaign123",
"title": "Climate Action Now",
"slug": "climate-action-now"
}
}
],
"pagination": {
"page": 1,
"limit": 10,
"total": 23,
"totalPages": 3
}
}
Differences from Public Route:
- Includes
representativeEmail,submittedByEmail(sensitive fields) - Returns all statuses (not just APPROVED)
- Includes
campaignrelation - Search across name, response text, submitter name
Search Logic:
if (search) {
where.OR = [
{ representativeName: { contains: search, mode: 'insensitive' } },
{ responseText: { contains: search, mode: 'insensitive' } },
{ submittedByName: { contains: search, mode: 'insensitive' } },
];
}
PATCH /api/responses/:id/status
Update response status (approve or reject).
Authentication: Required (Admin roles)
Path Parameters:
id(string): Response ID
Request Body:
{
"status": "APPROVED"
}
Valid Statuses: PENDING, APPROVED, REJECTED
Example Request:
# Approve response
curl -X PATCH -H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"status":"APPROVED"}' \
"http://api.cmlite.org/api/responses/clx1234567890/status"
# Reject response
curl -X PATCH -H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"status":"REJECTED"}' \
"http://api.cmlite.org/api/responses/clx1234567890/status"
Response (200 OK):
Returns updated response object (same format as GET).
Error Responses:
404 Not Found: Response not found
Use Cases:
- Manual moderation: approve legitimate responses, reject spam
- Bulk approval after reviewing pending queue
- Reject disputed responses without representative verification
POST /api/responses/:id/resend-verification
Resend verification email to representative.
Authentication: Required (Admin roles)
Path Parameters:
id(string): Response ID
Example Request:
curl -X POST -H "Authorization: Bearer <token>" \
"http://api.cmlite.org/api/responses/clx1234567890/resend-verification"
Response (200 OK):
{
"success": true
}
Error Responses:
400 Bad Request: No representative email on record404 Not Found: Response not found
Logic:
- Retrieve existing response
- Regenerate verification token (or reuse existing)
- Update
verificationTokenandverificationSentAtin database - Send verification email to
representativeEmail
Use Cases:
- Verification email wasn't delivered
- Representative lost the original email
- Token expired (more than 30 days old)
DELETE /api/responses/:id
Delete a response permanently.
Authentication: Required (Admin roles)
Path Parameters:
id(string): Response ID
Example Request:
curl -X DELETE -H "Authorization: Bearer <token>" \
"http://api.cmlite.org/api/responses/clx1234567890"
Response (204 No Content):
No response body.
Error Responses:
404 Not Found: Response not found
Cascading Deletes:
- All
ResponseUpvoterecords for this response (via Prisma cascade)
Service Functions
responsesService.submitResponse(slug, data, senderIp)
Submit new response to campaign.
Parameters:
slug(string): Campaign slugdata(SubmitResponseInput): Response datasenderIp(string, optional): Submitter's IP address
Returns:
{
id: string;
status: ResponseStatus;
verificationSent: boolean;
}
Validation:
- Campaign must exist and be ACTIVE
- Campaign must have
showResponseWall=true - If
sendVerification=true,representativeEmailis required
Verification Token:
let verificationToken: string | null = null;
if (data.sendVerification && data.representativeEmail) {
verificationToken = randomBytes(32).toString('hex'); // 64-char hex string
}
Metrics:
Calls recordResponseSubmission() to increment Prometheus counter.
responsesService.listApproved(slug, filters)
List approved responses for campaign with sorting.
Parameters:
{
page: number;
limit: number;
sort: 'recent' | 'upvotes' | 'verified';
level?: GovernmentLevel;
}
Returns:
{
responses: Response[];
pagination: Pagination;
}
responsesService.getStats(slug)
Get aggregate statistics for campaign responses.
Returns:
{
total: number;
verified: number;
totalUpvotes: number;
byLevel: Record<string, number>;
}
responsesService.upvote(responseId, userIp, userId)
Upvote a response.
Parameters:
responseId(string): Response IDuserIp(string, optional): User's IP addressuserId(string, optional): User ID (if logged in)
Returns:
{
success: boolean;
alreadyUpvoted?: boolean; // True if duplicate upvote attempt
}
Logic:
try {
await prisma.responseUpvote.create({
data: {
responseId,
userId: userId || null,
upvotedIp: !userId ? (userIp || null) : null,
},
});
await prisma.representativeResponse.update({
where: { id: responseId },
data: { upvoteCount: { increment: 1 } },
});
return { success: true };
} catch (err: any) {
if (err.code === 'P2002') { // Prisma unique constraint violation
return { success: false, alreadyUpvoted: true };
}
throw err;
}
responsesService.removeUpvote(responseId, userIp, userId)
Remove upvote from response.
Parameters:
responseId(string): Response IDuserIp(string, optional): User's IP addressuserId(string, optional): User ID (if logged in)
Returns:
{
success: boolean; // True if upvote was found and removed
}
responsesService.verify(responseId, token)
Verify a response via email link.
Parameters:
responseId(string): Response IDtoken(string): Verification token
Returns:
{
success: boolean;
campaignTitle?: string; // On success
reason?: string; // On failure
}
Failure Reasons:
"Invalid verification link"— Token doesn't match"Verification link has expired"— More than 30 days old
responsesService.report(responseId, token)
Report a response as invalid via email link.
Parameters:
responseId(string): Response IDtoken(string): Verification token (same as verify link)
Returns:
{
success: boolean;
campaignTitle?: string;
reason?: string;
}
Database Changes:
- Sets
status=REJECTED - Sets
isVerified=false - Sets
verifiedByto "Disputed by {email}"
responsesService.findAll(filters) (Admin)
List all responses with admin filters.
Parameters:
{
page: number;
limit: number;
status?: ResponseStatus;
campaignId?: string;
search?: string;
}
Returns:
{
responses: Response[];
pagination: Pagination;
}
responsesService.updateStatus(id, data) (Admin)
Update response status.
Throws: AppError(404) if not found
responsesService.deleteResponse(id) (Admin)
Delete response permanently.
Throws: AppError(404) if not found
responsesService.resendVerification(id) (Admin)
Resend verification email to representative.
Throws:
AppError(404)if response not foundAppError(400)if no representative email on record
Validation Schemas
Submit Response Schema
export const submitResponseSchema = z.object({
representativeName: z.string().min(1, 'Representative name is required'),
representativeLevel: z.nativeEnum(GovernmentLevel),
responseType: z.nativeEnum(ResponseType),
responseText: z.string().min(1, 'Response text is required'),
representativeTitle: z.string().optional(),
representativeEmail: z.string().email().optional(),
userComment: z.string().optional(),
submittedByName: z.string().optional(),
submittedByEmail: z.string().email().optional(),
isAnonymous: z.boolean().optional().default(false),
sendVerification: z.boolean().optional().default(false),
});
List Public Responses Schema
export const listPublicResponsesSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(20),
sort: z.enum(['recent', 'upvotes', 'verified']).optional().default('recent'),
level: z.nativeEnum(GovernmentLevel).optional(),
});
List Admin Responses Schema
export const listAdminResponsesSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(20),
status: z.nativeEnum(ResponseStatus).optional(),
campaignId: z.string().optional(),
search: z.string().optional(),
});
Code Examples
Public: Submit Response with Verification
import axios from 'axios';
const submitResponse = async (campaignSlug: string) => {
const { data } = await axios.post(
`/api/campaigns/${campaignSlug}/responses`,
{
representativeName: 'Chrystia Freeland',
representativeTitle: 'Deputy Prime Minister',
representativeLevel: 'FEDERAL',
representativeEmail: 'chrystia.freeland@parl.gc.ca',
responseType: 'EMAIL',
responseText: 'Thank you for writing. I appreciate your concerns regarding...',
userComment: 'Received this response 2 days after sending!',
submittedByName: 'Jane Doe',
submittedByEmail: 'jane@example.com',
isAnonymous: false,
sendVerification: true,
}
);
console.log(`Response submitted: ${data.id}`);
console.log(`Verification sent: ${data.verificationSent}`);
return data;
};
Public: Upvote Response
import axios from 'axios';
import { message } from 'antd';
const upvoteResponse = async (responseId: string) => {
try {
const { data } = await axios.post(`/api/responses/${responseId}/upvote`);
if (data.success) {
message.success('Upvoted!');
} else if (data.alreadyUpvoted) {
message.info('You already upvoted this response');
}
} catch (error) {
message.error('Failed to upvote');
}
};
Admin: Approve Response
import { api } from '@/lib/api';
import { message } from 'antd';
const approveResponse = async (responseId: string) => {
try {
await api.patch(`/api/responses/${responseId}/status`, {
status: 'APPROVED',
});
message.success('Response approved');
} catch (error) {
message.error('Failed to approve response');
}
};
Admin: Resend Verification
import { api } from '@/lib/api';
import { message } from 'antd';
const resendVerification = async (responseId: string) => {
try {
await api.post(`/api/responses/${responseId}/resend-verification`);
message.success('Verification email resent');
} catch (error: any) {
if (error.response?.status === 400) {
message.error('No representative email on record');
} else {
message.error('Failed to resend verification');
}
}
};
Frontend Integration
ResponsesPage (Admin)
The ResponsesPage component (admin/src/pages/ResponsesPage.tsx) provides:
- Response table with pagination
- Status filter (PENDING, APPROVED, REJECTED)
- Campaign filter
- Search by name, response text, or submitter
- Response detail drawer (shows full response + verification status)
- Status update actions (approve, reject)
- Resend verification button
- Delete response action
- Verification status badges (verified/unverified)
ResponseWallPage (Public)
The ResponseWallPage component (admin/src/pages/public/ResponseWallPage.tsx) provides:
- Response card grid layout
- Sort controls (recent, upvotes, verified)
- Government level filter
- Upvote buttons (IP-based for anonymous)
- Verified badges
- Submit response modal (opens from campaign page)
- Response statistics (total, verified, upvotes)
- Anonymous submission toggle
Performance Considerations
Upvote Constraints:
- Unique constraints prevent duplicate upvotes at database level
- No need for application-level deduplication logic
- Concurrent upvote attempts return
alreadyUpvoted: true
Indexing:
@@index([campaignId])— Fast filtering by campaign@@index([campaignSlug])— Fast public lookup@@index([status])— Fast admin filtering
Pagination:
- Max 100 results per page prevents excessive data transfer
- Default 20 results balances performance and UX
Troubleshooting
Issue: Verification email not delivered
Cause: SMTP configuration issue or email blocked by spam filter
Solution:
- Check
EMAIL_TEST_MODE=truein.env(emails go to MailHog) - Verify SMTP credentials in site settings
- Check spam folder on representative's email
- Admin: Use "Resend Verification" button
Issue: Verification link expired
Cause: More than 30 days since verification email sent
Solution:
- Admin: Use "Resend Verification" to generate new token
- New verification email sent with fresh 30-day expiry
Issue: Can't upvote response
Cause: Already upvoted, or response not approved
Solution:
- Check
alreadyUpvoted: truein response - Remove existing upvote first (DELETE endpoint)
- Verify response has
status=APPROVED
Issue: Response not appearing on public wall
Cause: Status is PENDING or REJECTED
Solution:
- Admin: Check response status in ResponsesPage
- Admin: Approve response manually if legitimate
- If verification email sent, representative must click verify link
Related Documentation
- Campaigns Module - Campaign configuration with response wall flag
- Email Service - Verification email sending
- Frontend: ResponsesPage - Admin moderation UI
- Frontend: ResponseWallPage - Public response wall
- Frontend: Public Campaign Page - Submit response integration
- API Reference: Responses - Complete endpoint reference
- Feature: Response Wall - Response wall feature guide