1320 lines
32 KiB
Markdown

# 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
```prisma
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
```prisma
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:**
```json
{
"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 name
- `representativeLevel` (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 response
- `representativeTitle` (optional): Representative's title/position
- `representativeEmail` (optional): Representative's email (required if `sendVerification=true`)
- `userComment` (optional): Submitter's comment about the response
- `submittedByName` (optional): Submitter's name (not shown if `isAnonymous=true`)
- `submittedByEmail` (optional): Submitter's email (not shown publicly)
- `isAnonymous` (optional, default: false): Hide submitter name on public wall
- `sendVerification` (optional, default: false): Send verification email to representative
**Response (201 Created):**
```json
{
"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 error
- `404 Not Found`: Campaign not found
- `429 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:**
```bash
# 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):**
```json
{
"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 `APPROVED` responses are returned
- `submittedByName` is null if `isAnonymous=true`
- `submittedByEmail` never exposed on public routes
- `representativeEmail` never exposed on public routes
**Sorting:**
```typescript
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:**
```bash
curl "http://api.cmlite.org/api/campaigns/climate-action-now/response-stats"
```
**Response (200 OK):**
```json
{
"total": 89,
"verified": 42,
"totalUpvotes": 347,
"byLevel": {
"FEDERAL": 32,
"PROVINCIAL": 28,
"MUNICIPAL": 21,
"SCHOOL_BOARD": 8
}
}
```
**Field Descriptions:**
- `total`: Total APPROVED responses for campaign
- `verified`: Count of APPROVED responses with `isVerified=true`
- `totalUpvotes`: Sum of all `upvoteCount` values
- `byLevel`: 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:**
```bash
# 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):**
```json
{
"success": true
}
```
**Response (200 OK, Already Upvoted):**
```json
{
"success": false,
"alreadyUpvoted": true
}
```
**Upvoting Logic:**
1. Verify response exists and is APPROVED
2. Create `ResponseUpvote` record:
- Logged-in: `userId` + `responseId` (allows upvoting from multiple IPs)
- Anonymous: `upvotedIp` + `responseId` (prevents duplicate upvotes from same IP)
3. Increment `upvoteCount` on response
4. If duplicate (Prisma P2002 error), return `alreadyUpvoted: true`
**Error Responses:**
- `400 Bad Request`: Response is not approved
- `404 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:**
```bash
# 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):**
```json
{
"success": true
}
```
**Response (200 OK, Not Upvoted):**
```json
{
"success": false
}
```
**Logic:**
1. Delete `ResponseUpvote` record matching `responseId` + `userId` (or `upvotedIp` if anonymous)
2. Decrement `upvoteCount` if deleted
3. Return `success: false` if 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 ID
- `token` (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:
```html
<!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 match
- `reason: "Verification link has expired"` — More than 30 days since sent
**Database Changes on Success:**
```typescript
await prisma.representativeResponse.update({
where: { id: responseId },
data: {
isVerified: true,
verifiedAt: new Date(),
verifiedBy: response.representativeEmail || 'Representative',
status: ResponseStatus.APPROVED,
},
});
```
**Expiry Logic:**
```typescript
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 ID
- `token` (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:
```html
<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:**
```typescript
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:**
```bash
# 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):**
```json
{
"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 `campaign` relation
- Search across name, response text, submitter name
**Search Logic:**
```typescript
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:**
```json
{
"status": "APPROVED"
}
```
**Valid Statuses:** `PENDING`, `APPROVED`, `REJECTED`
**Example Request:**
```bash
# 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:**
```bash
curl -X POST -H "Authorization: Bearer <token>" \
"http://api.cmlite.org/api/responses/clx1234567890/resend-verification"
```
**Response (200 OK):**
```json
{
"success": true
}
```
**Error Responses:**
- `400 Bad Request`: No representative email on record
- `404 Not Found`: Response not found
**Logic:**
1. Retrieve existing response
2. Regenerate verification token (or reuse existing)
3. Update `verificationToken` and `verificationSentAt` in database
4. 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:**
```bash
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 `ResponseUpvote` records for this response (via Prisma cascade)
---
## Service Functions
### responsesService.submitResponse(slug, data, senderIp)
Submit new response to campaign.
**Parameters:**
- `slug` (string): Campaign slug
- `data` (SubmitResponseInput): Response data
- `senderIp` (string, optional): Submitter's IP address
**Returns:**
```typescript
{
id: string;
status: ResponseStatus;
verificationSent: boolean;
}
```
**Validation:**
- Campaign must exist and be ACTIVE
- Campaign must have `showResponseWall=true`
- If `sendVerification=true`, `representativeEmail` is required
**Verification Token:**
```typescript
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:**
```typescript
{
page: number;
limit: number;
sort: 'recent' | 'upvotes' | 'verified';
level?: GovernmentLevel;
}
```
**Returns:**
```typescript
{
responses: Response[];
pagination: Pagination;
}
```
---
### responsesService.getStats(slug)
Get aggregate statistics for campaign responses.
**Returns:**
```typescript
{
total: number;
verified: number;
totalUpvotes: number;
byLevel: Record<string, number>;
}
```
---
### responsesService.upvote(responseId, userIp, userId)
Upvote a response.
**Parameters:**
- `responseId` (string): Response ID
- `userIp` (string, optional): User's IP address
- `userId` (string, optional): User ID (if logged in)
**Returns:**
```typescript
{
success: boolean;
alreadyUpvoted?: boolean; // True if duplicate upvote attempt
}
```
**Logic:**
```typescript
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 ID
- `userIp` (string, optional): User's IP address
- `userId` (string, optional): User ID (if logged in)
**Returns:**
```typescript
{
success: boolean; // True if upvote was found and removed
}
```
---
### responsesService.verify(responseId, token)
Verify a response via email link.
**Parameters:**
- `responseId` (string): Response ID
- `token` (string): Verification token
**Returns:**
```typescript
{
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 ID
- `token` (string): Verification token (same as verify link)
**Returns:**
```typescript
{
success: boolean;
campaignTitle?: string;
reason?: string;
}
```
**Database Changes:**
- Sets `status=REJECTED`
- Sets `isVerified=false`
- Sets `verifiedBy` to "Disputed by {email}"
---
### responsesService.findAll(filters) (Admin)
List all responses with admin filters.
**Parameters:**
```typescript
{
page: number;
limit: number;
status?: ResponseStatus;
campaignId?: string;
search?: string;
}
```
**Returns:**
```typescript
{
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 found
- `AppError(400)` if no representative email on record
---
## Validation Schemas
### Submit Response Schema
```typescript
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
```typescript
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
```typescript
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
```typescript
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
```typescript
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
```typescript
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
```typescript
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=true` in `.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: true` in 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](/v2/backend/modules/campaigns.md) - Campaign configuration with response wall flag
- [Email Service](/v2/backend/services/email-service.md) - Verification email sending
- [Frontend: ResponsesPage](/v2/frontend/pages/admin/responses-page.md) - Admin moderation UI
- [Frontend: ResponseWallPage](/v2/frontend/pages/public/response-wall-page.md) - Public response wall
- [Frontend: Public Campaign Page](/v2/frontend/pages/public/campaign-page.md) - Submit response integration
- [API Reference: Responses](/v2/api-reference/responses.md) - Complete endpoint reference
- [Feature: Response Wall](/v2/features/influence/response-wall.md) - Response wall feature guide