1034 lines
27 KiB
Markdown
1034 lines
27 KiB
Markdown
# 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
|
|
|
|
```mermaid
|
|
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](../../database/models/response.md) 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](../../database/models/response-upvote.md) 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](../../database/models/campaign.md) — Campaign association
|
|
- [User](../../database/models/user.md) — Moderation user
|
|
|
|
## API Endpoints
|
|
|
|
### Admin Endpoints
|
|
|
|
See [Responses Module API Reference](../../backend/modules/responses.md#endpoints) 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](../../backend/modules/responses.md#public-endpoints).
|
|
|
|
| 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):**
|
|
|
|
```typescript
|
|
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):**
|
|
|
|
```typescript
|
|
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):**
|
|
|
|
```typescript
|
|
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):**
|
|
|
|
```typescript
|
|
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:**
|
|
|
|
```html
|
|
<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):**
|
|
|
|
```typescript
|
|
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):**
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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:**
|
|
|
|
```typescript
|
|
// 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:**
|
|
|
|
```sql
|
|
-- 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):**
|
|
|
|
```typescript
|
|
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:**
|
|
|
|
```sql
|
|
-- 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:**
|
|
|
|
```typescript
|
|
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:**
|
|
|
|
```typescript
|
|
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:**
|
|
|
|
```typescript
|
|
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:**
|
|
|
|
```typescript
|
|
// 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](../../backend/modules/responses.md) — Full API reference
|
|
- [Campaigns Module](../../backend/modules/campaigns.md) — Campaign integration
|
|
- [Email Service](../../backend/modules/email.md) — Email verification
|
|
|
|
### Frontend Pages
|
|
|
|
- [ResponsesPage](../../frontend/pages/admin/responses-page.md) — Admin moderation
|
|
- [ResponseWallPage](../../frontend/pages/public/response-wall-page.md) — Public response wall
|
|
|
|
### Database Models
|
|
|
|
- [Response](../../database/models/response.md) — Response schema
|
|
- [ResponseUpvote](../../database/models/response-upvote.md) — Upvote tracking schema
|
|
- [Campaign](../../database/models/campaign.md) — Campaign schema
|
|
|
|
### Guides
|
|
|
|
- [Campaign Management](../influence/campaigns.md) — Campaign configuration
|
|
- [Email Templates](../email-templates/template-system.md) — Verification email templates
|