Meetyy โ Admin API Business Requirements Document
Status: Approved; partially implemented โ see Implementation Status
Phase: 10 (Notifications & Polish โ Admin dashboard)
Last reviewed: 2026-08-24
Supersedes: nothing. Extends doc.md ยง4 (Mentor Profile & Verification) and ยง13 (Database Schema).
Table of Contents
Implementation Status โ what has shipped against this document
- Purpose & Scope
- Actors & Roles
- Permission Matrix
- Mentor Application Lifecycle
- Verification Document Review
- User Account Moderation
- Feed Content Moderation
- Admin Audit Log
- Notifications
- Database Schema Changes
- Enums to Add
- File & Route Layout
- Acceptance Criteria
- Out of Scope
Resolved Decisions
| Implementation Status |
|---|
๐ This chapter is a status record, not a requirement. Everything below it is the specification as approved and is unchanged, except where the original text asserted something about the codebase that is no longer true โ those sentences are corrected in place and marked. Where the build deliberately diverged from the spec, the divergence is recorded here rather than quietly written into the requirement.
Verified against the codebase on 2026-08-24.
S.1 Chapter Status
| ยง | Area | Status | Evidence / gap |
|---|---|---|---|
| 2 | Actors & Roles | ๐ก Partial | app/Policies/Admin/ exists with AccountModerationPolicy and CommunityTagPolicy, both explicitly registered in AppServiceProvider as ยง2.2 requires. The moderator role is not seeded โ RolePermissionSeeder creates admin only. |
| 3 | Permission Matrix | ๐ด Not started | The group gate is still role:admin, not role:admin|moderator. No route declares permission: middleware; the seven seeded permissions are still enforced nowhere. ยง3.1's table is also wrong about which are seeded โ see the correction there. Pass 2 has not begun. |
| 4 | Mentor Application Lifecycle | ๐ก Partial | All four endpoints live. Approve and reject now notify the mentor, closing the ยง4.4 "fourth gap". All three ยง4.4 defects remain โ see S.3. |
| 5 | Verification Document Review | ๐ข Shipped | List, inspect, download, approve/reject with admin_notes; mentor notified via VerificationDocumentReviewedNotification. |
| 6 | User Account Moderation | ๐ข Shipped, diverges | Suspend / ban / reinstate live and role-agnostic, with token revocation, a transactional audit entry, and email. Login and every discovery query honour both flags. Built on the existing booleans, not the account_status column ยง6.2 mandates, so suspensions cannot expire โ see S.2. |
| 7 | Feed Content Moderation | ๐ก Partial | Profile reports shipped 2026-08-24 โ /admin/user-reports reads and triages the reports users file against each other, grouped by target (ยง7.1), one decision closing every pending report (ยง7.2), transactional audit entry included. feed_reports is no longer write-only and all four FeedReportStatus cases are now reachable. Post and comment reports are still untriaged, and moderation_status / hide / remove (ยง7.3) do not exist. |
| 8 | Admin Audit Log | ๐ก Partial | admin_audit_logs table, AdminAuditLog model, AdminAuditAction enum (all 15 cases), and AuditLogService are live. Only account moderation writes to it; mentor, document, and content actions do not. No read endpoint exists โ ยง8.3's GET /admin/audit-logs is unbuilt. History reaches the UI only as the moderation_history embed on account detail. |
| 9 | Notifications | ๐ก Partial | Shipped and dispatching: mentor approved/rejected, mentor registered (the ยง9.1 Log::info stub is gone), document reviewed, account suspended/banned/reinstated. Written but never dispatched: MentorSuspendedNotification, MentorReinstatedNotification, ContentModeratedNotification, ContentWarningNotification โ they await ยง4 and ยง7 wiring. |
| 10 | Database Schema Changes | ๐ก Partial | ยง10.1 admin_audit_logs created as specified. ยง10.3 users.account_status / suspended_until not created; is_active and is_banned survive. ยง10.6 moderation_status on feed content not created. |
| 11 | Enums to Add | ๐ก Partial | UserAccountStatus and AdminAuditAction exist. ModerationAction and ContentModerationStatus do not. MentorStatus, marked for deletion in ยง4.5, is still present. |
| 12 | File & Route Layout | ๐ก Partial | Convention honoured, but every layer sits one level deeper than ยง12.1 shows โ see S.2. |
| 13 | Acceptance Criteria | ๐ก Partial | Covered by tests/Feature/Admin/ for the shipped chapters. ยง13.4 (content) and ยง13.5 (audit read) have no tests because the features do not exist. |
| 14 | Out of Scope | ~ | Still accurate, with one addition โ see S.4. |
S.2 Divergences From Specification
These are places where the build and the document disagree. Each needs a decision: amend the specification, or complete the work.
1. Account state โ booleans retained, enum derived (ยง6.2, ยง10.3)
ยง6.2 was approved as a resolved decision: replace is_active + is_banned with a single account_status column, add suspended_until, and drop both booleans.
What shipped instead: App\Enums\User\UserAccountStatus exists with the specified cases, but it is derived, not stored โ its docblock states plainly that it "is NOT backed by a column". UserAccountStatus::fromUser() computes the state from the two booleans at read time, with ban taking precedence. AccountModerationService writes is_active = false to suspend and is_banned = true to ban.
What this costs:
inactiveis unreachable. The enum ships three cases, not four; user-initiated deactivation cannot be distinguished from admin suspension, because both areis_active = false.- Suspensions cannot expire. There is no
suspended_until, so ยง6.2's "auto-restores toactive" and ยง13.3's "aftersuspended_untilpasses they can log in" are not implementable as written. Every suspension is indefinite and needs a manual reinstate. - The illegal fourth combination is still representable.
is_active = false, is_banned = trueremains storable;fromUser()resolves it tobannedrather than preventing it.
What it buys: no data migration, and no change to the two existing call sites in AuthService and FeedSuggestionService.
๐ Open decision. Either ยง6.2 and ยง10.3 are amended to ratify the derived enum โ in which case ยง13.3's suspension-expiry criterion must be struck and the inactive case removed from ยง11 โ or the migration is scheduled and the derived enum becomes a cast. This document does not choose; it records that the choice has not been made.
2. Folder depth โ a feature level below ยง12.1 (ยง12.1)
ยง12.1 places admin classes directly under each Admin/ folder. The build adds a feature segment beneath it, matching .ai/guidelines/09-folder-structure.md:
| Layer | ยง12.1 shows | Actually built |
|---|---|---|
| Controllers | Api/V1/Admin/AdminMentorController.php |
Api/V1/Admin/Mentor/AdminMentorController.php |
| Services | Services/Admin/MentorManagementService.php |
Services/Admin/Mentor/MentorManagementService.php |
| Requests | Requests/Admin/UpdateMentorStatusRequest.php |
Requests/Admin/Mentor/UpdateMentorStatusRequest.php |
| Resources | Resources/Admin/MentorApplicationResource.php |
Resources/Admin/Mentor/MentorApplicationResource.php |
Policy names differ too: ยง12.1 names UserModerationPolicy and VerificationDocumentPolicy; the build has AccountModerationPolicy (registered against User) and CommunityTagPolicy.
๐ The build is right and the document is stale โ the feature segment is what guideline 09 requires. ยง12.1's tree is corrected below.
3. ยง4.4 defects were not fixed
The three data-loss defects ยง4.4 mandates fixing are all still live in MentorManagementService::updateStatus(). They are listed with current status in ยง4.4 itself.
S.3 ยง1.1 Open Loops โ Revisited
| # | Loop | Status |
|---|---|---|
| 1 | Feed reports | ๐ก Half closed. feed_reports is read at last, and all four FeedReportStatus cases are reachable โ but only for profile targets. Reports against posts and comments are still captured and never read, and there is still no takedown mechanism. |
| 2 | Verification documents | ๐ข Closed. Admins list, inspect, download, and decide. |
| 3 | User blocking | ๐ข Closed, bar expiry. Endpoints set both flags, with actor, reason, and timestamp in the audit log and all tokens revoked. is_active is no longer dead โ AuthService rejects login on it, and feed suggestions, user search, and all three mention services exclude on it. The only unmet part is time-boxing: no suspended_until, so nothing expires. |
| 4 | Mentor approval | ๐ก Partial. Notifications now fire; the two data-loss defects are not fixed and suspension is still unaudited. |
๐ ยง6.2's migration note โ "the login block must now also reject suspended" โ is satisfied without the migration: AuthService already had a ! $user->is_active check, so suspension blocks login for free. The ยง13.3 criterion that cannot pass is the second half โ "after suspended_until passes they can" โ because no such column exists. Every suspension is indefinite.
S.4 Shipped Outside This Document
Two admin surfaces exist that this BRD never specified.
| Surface | Notes |
|---|---|
| Community tag vocabulary | GET/POST/PUT/DELETE /api/v1/admin/community-tags, with AdminCommunityTagController, CommunityTagService, CommunityTagPolicy, and AdminCommunityTagResource. Manages the discovery tag set community owners pick from. ยง14 lists "community moderation" as out of scope; this is vocabulary administration rather than moderation, but it is admin surface area and belongs in the record. It writes no audit entry โ an admin can delete a tag off N communities untracked. |
| Admin panel (frontend) | A Vue 3 SPA at /admin, consuming this API and nothing else. Specified in ยง12.4. |
| 1. Purpose & Scope |
|---|
The platform today has no usable administrative surface. Four moderation capabilities were partially built โ schema, enums, and user-facing write paths exist โ but the administrative read and decision side was never implemented. The result is a set of dead-end data flows.
๐ As of 2026-08-24 this opening is historical. Loops 2 and 3 are closed and loop 4 is partly closed; loop 1 is untouched. The per-loop position is in Implementation Status ยงS.3. The paragraph is left as written because it states the problem this document was commissioned to solve.
1.1 The Four Open Loops
| # | Loop | What exists | What is missing | Today |
|---|---|---|---|---|
| 1 | Feed reports | Users file reports; FeedReportService::report() persists to feed_reports |
Nothing ever reads the table. 3 of 4 FeedReportStatus cases are unreachable. No takedown mechanism. |
๐ด |
| 2 | Verification documents | Mentors upload; admin_notes column and a 3-state status enum exist |
No admin endpoint can list, inspect, download, or decide on a document. | ๐ข |
| 3 | User blocking | users.is_banned blocks login and hides from suggestions |
No endpoint sets it. users.is_active is checked nowhere. No reason, actor, or timestamp recorded. |
๐ก |
| 4 | Mentor approval | Fully built โ 4 endpoints | Two data-loss defects (ยง4.4); no notification to the mentor; no suspension audit fields. | ๐ก |
๐ feed_reports is currently a write-only table. Every report a user has ever filed is unreadable by any part of the system. This is the single highest-priority gap in this document โ it means the platform accepts abuse reports and silently discards them.
1.2 In Scope
- Mentor application review โ approve, reject, suspend, reinstate
- Mentor verification document review
- User account moderation โ suspend, ban, reinstate; applies to mentees and mentors alike
- Feed content moderation โ report triage queue and content takedown
- Enablers required by the above: admin audit log, approve/reject/ban notifications, and enforcement of the already-seeded admin permissions
1.3 Explicitly Deferred
Community moderation, course moderation, subscription intervention, platform-settings CRUD, and the admin analytics dashboard. See ยง14.
| 2. Actors & Roles |
|---|
2.1 Roles
| Role | Exists today | Description |
|---|---|---|
admin |
โ Yes | Full administrative authority. All capabilities in this document. |
moderator |
โ New | Content moderation only. Triages feed reports and takes content down. Cannot ban users, approve mentors, or review verification documents. |
The moderator role exists so that day-to-day content triage โ the highest-volume, lowest-risk admin task โ can be delegated without granting authority over user accounts or mentor livelihoods.
๐ .ai/guidelines/08-managing-roles-permissions.md already names moderator as an expected role. This document is where it becomes real.
2.2 Authorization Model
Authorization today is role:admin middleware plus ad-hoc hasRole('admin') calls inside form requests. Seven admin permissions are seeded in RolePermissionSeeder and enforced nowhere. No app/Policies/ directory exists, in direct contradiction of guideline 08.
This document mandates a three-layer model:
| Layer | Mechanism | Purpose | Pass |
|---|---|---|---|
| 1. Gate | role:admin|moderator group middleware |
Keeps non-staff out of /api/v1/admin/* entirely |
1 |
| 3. Record | Policy per model, explicitly registered | Guards individual records and edge cases (e.g. an admin may not ban themselves) | 1 |
| 2. Capability | permission:{name} per route |
Distinguishes admin from moderator | 2 |
Layers 1 and 3 ship with the features. Layer 2 is added at the end of the build alongside the platform-wide permission design (ยง3.0) โ until then the admin/moderator split is enforced by policy.
๐ Policies must be explicitly registered in a service provider. Laravel's automatic policy discovery must not be relied upon โ guideline 08 forbids it, and the feature-folder layout in ยง12 breaks the naming convention discovery depends on.
| 3. Permission Matrix โ โ ๏ธ PROVISIONAL |
|---|
๐ This chapter is not final. The platform's permission set is being designed as a whole and will be settled at the end of the build, once every module's needs are known. The names and groupings below are a working straw man โ they exist so the rest of this document can refer to capabilities concretely, and so the capability boundaries (ยง3.2) can be agreed now. Expect the names to change.
3.0 Build Sequencing
Because permissions land last, admin features are built in two passes:
| Pass | Gate | When |
|---|---|---|
| 1 โ Ship the features | role:admin|moderator group middleware, plus policies for record-level rules (ยง6.4) |
Now |
| 2 โ Layer permissions | permission:{name} middleware per route; seeder updated |
End of build, with the platform-wide permission design |
๐ Nothing in chapters 4โ9 depends on a final permission name. Each capability is written against the role that holds it, so pass 1 is fully functional and pass 2 is an additive tightening โ adding a middleware to existing routes, not restructuring them.
๐ The one thing that must be decided now is the capability split in ยง3.2 โ what a moderator can and cannot do. That boundary shapes the policies and the route grouping, and is expensive to change later. The permission names carrying it are not.
3.1 Permissions (working names)
Wherever a seeded permission already fits a capability, it is reused rather than duplicated. Two new permissions are anticipated.
| Permission | Status | Grants |
|---|---|---|
approve-mentors |
Seeded, unenforced | Approve / reject / suspend / reinstate mentor applications |
manage-users |
Seeded, unenforced | Suspend, ban, and reinstate user accounts |
view-users |
Seeded, unenforced | List and inspect user accounts |
view-reports |
Seeded, unenforced | Read the feed report queue and the audit log |
manage-platform |
Seeded, unenforced | Reserved โ platform settings (deferred, ยง14) |
moderate-content |
๐ Anticipated | Act on reports: dismiss, warn, hide, remove |
review-documents |
๐ Anticipated | List, download, and decide on verification documents |
๐ Seven permissions are already seeded in RolePermissionSeeder and enforced nowhere. Whatever the final design concludes, that gap is the thing to close โ either wire them up or delete them. Leaving permissions in the database that grant nothing is worse than having none.
๐ Correction, 2026-08-24. The table above mislabels one row. RolePermissionSeeder seeds exactly these seven: manage-users, approve-mentors, manage-roles, manage-permissions, manage-settings, view-reports, manage-platform. view-users is not among them โ it is marked "Seeded, unenforced" above but has never existed. Three that are seeded (manage-roles, manage-permissions, manage-settings) do not appear in the table at all. Still enforced nowhere: no route in routes/ declares permission: middleware.
3.2 Capability Split โ decide now
This is the part that must be settled before building, because it shapes policies and route grouping. The permission names carrying it can change freely.
| Capability | admin |
moderator |
Working permission |
|---|---|---|---|
| Read the feed report queue | โ | โ | view-reports |
| Act on reports โ dismiss / warn / hide / remove | โ | โ | moderate-content |
| Read the audit log | โ | โ | view-reports |
| List and inspect user accounts | โ | โ | view-users |
| Approve / reject / suspend mentors | โ | โ | approve-mentors |
| Review verification documents | โ | โ | review-documents |
| Suspend / ban / reinstate accounts | โ | โ | manage-users |
| Platform settings (deferred, ยง14) | โ | โ | manage-platform |
admin receives everything. moderator receives the four ticked capabilities and nothing else.
๐ A moderator can see who filed a report and who authored the reported content, because triage is impossible without it โ but cannot act against those accounts. Content moderation and account moderation are deliberately separate authorities.
๐ During pass 1 this split is enforced by role checks and policies rather than permission middleware. The behaviour is identical from the caller's perspective; only the mechanism changes in pass 2.
| 4. Mentor Application Lifecycle |
|---|
4.1 States
The existing App\Enums\Mentor\ApprovalStatus is correct and unchanged:
| PENDING Awaiting admin review | APPROVED Active on platform | REJECTED Reason provided | SUSPENDED Admin action |
|---|
4.2 Legal Transitions
| From | To | Reason required | Effect |
|---|---|---|---|
pending |
approved |
โ | Sets is_available = true; mentor becomes publicly searchable and bookable |
pending |
rejected |
โ Required | Mentor keeps dashboard access; may resubmit |
rejected |
pending |
โ | Mentor resubmits application (mentor-initiated, not admin) |
rejected |
approved |
โ | Admin overturns a prior rejection |
approved |
suspended |
โ Required | Sets is_available = false; hidden from search; existing confirmed bookings are honoured |
suspended |
approved |
โ | Reinstatement; restores is_available |
approved |
rejected |
โ Required | Not permitted โ use suspended instead |
๐ Suspension does not cancel confirmed future bookings. Mentees who have already paid must not lose a session because of an administrative action against the mentor. Cancelling those bookings, and the refund path that would follow, is deliberately out of scope and deferred to the payments work.
4.3 Reason Field Semantics
rejection_reason is historical, not current-state. Once written it is never cleared by a subsequent transition; it is overwritten only by a new rejection. It survives on the table because it predates this document.
A suspension reason must not be written to rejection_reason โ the two are different events. Suspension reasons go to admin_audit_logs only (ยง10.2).
4.4 Defects to Fix
Three defects in MentorManagementService::updateStatus() must be corrected as part of this work.
๐ Status 2026-08-24: all three are still present. The endpoints shipped without the fixes, so every approval still erases the prior rejection reason and every suspension still erases the approval record. Verified against app/Services/Admin/Mentor/MentorManagementService.php.
| # | Defect | Current behaviour | Required behaviour | Fixed |
|---|---|---|---|---|
| 1 | Rejection history wiped | 'rejection_reason' => $data['rejection_reason'] ?? null runs on every transition. Approving a previously-rejected mentor silently erases why they were rejected. |
Write rejection_reason only when transitioning to rejected. |
โ |
| 2 | Approval audit destroyed | approved_by and approved_at are set to null on any non-approved transition. Suspending a mentor erases the record of who approved them and when. |
Never null these fields. They record the approval event, not the current state. | โ |
| 3 | Suspension untracked | A suspension leaves no trace of who did it or why. | Write a mentor.suspended entry to admin_audit_logs with actor, timestamp, and reason. No columns are added to mentor_profiles โ see ยง10.2. |
โ |
A fourth gap: the method fires no event and sends no notification, so a mentor is never told they were approved or rejected. See ยง9.
๐ โ
The fourth gap is closed. updateStatus() now dispatches MentorApprovedNotification and MentorRejectedNotification, the latter carrying the recorded reason. Suspension and reinstatement still notify nobody โ MentorSuspendedNotification and MentorReinstatedNotification exist as classes but are dispatched from nowhere.
4.5 Dead Code
App\Enums\Mentor\MentorStatus (integer-backed, 5 cases) is unused โ nothing casts to it, and mentor_profiles.approval_status uses ApprovalStatus. It should be deleted as part of this work to prevent a future developer wiring the wrong enum.
๐ Still present at app/Enums/Mentor/MentorStatus.php. Not deleted.
The mentor_auto_approval platform setting is seeded (default '0') but read nowhere. Either honour it in the approval flow or remove it; this document recommends honouring it โ when true, a submitted application transitions straight to approved with approved_by = null and an audit entry recording automatic approval.
| 5. Verification Document Review |
|---|
Mentors can upload, list, download, and delete their own KYC documents. Administrators can do none of these things, despite docs/verification-document-api.md stating that status is "set by admin review".
๐ โ
Chapter shipped; the paragraph above is historical. AdminVerificationDocumentController now provides list, show, download, and decide. The one requirement below that did not ship is the audit trail: ยง5.1 and ยง5.3 require every download to be logged, and AdminAuditAction::DocumentDownloaded exists for it, but download() writes no entry. Document approve/reject is likewise unaudited.
5.1 Capabilities
| Capability | Notes |
|---|---|
| List documents for a mentor profile | Grouped by document_type |
| Stream/download a document | Private disk; admin access must be audit-logged (ยง8) |
| Approve a document | Sets status approved |
| Reject a document | Sets status rejected; admin_notes required |
| Annotate without deciding | Write admin_notes, leave status pending |
5.2 Relationship to Profile Approval
Document decisions and profile approval are decoupled. Approving every document does not auto-approve the mentor profile, and approving a profile does not auto-approve its documents. The admin reviewing an application sees document statuses as decision input only.
๐ Decoupling is deliberate. Identity verification and suitability-to-mentor are different judgements โ a mentor may have a perfectly valid passport and still be rejected on professional grounds.
5.3 Access Control
Admin only. Moderators are excluded: these are identity documents, and access must stay as narrow as possible. Every download is written to the audit log with the acting admin, target document, and IP.
| 6. User Account Moderation |
|---|
6.1 The Problem
State is encoded as two booleans that no admin endpoint can set:
users.is_active(defaulttrue) โ read by no code anywhere in the applicationusers.is_banned(defaultfalse) โ read in exactly two places: login rejection and feed-suggestion exclusion
There is no banned_at, banned_by, ban_reason, or suspended_until. A ban is therefore unattributable, unexplainable, and cannot expire.
๐ Largely addressed, without the schema change. Attribution is solved: actor, reason, and timestamp for every suspend, ban, and reinstate live in admin_audit_logs, written in the same transaction as the state change. is_active is no longer dead โ beyond AccountModerationService writing it, it is now read by AuthService (login rejection), FeedSuggestionService, UserSearchService, MentorService, and the feed, community, and global mention services. The surviving gap is expiry: suspended_until was never added, so no suspension can be time-boxed and every one requires a manual reinstate. See ยงS.2.
6.2 Recommended Model
Replace both booleans with a single account_status column cast to a new UserAccountStatus enum:
| State | Login | Public visibility | Reversible | Notes |
|---|---|---|---|---|
active |
โ | โ | โ | Default |
inactive |
โ | โ | โ | User-initiated deactivation. Not an admin action; included so the enum is total. |
suspended |
โ | โ | โ Auto | Time-boxed. Auto-restores to active when suspended_until passes. |
banned |
โ | โ | โ Manual | Indefinite. Only an admin can lift it. |
๐ This replaces two booleans that could express four combinations โ two of which (is_active = false, is_banned = true) were meaningless โ with one column expressing exactly the four states that matter. It also satisfies guideline 02, which mandates an enum for every status field.
Migration path: is_banned = true โ banned; is_active = false โ inactive; otherwise active. Ban takes precedence. Two call sites must be updated โ AuthService (login block) and FeedSuggestionService (suggestion exclusion) โ and the login block must now also reject suspended.
6.3 Effects of Moderation
| Effect | On suspend | On ban |
|---|---|---|
| Revoke all Sanctum tokens | โ | โ |
| Blocked at login | โ | โ |
| Hidden from feed suggestions and search | โ | โ |
| Existing content hidden | โ | โ โ moderate content separately |
| Notification sent to user | โ | โ |
| Reason recorded | โ Required | โ Required |
๐ Banning an account does not cascade to their posts. Content takedown is a separate, separately-audited decision (ยง7) โ a banned user's past contributions may be entirely legitimate, and mass-deleting them would corrupt threads other users participated in.
6.4 Guards
- An admin may not moderate themselves
- An admin may not moderate another
adminโ enforced in policy, prevents privilege wars - Reinstating requires the same authority as imposing โ admin only
- A mentor's
account_statusand theirmentor_profiles.approval_statusare independent: banning a mentor's user account does not change their approval status, and vice versa
| 7. Feed Content Moderation |
|---|
๐ Partly shipped 2026-08-24 โ profile targets only. /api/v1/admin/user-reports implements this chapter for reports filed against a user: the queue groups by target with report counts and a reason breakdown (ยง7.1), and one decision resolves every pending report against them (ยง7.2). App\Enums\Admin\UserReportAction carries the four decisions โ dismiss, warn, uphold, escalate. ยง7.2's hide and remove are deliberately absent there: they set moderation_status on content, and a profile is not content; removing access stays account moderation (ยง6.3). Everything below about posts, comments, takedown semantics (ยง7.3), and nested replies is still unbuilt. ยง7.4 reporter-abuse volume is visible per target in the queue, but nothing throttles.
7.1 The Report Queue
feed_reports is polymorphic over FeedPost and FeedComment, with a unique constraint on (reportable_type, reportable_id, reporter_id) โ one report per user per target. Existing FeedReportStatus: pending, reviewed, dismissed, actioned.
The queue must support:
| Feature | Detail |
|---|---|
| Filter | By status, reason, target type, date range |
| Group by target | N reports against one post appear as one queue item with a report count |
| Sort | Report count desc (most-reported first), or oldest-first |
| Inspect | Full reported content, author, all reports against it, and reporter identities |
๐ Grouping is a hard requirement, not a nicety. A viral abusive post can attract hundreds of reports; a flat list would bury every other item in the queue behind one piece of content. The moderator decides once per target, and that decision resolves every report against it.
7.2 Triage Actions
One decision resolves all pending reports against the target.
| Action | Report status โ | Content effect | Author notified |
|---|---|---|---|
dismiss |
dismissed |
None โ content stays visible | โ |
warn |
actioned |
None โ content stays visible | โ |
hide |
actioned |
moderation_status = hidden |
โ |
remove |
actioned |
moderation_status = removed |
โ |
reviewed is reserved for a target inspected but deliberately left undecided (e.g. escalated to an admin).
7.3 Takedown Semantics
Takedown is a state change, not a delete.
| State | Visible to public | Visible to author | Visible to admin |
|---|---|---|---|
visible |
โ | โ | โ |
hidden |
โ | โ โ marked as under review | โ |
removed |
โ | โ | โ |
๐ Rows are never hard-deleted. The reports that triggered the takedown reference the content; deleting it would orphan the evidence and make the moderation decision unauditable and unappealable.
Hidden and removed content is excluded from timelines, search, hashtag feeds, and ranking. Counter columns (reaction_count, comment_count, share_count) on a parent post are decremented when a child comment is taken down.
Nested replies
feed_comments is self-referencing via parent_id, so a moderated comment may have replies beneath it. Taking down a comment does not cascade to its replies โ each reply is moderated on its own merits.
| Case | Behaviour |
|---|---|
Parent comment hidden or removed, replies visible |
Replies remain visible, re-parented in display to a tombstone placeholder ("This comment was removed") |
| Reply taken down, parent visible | Parent unaffected |
๐ Cascading takedown to replies would punish users who did nothing wrong โ a reply disagreeing with an abusive comment is often the most valuable content in the thread. The tombstone keeps the conversation readable without preserving the offending text.
comment_count on the post counts only visible comments, so a tombstoned parent is excluded from the count while its visible replies still contribute.
7.4 Reporter Abuse
Report volume per user is visible to moderators so that malicious mass-reporting is detectable. Automatic throttling of serial false reporters is deferred โ flagged here so the data needed for it is captured from day one.
| 8. Admin Audit Log |
|---|
Every state-changing admin action is recorded in a single append-only table. Today the only audit trail anywhere is approved_by / approved_at on mentor_profiles โ and ยง4.4 shows the current code destroys even that.
๐ Half built. admin_audit_logs exists to spec (ยง10.1), with AdminAuditLog, all fifteen AdminAuditAction cases (ยง8.1), and AuditLogService writing inside the caller's transaction. Only AccountModerationService calls it. Mentor decisions, document decisions, and document downloads write nothing โ so for those actions the "if the write fails, the action must fail" guarantee below is vacuous, because there is no write. ยง8.3's read endpoint does not exist: GET /api/v1/admin/audit-logs is unrouted, and the log surfaces only as the moderation_history embed on account and mentor detail responses.
๐ This table is the sole record of administrative history. Per ยง10.2, no entity table carries acting-admin, timestamp, or reason columns. That makes the log load-bearing rather than supplementary: if the write fails, the action must fail. The audit entry and the state change are written in the same database transaction, and the notification dispatches only after that transaction commits (ยง9.3). An unlogged moderation action is not permitted to exist.
8.1 Recorded Actions
| Domain | Actions |
|---|---|
| Mentor | mentor.approved, mentor.rejected, mentor.suspended, mentor.reinstated |
| Document | document.approved, document.rejected, document.downloaded |
| User | user.suspended, user.banned, user.reinstated |
| Content | report.dismissed, report.warned, content.hidden, content.removed, content.restored |
๐ Two added 2026-08-24: report.upheld and report.escalated. A report can be closed as actioned without a warning โ because the account itself was dealt with separately โ or reviewed and deliberately left undecided. Both are decisions, so both must be logged. Profile-report decisions are audited against the user, not the report, so they land in the same moderation_history an admin already reads next to that person's suspensions and bans (ยง8.3).
document.downloaded is a read action, logged because KYC document access is sensitive enough to warrant tracking on its own.
8.2 Properties
- Append-only. No update or delete endpoint exists. Not exposed for mutation at any layer.
- Records before and after. The
changesJSON column holds{"before": {...}, "after": {...}}for the affected fields. - Attributes the actor, their IP, and user agent.
- Carries the reason. Since no entity table stores one,
reasonhere is the only copy โ and is mandatory for every action that ยง4.2, ยง6.3, and ยง7.2 require a reason for. - Polymorphic target via
auditable_type/auditable_id, so any model can be audited. - Transactional. Written in the same transaction as the state change it describes.
- Readable by admins and moderators, filterable by admin, action, target, and date range.
- Retention: indefinite. Revisit only if table size becomes a real operational problem.
8.3 Reading History for a Record
Because history is no longer denormalised onto entities, any screen needing "who suspended this mentor and why" queries the log by target:
GET /api/v1/admin/audit-logs?auditable_type=MentorProfile&auditable_id={id}
The (auditable_type, auditable_id) index makes this a direct lookup. Admin detail screens โ mentor application, user account, moderated content โ each embed the most recent relevant entry so the reviewing admin sees prior actions without a second request.
| 9. Notifications |
|---|
The infrastructure is built and working โ notifications table with UUID morphs, mail and database channels both proven in Auth, Chat, Community, and Feed notification classes. The admin paths are the ones that were never wired.
9.1 Already Declared, Never Sent
App\Enums\Notification\NotificationType already declares three cases that no code dispatches:
| Case | Value | Recipient | Channels |
|---|---|---|---|
MentorRegistered |
mentor.registered |
Admins | In-app + email |
MentorApproved |
mentor.approved |
Mentor | In-app + email |
MentorRejected |
mentor.rejected |
Mentor | In-app + email |
MentorRegisteredEvent is dispatched and heard by SendMentorRegisteredNotification โ but that listener is a stub containing only a Log::info and a // TODO: Phase 2+ comment. Administrators are never told a new application arrived.
๐ โ
All three now dispatch. The listener stub is gone โ it notifies every user holding the admin role. MentorApproved and MentorRejected fire from MentorManagementService::updateStatus().
9.2 New Types Required
| Case | Value | Recipient | Channels |
|---|---|---|---|
MentorSuspended |
mentor.suspended |
Mentor | In-app + email |
MentorReinstated |
mentor.reinstated |
Mentor | In-app + email |
AccountSuspended |
account.suspended |
User | Email only |
AccountBanned |
account.banned |
User | Email only |
AccountReinstated |
account.reinstated |
User | Email only |
ContentModerated |
content.moderated |
Author | In-app + email |
ContentWarning |
content.warning |
Author | In-app |
๐ Status: AccountSuspended, AccountBanned, and AccountReinstated are built and dispatching. MentorSuspended, MentorReinstated, ContentModerated, and ContentWarning exist as notification classes in app/Notifications/Admin/ but are dispatched from nowhere โ they are staged for the ยง4 and ยง7 wiring and are dead code until then.
๐ Account suspension and ban notifications are email-only by necessity โ the user cannot log in to read an in-app notification. This is a functional requirement, not a preference.
9.3 Content
Every rejection, suspension, ban, and takedown notification includes the reason the admin recorded. An unexplained moderation action is not acceptable, and the reason fields are mandatory at the request-validation layer precisely so this is always possible.
Notifications dispatch after the database transaction commits, so a failed action never produces a notification.
| 10. Database Schema Changes |
|---|
๐ The live database is MySQL, so UUIDs are CHAR(36) and JSON columns are native json. doc.md ยง13 documents types in PostgreSQL terms; the tables below reflect what is actually deployed.
10.1 New table โ admin_audit_logs
| Column | Type | Constraints | Notes |
|---|---|---|---|
id |
CHAR(36) | PK | UUID |
admin_id |
CHAR(36) | FK โ users.id, RESTRICT | Never null; never cascade-delete an audit trail |
action |
VARCHAR(50) | NOT NULL | AdminAuditAction |
auditable_type |
VARCHAR(255) | NOT NULL | Morph type |
auditable_id |
CHAR(36) | NOT NULL | Morph id |
reason |
TEXT | NULLABLE | Admin-supplied justification |
changes |
JSON | NULLABLE | {"before": {โฆ}, "after": {โฆ}} |
ip_address |
VARCHAR(45) | NULLABLE | IPv6-capable |
user_agent |
VARCHAR(255) | NULLABLE | |
created_at |
TIMESTAMP | NOT NULL | No updated_at โ append-only |
Indexes: (auditable_type, auditable_id), (admin_id, created_at), (action, created_at)
๐ admin_id uses RESTRICT, not CASCADE. Deleting an admin must never silently erase the record of what they did.
10.2 Column Policy โ State vs. Audit
๐ Who / when / why is never stored on the entity. No moderated_by, moderated_at, moderation_reason, suspended_by, reviewed_by, resolution_notes, or equivalent is added to any table. That information lives in admin_audit_logs and only there. A single append-only log is the one place to look for "what did an admin do to this record", instead of a dozen half-populated column triplets scattered across the schema.
The exception is current state that queries read. Three columns are added because they are hot-path filters, not history:
| Column | Read on |
|---|---|
users.account_status |
Every login; every feed-suggestion and search query |
users.suspended_until |
Suspension expiry โ a future date, which a log of past events cannot express |
feed_posts.moderation_status / feed_comments.moderation_status |
Every timeline, search, hashtag, and ranking query |
๐ These three cannot be derived from the audit log at read time. Doing so would require a correlated subquery for the latest entry per row on every feed query โ unindexable and a guaranteed performance problem. State is denormalised deliberately; history is not duplicated.
10.3 users โ added
| Column | Type | Constraints | Notes |
|---|---|---|---|
account_status |
VARCHAR(20) | DEFAULT 'active', INDEX | Replaces is_active + is_banned |
suspended_until |
TIMESTAMP | NULLABLE | Null when banned (indefinite) |
Dropped: is_active, is_banned โ after data migration (ยง6.2).
Acting admin, timestamp, and reason for every suspension, ban, and reinstatement โ admin_audit_logs.
10.4 mentor_profiles โ unchanged
No columns added or dropped. approved_by, approved_at, and rejection_reason already exist and are retained with corrected write semantics (ยง4.4). Suspension detail โ who, when, why โ is recorded solely in admin_audit_logs.
10.5 feed_reports โ unchanged except indexing
No columns added. The existing status column carries the queue state (pending โ reviewed / dismissed / actioned); which action was taken, by whom, when, and with what note all live in admin_audit_logs, keyed to the report and to the moderated content.
Index added: (status, created_at) โ the queue's primary access path.
10.6 feed_posts and feed_comments โ added
One column on each table:
| Column | Type | Constraints | Notes |
|---|---|---|---|
moderation_status |
VARCHAR(20) | DEFAULT 'visible', INDEX | ContentModerationStatus |
Acting moderator, timestamp, and reason โ admin_audit_logs.
๐ Every existing feed query โ timeline, search, hashtag, suggestions, ranking โ must be updated to exclude non-visible content. A global query scope is the recommended mechanism; missing even one query path leaks removed content back into the product.
| 11. Enums to Add |
|---|
All follow guideline 02: label() plus static options().
| Enum | Namespace | Backing | Cases |
|---|---|---|---|
UserAccountStatus |
App\Enums\User |
string | active, inactive, suspended, banned |
ModerationAction |
App\Enums\Admin |
string | dismiss, warn, hide, remove |
ContentModerationStatus |
App\Enums\Feed |
string | visible, hidden, removed |
AdminAuditAction |
App\Enums\Admin |
string | Per ยง8.1 |
๐ Guideline 02 prefers integer-backed enums by default. These are string-backed by deliberate exception: every enum they sit alongside on these tables (ApprovalStatus, FeedReportStatus, FeedReportReason, FeedPostType, FeedPostVisibility) is string-backed. Consistency within a table beats consistency with the default.
To delete: App\Enums\Mentor\MentorStatus (ยง4.5).
๐ Status 2026-08-24:
| Enum | State |
|---|---|
UserAccountStatus |
โ
Exists โ but derived, not backed by a column, and ships 3 cases (active, suspended, banned). The inactive case specified above is absent because nothing can distinguish it from a suspension. See ยงS.2. |
AdminAuditAction |
โ Exists, all 15 cases per ยง8.1. |
ModerationAction |
โ Not created. Its hide/remove cases belong to content takedown, still unbuilt. App\Enums\Admin\UserReportAction (dismiss / warn / uphold / escalate) ships instead for profile reports โ a different decision set, deliberately named apart so the two do not collide. |
ContentModerationStatus |
โ Not created โ ยง7 unbuilt. |
MentorStatus |
โ Not deleted. Still at app/Enums/Mentor/MentorStatus.php. |
| 12. File & Route Layout |
|---|
Every admin surface is namespaced under Admin and prefixed admin. No admin logic may live in a shared feature folder. The existing mentor-management flow already follows this and is the reference pattern.
| Layer | Path | Existing precedent |
|---|---|---|
| Routes | routes/api/v1/admin/{resource}.php |
mentors.php |
| Controllers | app/Http/Controllers/Api/V1/Admin/{Feature}/Admin{Entity}Controller.php |
Mentor/AdminMentorController.php |
| Services | app/Services/Admin/{Feature}/{Entity}ManagementService.php |
Mentor/MentorManagementService.php |
| Requests | app/Http/Requests/Admin/{Feature}/{Action}{Entity}Request.php |
Mentor/UpdateMentorStatusRequest.php |
| Resources | app/Http/Resources/Admin/{Feature}/{Entity}Resource.php |
Mentor/MentorApplicationResource.php |
| Policies | app/Policies/Admin/{Entity}Policy.php |
AccountModerationPolicy.php |
| Enums | app/Enums/Admin/{Name}.php |
ModerationAction.php (note: NotificationType moved to app/Enums/Notification/ in Phase 10) |
| Notifications | app/Notifications/Admin/{Name}Notification.php |
๐ new directory |
| Tests | tests/Feature/Admin/Admin{Entity}Test.php |
AdminMentorTest.php |
๐ Corrected 2026-08-24. Every layer carries a {Feature} segment below Admin/ โ Account/, Community/, Mentee/, Mentor/ โ as .ai/guidelines/09-folder-structure.md requires. The original table and the ยง12.1 tree omitted it. Policies are the exception: they sit flat in app/Policies/Admin/.
12.1 Folder Structure
Every file this document implies. + is new, ~ is modified, everything else is existing context.
app/
โโโ Enums/
โ โโโ Admin/
โ โ โโโ NotificationType.php ~ add moderation + account cases (ยง9.2)
โ โ โโโ AdminAuditAction.php +
โ โ โโโ ModerationAction.php +
โ โโโ Feed/
โ โ โโโ ContentModerationStatus.php +
โ โโโ Mentor/
โ โ โโโ ApprovalStatus.php unchanged
โ โ โโโ MentorStatus.php โ DELETE โ dead code (ยง4.5)
โ โโโ User/
โ โโโ UserAccountStatus.php +
โโโ Http/
โ โโโ Controllers/Api/V1/Admin/
โ โ โโโ AdminMentorController.php ~ notifications + audit
โ โ โโโ AdminVerificationDocumentController.php +
โ โ โโโ AdminUserController.php +
โ โ โโโ AdminFeedReportController.php +
โ โ โโโ AdminAuditLogController.php +
โ โโโ Requests/Admin/
โ โ โโโ UpdateMentorStatusRequest.php ~ require reason on reject/suspend
โ โ โโโ ReviewVerificationDocumentRequest.php +
โ โ โโโ ModerateUserRequest.php +
โ โ โโโ ResolveFeedReportRequest.php +
โ โ โโโ IndexAuditLogRequest.php +
โ โโโ Resources/Admin/
โ โโโ MentorApplicationResource.php unchanged โ snake_case (ยง12.3)
โ โโโ AdminVerificationDocumentResource.php +
โ โโโ AdminUserResource.php +
โ โโโ FeedReportResource.php + grouped-by-target shape (ยง7.1)
โ โโโ AuditLogResource.php +
โโโ Models/
โ โโโ Admin/
โ โ โโโ AdminAuditLog.php +
โ โโโ User.php ~ account_status cast, drop bool casts
โ โโโ Feed/
โ โโโ FeedPost.php ~ moderation_status cast + scope
โ โโโ FeedComment.php ~ moderation_status cast + scope
โโโ Policies/Admin/ + NEW DIRECTORY
โ โโโ MentorProfilePolicy.php +
โ โโโ UserModerationPolicy.php + self/peer-admin guards (ยง6.4)
โ โโโ FeedReportPolicy.php +
โ โโโ VerificationDocumentPolicy.php +
โโโ Services/Admin/
โ โโโ MentorManagementService.php ~ fix 3 defects (ยง4.4)
โ โโโ VerificationDocumentReviewService.php +
โ โโโ UserModerationService.php +
โ โโโ ContentModerationService.php +
โ โโโ AuditLogService.php + used by all of the above
โโโ Notifications/Admin/ + NEW DIRECTORY
โ โโโ MentorApprovedNotification.php +
โ โโโ MentorRejectedNotification.php +
โ โโโ MentorSuspendedNotification.php +
โ โโโ AccountModeratedNotification.php + mail-only (ยง9.2)
โ โโโ ContentModeratedNotification.php +
โโโ Listeners/Admin/
โโโ SendMentorRegisteredNotification.php ~ replace the Log::info stub
database/
โโโ migrations/
โ โโโ ..._create_admin_audit_logs_table.php +
โ โโโ ..._add_account_status_to_users_table.php + incl. data backfill (ยง6.2)
โ โโโ ..._add_moderation_status_to_feed_content.php + posts + comments
โโโ seeders/
โโโ RolePermissionSeeder.php ~ add `moderator` role (pass 1);
permissions revisited in pass 2 (ยง3.0)
routes/api/v1/admin/
โโโ mentors.php ~ relax gate to admin|moderator
โโโ verification-documents.php +
โโโ users.php +
โโโ feed-reports.php +
โโโ audit-logs.php +
tests/Feature/Admin/
โโโ AdminMentorTest.php ~ update for corrected write semantics
โโโ AdminVerificationDocumentTest.php +
โโโ AdminUserModerationTest.php +
โโโ AdminFeedReportTest.php +
โโโ AdminAuditLogTest.php +
๐ app/Policies/ and app/Notifications/Admin/ do not exist yet. Creating app/Policies/ also means registering every policy explicitly in a service provider โ auto-discovery cannot find them under a Admin/ subfolder (ยง2.2).
๐ AuditLogService is a shared dependency of all four moderation services, not a standalone feature. Since the audit write shares a transaction with the state change (ยง8), it is injected into each service rather than invoked from controllers.
12.1.1 As Built
The tree above is the plan. This is what exists on 2026-08-24 โ the naming differs, and the community-tag module (ยงS.4) was never in the plan at all.
app/
โโโ Enums/
โ โโโ Admin/AdminAuditAction.php โ
15 cases
โ โโโ Mentor/MentorStatus.php โ still present (ยง4.5)
โ โโโ User/UserAccountStatus.php โ ๏ธ derived, not a cast (ยงS.2)
โโโ Http/Controllers/Api/V1/Admin/
โ โโโ Account/AdminAccountController.php โ
suspend / ban / reinstate
โ โโโ Community/AdminCommunityTagController.php โ unplanned (ยงS.4)
โ โโโ Report/AdminUserReportController.php โ
ยง7, profile targets
โ โโโ Mentee/AdminMenteeController.php โ
index / show
โ โโโ Mentor/
โ โโโ AdminMentorController.php โ ๏ธ ยง4.4 defects unfixed
โ โโโ AdminVerificationDocumentController.php โ ๏ธ downloads unaudited
โโโ Http/Requests/Admin/{Account,Community,Mentee,Mentor}/
โโโ Http/Resources/Admin/
โ โโโ Account/AdminAccountResource.php
โ โโโ Audit/AuditLogResource.php โ
embed only โ no endpoint
โ โโโ Community/AdminCommunityTagResource.php
โ โโโ Mentor/{MentorApplication,VerificationDocument}Resource.php
โโโ Models/Admin/AdminAuditLog.php โ
โโโ Policies/Admin/
โ โโโ AccountModerationPolicy.php โ
registered on User
โ โโโ CommunityTagPolicy.php โ
registered on CommunityTag
โ โโโ UserReportPolicy.php โ
named Gate abilities
โโโ Services/Admin/
โ โโโ Account/AccountModerationService.php โ
only audit-log writer
โ โโโ Audit/AuditLogService.php โ
โ โโโ Community/CommunityTagService.php โ unplanned
โ โโโ Report/UserReportModerationService.php โ
audits every decision
โ โโโ Mentee/MenteeManagementService.php โ
read-only
โ โโโ Mentor/{MentorManagement,VerificationDocumentReview}Service.php
โโโ Notifications/Admin/ โ ๏ธ 4 of 7 never dispatched (ยง9.2)
โโโ Listeners/Admin/SendMentorRegisteredNotification.php โ
stub replaced
database/migrations/
โโโ ..._create_admin_audit_logs_table.php โ
(users.account_status, feed moderation_status โ not created)
routes/api/v1/admin/
โโโ accounts.php community-tags.php mentees.php
โโโ mentors.php user-reports.php verification-documents.php
(audit-logs.php, and content takedown โ not created)
tests/Feature/Admin/
โโโ AdminAccountModerationTest.php AdminCommunityTagTest.php
โโโ AdminMentorTest.php AdminVerificationDocumentTest.php
โโโ AdminUserReportTest.php โ 27 cases
โโโ AdminPanelShellTest.php โ ยง12.4
12.2 Routing
- URL prefix
/api/v1/admin/...; route namesadmin.{resource}.{action} - No new mount point is needed.
routes/api/admin.phpalready applies the prefix and middleware and auto-includes everything inroutes/api/v1/admin/. A new route file is picked up by dropping it in that folder. - The group middleware relaxes from
role:admintorole:admin|moderator; each route then declares its ownpermission:{name}middleware (ยง2.2)
12.3 Resource Key Convention
New admin resources emit snake_case, matching the existing MentorApplicationResource and the rest of the codebase.
๐ Snake_case is the house convention: 51 of 64 resource classes use it, against 10 using camelCase. This deviates from .ai/guidelines/07-form-requests-and-resources.md, which shows camelCase in its examples โ but the guideline is describing the Laravel default, not the convention this codebase actually settled on. Consistency with 51 shipped resources beats consistency with an example. No existing resource is changed by this document.
๐ The 10 camelCase outliers are concentrated in Booking/, Mentor/Session*, and Location/. Reconciling them is a separate cleanup, out of scope here.
12.4 Admin Panel โ Frontend
Added 2026-08-24. This document specified an API; the panel is the first consumer of it.
A Vue 3 single-page app, served by this repository at /admin and built by the existing Vite pipeline. It is written as a pure API client: no Blade views, no Eloquent access, no session, no server-rendered state. It authenticates with a Sanctum bearer token and reads and writes only through /api/v1/*.
๐ The panel deliberately adds no backend surface. Every screen is built from endpoints this document already specified, so that when the standalone frontend is built the API does not have to be written twice. Where a screen wanted something the API does not expose, the screen went without it rather than growing an endpoint โ see the overview note below.
Layout
resources/
โโโ css/admin.css Tailwind entry
โโโ views/admin.blade.php shell: #admin-app + API base meta tag
โโโ js/admin/
โโโ api/client.js axios, bearer token, error normalisation
โโโ api/endpoints.js every backend call, one file
โโโ router/index.js routes + auth guard
โโโ stores/{auth,toast}.js
โโโ composables/{useResourceList,useDetail}.js
โโโ constants/enums.js mirrors of the PHP enums
โโโ components/ layouts/ pages/
โโโ README.md lift-out procedure
routes/web.php GET /admin/{any?} โ view('admin')
tests/Feature/Admin/AdminPanelShellTest.php
Screens and the endpoints behind them
| Screen | Endpoints |
|---|---|
| Sign in (incl. the 2FA branch) | POST /login, POST /2fa/challenge, POST /2fa/challenge/resend, GET /me, POST /logout |
| Overview | GET /admin/mentors, /admin/mentors/pending, /admin/mentees, /admin/verification-documents |
| Mentor applications โ list, detail, decide | GET /admin/mentors, GET /admin/mentors/{profileId}, PATCH /admin/mentors/{profileId}/status |
| Mentees โ list, detail | GET /admin/mentees, GET /admin/mentees/{userId} |
| Account moderation (embedded on both detail screens) | PATCH /admin/accounts/{userId}/suspend ยท /ban ยท /reinstate |
| User reports โ triage queue, detail, decide | GET /admin/user-reports, GET /{userId}, PATCH /{userId}/resolve |
| Verification documents โ review, download | GET /admin/verification-documents, GET /{id}, GET /{id}/download, PATCH /{id}/status |
| Community tags โ CRUD | GET/POST /admin/community-tags, PUT/DELETE /admin/community-tags/{tagId} |
No screen exists for ยง8.3 (audit-log browsing) or for post/comment takedown, because neither endpoint set exists. The account, mentor, and report detail screens render the moderation_history embed instead.
The report queue is the one screen whose state lives in a Pinia store rather than the URL: triage is a drill-in/drill-out loop, and an admin returning from a decision expects the queue as they left it โ which a route-query list loses when the detail screen replaces the query.
Portability
Three things couple the panel to this repository, and nothing else:
| Coupling | Where | To move it |
|---|---|---|
| API base URL | api/client.js |
Set VITE_ADMIN_API_BASE_URL; it falls back to the <meta name="admin-api-base-url"> tag, then to /api/v1 |
| Router base | router/index.js โ ROUTER_BASE |
'/admin' โ '/' |
| Host document | resources/views/admin.blade.php |
Replace with an index.html supplying #admin-app |
Everything under pages/, components/, stores/, and composables/ moves unchanged. Serving the panel cross-origin additionally requires CORS: config/cors.php is not published in this application, so it must be published (php artisan config:publish cors) and configured to admit the new host. Same-origin under /admin needs none of this, which is why it was not set up.
Constraints the API imposes on the client
Recorded here because they are contracts, not implementation detail:
- Two error shapes.
error_response()returns{ status, message, data }, but validation and authorization failures come from Laravel's default renderer as{ message, errors }โbootstrap/app.phpregisters no custom renderer. The client normalises both. This contradicts.ai/guidelines/05-error-handling.md, which documents the envelope for 422s; the guideline describes an intent the code does not implement. - Private-disk downloads need XHR.
GET /verification-documents/{id}/downloadsits behindauth:sanctum, so an<a href>401s. The client fetches a blob with the token attached. - Status transitions are single-use. Both
PATCH .../statusendpoints return 422 when the record already holds the requested status, so the UI never offers the current one. rejection_reasonis written on every mentor transition (ยง4.4 defect 1). Until that is fixed, the client sends the field only on a rejection โ a note attached to an approval would otherwise be filed as a rejection reason.- No aggregate endpoint exists. Every figure on the overview is the
pagination.totalof an ordinary list call made withper_page=1. A real dashboard wants one endpoint; ยง14 defers it. MenteeProfileResource.ratingis fabricated โ['average' => rand(1, 5), 'count' => rand(1, 100)], a placeholder that shipped. The panel omits mentee ratings rather than show an administrator invented numbers. This is a defect in the resource, not in the panel.
| 13. Acceptance Criteria |
|---|
13.1 Authorization
Written against roles and capabilities, not permission names, so these tests survive the pass-2 permission design unchanged (ยง3.0).
- A user with no role receives 403 on every
/api/v1/admin/*route - A
mentorormenteereceives 403 on every/api/v1/admin/*route - A
moderatorcan list and action feed reports, and read the audit log - A
moderatorreceives 403 on mentor approval, document review, and user moderation - An
adminsucceeds on all of the above - An admin cannot moderate their own account, or another
admin(403) - Every policy is explicitly registered; none rely on auto-discovery
๐ No test asserts on a permission name. Pass 2 adds permission: middleware to routes already covered by these tests; if the capability split is implemented correctly, the entire suite passes unchanged before and after. That is the regression check for the permission rollout.
13.2 Mentor Lifecycle
- Each transition in ยง4.2 succeeds; each illegal transition returns 422
- Rejecting without a reason returns 422
- Approving a previously-rejected mentor preserves
rejection_reason(defect 1) - Suspending an approved mentor preserves
approved_byandapproved_at(defect 2) - Suspending writes a
mentor.suspendedaudit entry carrying actor, timestamp, and reason (defect 3) - No acting-admin, timestamp, or reason column is added to any entity table (ยง10.2)
- If the audit write fails, the state change rolls back with it
- Approving sets
is_available = true; suspending sets itfalse - A confirmed booking survives its mentor's suspension
- Approve and reject each dispatch a notification to the mentor
13.3 User Moderation
- Suspending and banning both revoke all Sanctum tokens
- A suspended user cannot log in; after
suspended_untilpasses they can - A banned user cannot log in until an admin reinstates them
- Moderated users are absent from feed suggestions and search
- A banned user's posts remain visible (no cascade)
- Every action records actor, timestamp, and reason; every action emails the user
- Data migration maps all existing
is_banned/is_activerows correctly
13.4 Content Moderation
- The queue returns pending reports, grouped by target, with accurate counts
- One decision resolves every pending report against that target
hideandremovesetmoderation_status; neither deletes a row- Hidden/removed content is absent from timeline, search, hashtag feeds, and suggestions
- An author sees their own hidden content marked as under review; removed content is invisible to them
- Taking down a comment decrements the parent post's
comment_count - Taking down a comment leaves its replies visible; the post's
comment_countcounts onlyvisiblecomments dismisssends no notification;warn,hide, andremoveeach notify the author
13.5 Audit Log
- Every action in ยง8.1 writes exactly one entry
- Entries capture actor, target, before/after, reason, IP
- Downloading a verification document writes an entry
- No API path can update or delete an entry
- The log is readable and filterable by both admins and moderators
- A failed audit write rolls back the state change โ no action is ever applied unlogged
- Querying by
auditable_type+auditable_idreturns a record's full admin history - Every reason required by ยง4.2, ยง6.3, and ยง7.2 is retrievable from the log alone, since no entity table holds a copy
13.6 Regression
- The existing
AdminMentorTestsuite passes, updated for the corrected write semantics - Auth tests pass against
account_statusreplacing the two booleans - Feed tests pass with the moderation scope applied
| 14. Out of Scope |
|---|
Deferred to later phases, listed so the boundary is explicit:
| Area | Note |
|---|---|
| Community moderation | CommunityMemberStatus ban/remove is community-owner-scoped only; no platform-admin override exists. CommunityPost and CommunityPostComment are unreportable โ feed_reports is polymorphic but wired only to feed content. |
| Course moderation | No admin force-unpublish. |
| Subscription intervention | doc.md ยง9 references pricing changes "with admin notice"; no such mechanism exists. |
| Platform settings CRUD | platform_settings and its seeder exist; no admin surface. The seeded manage-platform permission is reserved for it, pending the pass-2 design (ยง3.0). |
| Admin analytics dashboard | The remainder of Phase 10. |
| Appeals workflow | Users cannot currently contest a ban or takedown in-product. Recommended as the next admin increment โ every decision this document specifies is recorded with a reason, so an appeals flow has the data it needs. |
| Automated moderation | No spam detection or auto-flagging. All triage is human. |
| Reporter throttling | ยง7.4 โ data captured, enforcement deferred. |
| Admin panel screens for ยง8.3 | ยง12.4 covers only the endpoints that exist. Audit-log browsing gets a screen when its API is built; profile-report triage now has one. |
| Content takedown UI | The ยง7 panel covers profile reports only. Post and comment triage needs moderation_status (ยง10.6) first. |
๐ One item left this list without being added to it: admin management of the community discovery tag vocabulary shipped as /api/v1/admin/community-tags (ยงS.4). It is vocabulary administration rather than the community moderation deferred above, but it is unspecified admin surface โ and it writes no audit entry, so tag deletions across N communities are untracked. Either fold it into this document or record it in its own.
Resolved Decisions
- User account state โ enum. โ
Approved.
is_activeandis_bannedare replaced by a singleaccount_statuscolumn cast toUserAccountStatus(ยง6.2), with the data migration and theAuthService/FeedSuggestionServiceupdates in scope. - Resource key casing. โ Resolved as snake_case (ยง12.3). No change to any existing resource; new admin resources follow the codebase's dominant convention.