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:
- User submits response → Response service saves with PENDING status
- Verification email sent → User clicks link to verify email
- Email verified → Response marked as email verified
- Admin reviews → Moderates response (approve/reject)
- Response approved → Appears on public response wall
- Users upvote → Upvote service tracks votes, increments count
- 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 queriesemail, campaignId— Prevent duplicate submissionsisEmailVerified— 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:
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:
- Navigate to Influence > Responses
- Click Pending filter tab
- View pending responses requiring moderation
- 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:
- Click View on response row
- Review response details:
- Campaign name
- Response type
- Submitter name and email
- Message content
- Screenshot (if uploaded)
- Email verification status
- Submission date
- 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:
- Click Approve or Reject button
- Add moderation notes (optional but recommended)
- Confirm action
- Response status updated
- If approved → appears on public response wall
- 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:
- Select multiple responses (checkboxes)
- Click Bulk Actions dropdown
- Choose action:
- Approve selected
- Reject selected
- Delete selected
- Confirm bulk action
- 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:
- User completes campaign action (sends email)
- Clicks Share Your Response link
- Navigated to
/responses/{campaignId}/submit - Fills in response form:
- Response type (dropdown)
- Name
- Postal code (optional)
- Message (what they did)
- Screenshot (optional upload)
- Clicks Submit Response
- System saves response as PENDING
- 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:
- User receives verification email
- Clicks verification link
- Navigated to
/api/public/responses/verify/{token} - System verifies email
- Response marked as email verified
- User redirected to response wall
- 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:
- User visits
/responses/{campaignId} - Sees approved responses
- Responses sorted by upvotes (most upvoted first)
- Can upvote responses
- 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:
- User clicks upvote button on response
- System checks for existing upvote (IP + user)
- If first upvote → increment count, save upvote record
- If already upvoted → show message "You already upvoted this"
- 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:
- Check email service logs →
docker compose logs api | grep "verification" - Verify SMTP configuration → test with
/api/auth/test-email - Check EMAIL_TEST_MODE → if true, email sent to MailHog (localhost:8025)
- 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:
- Check database constraints → should have unique constraint on
responseId, ipAddress - Verify transaction → upvote creation and count increment must be atomic
- Check IP address extraction → ensure
req.ipis 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:
- Check file size → max 5MB
- Verify file format → must be image (jpg/jpeg/png/gif/webp)
- Check upload directory permissions →
/uploads/responsesmust be writable - 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}`;
}
Related Documentation
Backend Modules
- Responses Module — Full API reference
- Campaigns Module — Campaign integration
- Email Service — Email verification
Frontend Pages
- ResponsesPage — Admin moderation
- ResponseWallPage — Public response wall
Database Models
- Response — Response schema
- ResponseUpvote — Upvote tracking schema
- Campaign — Campaign schema
Guides
- Campaign Management — Campaign configuration
- Email Templates — Verification email templates