249 lines
7.7 KiB
TypeScript
249 lines
7.7 KiB
TypeScript
import { Router, Request, Response, NextFunction } from 'express';
|
|
import { responsesService } from './responses.service';
|
|
import {
|
|
submitResponseSchema,
|
|
listPublicResponsesSchema,
|
|
listAdminResponsesSchema,
|
|
updateResponseStatusSchema,
|
|
} from './responses.schemas';
|
|
import { validate } from '../../../middleware/validate';
|
|
import { authenticate } from '../../../middleware/auth.middleware';
|
|
import { optionalAuth } from '../../../middleware/auth.middleware';
|
|
import { requireRole } from '../../../middleware/rbac.middleware';
|
|
import { responseRateLimit } from '../../../middleware/rate-limit';
|
|
import { INFLUENCE_ROLES } from '../../../utils/roles';
|
|
|
|
// --- Campaign-scoped public routes (mount at /api/campaigns) ---
|
|
const campaignPublicRouter = Router();
|
|
|
|
// GET /api/campaigns/:slug/responses
|
|
campaignPublicRouter.get(
|
|
'/:slug/responses',
|
|
validate(listPublicResponsesSchema, 'query'),
|
|
async (req: Request, res: Response, next: NextFunction) => {
|
|
try {
|
|
const slug = req.params.slug as string;
|
|
const result = await responsesService.listApproved(slug, req.query as any);
|
|
res.json(result);
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
}
|
|
);
|
|
|
|
// GET /api/campaigns/:slug/response-stats
|
|
campaignPublicRouter.get(
|
|
'/:slug/response-stats',
|
|
async (req: Request, res: Response, next: NextFunction) => {
|
|
try {
|
|
const slug = req.params.slug as string;
|
|
const stats = await responsesService.getStats(slug);
|
|
res.json(stats);
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
}
|
|
);
|
|
|
|
// POST /api/campaigns/:slug/responses
|
|
campaignPublicRouter.post(
|
|
'/:slug/responses',
|
|
responseRateLimit,
|
|
validate(submitResponseSchema),
|
|
async (req: Request, res: Response, next: NextFunction) => {
|
|
try {
|
|
const slug = req.params.slug as string;
|
|
const senderIp = req.ip || req.socket.remoteAddress;
|
|
const result = await responsesService.submitResponse(slug, req.body, senderIp);
|
|
res.status(201).json(result);
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
}
|
|
);
|
|
|
|
// --- Response-scoped public routes (mount at /api/responses) ---
|
|
const responsesPublicRouter = Router();
|
|
|
|
// POST /api/responses/:id/upvote
|
|
responsesPublicRouter.post(
|
|
'/:id/upvote',
|
|
optionalAuth,
|
|
async (req: Request, res: Response, next: NextFunction) => {
|
|
try {
|
|
const id = req.params.id as string;
|
|
const userIp = req.ip || req.socket.remoteAddress;
|
|
const userId = req.user?.id;
|
|
const result = await responsesService.upvote(id, userIp, userId);
|
|
res.json(result);
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
}
|
|
);
|
|
|
|
// DELETE /api/responses/:id/upvote
|
|
responsesPublicRouter.delete(
|
|
'/:id/upvote',
|
|
optionalAuth,
|
|
async (req: Request, res: Response, next: NextFunction) => {
|
|
try {
|
|
const id = req.params.id as string;
|
|
const userIp = req.ip || req.socket.remoteAddress;
|
|
const userId = req.user?.id;
|
|
const result = await responsesService.removeUpvote(id, userIp, userId);
|
|
res.json(result);
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
}
|
|
);
|
|
|
|
// GET /api/responses/:id/verify/:token — returns HTML page
|
|
responsesPublicRouter.get(
|
|
'/:id/verify/:token',
|
|
async (req: Request, res: Response, next: NextFunction) => {
|
|
try {
|
|
const id = req.params.id as string;
|
|
const token = req.params.token as string;
|
|
const result = await responsesService.verify(id, token);
|
|
|
|
const html = result.success
|
|
? buildResultPage('Response Verified', `Thank you for verifying this response for the "${result.campaignTitle}" campaign. The response has been approved and will now appear on the public response wall.`, '#16a34a')
|
|
: buildResultPage('Verification Failed', result.reason || 'Unable to verify this response.', '#dc2626');
|
|
|
|
res.type('html').send(html);
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
}
|
|
);
|
|
|
|
// GET /api/responses/:id/report/:token — returns HTML page
|
|
responsesPublicRouter.get(
|
|
'/:id/report/:token',
|
|
async (req: Request, res: Response, next: NextFunction) => {
|
|
try {
|
|
const id = req.params.id as string;
|
|
const token = req.params.token as string;
|
|
const result = await responsesService.report(id, token);
|
|
|
|
const html = result.success
|
|
? buildResultPage('Response Reported', `This response for the "${result.campaignTitle}" campaign has been flagged as invalid and removed from the public response wall. Thank you for letting us know.`, '#dc2626')
|
|
: buildResultPage('Report Failed', result.reason || 'Unable to process this report.', '#dc2626');
|
|
|
|
res.type('html').send(html);
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
}
|
|
);
|
|
|
|
// --- Admin routes (mount at /api/responses) ---
|
|
const responsesAdminRouter = Router();
|
|
responsesAdminRouter.use(authenticate);
|
|
responsesAdminRouter.use(requireRole(...INFLUENCE_ROLES));
|
|
|
|
// GET /api/responses
|
|
responsesAdminRouter.get(
|
|
'/',
|
|
validate(listAdminResponsesSchema, 'query'),
|
|
async (req: Request, res: Response, next: NextFunction) => {
|
|
try {
|
|
const result = await responsesService.findAll(req.query as any);
|
|
res.json(result);
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
}
|
|
);
|
|
|
|
// PATCH /api/responses/:id/status
|
|
responsesAdminRouter.patch(
|
|
'/:id/status',
|
|
validate(updateResponseStatusSchema),
|
|
async (req: Request, res: Response, next: NextFunction) => {
|
|
try {
|
|
const id = req.params.id as string;
|
|
const result = await responsesService.updateStatus(id, req.body);
|
|
res.json(result);
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
}
|
|
);
|
|
|
|
// POST /api/responses/:id/resend-verification
|
|
responsesAdminRouter.post(
|
|
'/:id/resend-verification',
|
|
async (req: Request, res: Response, next: NextFunction) => {
|
|
try {
|
|
const id = req.params.id as string;
|
|
const result = await responsesService.resendVerification(id);
|
|
res.json(result);
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
}
|
|
);
|
|
|
|
// DELETE /api/responses/:id
|
|
responsesAdminRouter.delete(
|
|
'/:id',
|
|
async (req: Request, res: Response, next: NextFunction) => {
|
|
try {
|
|
const id = req.params.id as string;
|
|
await responsesService.deleteResponse(id);
|
|
res.status(204).send();
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
}
|
|
);
|
|
|
|
export { campaignPublicRouter as responseCampaignPublicRouter, responsesPublicRouter, responsesAdminRouter };
|
|
|
|
// --- HTML page builder for verify/report endpoints ---
|
|
function escapeHtml(unsafe: string): string {
|
|
return unsafe
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
function buildResultPage(title: string, message: string, accentColor: string): string {
|
|
const escapedTitle = escapeHtml(title);
|
|
const escapedMessage = escapeHtml(message);
|
|
|
|
return `<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>${escapedTitle} - Changemaker Lite</title>
|
|
<style>
|
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 0; padding: 40px 20px; background: #f8fafc; color: #334155; }
|
|
.container { max-width: 500px; margin: 0 auto; text-align: center; }
|
|
.card { background: white; border-radius: 12px; padding: 40px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
|
|
.icon { font-size: 48px; margin-bottom: 16px; }
|
|
h1 { color: ${accentColor}; font-size: 24px; margin: 0 0 16px; }
|
|
p { font-size: 16px; line-height: 1.6; color: #64748b; margin: 0; }
|
|
.brand { margin-top: 32px; font-size: 14px; color: #94a3b8; }
|
|
.brand strong { color: #2563eb; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<div class="card">
|
|
<div class="icon">${accentColor === '#16a34a' ? '✓' : '✗'}</div>
|
|
<h1>${escapedTitle}</h1>
|
|
<p>${escapedMessage}</p>
|
|
</div>
|
|
<div class="brand">Powered by <strong>Changemaker Lite</strong></div>
|
|
</div>
|
|
</body>
|
|
</html>`;
|
|
}
|