Add comprehensive feature request admin functionality

- Update data model to include status, adminReply, and updatedAt fields
- Hide user emails from public API responses for privacy
- Add admin-only endpoints: list, reply, update status, delete
- Create admin-feature-requests.html with full management UI
- Add status badges and admin replies to public feature requests page
- Add Feature Requests link to all admin page sidebars

Admin capabilities:
- View all feature requests with author emails (admin only)
- Reply to feature requests with admin responses visible to public
- Update status: pending, planned, in-progress, completed, declined
- Delete feature requests
- Filter and sort by status, votes, date
This commit is contained in:
southseact-3d
2026-02-10 13:27:36 +00:00
parent 0205820589
commit 25d23d8dd1
14 changed files with 749 additions and 4 deletions

View File

@@ -11217,7 +11217,6 @@ async function handleAdminWithdrawalUpdate(req, res) {
async function handleFeatureRequestsList(req, res) {
const session = getUserSession(req);
const userId = session?.userId || '';
const userEmail = userId ? (findUserById(userId)?.email || '') : '';
const sorted = [...featureRequestsDb].sort((a, b) => {
if (b.votes !== a.votes) return b.votes - a.votes;
@@ -11230,7 +11229,8 @@ async function handleFeatureRequestsList(req, res) {
description: fr.description,
votes: fr.votes,
createdAt: fr.createdAt,
authorEmail: fr.authorEmail,
status: fr.status || 'pending',
adminReply: fr.adminReply || '',
hasVoted: userId ? fr.upvoters.includes(userId) : false,
}));
@@ -11264,7 +11264,10 @@ async function handleFeatureRequestCreate(req, res) {
upvoters: [session.userId],
authorEmail: user.email || '',
authorId: session.userId,
status: 'pending',
adminReply: '',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
featureRequestsDb.push(featureRequest);
@@ -11279,7 +11282,8 @@ async function handleFeatureRequestCreate(req, res) {
description: featureRequest.description,
votes: featureRequest.votes,
createdAt: featureRequest.createdAt,
authorEmail: featureRequest.authorEmail,
status: featureRequest.status,
adminReply: featureRequest.adminReply,
hasVoted: true,
}
});
@@ -11315,6 +11319,109 @@ async function handleFeatureRequestUpvote(req, res, id) {
});
}
async function handleAdminFeatureRequestsList(req, res) {
const session = getAdminSession(req);
if (!session) {
return sendJson(res, 403, { error: 'Admin access required' });
}
const sorted = [...featureRequestsDb].sort((a, b) => {
if (b.votes !== a.votes) return b.votes - a.votes;
return new Date(b.createdAt) - new Date(a.createdAt);
});
const result = sorted.map(fr => ({
id: fr.id,
title: fr.title,
description: fr.description,
votes: fr.votes,
createdAt: fr.createdAt,
updatedAt: fr.updatedAt,
authorEmail: fr.authorEmail,
authorId: fr.authorId,
status: fr.status || 'pending',
adminReply: fr.adminReply || '',
upvoters: fr.upvoters || [],
}));
sendJson(res, 200, { featureRequests: result });
}
async function handleFeatureRequestReply(req, res, id) {
const session = getAdminSession(req);
if (!session) {
return sendJson(res, 403, { error: 'Admin access required' });
}
const featureRequest = featureRequestsDb.find(fr => fr.id === id);
if (!featureRequest) {
return sendJson(res, 404, { error: 'Feature request not found' });
}
try {
const body = await parseJsonBody(req);
const reply = (body.reply || '').toString().trim();
featureRequest.adminReply = reply;
featureRequest.updatedAt = new Date().toISOString();
await persistFeatureRequestsDb();
log('Feature request reply added', { id, adminId: session.userId });
sendJson(res, 200, { ok: true, adminReply: reply });
} catch (error) {
sendJson(res, 400, { error: error.message || 'Unable to add reply' });
}
}
async function handleFeatureRequestUpdateStatus(req, res, id) {
const session = getAdminSession(req);
if (!session) {
return sendJson(res, 403, { error: 'Admin access required' });
}
const featureRequest = featureRequestsDb.find(fr => fr.id === id);
if (!featureRequest) {
return sendJson(res, 404, { error: 'Feature request not found' });
}
try {
const body = await parseJsonBody(req);
const status = (body.status || '').toString().trim();
const validStatuses = ['pending', 'planned', 'in-progress', 'completed', 'declined'];
if (!validStatuses.includes(status)) {
return sendJson(res, 400, { error: 'Invalid status. Must be one of: ' + validStatuses.join(', ') });
}
featureRequest.status = status;
featureRequest.updatedAt = new Date().toISOString();
await persistFeatureRequestsDb();
log('Feature request status updated', { id, status, adminId: session.userId });
sendJson(res, 200, { ok: true, status });
} catch (error) {
sendJson(res, 400, { error: error.message || 'Unable to update status' });
}
}
async function handleFeatureRequestDelete(req, res, id) {
const session = getAdminSession(req);
if (!session) {
return sendJson(res, 403, { error: 'Admin access required' });
}
const index = featureRequestsDb.findIndex(fr => fr.id === id);
if (index === -1) {
return sendJson(res, 404, { error: 'Feature request not found' });
}
featureRequestsDb.splice(index, 1);
await persistFeatureRequestsDb();
log('Feature request deleted', { id, adminId: session.userId });
sendJson(res, 200, { ok: true });
}
async function handleContactMessagesList(req, res) {
const session = getAdminSession(req);
@@ -17369,6 +17476,14 @@ async function routeInternal(req, res, url, pathname) {
if (req.method === 'POST' && pathname === '/api/feature-requests') return handleFeatureRequestCreate(req, res);
const featureUpvoteMatch = pathname.match(/^\/api\/feature-requests\/([a-f0-9\-]+)\/upvote$/i);
if (req.method === 'POST' && featureUpvoteMatch) return handleFeatureRequestUpvote(req, res, featureUpvoteMatch[1]);
// Admin feature request endpoints
if (req.method === 'GET' && pathname === '/api/admin/feature-requests') return handleAdminFeatureRequestsList(req, res);
const featureRequestReplyMatch = pathname.match(/^\/api\/admin\/feature-requests\/([a-f0-9\-]+)\/reply$/i);
if (req.method === 'POST' && featureRequestReplyMatch) return handleFeatureRequestReply(req, res, featureRequestReplyMatch[1]);
const featureRequestStatusMatch = pathname.match(/^\/api\/admin\/feature-requests\/([a-f0-9\-]+)\/status$/i);
if (req.method === 'POST' && featureRequestStatusMatch) return handleFeatureRequestUpdateStatus(req, res, featureRequestStatusMatch[1]);
const featureRequestDeleteMatch = pathname.match(/^\/api\/admin\/feature-requests\/([a-f0-9\-]+)$/i);
if (req.method === 'DELETE' && featureRequestDeleteMatch) return handleFeatureRequestDelete(req, res, featureRequestDeleteMatch[1]);
if (req.method === 'POST' && pathname === '/api/contact') return handleContactMessageCreate(req, res);
const contactMessagesMatch = pathname.match(/^\/api\/contact\/messages$/i);
if (req.method === 'GET' && contactMessagesMatch) return handleContactMessagesList(req, res);