admin-system

star 130

AdminController, photo approval, tag editing, deletion, MetricsService integration, admin middleware, verification queue, and admin XP.

OpenLitterMap By OpenLitterMap schedule Updated 3/2/2026

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.phpdeletePhoto() for metric reversal before soft delete
  • app/Helpers/helpers.phprewardXpToAdmin(), 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

  1. Approve requires non-null summary. If photo.summary is null → 422. No tags = nothing to approve.
  2. Approve is atomic and idempotent. WHERE is_public = true AND verified < ADMIN_APPROVED. Second approve matches 0 rows, no event fires, no XP awarded.
  3. School photos excluded. is_public = false photos never enter admin queue. The WHERE is_public = true condition enforces this.
  4. MetricsService before soft delete. deletePhoto() runs BEFORE $photo->delete(). If it throws, delete is aborted.
  5. Soft delete only. $photo->delete() uses SoftDeletes trait. No hard deletes.
  6. Full event constructor. TagsVerifiedByAdmin needs all 6 args: photo_id, user_id, country_id, state_id, city_id, team_id.
  7. No S3 deletion on approve. Photos remain viewable after approval. S3 cleanup is deferred (Phase 2).
  8. Admin XP is conditional. rewardXpToAdmin() only called when an action actually changed state (row updated).
  9. Tag edits use DB::transaction. Delete existing PhotoTags + AddTagsToPhotoAction::run() + approve, all atomic.
  10. Admin middleware uses web guard. Auth::user() (default = web). Tests use actingAs($user) with no guard arg.

Patterns

Approve flow

// Precondition
if ($photo->summary === null) return 422;

// Atomic update
$affected = Photo::where('id', $photo->id)
    ->where('is_public', true)
    ->where('verified', '<', VerificationStatus::ADMIN_APPROVED->value)
    ->update(['verified' => VerificationStatus::ADMIN_APPROVED->value]);

// Only if row was updated
if ($affected > 0) {
    event(new TagsVerifiedByAdmin($photo->id, $photo->user_id, ...));
    rewardXpToAdmin();
}

Delete flow

if ($photo->processed_at !== null) {
    $this->metricsService->deletePhoto($photo); // Reverses metrics
}
// Littercoin detachment...
$photo->delete(); // Soft 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();
// Then same atomic approve as verify()

Countries queue query (v5)

Photo::query()
    ->where('verified', '<', VerificationStatus::ADMIN_APPROVED->value)
    ->where('is_public', true)
    ->whereNotNull('summary')
    ->groupBy('country_id');

Queue endpoint pattern

// GET /api/admin/photos — AdminQueueController
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);
// Response manually built (avoids Location accessor crash on null updated_at)
// Includes new_tags[] with hydrated PhotoTag data for frontend tag editing

Stats endpoint pattern

// GET /api/admin/stats — AdminStatsController (cached 60s)
Cache::remember('admin:dashboard:stats', 60, function () {
    // queue_total: is_public=true, verified=VERIFIED, summary NOT NULL
    // queue_today: same + created_at >= today
    // by_verification: grouped by VerificationStatus enum labels
    // by_country: top 20 countries with pending photos
    // total_users, users_today, flagged_usernames
});

User list endpoint pattern

// GET /api/admin/users — AdminUsersController@index
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);
// Response includes pending_photos count per user (is_public=true, verified < ADMIN_APPROVED)

Trust toggle pattern

// POST /api/admin/users/{user}/trust — superadmin only
// Sets verification_required = !trusted
// Does NOT retroactively approve existing photos
// No logAdminAction() — schema needs target_type for user-target actions

Approve-all pattern

// POST /api/admin/users/{user}/approve-all — superadmin only, max 500
// Same atomic WHERE as verify(): is_public=true AND verified < ADMIN_APPROVED
// Fires TagsVerifiedByAdmin per photo + rewardXpToAdmin()

Username moderation pattern

// PATCH /api/admin/users/{user}/username — superadmin only
// UpdateUsernameRequest: 3-30 chars, alphanumeric + hyphens, unique
// Clears username_flagged after edit
// username_flagged set to true when user self-changes username (in ApiSettingsController)

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
Install via CLI
npx skills add https://github.com/OpenLitterMap/openlittermap-web --skill admin-system
Repository Details
star Stars 130
call_split Forks 41
navigation Branch main
article Path SKILL.md
More from Creator
OpenLitterMap
OpenLitterMap Explore all skills →