923 lines
30 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const nocodbService = require('../services/nocodb');
const emailService = require('../services/email');
const crypto = require('crypto');
const { validateResponse } = require('../utils/validators');
/**
* Get all responses for a campaign
* Public endpoint - no authentication required
* @param {Object} req - Express request object
* @param {Object} res - Express response object
*/
async function getCampaignResponses(req, res) {
try {
const { slug } = req.params;
const { sort = 'recent', level = '', offset = 0, limit = 20, status = 'approved' } = req.query;
// Get campaign by slug first
const campaign = await nocodbService.getCampaignBySlug(slug);
if (!campaign) {
return res.status(404).json({ error: 'Campaign not found' });
}
// Build filter conditions
// NocoDB v2 API requires ~and operator between multiple conditions
let whereConditions = [
`(Campaign Slug,eq,${slug})~and(Status,eq,${status})`
];
if (level) {
whereConditions[0] += `~and(Representative Level,eq,${level})`;
}
// Build sort order
let sortOrder = '-CreatedAt'; // Default to most recent
if (sort === 'upvotes') {
sortOrder = '-Upvote Count';
} else if (sort === 'verified') {
sortOrder = '-Is Verified,-CreatedAt';
}
// Fetch responses
const responses = await nocodbService.getRepresentativeResponses({
where: whereConditions[0], // Use the first (and only) element
sort: sortOrder,
offset: parseInt(offset),
limit: parseInt(limit)
});
console.log(`Found ${responses.length} responses for campaign ${slug} with status ${status}`);
if (responses.length > 0) {
console.log('First response sample:', JSON.stringify(responses[0], null, 2));
}
// For each response, check if current user has upvoted
const userId = req.user?.id;
const userEmail = req.user?.email;
const responsesWithUpvoteStatus = await Promise.all(responses.map(async (response) => {
let hasUpvoted = false;
if (userId || userEmail) {
const upvotes = await nocodbService.getResponseUpvotes({
where: `(Response ID,eq,${response.id})`
});
hasUpvoted = upvotes.some(upvote =>
(userId && upvote.user_id === userId) ||
(userEmail && upvote.user_email === userEmail)
);
}
return {
...response,
hasUpvoted
};
}));
res.json({
success: true,
responses: responsesWithUpvoteStatus,
pagination: {
offset: parseInt(offset),
limit: parseInt(limit),
hasMore: responses.length === parseInt(limit)
}
});
} catch (error) {
console.error('Error getting campaign responses:', error);
res.status(500).json({ error: 'Failed to get campaign responses' });
}
}
/**
* Submit a new response
* Optional authentication - allows anonymous submissions
* @param {Object} req - Express request object
* @param {Object} res - Express response object
*/
async function submitResponse(req, res) {
try {
const { slug } = req.params;
const responseData = req.body;
// Get campaign by slug first
const campaign = await nocodbService.getCampaignBySlug(slug);
if (!campaign) {
return res.status(404).json({ error: 'Campaign not found' });
}
// Validate response data
const validation = validateResponse(responseData);
if (!validation.valid) {
return res.status(400).json({ error: validation.error });
}
// Handle file upload if present
let screenshotUrl = null;
if (req.file) {
screenshotUrl = `/uploads/responses/${req.file.filename}`;
}
// DEBUG: Log verification-related fields
console.log('=== VERIFICATION DEBUG ===');
console.log('send_verification from form:', responseData.send_verification);
console.log('representative_email from form:', responseData.representative_email);
console.log('representative_name from form:', responseData.representative_name);
// Generate verification token if verification is requested and email is provided
let verificationToken = null;
let verificationSentAt = null;
// Handle send_verification - could be string, boolean, or array from form
let sendVerificationValue = responseData.send_verification;
if (Array.isArray(sendVerificationValue)) {
// If it's an array, check if any value indicates true
sendVerificationValue = sendVerificationValue.some(val => val === 'true' || val === true || val === 'on');
}
const sendVerification = sendVerificationValue === 'true' || sendVerificationValue === true || sendVerificationValue === 'on';
console.log('sendVerification evaluated to:', sendVerification);
// Handle representative_email - could be string or array from form
let representativeEmail = responseData.representative_email;
if (Array.isArray(representativeEmail)) {
representativeEmail = representativeEmail[0]; // Take first email if array
}
representativeEmail = representativeEmail || null;
console.log('representativeEmail after processing:', representativeEmail);
if (sendVerification && representativeEmail) {
// Generate a secure random token
verificationToken = crypto.randomBytes(32).toString('hex');
verificationSentAt = new Date().toISOString();
console.log('Generated verification token:', verificationToken.substring(0, 16) + '...');
console.log('Verification sent at:', verificationSentAt);
} else {
console.log('Skipping verification token generation. sendVerification:', sendVerification, 'representativeEmail:', representativeEmail);
}
// Normalize is_anonymous checkbox value
const isAnonymous = responseData.is_anonymous === true ||
responseData.is_anonymous === 'true' ||
responseData.is_anonymous === 'on';
// Prepare response data for NocoDB
const newResponse = {
campaign_id: campaign.ID || campaign.Id || campaign.id || campaign['Campaign ID'],
campaign_slug: slug,
representative_name: responseData.representative_name,
representative_title: responseData.representative_title || null,
representative_level: responseData.representative_level,
response_type: responseData.response_type,
response_text: responseData.response_text,
user_comment: responseData.user_comment || null,
screenshot_url: screenshotUrl,
submitted_by_name: responseData.submitted_by_name || null,
submitted_by_email: responseData.submitted_by_email || null,
submitted_by_user_id: req.user?.id || null,
is_anonymous: isAnonymous,
status: 'pending', // All submissions start as pending
is_verified: false,
representative_email: representativeEmail,
verification_token: verificationToken,
verification_sent_at: verificationSentAt,
verified_at: null,
verified_by: null,
upvote_count: 0,
submitted_ip: req.ip || req.connection.remoteAddress
};
console.log('Submitting response with campaign_id:', newResponse.campaign_id, 'from campaign:', campaign);
// Create response in database
const createdResponse = await nocodbService.createRepresentativeResponse(newResponse);
// Send verification email if requested
let verificationEmailSent = false;
if (sendVerification && representativeEmail && verificationToken) {
try {
const baseUrl = process.env.BASE_URL || `${req.protocol}://${req.get('host')}`;
const verificationUrl = `${baseUrl}/api/responses/${createdResponse.id}/verify/${verificationToken}`;
const reportUrl = `${baseUrl}/api/responses/${createdResponse.id}/report/${verificationToken}`;
const campaignTitle = campaign.Title || campaign.title || 'Unknown Campaign';
const submittedDate = new Date().toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
await emailService.sendResponseVerification({
representativeEmail,
representativeName: responseData.representative_name,
campaignTitle,
responseType: responseData.response_type,
responseText: responseData.response_text,
submittedDate,
submitterName: responseData.is_anonymous ? 'Anonymous' : (responseData.submitted_by_name || 'A constituent'),
verificationUrl,
reportUrl
});
verificationEmailSent = true;
console.log('Verification email sent successfully to:', representativeEmail);
} catch (emailError) {
console.error('Failed to send verification email:', emailError);
// Don't fail the whole request if email fails
}
}
const responseMessage = verificationEmailSent
? 'Response submitted successfully. A verification email has been sent to the representative. Your response will be visible after moderation.'
: 'Response submitted successfully. It will be visible after moderation.';
res.status(201).json({
success: true,
message: responseMessage,
response: createdResponse,
verificationEmailSent
});
} catch (error) {
console.error('Error submitting response:', error);
res.status(500).json({ error: 'Failed to submit response' });
}
}
/**
* Upvote a response
* Optional authentication - allows anonymous upvotes with IP tracking
* @param {Object} req - Express request object
* @param {Object} res - Express response object
*/
async function upvoteResponse(req, res) {
try {
const { id } = req.params;
// Get response
const response = await nocodbService.getRepresentativeResponseById(id);
if (!response) {
return res.status(404).json({ error: 'Response not found' });
}
const userId = req.user?.id;
const userEmail = req.user?.email;
const userIp = req.ip || req.connection.remoteAddress;
// Check if already upvoted
const existingUpvotes = await nocodbService.getResponseUpvotes({
where: `(Response ID,eq,${id})`
});
const alreadyUpvoted = existingUpvotes.some(upvote =>
(userId && upvote.user_id === userId) ||
(userEmail && upvote.user_email === userEmail) ||
(upvote.upvoted_ip === userIp)
);
if (alreadyUpvoted) {
return res.status(400).json({ error: 'You have already upvoted this response' });
}
// Create upvote
await nocodbService.createResponseUpvote({
response_id: id,
user_id: userId || null,
user_email: userEmail || null,
upvoted_ip: userIp
});
// Increment upvote count
const newCount = (response.upvote_count || 0) + 1;
await nocodbService.updateRepresentativeResponse(id, {
upvote_count: newCount
});
res.json({
success: true,
message: 'Response upvoted successfully',
upvoteCount: newCount
});
} catch (error) {
console.error('Error upvoting response:', error);
res.status(500).json({ error: 'Failed to upvote response' });
}
}
/**
* Remove upvote from a response
* Optional authentication
* @param {Object} req - Express request object
* @param {Object} res - Express response object
*/
async function removeUpvote(req, res) {
try {
const { id } = req.params;
// Get response
const response = await nocodbService.getRepresentativeResponseById(id);
if (!response) {
return res.status(404).json({ error: 'Response not found' });
}
const userId = req.user?.id;
const userEmail = req.user?.email;
const userIp = req.ip || req.connection.remoteAddress;
// Find upvote to remove
const upvotes = await nocodbService.getResponseUpvotes({
where: `(Response ID,eq,${id})`
});
const upvoteToRemove = upvotes.find(upvote =>
(userId && upvote.user_id === userId) ||
(userEmail && upvote.user_email === userEmail) ||
(upvote.upvoted_ip === userIp)
);
if (!upvoteToRemove) {
return res.status(400).json({ error: 'You have not upvoted this response' });
}
// Delete upvote
await nocodbService.deleteResponseUpvote(upvoteToRemove.id);
// Decrement upvote count
const newCount = Math.max((response.upvote_count || 0) - 1, 0);
await nocodbService.updateRepresentativeResponse(id, {
upvote_count: newCount
});
res.json({
success: true,
message: 'Upvote removed successfully',
upvoteCount: newCount
});
} catch (error) {
console.error('Error removing upvote:', error);
res.status(500).json({ error: 'Failed to remove upvote' });
}
}
/**
* Get response statistics for a campaign
* Public endpoint
* @param {Object} req - Express request object
* @param {Object} res - Express response object
*/
async function getResponseStats(req, res) {
try {
const { slug } = req.params;
// Get campaign by slug first
const campaign = await nocodbService.getCampaignBySlug(slug);
if (!campaign) {
return res.status(404).json({ error: 'Campaign not found' });
}
// Get all approved responses for this campaign
const responses = await nocodbService.getRepresentativeResponses({
where: `(Campaign Slug,eq,${slug})~and(Status,eq,approved)`
});
// Calculate stats
const totalResponses = responses.length;
const verifiedResponses = responses.filter(r => r.is_verified).length;
const totalUpvotes = responses.reduce((sum, r) => sum + (r.upvote_count || 0), 0);
// Count by level
const byLevel = {
Federal: responses.filter(r => r.representative_level === 'Federal').length,
Provincial: responses.filter(r => r.representative_level === 'Provincial').length,
Municipal: responses.filter(r => r.representative_level === 'Municipal').length,
'School Board': responses.filter(r => r.representative_level === 'School Board').length
};
res.json({
success: true,
stats: {
totalResponses,
verifiedResponses,
totalUpvotes,
byLevel
}
});
} catch (error) {
console.error('Error getting response stats:', error);
res.status(500).json({ error: 'Failed to get response statistics' });
}
}
// Admin and campaign owner functions
/**
* Get all responses (admin or campaign owners - includes pending/rejected)
* Campaign owners only see responses to their own campaigns
* @param {Object} req - Express request object
* @param {Object} res - Express response object
*/
async function getAdminResponses(req, res) {
try {
const { status = 'all', campaign_slug = '', offset = 0, limit = 50 } = req.query;
console.log(`User fetching responses with status filter: ${status}, campaign: ${campaign_slug}`);
const isAdmin = (req.user && req.user.isAdmin) || req.session?.isAdmin || false;
const sessionUserId = req.user?.id ?? req.session?.userId ?? null;
const sessionUserEmail = req.user?.email ?? req.session?.userEmail ?? null;
// If not admin, we need to filter by campaign ownership
let campaignsToFilter = [];
if (!isAdmin) {
// Get all campaigns owned by this user
const allCampaigns = await nocodbService.getAllCampaigns();
campaignsToFilter = allCampaigns.filter(campaign => {
const createdById = campaign['Created By User ID'] ?? campaign.created_by_user_id ?? null;
const createdByEmail = campaign['Created By User Email'] ?? campaign.created_by_user_email ?? null;
return (
(createdById != null && sessionUserId != null && String(createdById) === String(sessionUserId)) ||
(createdByEmail && sessionUserEmail && String(createdByEmail).toLowerCase() === String(sessionUserEmail).toLowerCase())
);
});
console.log(`User owns ${campaignsToFilter.length} campaigns out of ${allCampaigns.length} total`);
}
let whereConditions = [];
// Filter by status
if (status !== 'all') {
whereConditions.push(`(Status,eq,${status})`);
}
// Filter by campaign slug if provided
if (campaign_slug) {
const slugCondition = `(Campaign Slug,eq,${campaign_slug})`;
if (whereConditions.length > 0) {
whereConditions[0] += `~and${slugCondition}`;
} else {
whereConditions.push(slugCondition);
}
}
const responses = await nocodbService.getRepresentativeResponses({
where: whereConditions.length > 0 ? whereConditions[0] : undefined,
sort: '-CreatedAt',
offset: parseInt(offset),
limit: parseInt(limit)
});
console.log(`Found ${responses.length} total responses before filtering`);
// Filter responses by campaign ownership if not admin
let filteredResponses = responses;
if (!isAdmin && campaignsToFilter.length > 0) {
const ownedCampaignSlugs = campaignsToFilter.map(c => c['Campaign Slug'] || c.slug);
filteredResponses = responses.filter(r => ownedCampaignSlugs.includes(r.campaign_slug));
console.log(`After ownership filter: ${filteredResponses.length} responses`);
} else if (!isAdmin) {
// User owns no campaigns, return empty
filteredResponses = [];
}
if (filteredResponses.length > 0) {
console.log('Response statuses:', filteredResponses.map(r => `ID:${r.id} Status:${r.status}`).join(', '));
}
res.json({
success: true,
responses: filteredResponses,
pagination: {
offset: parseInt(offset),
limit: parseInt(limit),
hasMore: filteredResponses.length === parseInt(limit)
}
});
} catch (error) {
console.error('Error getting admin responses:', error);
res.status(500).json({ error: 'Failed to get responses' });
}
}
/**
* Update response status (admin or campaign owner)
* @param {Object} req - Express request object
* @param {Object} res - Express response object
*/
async function updateResponseStatus(req, res) {
try {
const { id } = req.params;
const { status } = req.body;
console.log(`Updating response ${id} to status: ${status}`);
if (!['pending', 'approved', 'rejected'].includes(status)) {
return res.status(400).json({ error: 'Invalid status' });
}
// Get the response to check campaign ownership
const response = await nocodbService.getRepresentativeResponseById(id);
if (!response) {
return res.status(404).json({ error: 'Response not found' });
}
// Get campaign to check ownership
const campaignId = response.campaign_id;
const campaign = await nocodbService.getCampaignById(campaignId);
if (!campaign) {
return res.status(404).json({ error: 'Campaign not found' });
}
// Check if user is admin or campaign owner
const isAdmin = (req.user && req.user.isAdmin) || req.session?.isAdmin || false;
const sessionUserId = req.user?.id ?? req.session?.userId ?? null;
const sessionUserEmail = req.user?.email ?? req.session?.userEmail ?? null;
if (!isAdmin) {
const createdById = campaign['Created By User ID'] ?? campaign.created_by_user_id ?? null;
const createdByEmail = campaign['Created By User Email'] ?? campaign.created_by_user_email ?? null;
const ownsCampaign = (
(createdById != null && sessionUserId != null && String(createdById) === String(sessionUserId)) ||
(createdByEmail && sessionUserEmail && String(createdByEmail).toLowerCase() === String(sessionUserEmail).toLowerCase())
);
if (!ownsCampaign) {
return res.status(403).json({ error: 'You can only moderate responses to your own campaigns' });
}
}
const updated = await nocodbService.updateRepresentativeResponse(id, { status });
console.log('Updated response:', JSON.stringify(updated, null, 2));
res.json({
success: true,
message: `Response ${status} successfully`
});
} catch (error) {
console.error('Error updating response status:', error);
res.status(500).json({ error: 'Failed to update response status' });
}
}
/**
* Update response (admin only)
* @param {Object} req - Express request object
* @param {Object} res - Express response object
*/
async function updateResponse(req, res) {
try {
const { id } = req.params;
const updates = req.body;
await nocodbService.updateRepresentativeResponse(id, updates);
res.json({
success: true,
message: 'Response updated successfully'
});
} catch (error) {
console.error('Error updating response:', error);
res.status(500).json({ error: 'Failed to update response' });
}
}
/**
* Delete response (admin only)
* @param {Object} req - Express request object
* @param {Object} res - Express response object
*/
async function deleteResponse(req, res) {
try {
const { id } = req.params;
// Delete all upvotes for this response first
const upvotes = await nocodbService.getResponseUpvotes({
where: `(Response ID,eq,${id})`
});
for (const upvote of upvotes) {
await nocodbService.deleteResponseUpvote(upvote.id);
}
// Delete the response
await nocodbService.deleteRepresentativeResponse(id);
res.json({
success: true,
message: 'Response deleted successfully'
});
} catch (error) {
console.error('Error deleting response:', error);
res.status(500).json({ error: 'Failed to delete response' });
}
}
/**
* Verify a response using verification token
* Public endpoint - no authentication required
* @param {Object} req - Express request object
* @param {Object} res - Express response object
*/
async function verifyResponse(req, res) {
try {
const { id, token } = req.params;
console.log('=== VERIFICATION ATTEMPT ===');
console.log('Response ID:', id);
console.log('Token from URL:', token);
// Get the response
const response = await nocodbService.getRepresentativeResponseById(id);
if (!response) {
console.log('Response not found for ID:', id);
return res.status(404).send(`
<!DOCTYPE html>
<html>
<head>
<title>Response Not Found</title>
<style>
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
h1 { color: #e74c3c; }
</style>
</head>
<body>
<h1>❌ Response Not Found</h1>
<p>The response you're trying to verify could not be found.</p>
<p>It may have been deleted or the link may be incorrect.</p>
</body>
</html>
`);
}
console.log('Response found:', {
id: response.id,
verification_token: response.verification_token,
verification_token_type: typeof response.verification_token,
token_from_url: token,
token_from_url_type: typeof token,
tokens_match: response.verification_token === token
});
// Check if token matches
if (response.verification_token !== token) {
console.log('Token mismatch! Expected:', response.verification_token, 'Got:', token);
return res.status(403).send(`
<!DOCTYPE html>
<html>
<head>
<title>Invalid Verification Token</title>
<style>
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
h1 { color: #e74c3c; }
</style>
</head>
<body>
<h1>❌ Invalid Verification Token</h1>
<p>The verification link is invalid or has expired.</p>
</body>
</html>
`);
}
// Check if already verified
if (response.verified_at) {
return res.send(`
<!DOCTYPE html>
<html>
<head>
<title>Already Verified</title>
<style>
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
h1 { color: #3498db; }
</style>
</head>
<body>
<h1> Already Verified</h1>
<p>This response has already been verified on ${new Date(response.verified_at).toLocaleDateString()}.</p>
</body>
</html>
`);
}
// Update response to verified
const updatedData = {
is_verified: true,
verified_at: new Date().toISOString(),
verified_by: response.representative_email || 'Representative',
status: 'approved' // Auto-approve when verified by representative
};
await nocodbService.updateRepresentativeResponse(id, updatedData);
res.send(`
<!DOCTYPE html>
<html>
<head>
<title>Response Verified</title>
<style>
body {
font-family: Arial, sans-serif;
text-align: center;
padding: 50px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.container {
background: white;
color: #333;
padding: 40px;
border-radius: 10px;
max-width: 600px;
margin: 0 auto;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
h1 { color: #27ae60; margin-top: 0; }
.checkmark { font-size: 60px; }
a { color: #3498db; text-decoration: none; }
a:hover { text-decoration: underline; }
</style>
</head>
<body>
<div class="container">
<div class="checkmark">✅</div>
<h1>Response Verified!</h1>
<p>Thank you for verifying this response.</p>
<p>The response has been marked as verified and will now appear with a verification badge on the Response Wall.</p>
<p style="margin-top: 30px; font-size: 14px; color: #7f8c8d;">
You can close this window now.
</p>
</div>
</body>
</html>
`);
} catch (error) {
console.error('Error verifying response:', error);
res.status(500).send(`
<!DOCTYPE html>
<html>
<head>
<title>Verification Error</title>
<style>
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
h1 { color: #e74c3c; }
</style>
</head>
<body>
<h1>❌ Verification Error</h1>
<p>An error occurred while verifying the response.</p>
<p>Please try again later or contact support.</p>
</body>
</html>
`);
}
}
/**
* Report a response as invalid using verification token
* Public endpoint - no authentication required
* @param {Object} req - Express request object
* @param {Object} res - Express response object
*/
async function reportResponse(req, res) {
try {
const { id, token } = req.params;
// Get the response
const response = await nocodbService.getRepresentativeResponseById(id);
if (!response) {
return res.status(404).send(`
<!DOCTYPE html>
<html>
<head>
<title>Response Not Found</title>
<style>
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
h1 { color: #e74c3c; }
</style>
</head>
<body>
<h1>❌ Response Not Found</h1>
<p>The response you're trying to report could not be found.</p>
</body>
</html>
`);
}
// Check if token matches
if (response.verification_token !== token) {
return res.status(403).send(`
<!DOCTYPE html>
<html>
<head>
<title>Invalid Token</title>
<style>
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
h1 { color: #e74c3c; }
</style>
</head>
<body>
<h1>❌ Invalid Token</h1>
<p>The report link is invalid or has expired.</p>
</body>
</html>
`);
}
// Update response status to rejected (disputed by representative)
const updatedData = {
status: 'rejected',
is_verified: false,
verified_at: null,
verified_by: `Disputed by ${response.representative_email || 'Representative'}`
};
await nocodbService.updateRepresentativeResponse(id, updatedData);
res.send(`
<!DOCTYPE html>
<html>
<head>
<title>Response Reported</title>
<style>
body {
font-family: Arial, sans-serif;
text-align: center;
padding: 50px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.container {
background: white;
color: #333;
padding: 40px;
border-radius: 10px;
max-width: 600px;
margin: 0 auto;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
h1 { color: #e74c3c; margin-top: 0; }
.icon { font-size: 60px; }
</style>
</head>
<body>
<div class="container">
<div class="icon">⚠️</div>
<h1>Response Reported</h1>
<p>Thank you for reporting this response.</p>
<p>The response has been marked as disputed and will be hidden from public view while we investigate.</p>
<p style="margin-top: 30px; font-size: 14px; color: #7f8c8d;">
You can close this window now.
</p>
</div>
</body>
</html>
`);
} catch (error) {
console.error('Error reporting response:', error);
res.status(500).send(`
<!DOCTYPE html>
<html>
<head>
<title>Report Error</title>
<style>
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
h1 { color: #e74c3c; }
</style>
</head>
<body>
<h1>❌ Report Error</h1>
<p>An error occurred while reporting the response.</p>
<p>Please try again later or contact support.</p>
</body>
</html>
`);
}
}
module.exports = {
getCampaignResponses,
submitResponse,
upvoteResponse,
removeUpvote,
getResponseStats,
getAdminResponses,
updateResponseStatus,
updateResponse,
deleteResponse,
verifyResponse,
reportResponse
};