Appeals Review
Admins review restriction appeals — requests from suspended or banned users asking to have the restriction lifted. This is the admin-facing half of Verification → Restriction appeals; the user-facing half (submitting an appeal from Settings → Account status) is documented there. Both were built this pass — previously appealRestriction/getAppealStatus existed only as broken stubs with no admin review surface at all.
Implementation checklist
- List pending appeals (
adminGetAppeals) — defaults tostatus: 'pending', oldest first; paginated (20/page) - Review an appeal — approve (
adminReviewAppealwithdecision: 'approved') actually lifts the restriction by calling the realunsuspendUser/unbanUserlogic inuser-moderation.manager.js(picking whichever matches the user's currentaccountStatus), not just marking the appeal reviewed - Review an appeal — deny (
adminReviewAppealwithdecision: 'denied') lets the admin add notes explaining the decision (thenotesfield is optional, not enforced by the API); the restriction stays in place and any notes are shown back to the user on their own Account status page
There is no bulk-review action — appeals are reviewed one at a time.
Where this lives
Backend
apps/backend/graphql/types/admin/appeal-admin.type.js—adminGetAppeals,adminReviewAppeal, sharing theAppealStatustype declared inappeal.type.js(same pattern asCoinCashoutbeing shared between the client and admin payout type files)apps/backend/graphql/resolvers/admin/appeal-admin.resolver.js— both operations gated on theMODERATE_CONTENTpermission (reused rather than adding a new permission key — the closest existing precedent is the identity-verification review queue, which is the same "user submits something, admin reviews it" shape)apps/backend/managers/user-managers/appeal.manager.js—getAppeals/reviewAppeal; built against the realuser.accountStatus/suspensionReason/suspendedUntilrestriction system used byuser-moderation.manager.js, deliberately not the separate deadisRestricted/restrictedUntilsystem inrestrictions-limits.manager.jsapps/backend/data-access-services/user/appeal.access-service.js—appealtable access, new dedicated table (migration20260718000000-create-appeal.js, indexed on(user_id, status))
Frontend — apps/frontend-admin/src/app/moderation/appeals/page.tsx (/moderation/appeals), gated client-side on MODERATE_CONTENT, nav entry conditionally shown in AdminLayout.tsx. A paginated table shows each appeal's user, current account status (with suspension/ban reason if present), and a truncated appeal reason; a "Review" button opens a modal with the full reason and Approve/Deny actions — Deny reveals an optional notes textarea before the denial is confirmed (nothing forces it to be filled in).
GraphQL reference
query AdminGetAppeals($status: String, $limit: Int, $offset: Int) {
adminGetAppeals(status: $status, limit: $limit, offset: $offset) {
total limit offset
appeals {
id status appealType reason submittedAt reviewedAt decisionNotes
user { id username email accountStatus suspensionReason suspendedUntil }
}
}
}
mutation AdminReviewAppeal($appealId: ID!, $decision: String!, $notes: String) {
adminReviewAppeal(appealId: $appealId, decision: $decision, notes: $notes) {
success
message
appeal { id status }
}
}