| name | admin-system |
| description | AdminController, photo approval, tag editing, deletion, MetricsService integration, admin middleware, verification queue, and admin XP. |
Admin System
Admin photo review: approve, edit tags, delete. Quality gate between user submissions and public data.
Key Files
Backend — Photo Review (Phase 1–2)
app/Http/Controllers/AdminController.php — 4 methods: verify, destroy, updateDelete, getCountriesWithPhotos
app/Http/Controllers/Admin/AdminQueueController.php — Paginated queue endpoint with filters + tag transform (GET /api/admin/photos)
app/Http/Controllers/Admin/AdminResetTagsController.php — Reset tags on a photo: reverse metrics, delete PhotoTags, reset state to unverified
app/Http/Middleware/IsAdmin.php — checks hasRole('admin') or hasRole('superadmin') on web guard
app/Events/TagsVerifiedByAdmin.php — event with full constructor (photo_id, user_id, country_id, state_id, city_id, team_id)
app/Actions/Tags/AddTagsToPhotoAction.php — v5 tag pipeline (creates PhotoTags, summary, XP)
app/Services/Metrics/MetricsService.php — deletePhoto() for metric reversal before soft delete
app/Helpers/helpers.php — rewardXpToAdmin(), logAdminAction()
Backend — User Management, Stats, Username Moderation (Phase 3)
app/Http/Controllers/Admin/AdminStatsController.php — Dashboard stats (cached 60s): queue totals, by-verification, by-country, user counts, flagged usernames
app/Http/Controllers/Admin/AdminUsersController.php — 4 methods: index (list/search/filter), trust (toggle), approveAll (bulk), updateUsername (moderation)
app/Http/Requests/Admin/UpdateUsernameRequest.php — Validation: 3–30 chars, alphanumeric + hyphens, unique. Superadmin auth gate.
app/Mail/SchoolManagerInvite.php — Queued email sent when school_manager role is granted via toggleSchoolManager()
Frontend
resources/js/stores/admin.js — Pinia store: fetchPhotos, fetchCountries, approvePhoto, deletePhoto, updateTagsAndApprove, fetchUsers, fetchStats, toggleTrust, approveAllForUser, updateUsername
resources/js/views/Admin/AdminQueue.vue — Photo review page (three-panel: filters | photo | tags)
resources/js/views/Admin/AdminUsers.vue — User management page (stats cards, search, filters, table)
resources/js/views/Admin/components/AdminQueueHeader.vue — Header bar with pending count, navigation, action buttons
resources/js/views/Admin/components/AdminQueueFilters.vue — Filter sidebar (country, photo ID, user ID, date range)
resources/js/views/Admin/components/UserRow.vue — Table row: trust toggle, approve all, username editor (superadmin gated)
Tests (49 tests total)
tests/Feature/Admin/AdminVerificationTest.php — 8 tests (approve, delete, edit+approve, retag, idempotency, auth, school exclusion)
tests/Feature/Admin/AdminQueueTest.php — 12 tests (queue endpoint: filters, pagination, exclusions, auth)
tests/Feature/Admin/AdminResetTagsTest.php — 4 tests (reset clears state, metrics reversal, skip approved, non-admin rejected)
tests/Feature/Admin/AdminStatsTest.php — 5 tests (cache, counts, by-verification, by-country, auth)
tests/Feature/Admin/AdminUsersTest.php — 9 tests (list, search, sort, trust filter, flagged filter, pagination, auth, school manager invite email on grant, no email on revoke)
tests/Feature/Admin/AdminTrustTest.php — 10 tests (trust toggle, approve-all, superadmin-only, school exclusion, idempotency)
tests/Feature/Admin/AdminUsernameModerationTest.php — 13 tests (username edit, flagging lifecycle, validation rules, auth)
readme/Admin.md — full spec (Phase 1–3 complete)
Invariants
- Approve requires non-null summary. If
photo.summary is null → 422. No tags = nothing to approve.
- Approve is atomic and idempotent.
WHERE is_public = true AND verified < ADMIN_APPROVED. Second approve matches 0 rows, no event fires, no XP awarded.
- School photos excluded.
is_public = false photos never enter admin queue. The WHERE is_public = true condition enforces this.
- MetricsService before soft delete.
deletePhoto() runs BEFORE $photo->delete(). If it throws, delete is aborted.
- Soft delete only.
$photo->delete() uses SoftDeletes trait. No hard deletes.
- Full event constructor.
TagsVerifiedByAdmin needs all 6 args: photo_id, user_id, country_id, state_id, city_id, team_id.
- No S3 deletion on approve. Photos remain viewable after approval. S3 cleanup is deferred (Phase 2).
- Admin XP is conditional.
rewardXpToAdmin() only called when an action actually changed state (row updated).
- Tag edits use DB::transaction. Delete existing PhotoTags +
AddTagsToPhotoAction::run() + approve, all atomic.
- Admin middleware uses web guard.
Auth::user() (default = web). Tests use actingAs($user) with no guard arg.
Patterns
Approve flow
if ($photo->summary === null) return 422;
$affected = Photo::where('id', $photo->id)
->where('is_public', true)
->where('verified', '<', VerificationStatus::ADMIN_APPROVED->value)
->update(['verified' => VerificationStatus::ADMIN_APPROVED->value]);
if ($affected > 0) {
event(new TagsVerifiedByAdmin($photo->id, $photo->user_id, ...));
rewardXpToAdmin();
}
Delete flow
if ($photo->processed_at !== null) {
$this->metricsService->deletePhoto($photo);
}
$photo->delete();
rewardXpToAdmin();
Edit + approve flow
DB::transaction(function () use ($request, $photo) {
PhotoTag::where('photo_id', $photo->id)->delete();
$this->addTagsAction->run($photo->user_id, $photo->id, $request->tags);
});
$photo->refresh();
Countries queue query (v5)
Photo::query()
->where('verified', '<', VerificationStatus::ADMIN_APPROVED->value)
->where('is_public', true)
->whereNotNull('summary')
->groupBy('country_id');
Queue endpoint pattern
Photo::query()
->where('is_public', true)
->where('verified', '<', VerificationStatus::ADMIN_APPROVED->value)
->whereNotNull('summary')
->with(['user:id,name', 'countryRelation:id,country,shortcode',
'photoTags.category', 'photoTags.object',
'photoTags.primaryCustomTag', 'photoTags.extraTags.extraTag'])
->when($request->country_id, fn ($q) => $q->where('country_id', $request->country_id))
->when($request->user_id, fn ($q) => $q->where('user_id', $request->user_id))
->when($request->photo_id, fn ($q) => $q->where('id', $request->photo_id))
->when($request->date_from, fn ($q) => $q->where('created_at', '>=', $request->date_from))
->when($request->date_to, fn ($q) => $q->where('created_at', '<=', $request->date_to))
->orderBy('created_at', 'asc')
->paginate($perPage);
Stats endpoint pattern
Cache::remember('admin:dashboard:stats', 60, function () {
});
User list endpoint pattern
User::query()
->withCount('photos')
->with('roles:id,name')
->when($search, fn ($q) => $q->where(function ($q) use ($search) {
$q->where('name', 'LIKE', "%{$search}%")
->orWhere('username', 'LIKE', "%{$search}%")
->orWhere('email', 'LIKE', "%{$search}%");
}))
->when($trustFilter === 'trusted', fn ($q) => $q->where('verification_required', false))
->when($trustFilter === 'untrusted', fn ($q) => $q->where('verification_required', true))
->when($flagged, fn ($q) => $q->where('username_flagged', true))
->orderBy($sortBy, $sortDir)
->paginate($perPage);
Trust toggle pattern
Approve-all pattern
Username moderation pattern
Routes
All under /api/admin/ prefix with admin middleware:
Photo review (admin + superadmin)
| Method | Route | Controller Method |
|---|
| GET | /api/admin/photos | AdminQueueController (paginated queue) |
| POST | /api/admin/verify | AdminController@verify |
| POST | /api/admin/destroy | AdminController@destroy |
| POST | /api/admin/contentsupdatedelete | AdminController@updateDelete |
| POST | /api/admin/reset-tags | AdminResetTagsController (reset to unverified) |
| GET | /api/admin/get-countries-with-photos | AdminController@getCountriesWithPhotos |
Dashboard stats (admin + superadmin)
| Method | Route | Controller Method |
|---|
| GET | /api/admin/stats | AdminStatsController (cached 60s) |
User management
| Method | Route | Controller Method | Auth |
|---|
| GET | /api/admin/users | AdminUsersController@index | admin |
| POST | /api/admin/users/{user}/trust | AdminUsersController@trust | superadmin |
| POST | /api/admin/users/{user}/approve-all | AdminUsersController@approveAll | superadmin |
| PATCH | /api/admin/users/{user}/username | AdminUsersController@updateUsername | superadmin |
| POST | /api/admin/users/{user}/school-manager | AdminUsersController@toggleSchoolManager | superadmin |
Deprecated routes (return 410 Gone)
| Route | Was | Replaced by |
|---|
/api/admin/verify-tags-as-correct | VerifyImageWithTagsController | AdminController::verify() |
/api/admin/find-photo-by-id | FindPhotoByIdController | AdminQueueController |
/api/admin/get-next-image-to-verify | GetNextImageToVerifyController | AdminQueueController |
/api/admin/go-back-one | GoBackOnePhotoController | AdminQueueController |
Frontend routes
| Path | Component | Purpose |
|---|
/admin/queue | AdminQueue.vue | Photo review queue UI |
/admin/users | AdminUsers.vue | User management + username moderation |
/admin/redis/:userId? | Redis.vue | Redis analytics |
Roles (Spatie, web guard)
| ID | Role | Access |
|---|
| 1 | superadmin | All admin actions + trust management + username moderation + Horizon |
| 2 | admin | Photo review (approve, edit, delete) + user list + stats |
| 3 | helper | Tag editing only (bounding box annotation role) |
Superadmin-only actions
- Toggle user trust (
POST /api/admin/users/{user}/trust)
- Bulk approve user's photos (
POST /api/admin/users/{user}/approve-all)
- Moderate usernames (
PATCH /api/admin/users/{user}/username)
- View Horizon dashboard
Common Mistakes
- Using
auth:api guard in tests. Admin middleware uses default (web) guard. Use actingAs($user) with no guard argument.
- Forgetting
/api prefix. Routes are in routes/api.php which adds /api prefix. Use /api/admin/verify, not /admin/verify.
- Firing event for no-op approve. Only fire
TagsVerifiedByAdmin when $affected > 0.
- Hard deleting photos. Always
$photo->delete() (soft delete). Never $photo->forceDelete().
- Skipping MetricsService on delete. Must call
deletePhoto() before soft delete if processed_at is set.
- Using AddTagsTrait. Deleted (zero consumers after BoundingBoxController retirement). Use
AddTagsToPhotoAction::run() (v5 action in App\Actions\Tags). CalculateTagsDifferenceAction also deleted — was only used by AddTagsTrait.
- Approving school photos.
is_public = false photos are excluded by the atomic WHERE. Don't add special handling.
Common Mistakes (Queue)
- Using
$photo->toArray() for queue response. The Location model's updatedAtDiffForHumans accessor crashes on null updated_at. Build the response array manually.
- Forgetting
whereNotNull('summary') in queue query. Untagged photos must not appear in the admin queue.
- Not capping
per_page. Always min($request->per_page, 50) to prevent abuse.
Common Mistakes (User Management)
- Allowing admin (not superadmin) to toggle trust. Trust, approve-all, and username moderation are superadmin-only. Check
hasRole('superadmin').
- Expecting trust to auto-approve.
trust() sets verification_required but does NOT approve existing photos. Use approveAll() separately.
- Forgetting
username_flagged lifecycle. User self-change → flagged=true. Superadmin edit → flagged=false. The flag drives the /api/admin/users?flagged=true filter.
- Username validation. Via
UpdateUsernameRequest: 3–30 chars, /^[a-zA-Z0-9-]+$/, unique. Don't duplicate validation in controller.
- Stats cache key.
admin:dashboard:stats — 60s TTL. Don't forget to invalidate if adding manual cache busting.
Phase Status
- Phase 1: COMPLETE — 4 AdminController methods, 4 deprecated controllers retired (410), AdminResetTagsController v5-fixed
- Phase 2: COMPLETE — Queue endpoint (
AdminQueueController, 12 tests) + Queue UI (AdminQueue.vue with filters, tag editing, approve/edit/delete). Reuses existing tagging components.
- Phase 3: COMPLETE — User management (
AdminUsersController: list/search/filter, trust toggle, approve-all, username moderation, school manager toggle + invite email), dashboard stats (AdminStatsController), username flagging system. 51 total admin tests passing across 7 test files.
- Bbox pipeline: RETIRED —
BoundingBoxController returns 410 Gone on all 5 endpoints (/api/bbox/*). Was entirely v4 with broken TagsVerifiedByAdmin signature. Routes left wired for clean 410 responses.
- Facilitator Queue: COMPLETE — Parallel system for school team teachers. Same 3-panel layout as admin queue, team-scoped. Reuses tagging v2 components (PhotoViewer, UnifiedTagSearch, ActiveTagsList). See
readme/Teams.md and teams-safeguarding skill. Key differences from admin queue: status filter (pending/approved/all) instead of country filter, Revoke replaces Reset, member stats tab, safeguarding pseudonyms.
- Phase 4: Future — AI pre-tagging, multi-admin claim queue, confidence scoring, batch approve endpoint, permission-granular access