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 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):

{
  "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:

# 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 APPROVED responses are returned
  • submittedByName is null if isAnonymous=true
  • submittedByEmail never exposed on public routes
  • representativeEmail never 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 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:

# 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:

  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:

# 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:

  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:

<!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:

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 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:

<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 campaign relation
  • 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 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:

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:

{
  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:

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 ID
  • userIp (string, optional): User's IP address
  • userId (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 ID
  • userIp (string, optional): User's IP address
  • userId (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 ID
  • token (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 ID
  • token (string): Verification token (same as verify link)

Returns:

{
  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:

{
  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 found
  • AppError(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=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

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