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,
${this.escapeHtml(error.message)}
+There are no responses matching your filters.
+${this.escapeHtml(response.response_text)}
+ ${response.user_comment ? ` +${this.escapeHtml(response.user_comment)}
+ ` : ''} + ${response.screenshot_url ? ` +