2025-10-11 22:56:48 -06:00

548 lines
17 KiB
JavaScript

const nocodbService = require('../services/nocodb');
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}`;
}
// 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: responseData.is_anonymous || false,
status: 'pending', // All submissions start as pending
is_verified: false,
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);
res.status(201).json({
success: true,
message: 'Response submitted successfully. It will be visible after moderation.',
response: createdResponse
});
} 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' });
}
}
module.exports = {
getCampaignResponses,
submitResponse,
upvoteResponse,
removeUpvote,
getResponseStats,
getAdminResponses,
updateResponseStatus,
updateResponse,
deleteResponse
};