27 KiB

Response Wall System

Overview

The response wall system allows campaign participants to share their advocacy actions publicly, creating social proof and encouraging further participation. It includes email verification, admin moderation, upvoting capabilities, and screenshot uploads to showcase genuine participation.

Key Capabilities:

  • Multiple response types: EMAIL, LETTER, PHONE_CALL, MEETING, SOCIAL_MEDIA, OTHER
  • Email verification: Prevent spam with email confirmation
  • Admin moderation: PENDING → APPROVED/REJECTED workflow
  • Upvoting system: Community engagement with IP + user tracking
  • Screenshot uploads: Visual proof of participation
  • Public response wall: SEO-friendly public display
  • Moderation dashboard: Admin tools for reviewing submissions

Use Cases:

  • Public display of campaign participation
  • Social proof for advocacy campaigns
  • Community engagement and sharing
  • Response verification and moderation
  • Campaign effectiveness metrics

Architecture

graph TD
    A[Public User] -->|Submit Response| B[ResponseWallPage]
    B -->|POST /api/public/responses| C[Response Service]
    C -->|Save| D[(Response Model)]
    C -->|Send| E[Email Service]
    E -->|Verification Email| F[User Inbox]

    F -->|Click Link| G[Verify Endpoint]
    G -->|Update| D

    H[Admin User] -->|Review| I[ResponsesPage]
    I -->|GET /api/responses| C
    I -->|Approve/Reject| C
    C -->|Update Status| D

    J[Public User] -->|View Wall| K[ResponseWallPage]
    K -->|GET /api/public/responses/:campaignId| C
    C -->|Filter APPROVED| D

    K -->|Upvote| L[Upvote Service]
    L -->|Track| M[(ResponseUpvote Model)]
    L -->|Increment| D

    style D fill:#e1f5ff
    style M fill:#e1f5ff
    style E fill:#fff4e1

Flow Description:

  1. User submits response → Response service saves with PENDING status
  2. Verification email sent → User clicks link to verify email
  3. Email verified → Response marked as email verified
  4. Admin reviews → Moderates response (approve/reject)
  5. Response approved → Appears on public response wall
  6. Users upvote → Upvote service tracks votes, increments count
  7. Public views wall → Only approved responses displayed

Database Models

Response Model

See Response Model Documentation for full schema.

Key Fields:

Field Type Description
id String (UUID) Primary key
campaignId String Associated campaign
responseType Enum EMAIL, LETTER, PHONE_CALL, MEETING, SOCIAL_MEDIA, OTHER
message String User's response message
screenshotUrl String? Uploaded screenshot URL
name String Submitter's name
email String Submitter's email
postalCode String? Submitter's postal code
isEmailVerified Boolean Email verification status
status Enum PENDING, APPROVED, REJECTED
upvotes Int Number of upvotes
moderatedByUserId String? Admin who moderated
moderationNotes String? Admin notes

Indexes:

  • campaignId, status — For public wall queries
  • email, campaignId — Prevent duplicate submissions
  • isEmailVerified — Filter unverified responses

ResponseUpvote Model

See ResponseUpvote Model Documentation for full schema.

Key Fields:

Field Type Description
id String (UUID) Primary key
responseId String Associated response
ipAddress String? Voter IP address
userId String? Voter user ID (if logged in)

Constraints:

  • Unique constraint on responseId, ipAddress — Prevent duplicate upvotes by IP
  • Unique constraint on responseId, userId — Prevent duplicate upvotes by user

Related Models:

  • Campaign — Campaign association
  • User — Moderation user

API Endpoints

Admin Endpoints

See Responses Module API Reference for full details.

Method Endpoint Auth Description
GET /api/responses SUPER_ADMIN, INFLUENCE_ADMIN List all responses (paginated, filterable)
GET /api/responses/:id SUPER_ADMIN, INFLUENCE_ADMIN Get response details
PATCH /api/responses/:id/moderate SUPER_ADMIN, INFLUENCE_ADMIN Approve/reject response
DELETE /api/responses/:id SUPER_ADMIN Delete response

Public Endpoints

See Responses Module API Reference.

Method Endpoint Auth Description
GET /api/public/responses/:campaignId None List approved responses for campaign
POST /api/public/responses None Submit new response
GET /api/public/responses/verify/:token None Verify email via token
POST /api/public/responses/:id/upvote None Upvote response (IP/user tracked)

Configuration

Environment Variables

Variable Type Default Description
EMAIL_TEST_MODE boolean false Send verification emails to MailHog
SMTP_FROM_EMAIL string - Sender email for verification
SMTP_FROM_NAME string - Sender name for verification
RESPONSE_VERIFICATION_URL string - Base URL for verification links

Campaign Feature Flags

Response wall behavior configured per campaign:

Flag Description
showResponseWall Enable response wall for campaign
requireEmailVerification Require email verification before display
allowAnonymousResponses Allow submissions without login

Upload Configuration

Screenshots uploaded to /uploads/responses/{responseId}/{filename}.

Limits:

  • Max file size: 5MB
  • Allowed formats: jpg, jpeg, png, gif, webp

Admin Workflow

1. View Pending Responses

[Screenshot: ResponsesPage with pending filter active]

Steps:

  1. Navigate to Influence > Responses
  2. Click Pending filter tab
  3. View pending responses requiring moderation
  4. Sort by submission date (newest first)

Code Example (ResponsesPage.tsx):

const [responses, setResponses] = useState<Response[]>([]);
const [filter, setFilter] = useState<'all' | 'pending' | 'approved' | 'rejected'>('pending');

useEffect(() => {
  const fetchResponses = async () => {
    const params = new URLSearchParams();

    if (filter !== 'all') {
      params.set('status', filter.toUpperCase());
    }

    const { data } = await api.get(`/responses?${params.toString()}`);
    setResponses(data.responses);
  };

  fetchResponses();
}, [filter]);

return (
  <Card>
    <Tabs activeKey={filter} onChange={setFilter}>
      <TabPane tab="Pending" key="pending" />
      <TabPane tab="Approved" key="approved" />
      <TabPane tab="Rejected" key="rejected" />
      <TabPane tab="All" key="all" />
    </Tabs>

    <Table dataSource={responses} columns={columns} />
  </Card>
);

2. Review Response Details

[Screenshot: Response detail drawer with full content]

Steps:

  1. Click View on response row
  2. Review response details:
    • Campaign name
    • Response type
    • Submitter name and email
    • Message content
    • Screenshot (if uploaded)
    • Email verification status
    • Submission date
  3. Check for spam/inappropriate content

Moderation Checklist:

  • ✓ Message is genuine and relevant
  • ✓ Screenshot matches claimed action (if provided)
  • ✓ Email verified (if required by campaign)
  • ✓ No profanity or inappropriate content
  • ✓ Not duplicate submission

3. Approve or Reject Response

[Screenshot: Response detail drawer with approve/reject buttons]

Steps:

  1. Click Approve or Reject button
  2. Add moderation notes (optional but recommended)
  3. Confirm action
  4. Response status updated
  5. If approved → appears on public response wall
  6. If rejected → hidden from public, admin can view

Code Example (responses.service.ts):

async moderateResponse(
  responseId: string,
  status: 'APPROVED' | 'REJECTED',
  moderatorUserId: string,
  notes?: string
): Promise<Response> {
  const response = await this.prisma.response.update({
    where: { id: responseId },
    data: {
      status,
      moderatedByUserId: moderatorUserId,
      moderationNotes: notes,
      moderatedAt: new Date()
    },
    include: {
      campaign: true,
      moderatedBy: {
        select: { name: true, email: true }
      }
    }
  });

  // Send notification email if campaign has notifyOnResponse enabled
  if (status === 'APPROVED' && response.campaign.notifyOnResponse) {
    await this.emailService.send({
      to: response.email,
      subject: `Your response was approved`,
      template: 'response-approved',
      variables: {
        name: response.name,
        campaignTitle: response.campaign.title,
        responseWallUrl: `${process.env.FRONTEND_URL}/responses/${response.campaignId}`
      }
    });
  }

  logger.info(`Response ${responseId} ${status} by user ${moderatorUserId}`);

  return response;
}

4. Bulk Moderation Actions

[Screenshot: ResponsesPage with bulk action toolbar]

Steps:

  1. Select multiple responses (checkboxes)
  2. Click Bulk Actions dropdown
  3. Choose action:
    • Approve selected
    • Reject selected
    • Delete selected
  4. Confirm bulk action
  5. All selected responses updated

Code Example (ResponsesPage.tsx):

const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);

const handleBulkApprove = async () => {
  try {
    await Promise.all(
      selectedRowKeys.map(id =>
        api.patch(`/responses/${id}/moderate`, {
          status: 'APPROVED',
          notes: 'Bulk approved'
        })
      )
    );

    message.success(`Approved ${selectedRowKeys.length} responses`);
    setSelectedRowKeys([]);
    fetchResponses();
  } catch (error) {
    message.error('Failed to bulk approve responses');
  }
};

const rowSelection = {
  selectedRowKeys,
  onChange: setSelectedRowKeys
};

return (
  <>
    {selectedRowKeys.length > 0 && (
      <Space style={{ marginBottom: 16 }}>
        <Button onClick={handleBulkApprove}>Approve Selected</Button>
        <Button onClick={handleBulkReject}>Reject Selected</Button>
        <Button danger onClick={handleBulkDelete}>Delete Selected</Button>
      </Space>
    )}

    <Table rowSelection={rowSelection} dataSource={responses} columns={columns} />
  </>
);

Public Workflow

1. Submit Response

[Screenshot: Response submission form on ResponseWallPage]

User Journey:

  1. User completes campaign action (sends email)
  2. Clicks Share Your Response link
  3. Navigated to /responses/{campaignId}/submit
  4. Fills in response form:
    • Response type (dropdown)
    • Name
    • Email
    • Postal code (optional)
    • Message (what they did)
    • Screenshot (optional upload)
  5. Clicks Submit Response
  6. System saves response as PENDING
  7. Verification email sent (if required)

Code Example (ResponseWallPage.tsx):

const handleSubmit = async (values: any) => {
  try {
    const formData = new FormData();
    formData.append('campaignId', campaignId);
    formData.append('responseType', values.responseType);
    formData.append('name', values.name);
    formData.append('email', values.email);
    formData.append('message', values.message);

    if (values.postalCode) {
      formData.append('postalCode', values.postalCode);
    }

    if (values.screenshot?.[0]?.originFileObj) {
      formData.append('screenshot', values.screenshot[0].originFileObj);
    }

    await axios.post('/api/public/responses', formData, {
      headers: { 'Content-Type': 'multipart/form-data' }
    });

    if (campaign.requireEmailVerification) {
      message.success('Response submitted! Please check your email to verify.');
    } else {
      message.success('Response submitted! It will appear after admin approval.');
    }

    form.resetFields();
  } catch (error) {
    message.error('Failed to submit response');
  }
};

return (
  <Form form={form} onFinish={handleSubmit} layout="vertical">
    <Form.Item
      name="responseType"
      label="What did you do?"
      rules={[{ required: true }]}
    >
      <Select>
        <Option value="EMAIL">Sent Email</Option>
        <Option value="LETTER">Sent Letter</Option>
        <Option value="PHONE_CALL">Made Phone Call</Option>
        <Option value="MEETING">Attended Meeting</Option>
        <Option value="SOCIAL_MEDIA">Posted on Social Media</Option>
        <Option value="OTHER">Other</Option>
      </Select>
    </Form.Item>

    <Form.Item
      name="name"
      label="Your Name"
      rules={[{ required: true }]}
    >
      <Input />
    </Form.Item>

    <Form.Item
      name="email"
      label="Your Email"
      rules={[
        { required: true },
        { type: 'email' }
      ]}
    >
      <Input />
    </Form.Item>

    <Form.Item
      name="message"
      label="Tell us more"
      rules={[{ required: true }]}
    >
      <Input.TextArea rows={4} placeholder="Describe what you did..." />
    </Form.Item>

    <Form.Item
      name="screenshot"
      label="Upload Screenshot (optional)"
      valuePropName="fileList"
      getValueFromEvent={e => Array.isArray(e) ? e : e?.fileList}
    >
      <Upload
        listType="picture"
        maxCount={1}
        beforeUpload={() => false}
      >
        <Button icon={<UploadOutlined />}>Upload Screenshot</Button>
      </Upload>
    </Form.Item>

    <Form.Item>
      <Button type="primary" htmlType="submit">
        Submit Response
      </Button>
    </Form.Item>
  </Form>
);

2. Verify Email

[Screenshot: Email verification success page]

User Journey:

  1. User receives verification email
  2. Clicks verification link
  3. Navigated to /api/public/responses/verify/{token}
  4. System verifies email
  5. Response marked as email verified
  6. User redirected to response wall
  7. Message: "Email verified! Your response will appear after admin approval."

Verification Email Template:

<h1>Verify Your Response</h1>

<p>Hi {{name}},</p>

<p>Thanks for sharing your response to <strong>{{campaignTitle}}</strong>!</p>

<p>Please verify your email address by clicking the link below:</p>

<p>
  <a href="{{verificationUrl}}">Verify Email</a>
</p>

<p>This link will expire in 24 hours.</p>

<p>If you didn't submit this response, you can safely ignore this email.</p>

3. View Response Wall

[Screenshot: Public response wall with approved responses]

User Journey:

  1. User visits /responses/{campaignId}
  2. Sees approved responses
  3. Responses sorted by upvotes (most upvoted first)
  4. Can upvote responses
  5. Can filter by response type

Code Example (ResponseWallPage.tsx):

const [responses, setResponses] = useState<Response[]>([]);
const [filter, setFilter] = useState<string | null>(null);

useEffect(() => {
  const fetchResponses = async () => {
    const params = new URLSearchParams();

    if (filter) {
      params.set('responseType', filter);
    }

    const { data } = await axios.get(
      `/api/public/responses/${campaignId}?${params.toString()}`
    );

    setResponses(data);
  };

  fetchResponses();
}, [campaignId, filter]);

return (
  <PublicLayout>
    <Space direction="vertical" size="large" style={{ width: '100%' }}>
      <Typography.Title level={2}>Response Wall</Typography.Title>

      <Radio.Group value={filter} onChange={e => setFilter(e.target.value)}>
        <Radio.Button value={null}>All</Radio.Button>
        <Radio.Button value="EMAIL">Emails</Radio.Button>
        <Radio.Button value="LETTER">Letters</Radio.Button>
        <Radio.Button value="PHONE_CALL">Calls</Radio.Button>
        <Radio.Button value="MEETING">Meetings</Radio.Button>
        <Radio.Button value="SOCIAL_MEDIA">Social Media</Radio.Button>
      </Radio.Group>

      <List
        dataSource={responses}
        renderItem={response => (
          <ResponseCard
            response={response}
            onUpvote={handleUpvote}
          />
        )}
      />
    </Space>
  </PublicLayout>
);

4. Upvote Response

[Screenshot: Response card with upvote button]

User Journey:

  1. User clicks upvote button on response
  2. System checks for existing upvote (IP + user)
  3. If first upvote → increment count, save upvote record
  4. If already upvoted → show message "You already upvoted this"
  5. Upvote count updated in real-time

Code Example (responses-public.routes.ts):

router.post('/:id/upvote', async (req, res) => {
  try {
    const { id } = req.params;
    const ipAddress = req.ip;
    const userId = req.user?.id; // If authenticated

    // Check for existing upvote
    const existingUpvote = await prisma.responseUpvote.findFirst({
      where: {
        responseId: id,
        OR: [
          { ipAddress },
          userId ? { userId } : {}
        ]
      }
    });

    if (existingUpvote) {
      return res.status(400).json({ error: 'You already upvoted this response' });
    }

    // Create upvote and increment count (transaction)
    await prisma.$transaction([
      prisma.responseUpvote.create({
        data: {
          responseId: id,
          ipAddress,
          userId
        }
      }),
      prisma.response.update({
        where: { id },
        data: {
          upvotes: { increment: 1 }
        }
      })
    ]);

    res.json({ success: true });
  } catch (error) {
    logger.error('Failed to upvote response:', error);
    res.status(500).json({ error: 'Failed to upvote response' });
  }
});

Volunteer Workflow

Not applicable — response wall is public-facing.

Code Examples

Backend: Email Verification Token

// api/src/modules/influence/responses/responses.service.ts

import crypto from 'crypto';

async createResponse(data: any): Promise<Response> {
  const verificationToken = crypto.randomBytes(32).toString('hex');

  const response = await this.prisma.response.create({
    data: {
      ...data,
      status: 'PENDING',
      isEmailVerified: false,
      verificationToken,
      verificationTokenExpires: new Date(Date.now() + 24 * 60 * 60 * 1000) // 24h
    }
  });

  // Send verification email
  await this.emailService.send({
    to: response.email,
    subject: 'Verify your response',
    template: 'response-verification',
    variables: {
      name: response.name,
      campaignTitle: response.campaign.title,
      verificationUrl: `${process.env.RESPONSE_VERIFICATION_URL}/api/public/responses/verify/${verificationToken}`
    }
  });

  return response;
}

async verifyEmail(token: string): Promise<Response> {
  const response = await this.prisma.response.findFirst({
    where: {
      verificationToken: token,
      verificationTokenExpires: {
        gt: new Date()
      }
    }
  });

  if (!response) {
    throw new Error('Invalid or expired verification token');
  }

  return this.prisma.response.update({
    where: { id: response.id },
    data: {
      isEmailVerified: true,
      verificationToken: null,
      verificationTokenExpires: null
    }
  });
}

Frontend: Response Card Component

// admin/src/components/influence/ResponseCard.tsx

import React from 'react';
import { Card, Space, Typography, Tag, Button, Avatar } from 'antd';
import { LikeOutlined, LikeFilled } from '@ant-design/icons';
import type { Response } from '../../types/api';

interface ResponseCardProps {
  response: Response;
  onUpvote: (id: string) => void;
  hasUpvoted?: boolean;
}

const ResponseCard: React.FC<ResponseCardProps> = ({
  response,
  onUpvote,
  hasUpvoted
}) => {
  const typeColors: Record<string, string> = {
    EMAIL: 'blue',
    LETTER: 'green',
    PHONE_CALL: 'orange',
    MEETING: 'purple',
    SOCIAL_MEDIA: 'cyan',
    OTHER: 'default'
  };

  const typeLabels: Record<string, string> = {
    EMAIL: 'Sent Email',
    LETTER: 'Sent Letter',
    PHONE_CALL: 'Made Call',
    MEETING: 'Attended Meeting',
    SOCIAL_MEDIA: 'Posted on Social Media',
    OTHER: 'Other Action'
  };

  return (
    <Card>
      <Space direction="vertical" size="small" style={{ width: '100%' }}>
        <Space>
          <Avatar>{response.name[0].toUpperCase()}</Avatar>
          <Space direction="vertical" size={0}>
            <Typography.Text strong>{response.name}</Typography.Text>
            <Typography.Text type="secondary">
              {new Date(response.createdAt).toLocaleDateString()}
            </Typography.Text>
          </Space>
        </Space>

        <Tag color={typeColors[response.responseType]}>
          {typeLabels[response.responseType]}
        </Tag>

        <Typography.Paragraph>{response.message}</Typography.Paragraph>

        {response.screenshotUrl && (
          <img
            src={response.screenshotUrl}
            alt="Response screenshot"
            style={{ maxWidth: '100%', borderRadius: 4 }}
          />
        )}

        <Button
          type="text"
          icon={hasUpvoted ? <LikeFilled /> : <LikeOutlined />}
          onClick={() => onUpvote(response.id)}
          disabled={hasUpvoted}
        >
          {response.upvotes} {response.upvotes === 1 ? 'upvote' : 'upvotes'}
        </Button>
      </Space>
    </Card>
  );
};

export default ResponseCard;

Troubleshooting

Verification Email Not Received

Symptoms:

  • User doesn't receive verification email
  • Email not in spam folder

Solutions:

  1. Check email service logs → docker compose logs api | grep "verification"
  2. Verify SMTP configuration → test with /api/auth/test-email
  3. Check EMAIL_TEST_MODE → if true, email sent to MailHog (localhost:8025)
  4. Resend verification email → manual resend via admin UI

Manual Resend:

// Admin UI: ResponsesPage
const handleResendVerification = async (responseId: string) => {
  await api.post(`/responses/${responseId}/resend-verification`);
  message.success('Verification email resent');
};

Duplicate Upvotes

Symptoms:

  • User can upvote same response multiple times
  • Upvote count inflated

Solutions:

  1. Check database constraints → should have unique constraint on responseId, ipAddress
  2. Verify transaction → upvote creation and count increment must be atomic
  3. Check IP address extraction → ensure req.ip is correct (consider X-Forwarded-For)

Database Fix:

-- Add unique constraint if missing
ALTER TABLE response_upvotes
ADD CONSTRAINT unique_response_ip
UNIQUE (response_id, ip_address);

ALTER TABLE response_upvotes
ADD CONSTRAINT unique_response_user
UNIQUE (response_id, user_id)
WHERE user_id IS NOT NULL;

Screenshot Upload Fails

Symptoms:

  • Upload spinner never completes
  • Error: "File too large"

Solutions:

  1. Check file size → max 5MB
  2. Verify file format → must be image (jpg/jpeg/png/gif/webp)
  3. Check upload directory permissions → /uploads/responses must be writable
  4. Increase Nginx upload limit → client_max_body_size 10M;

Code Fix (responses.service.ts):

import sharp from 'sharp';

async uploadScreenshot(
  file: Express.Multer.File,
  responseId: string
): Promise<string> {
  const uploadDir = `/uploads/responses/${responseId}`;
  await fs.mkdir(uploadDir, { recursive: true });

  const filename = `${Date.now()}-${file.originalname}`;

  // Optimize image (max 1200px width, 85% quality)
  await sharp(file.buffer)
    .resize(1200, null, { withoutEnlargement: true })
    .jpeg({ quality: 85 })
    .toFile(`${uploadDir}/${filename}`);

  return `${uploadDir}/${filename}`;
}

Performance Considerations

Query Optimization

Index Strategy:

-- Composite index for public wall queries
CREATE INDEX idx_response_campaign_status
  ON responses (campaign_id, status)
  WHERE status = 'APPROVED';

-- Index for sorting by upvotes
CREATE INDEX idx_response_upvotes
  ON responses (upvotes DESC);

-- Index for email verification lookups
CREATE INDEX idx_response_verification_token
  ON responses (verification_token)
  WHERE verification_token IS NOT NULL;

Optimized Public Query:

const responses = await prisma.response.findMany({
  where: {
    campaignId,
    status: 'APPROVED',
    isEmailVerified: true
  },
  orderBy: [
    { upvotes: 'desc' },
    { createdAt: 'desc' }
  ],
  take: 50,
  skip: page * 50
});

Caching Strategy

Redis Caching for Response Wall:

import { redisClient } from '../../../config/redis';

async getApprovedResponses(campaignId: string): Promise<Response[]> {
  const cacheKey = `responses:${campaignId}`;

  // Check cache
  const cached = await redisClient.get(cacheKey);
  if (cached) {
    return JSON.parse(cached);
  }

  // Query database
  const responses = await prisma.response.findMany({
    where: {
      campaignId,
      status: 'APPROVED',
      isEmailVerified: true
    },
    orderBy: { upvotes: 'desc' }
  });

  // Cache for 5 minutes
  await redisClient.setex(cacheKey, 300, JSON.stringify(responses));

  return responses;
}

// Invalidate cache on moderation
async moderateResponse(responseId: string, status: string) {
  const response = await prisma.response.update({
    where: { id: responseId },
    data: { status }
  });

  // Invalidate cache
  await redisClient.del(`responses:${response.campaignId}`);

  return response;
}

Screenshot Optimization

Image Processing Pipeline:

import sharp from 'sharp';

async optimizeScreenshot(file: Express.Multer.File): Promise<Buffer> {
  return sharp(file.buffer)
    .resize(1200, null, {
      withoutEnlargement: true,
      fit: 'inside'
    })
    .jpeg({
      quality: 85,
      progressive: true
    })
    .toBuffer();
}

CDN Integration:

// Upload optimized screenshots to CDN
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';

const s3 = new S3Client({ region: 'us-east-1' });

async uploadToCDN(buffer: Buffer, key: string): Promise<string> {
  await s3.send(new PutObjectCommand({
    Bucket: process.env.S3_BUCKET,
    Key: `responses/${key}`,
    Body: buffer,
    ContentType: 'image/jpeg',
    CacheControl: 'max-age=31536000' // 1 year
  }));

  return `${process.env.CDN_URL}/responses/${key}`;
}

Backend Modules

Frontend Pages

Database Models

Guides