diff --git a/influence/app/controllers/responses.js b/influence/app/controllers/responses.js index 12f865f..f95a734 100644 --- a/influence/app/controllers/responses.js +++ b/influence/app/controllers/responses.js @@ -291,7 +291,7 @@ async function getResponseStats(req, res) { // Get all approved responses for this campaign const responses = await nocodbService.getRepresentativeResponses({ - where: `(Campaign Slug,eq,${slug}),(Status,eq,approved)` + where: `(Campaign Slug,eq,${slug})~and(Status,eq,approved)` }); // Calculate stats @@ -323,43 +323,91 @@ async function getResponseStats(req, res) { } } -// Admin functions +// Admin and campaign owner functions /** - * Get all responses (admin only - includes pending/rejected) + * 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', offset = 0, limit = 50 } = req.query; + const { status = 'all', campaign_slug = '', offset = 0, limit = 50 } = req.query; - console.log(`Admin fetching responses with status filter: ${status}`); + 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.join(','), + where: whereConditions.length > 0 ? whereConditions[0] : undefined, sort: '-CreatedAt', offset: parseInt(offset), limit: parseInt(limit) }); - console.log(`Admin found ${responses.length} total responses`); - if (responses.length > 0) { - console.log('Response statuses:', responses.map(r => `ID:${r.id} Status:${r.status}`).join(', ')); + 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, + responses: filteredResponses, pagination: { offset: parseInt(offset), limit: parseInt(limit), - hasMore: responses.length === parseInt(limit) + hasMore: filteredResponses.length === parseInt(limit) } }); @@ -370,7 +418,7 @@ async function getAdminResponses(req, res) { } /** - * Update response status (admin only) + * Update response status (admin or campaign owner) * @param {Object} req - Express request object * @param {Object} res - Express response object */ @@ -385,6 +433,38 @@ async function updateResponseStatus(req, res) { 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)); diff --git a/influence/app/public/admin.html b/influence/app/public/admin.html index c23ab7b..24c5ee2 100644 --- a/influence/app/public/admin.html +++ b/influence/app/public/admin.html @@ -687,6 +687,81 @@ background: white; } + /* Response moderation styles */ + .response-card { + background: white; + border: 1px solid #ddd; + border-radius: 8px; + padding: 1.5rem; + margin-bottom: 1rem; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + } + + .response-card.pending { + border-left: 4px solid #f39c12; + } + + .response-card.approved { + border-left: 4px solid #27ae60; + } + + .response-card.rejected { + border-left: 4px solid #e74c3c; + } + + .response-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 1rem; + } + + .response-meta { + color: #666; + font-size: 0.9rem; + margin-bottom: 1rem; + } + + .response-meta p { + margin: 0.25rem 0; + } + + .response-content { + background: #f8f9fa; + padding: 1rem; + border-radius: 4px; + margin-bottom: 1rem; + } + + .response-content h4 { + margin: 0 0 0.5rem 0; + color: #2c3e50; + font-size: 1rem; + } + + .response-content p { + margin: 0; + color: #444; + line-height: 1.5; + } + + .response-actions { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + } + + .response-screenshot { + max-width: 200px; + margin-top: 0.5rem; + border-radius: 4px; + cursor: pointer; + } + + .response-screenshot:hover { + opacity: 0.8; + } + @media (max-width: 768px) { .admin-nav { flex-direction: column; @@ -994,14 +1069,25 @@ Sincerely,
-
-

Response Moderation

- +

Response Moderation

+ +
+
+ + +
+ +
+ + +
+

Your Campaign Analytics

@@ -841,6 +918,39 @@ Sincerely,
+ +
+

Moderate Responses

+ +
+
+ + +
+ +
+ + +
+
+ + + +
+ +
+
+

Account Settings

diff --git a/influence/app/public/js/admin.js b/influence/app/public/js/admin.js index e56b3ce..9dcd6be 100644 --- a/influence/app/public/js/admin.js +++ b/influence/app/public/js/admin.js @@ -117,6 +117,14 @@ class AdminPanel { this.loadAdminResponses(); }); } + + // Response campaign filter + const responseCampaignFilter = document.getElementById('admin-campaign-filter'); + if (responseCampaignFilter) { + responseCampaignFilter.addEventListener('change', () => { + this.loadAdminResponses(); + }); + } } setupFormInteractions() { @@ -1065,14 +1073,23 @@ class AdminPanel { // Response Moderation Functions async loadAdminResponses() { const status = document.getElementById('admin-response-status').value; + const campaignSlug = document.getElementById('admin-campaign-filter')?.value || ''; const container = document.getElementById('admin-responses-container'); const loading = document.getElementById('responses-loading'); + // Populate campaign filter if not already done + await this.populateAdminCampaignFilter(); + loading.classList.remove('hidden'); container.innerHTML = ''; try { const params = new URLSearchParams({ status, limit: 100 }); + + if (campaignSlug) { + params.append('campaign_slug', campaignSlug); + } + const response = await window.apiClient.get(`/admin/responses?${params}`); loading.classList.add('hidden'); @@ -1089,6 +1106,29 @@ class AdminPanel { } } + async populateAdminCampaignFilter() { + const filterSelect = document.getElementById('admin-campaign-filter'); + if (!filterSelect || filterSelect.dataset.populated === 'true') return; + + // Use already loaded campaigns or fetch them + if (this.campaigns.length === 0) { + await this.loadCampaigns(); + } + + // Clear existing options except the first one + filterSelect.innerHTML = ''; + + // Add campaign options + this.campaigns.forEach(campaign => { + const option = document.createElement('option'); + option.value = campaign.slug; + option.textContent = campaign.title; + filterSelect.appendChild(option); + }); + + filterSelect.dataset.populated = 'true'; + } + renderAdminResponses(responses) { const container = document.getElementById('admin-responses-container'); diff --git a/influence/app/public/js/dashboard.js b/influence/app/public/js/dashboard.js index eb655f9..e7d6b1e 100644 --- a/influence/app/public/js/dashboard.js +++ b/influence/app/public/js/dashboard.js @@ -121,6 +121,22 @@ class UserDashboard { }); } + // Response filter changes + const responseCampaignFilter = document.getElementById('responses-campaign-filter'); + const responseStatusFilter = document.getElementById('responses-status-filter'); + + if (responseCampaignFilter) { + responseCampaignFilter.addEventListener('change', () => { + this.loadResponses(); + }); + } + + if (responseStatusFilter) { + responseStatusFilter.addEventListener('change', () => { + this.loadResponses(); + }); + } + // Campaign actions using event delegation document.addEventListener('click', (e) => { if (e.target.matches('[data-action="view-campaign"]')) { @@ -141,6 +157,16 @@ class UserDashboard { if (e.target.matches('[data-action="go-to-create"]')) { this.switchTab('create'); } + // Response moderation actions + if (e.target.matches('[data-action="approve-response"]')) { + this.moderateResponse(e.target.dataset.responseId, 'approved'); + } + if (e.target.matches('[data-action="reject-response"]')) { + this.moderateResponse(e.target.dataset.responseId, 'rejected'); + } + if (e.target.matches('[data-action="pending-response"]')) { + this.moderateResponse(e.target.dataset.responseId, 'pending'); + } }); // Setup campaign selector dropdowns @@ -374,6 +400,8 @@ class UserDashboard { this.loadUserCampaigns(); } else if (tabName === 'analytics') { this.loadAnalytics(); + } else if (tabName === 'responses') { + this.loadResponses(); } } @@ -1015,6 +1043,183 @@ class UserDashboard { } } + // Response Moderation Methods + async loadResponses() { + const loadingDiv = document.getElementById('responses-loading'); + const listDiv = document.getElementById('responses-list'); + + if (loadingDiv) loadingDiv.classList.remove('hidden'); + + try { + // Populate campaign filter dropdown if not already done + await this.populateResponseCampaignFilter(); + + // Get filter values + const campaignSlug = document.getElementById('responses-campaign-filter')?.value || ''; + const status = document.getElementById('responses-status-filter')?.value || 'all'; + + // Build query params + const params = new URLSearchParams({ + status: status, + limit: 100 + }); + + if (campaignSlug) { + params.append('campaign_slug', campaignSlug); + } + + const response = await window.apiClient.get(`/admin/responses?${params.toString()}`); + + if (response.success) { + this.renderResponsesList(response.responses || []); + } else { + throw new Error(response.error || 'Failed to load responses'); + } + } catch (error) { + console.error('Load responses error:', error); + if (listDiv) { + listDiv.innerHTML = ` +
+

Error loading responses

+

${this.escapeHtml(error.message)}

+
+ `; + } + } finally { + if (loadingDiv) loadingDiv.classList.add('hidden'); + } + } + + async populateResponseCampaignFilter() { + const filterSelect = document.getElementById('responses-campaign-filter'); + if (!filterSelect || filterSelect.dataset.populated === 'true') return; + + // Use already loaded campaigns + if (this.campaigns.length === 0) { + await this.loadUserCampaigns(); + } + + // Clear existing options except the first one + filterSelect.innerHTML = ''; + + // Add campaign options + this.campaigns.forEach(campaign => { + const option = document.createElement('option'); + option.value = campaign.slug; + option.textContent = campaign.title; + filterSelect.appendChild(option); + }); + + filterSelect.dataset.populated = 'true'; + } + + renderResponsesList(responses) { + const listDiv = document.getElementById('responses-list'); + if (!listDiv) return; + + if (responses.length === 0) { + listDiv.innerHTML = ` +
+

No responses found

+

There are no responses matching your filters.

+
+ `; + return; + } + + listDiv.innerHTML = responses.map(response => this.renderResponseCard(response)).join(''); + } + + renderResponseCard(response) { + const statusLabel = { + pending: '⏳ Pending', + approved: '✅ Approved', + rejected: '❌ Rejected' + }[response.status] || response.status; + + const levelEmoji = { + 'Federal': '🍁', + 'Provincial': '🏛️', + 'Municipal': '🏙️', + 'School Board': '🎓' + }[response.representative_level] || '📍'; + + // Show appropriate action buttons based on status + let actionButtons = ''; + if (response.status === 'pending') { + actionButtons = ` + + + `; + } else if (response.status === 'approved') { + actionButtons = ` + + + `; + } else if (response.status === 'rejected') { + actionButtons = ` + + + `; + } + + return ` +
+
+
+ ${statusLabel} +
+ Submitted ${this.formatDate(response.created_at)} +
+ +
+

Campaign: ${this.escapeHtml(response.campaign_slug)}

+

${levelEmoji} Representative: ${this.escapeHtml(response.representative_name)}

+ ${response.representative_title ? `

Title: ${this.escapeHtml(response.representative_title)}

` : ''} +

Response Type: ${this.escapeHtml(response.response_type)}

+ ${response.submitted_by_name ? `

Submitted By: ${this.escapeHtml(response.submitted_by_name)}

` : ''} + ${response.submitted_by_email ? `

Email: ${this.escapeHtml(response.submitted_by_email)}

` : ''} +
+ +
+

Response:

+

${this.escapeHtml(response.response_text)}

+ ${response.user_comment ? ` +

Submitter Comment:

+

${this.escapeHtml(response.user_comment)}

+ ` : ''} + ${response.screenshot_url ? ` + Response screenshot + ` : ''} +
+ +
+ ${actionButtons} + 👍 ${response.upvote_count || 0} upvotes +
+
+ `; + } + + async moderateResponse(responseId, newStatus) { + try { + const response = await window.apiClient.patch(`/admin/responses/${responseId}/status`, { + status: newStatus + }); + + if (response.success) { + this.showMessage(`Response ${newStatus} successfully!`, 'success'); + // Reload responses to update the display + await this.loadResponses(); + } else { + throw new Error(response.error || 'Failed to update response status'); + } + } catch (error) { + console.error('Moderate response error:', error); + this.showMessage('Failed to moderate response: ' + error.message, 'error'); + } + } + // Campaign Creation Methods async handleCreateCampaign(e) { e.preventDefault(); diff --git a/influence/app/routes/api.js b/influence/app/routes/api.js index 8ef3c22..6414c59 100644 --- a/influence/app/routes/api.js +++ b/influence/app/routes/api.js @@ -214,9 +214,9 @@ router.post( router.post('/responses/:id/upvote', optionalAuth, rateLimiter.general, responsesController.upvoteResponse); router.delete('/responses/:id/upvote', optionalAuth, rateLimiter.general, responsesController.removeUpvote); -// Admin Response Management Routes -router.get('/admin/responses', requireAdmin, rateLimiter.general, responsesController.getAdminResponses); -router.patch('/admin/responses/:id/status', requireAdmin, rateLimiter.general, +// Admin and Campaign Owner Response Management Routes +router.get('/admin/responses', requireNonTemp, rateLimiter.general, responsesController.getAdminResponses); +router.patch('/admin/responses/:id/status', requireNonTemp, rateLimiter.general, [body('status').isIn(['pending', 'approved', 'rejected']).withMessage('Invalid status')], handleValidationErrors, responsesController.updateResponseStatus