Skip to main content

User Feedback

Admins can review, triage, and respond to user-submitted feedback (bug reports, feature requests, complaints, suggestions, and general feedback) from /feedback. Users already had a full submission + voting surface (submitFeedback, publicFeedback, upvoteFeedback/downvoteFeedback — see User Feedback), but there was previously zero admin-side way to see, prioritize, or respond to any of it.

This was built almost entirely on top of existing infrastructure: database/models/UserFeedback.js already had every column needed for triage (status, priority, adminResponse, reviewedBy, reviewedAt), and managers/user-managers/user-feedback.manager.js already had most of the business logic (getAllFeedback, updateStatus, setPriority, getFeedbackStats). What was missing was the admin GraphQL surface itself, a respondToFeedback action that sets adminResponse/reviewedBy/reviewedAt together, and filtering support on getAll.

Implementation checklist

Admin triage (/feedback, requires MANAGE_FEEDBACK)

  • List feedback with optional status/priority/feedbackType filters, newest first (adminGetFeedback)
  • Aggregate counts by type/status/priority plus total up/downvotes (adminGetFeedbackStats)
  • Update an item's triage status — pending | in_review | in_progress | resolved | rejected | duplicate (adminUpdateFeedbackStatus)
  • Set an item's priority — low | medium | high | critical (adminSetFeedbackPriority)
  • Write an admin response, which also records who reviewed the item and when in one action (adminRespondToFeedback)

Two real gaps closed along the way

  • data-access-services/user/user-feedback.access-service.js#getAll(options) only supported {limit, offset} — no filtering. It now accepts optional status/priority/feedbackType and merges them into the Sequelize where. The manager's older getByStatus/getByPriority methods called access-service methods (getByStatus/getByPriority) that never existed — an is not a function bug if either was ever actually invoked. They're left as unreferenced dead code (nothing calls them) rather than three near-duplicate list methods; getAllFeedback/getAll with filters replaces their intended purpose.
  • UserFeedback.reviewedBy is meant to hold an admin's id, but the original migration (20220122175043-create-user-feedback.js) pointed its foreign key at user (a copy-paste from the other columns on that table, which correctly reference the feedback submitter). Migration 20260802140000-fix-user-feedback-reviewed-by-references-admin-user.js corrects the FK to reference admin_user (the same distinction appeal.reviewed_by already got right), nulling out the handful of existing rows whose reviewed_by held a stray user id first (pre-existing seed data — never a real admin action, since no admin review surface existed until now). The model association (UserFeedback.js) was corrected to match (belongsTo(models.AdminUser, ...)).

A deliberate schema tradeoff

status/priority/adminResponse/reviewedBy/reviewedAt were already declared on the shared UserFeedback GraphQL type (the full schema is assembled once before being split into client/admin scopes, so a type declared once resolves in both). That means a regular user's myFeedback/feedbackById queries can also read those fields on their own feedback — not a data leak (both queries are already ownership-checked), and arguably useful (a user can see the admin's response to their own report). See the comment at the top of graphql/types/user-feedback.type.js.

Where this lives

Backend

Frontendapps/frontend-admin/src/app/feedback/page.tsx + FeedbackContent.tsx. Nav entry gated by the MANAGE_FEEDBACK permission (or super_admin), same pattern as the MANAGE_APP_VERSIONS-gated App Version Control section.

PermissionMANAGE_FEEDBACK, added to the real permission catalog in admin-user.manager.js#getAdminPermissions (category content_moderation, alongside MODERATE_CONTENT/REMOVE_CONTENT).

GraphQL reference

# Admin (requires MANAGE_FEEDBACK)
query AdminGetFeedback($status: String, $priority: String, $feedbackType: String, $limit: Int, $offset: Int) {
adminGetFeedback(status: $status, priority: $priority, feedbackType: $feedbackType, limit: $limit, offset: $offset) {
id title description feedbackType status priority adminResponse
reviewedBy reviewedAt upvotes downvotes createdAt
user { id username profilePicture }
}
}

query AdminGetFeedbackStats {
adminGetFeedbackStats { total byType byStatus byPriority totalUpvotes totalDownvotes }
}

mutation AdminUpdateFeedbackStatus($id: ID!, $status: String!) {
adminUpdateFeedbackStatus(id: $id, status: $status) { id status }
}

mutation AdminSetFeedbackPriority($id: ID!, $priority: String!) {
adminSetFeedbackPriority(id: $id, priority: $priority) { id priority }
}

mutation AdminRespondToFeedback($id: ID!, $response: String!) {
adminRespondToFeedback(id: $id, response: $response) { id adminResponse reviewedBy reviewedAt }
}

# Client (authenticated - unchanged by this work)
query MyFeedback { myFeedback { id title status priority adminResponse } }
mutation SubmitFeedback($input: SubmitFeedbackInput!) { submitFeedback(input: $input) { id } }