MeetyyAPI
Documentation / Admin API (BRD)

Meetyy โ€” Admin API Business Requirements Document

Status: Draft for approval Phase: 10 (Notifications & Polish โ€” Admin dashboard) Supersedes: nothing. Extends doc.md ยง4 (Mentor Profile & Verification) and ยง13 (Database Schema).


Table of Contents

  1. Purpose & Scope
  2. Actors & Roles
  3. Permission Matrix
  4. Mentor Application Lifecycle
  5. Verification Document Review
  6. User Account Moderation
  7. Feed Content Moderation
  8. Admin Audit Log
  9. Notifications
  10. Database Schema Changes
  11. Enums to Add
  12. File & Route Layout
  13. Acceptance Criteria
  14. Out of Scope

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.

1.1 The Four Open Loops

# Loop What exists What is missing
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.

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.

# Defect Current behaviour Required behaviour
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.

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.

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".

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 (default true) โ€” read by no code anywhere in the application
  • users.is_banned (default false) โ€” 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.

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_status and their mentor_profiles.approval_status are independent: banning a mentor's user account does not change their approval status, and vice versa

7. Feed Content Moderation

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.

๐Ÿ“Œ 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

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 changes JSON 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, reason here 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.

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

๐Ÿ“Œ 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).


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/Admin{Entity}Controller.php AdminMentorController.php
Services app/Services/Admin/{Entity}ManagementService.php MentorManagementService.php
Requests app/Http/Requests/Admin/{Action}{Entity}Request.php UpdateMentorStatusRequest.php
Resources app/Http/Resources/Admin/{Entity}Resource.php MentorApplicationResource.php
Policies app/Policies/Admin/{Entity}Policy.php ๐Ÿ†• new directory
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

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.2 Routing

  • URL prefix /api/v1/admin/...; route names admin.{resource}.{action}
  • No new mount point is needed. routes/api/admin.php already applies the prefix and middleware and auto-includes everything in routes/api/v1/admin/. A new route file is picked up by dropping it in that folder.
  • The group middleware relaxes from role:admin to role:admin|moderator; each route then declares its own permission:{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.


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 mentor or mentee receives 403 on every /api/v1/admin/* route
  • A moderator can list and action feed reports, and read the audit log
  • A moderator receives 403 on mentor approval, document review, and user moderation
  • An admin succeeds 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_by and approved_at (defect 2)
  • Suspending writes a mentor.suspended audit 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 it false
  • 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_until passes 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_active rows 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
  • hide and remove set moderation_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_count counts only visible comments
  • dismiss sends no notification; warn, hide, and remove each 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_id returns 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 AdminMentorTest suite passes, updated for the corrected write semantics
  • Auth tests pass against account_status replacing 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.

Resolved Decisions

  1. User account state โ†’ enum. โœ… Approved. is_active and is_banned are replaced by a single account_status column cast to UserAccountStatus (ยง6.2), with the data migration and the AuthService / FeedSuggestionService updates in scope.
  2. Resource key casing. โœ… Resolved as snake_case (ยง12.3). No change to any existing resource; new admin resources follow the codebase's dominant convention.