Response wall build out
This commit is contained in:
parent
ccececaf25
commit
9da13d6d3d
@ -291,7 +291,7 @@ async function getResponseStats(req, res) {
|
|||||||
|
|
||||||
// Get all approved responses for this campaign
|
// Get all approved responses for this campaign
|
||||||
const responses = await nocodbService.getRepresentativeResponses({
|
const responses = await nocodbService.getRepresentativeResponses({
|
||||||
where: `(Campaign Slug,eq,${slug}),(Status,eq,approved)`
|
where: `(Campaign Slug,eq,${slug})~and(Status,eq,approved)`
|
||||||
});
|
});
|
||||||
|
|
||||||
// Calculate stats
|
// 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} req - Express request object
|
||||||
* @param {Object} res - Express response object
|
* @param {Object} res - Express response object
|
||||||
*/
|
*/
|
||||||
async function getAdminResponses(req, res) {
|
async function getAdminResponses(req, res) {
|
||||||
try {
|
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 = [];
|
let whereConditions = [];
|
||||||
|
|
||||||
|
// Filter by status
|
||||||
if (status !== 'all') {
|
if (status !== 'all') {
|
||||||
whereConditions.push(`(Status,eq,${status})`);
|
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({
|
const responses = await nocodbService.getRepresentativeResponses({
|
||||||
where: whereConditions.join(','),
|
where: whereConditions.length > 0 ? whereConditions[0] : undefined,
|
||||||
sort: '-CreatedAt',
|
sort: '-CreatedAt',
|
||||||
offset: parseInt(offset),
|
offset: parseInt(offset),
|
||||||
limit: parseInt(limit)
|
limit: parseInt(limit)
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`Admin found ${responses.length} total responses`);
|
console.log(`Found ${responses.length} total responses before filtering`);
|
||||||
if (responses.length > 0) {
|
|
||||||
console.log('Response statuses:', responses.map(r => `ID:${r.id} Status:${r.status}`).join(', '));
|
// 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({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
responses,
|
responses: filteredResponses,
|
||||||
pagination: {
|
pagination: {
|
||||||
offset: parseInt(offset),
|
offset: parseInt(offset),
|
||||||
limit: parseInt(limit),
|
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} req - Express request object
|
||||||
* @param {Object} res - Express response object
|
* @param {Object} res - Express response object
|
||||||
*/
|
*/
|
||||||
@ -385,6 +433,38 @@ async function updateResponseStatus(req, res) {
|
|||||||
return res.status(400).json({ error: 'Invalid 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 });
|
const updated = await nocodbService.updateRepresentativeResponse(id, { status });
|
||||||
console.log('Updated response:', JSON.stringify(updated, null, 2));
|
console.log('Updated response:', JSON.stringify(updated, null, 2));
|
||||||
|
|
||||||
|
|||||||
@ -687,6 +687,81 @@
|
|||||||
background: white;
|
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) {
|
@media (max-width: 768px) {
|
||||||
.admin-nav {
|
.admin-nav {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -994,15 +1069,26 @@ Sincerely,
|
|||||||
|
|
||||||
<!-- Response Moderation Tab -->
|
<!-- Response Moderation Tab -->
|
||||||
<div id="responses-tab" class="tab-content">
|
<div id="responses-tab" class="tab-content">
|
||||||
<div class="form-row" style="align-items: center; margin-bottom: 2rem;">
|
<h2>Response Moderation</h2>
|
||||||
<h2 style="margin: 0;">Response Moderation</h2>
|
|
||||||
<select id="admin-response-status" class="form-control" style="max-width: 200px; margin-left: auto;">
|
<div style="display: flex; gap: 1rem; margin-bottom: 2rem; flex-wrap: wrap;">
|
||||||
<option value="pending">Pending</option>
|
<div class="campaign-selector" style="flex: 1; min-width: 300px; margin: 0;">
|
||||||
|
<label for="admin-campaign-filter">Filter by Campaign:</label>
|
||||||
|
<select id="admin-campaign-filter" style="width: 100%; padding: 0.75rem; border: 2px solid #e0e6ed; border-radius: 8px;">
|
||||||
|
<option value="">All Campaigns</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="campaign-selector" style="flex: 1; min-width: 200px; margin: 0;">
|
||||||
|
<label for="admin-response-status">Filter by Status:</label>
|
||||||
|
<select id="admin-response-status" style="width: 100%; padding: 0.75rem; border: 2px solid #e0e6ed; border-radius: 8px;">
|
||||||
|
<option value="all">All Statuses</option>
|
||||||
|
<option value="pending" selected>Pending</option>
|
||||||
<option value="approved">Approved</option>
|
<option value="approved">Approved</option>
|
||||||
<option value="rejected">Rejected</option>
|
<option value="rejected">Rejected</option>
|
||||||
<option value="all">All</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="responses-loading" class="loading hidden">
|
<div id="responses-loading" class="loading hidden">
|
||||||
<div class="spinner"></div>
|
<div class="spinner"></div>
|
||||||
|
|||||||
@ -6,25 +6,33 @@
|
|||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
color: white;
|
color: white;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
border-radius: 8px;
|
border-radius: 12px;
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-item {
|
.stat-item {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
padding: 0 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-number {
|
.stat-number {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 2.5rem;
|
font-size: 3rem;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
|
color: #ffffff;
|
||||||
|
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-label {
|
.stat-label {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 0.9rem;
|
font-size: 1rem;
|
||||||
opacity: 0.9;
|
color: #ffffff;
|
||||||
|
opacity: 1;
|
||||||
|
font-weight: 500;
|
||||||
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.response-controls {
|
.response-controls {
|
||||||
|
|||||||
@ -510,6 +510,81 @@
|
|||||||
background: white;
|
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) {
|
@media (max-width: 768px) {
|
||||||
.dashboard-nav {
|
.dashboard-nav {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -557,6 +632,7 @@
|
|||||||
<button class="nav-btn active" data-tab="campaigns">My Campaigns</button>
|
<button class="nav-btn active" data-tab="campaigns">My Campaigns</button>
|
||||||
<button class="nav-btn" data-tab="create">Create Campaign</button>
|
<button class="nav-btn" data-tab="create">Create Campaign</button>
|
||||||
<button class="nav-btn" data-tab="edit">Edit Campaign</button>
|
<button class="nav-btn" data-tab="edit">Edit Campaign</button>
|
||||||
|
<button class="nav-btn" data-tab="responses">Responses</button>
|
||||||
<button class="nav-btn" data-tab="analytics">Analytics</button>
|
<button class="nav-btn" data-tab="analytics">Analytics</button>
|
||||||
<button class="nav-btn" data-tab="account">Account</button>
|
<button class="nav-btn" data-tab="account">Account</button>
|
||||||
</nav>
|
</nav>
|
||||||
@ -810,6 +886,7 @@ Sincerely,
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Analytics Tab -->
|
||||||
<!-- Analytics Tab -->
|
<!-- Analytics Tab -->
|
||||||
<div id="analytics-tab" class="tab-content">
|
<div id="analytics-tab" class="tab-content">
|
||||||
<h2>Your Campaign Analytics</h2>
|
<h2>Your Campaign Analytics</h2>
|
||||||
@ -841,6 +918,39 @@ Sincerely,
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Responses Tab -->
|
||||||
|
<div id="responses-tab" class="tab-content">
|
||||||
|
<h2>Moderate Responses</h2>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 1rem; margin-bottom: 2rem; flex-wrap: wrap;">
|
||||||
|
<div class="campaign-selector" style="flex: 1; min-width: 300px; margin: 0;">
|
||||||
|
<label for="responses-campaign-filter">Filter by Campaign:</label>
|
||||||
|
<select id="responses-campaign-filter" style="width: 100%; padding: 0.75rem; border: 2px solid #e0e6ed; border-radius: 8px;">
|
||||||
|
<option value="">All My Campaigns</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="campaign-selector" style="flex: 1; min-width: 200px; margin: 0;">
|
||||||
|
<label for="responses-status-filter">Filter by Status:</label>
|
||||||
|
<select id="responses-status-filter" style="width: 100%; padding: 0.75rem; border: 2px solid #e0e6ed; border-radius: 8px;">
|
||||||
|
<option value="all">All Statuses</option>
|
||||||
|
<option value="pending" selected>Pending</option>
|
||||||
|
<option value="approved">Approved</option>
|
||||||
|
<option value="rejected">Rejected</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="responses-loading" class="loading hidden">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>Loading responses...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="responses-list">
|
||||||
|
<!-- Responses will be loaded here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Account Tab -->
|
<!-- Account Tab -->
|
||||||
<div id="account-tab" class="tab-content">
|
<div id="account-tab" class="tab-content">
|
||||||
<h2>Account Settings</h2>
|
<h2>Account Settings</h2>
|
||||||
|
|||||||
@ -117,6 +117,14 @@ class AdminPanel {
|
|||||||
this.loadAdminResponses();
|
this.loadAdminResponses();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Response campaign filter
|
||||||
|
const responseCampaignFilter = document.getElementById('admin-campaign-filter');
|
||||||
|
if (responseCampaignFilter) {
|
||||||
|
responseCampaignFilter.addEventListener('change', () => {
|
||||||
|
this.loadAdminResponses();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setupFormInteractions() {
|
setupFormInteractions() {
|
||||||
@ -1065,14 +1073,23 @@ class AdminPanel {
|
|||||||
// Response Moderation Functions
|
// Response Moderation Functions
|
||||||
async loadAdminResponses() {
|
async loadAdminResponses() {
|
||||||
const status = document.getElementById('admin-response-status').value;
|
const status = document.getElementById('admin-response-status').value;
|
||||||
|
const campaignSlug = document.getElementById('admin-campaign-filter')?.value || '';
|
||||||
const container = document.getElementById('admin-responses-container');
|
const container = document.getElementById('admin-responses-container');
|
||||||
const loading = document.getElementById('responses-loading');
|
const loading = document.getElementById('responses-loading');
|
||||||
|
|
||||||
|
// Populate campaign filter if not already done
|
||||||
|
await this.populateAdminCampaignFilter();
|
||||||
|
|
||||||
loading.classList.remove('hidden');
|
loading.classList.remove('hidden');
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({ status, limit: 100 });
|
const params = new URLSearchParams({ status, limit: 100 });
|
||||||
|
|
||||||
|
if (campaignSlug) {
|
||||||
|
params.append('campaign_slug', campaignSlug);
|
||||||
|
}
|
||||||
|
|
||||||
const response = await window.apiClient.get(`/admin/responses?${params}`);
|
const response = await window.apiClient.get(`/admin/responses?${params}`);
|
||||||
|
|
||||||
loading.classList.add('hidden');
|
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 = '<option value="">All Campaigns</option>';
|
||||||
|
|
||||||
|
// 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) {
|
renderAdminResponses(responses) {
|
||||||
const container = document.getElementById('admin-responses-container');
|
const container = document.getElementById('admin-responses-container');
|
||||||
|
|
||||||
|
|||||||
@ -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
|
// Campaign actions using event delegation
|
||||||
document.addEventListener('click', (e) => {
|
document.addEventListener('click', (e) => {
|
||||||
if (e.target.matches('[data-action="view-campaign"]')) {
|
if (e.target.matches('[data-action="view-campaign"]')) {
|
||||||
@ -141,6 +157,16 @@ class UserDashboard {
|
|||||||
if (e.target.matches('[data-action="go-to-create"]')) {
|
if (e.target.matches('[data-action="go-to-create"]')) {
|
||||||
this.switchTab('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
|
// Setup campaign selector dropdowns
|
||||||
@ -374,6 +400,8 @@ class UserDashboard {
|
|||||||
this.loadUserCampaigns();
|
this.loadUserCampaigns();
|
||||||
} else if (tabName === 'analytics') {
|
} else if (tabName === 'analytics') {
|
||||||
this.loadAnalytics();
|
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 = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<h3>Error loading responses</h3>
|
||||||
|
<p>${this.escapeHtml(error.message)}</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
} 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 = '<option value="">All My Campaigns</option>';
|
||||||
|
|
||||||
|
// 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 = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<h3>No responses found</h3>
|
||||||
|
<p>There are no responses matching your filters.</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
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 = `
|
||||||
|
<button class="btn btn-success" data-action="approve-response" data-response-id="${response.id}">✅ Approve</button>
|
||||||
|
<button class="btn btn-danger" data-action="reject-response" data-response-id="${response.id}">❌ Reject</button>
|
||||||
|
`;
|
||||||
|
} else if (response.status === 'approved') {
|
||||||
|
actionButtons = `
|
||||||
|
<button class="btn btn-secondary" data-action="pending-response" data-response-id="${response.id}">⏳ Move to Pending</button>
|
||||||
|
<button class="btn btn-danger" data-action="reject-response" data-response-id="${response.id}">❌ Reject</button>
|
||||||
|
`;
|
||||||
|
} else if (response.status === 'rejected') {
|
||||||
|
actionButtons = `
|
||||||
|
<button class="btn btn-success" data-action="approve-response" data-response-id="${response.id}">✅ Approve</button>
|
||||||
|
<button class="btn btn-secondary" data-action="pending-response" data-response-id="${response.id}">⏳ Move to Pending</button>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="response-card ${response.status}" data-response-id="${response.id}">
|
||||||
|
<div class="response-header">
|
||||||
|
<div>
|
||||||
|
<span class="status-badge status-${response.status}">${statusLabel}</span>
|
||||||
|
</div>
|
||||||
|
<small style="color: #666;">Submitted ${this.formatDate(response.created_at)}</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="response-meta">
|
||||||
|
<p><strong>Campaign:</strong> ${this.escapeHtml(response.campaign_slug)}</p>
|
||||||
|
<p><strong>${levelEmoji} Representative:</strong> ${this.escapeHtml(response.representative_name)}</p>
|
||||||
|
${response.representative_title ? `<p><strong>Title:</strong> ${this.escapeHtml(response.representative_title)}</p>` : ''}
|
||||||
|
<p><strong>Response Type:</strong> ${this.escapeHtml(response.response_type)}</p>
|
||||||
|
${response.submitted_by_name ? `<p><strong>Submitted By:</strong> ${this.escapeHtml(response.submitted_by_name)}</p>` : ''}
|
||||||
|
${response.submitted_by_email ? `<p><strong>Email:</strong> ${this.escapeHtml(response.submitted_by_email)}</p>` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="response-content">
|
||||||
|
<h4>Response:</h4>
|
||||||
|
<p>${this.escapeHtml(response.response_text)}</p>
|
||||||
|
${response.user_comment ? `
|
||||||
|
<h4 style="margin-top: 1rem;">Submitter Comment:</h4>
|
||||||
|
<p>${this.escapeHtml(response.user_comment)}</p>
|
||||||
|
` : ''}
|
||||||
|
${response.screenshot_url ? `
|
||||||
|
<img src="${response.screenshot_url}" alt="Response screenshot" class="response-screenshot">
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="response-actions">
|
||||||
|
${actionButtons}
|
||||||
|
<span style="margin-left: auto; color: #666;">👍 ${response.upvote_count || 0} upvotes</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
// Campaign Creation Methods
|
||||||
async handleCreateCampaign(e) {
|
async handleCreateCampaign(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@ -214,9 +214,9 @@ router.post(
|
|||||||
router.post('/responses/:id/upvote', optionalAuth, rateLimiter.general, responsesController.upvoteResponse);
|
router.post('/responses/:id/upvote', optionalAuth, rateLimiter.general, responsesController.upvoteResponse);
|
||||||
router.delete('/responses/:id/upvote', optionalAuth, rateLimiter.general, responsesController.removeUpvote);
|
router.delete('/responses/:id/upvote', optionalAuth, rateLimiter.general, responsesController.removeUpvote);
|
||||||
|
|
||||||
// Admin Response Management Routes
|
// Admin and Campaign Owner Response Management Routes
|
||||||
router.get('/admin/responses', requireAdmin, rateLimiter.general, responsesController.getAdminResponses);
|
router.get('/admin/responses', requireNonTemp, rateLimiter.general, responsesController.getAdminResponses);
|
||||||
router.patch('/admin/responses/:id/status', requireAdmin, rateLimiter.general,
|
router.patch('/admin/responses/:id/status', requireNonTemp, rateLimiter.general,
|
||||||
[body('status').isIn(['pending', 'approved', 'rejected']).withMessage('Invalid status')],
|
[body('status').isIn(['pending', 'approved', 'rejected']).withMessage('Invalid status')],
|
||||||
handleValidationErrors,
|
handleValidationErrors,
|
||||||
responsesController.updateResponseStatus
|
responsesController.updateResponseStatus
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user