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