OpenLitterMap v5

June 8, 2026 · View on GitHub

Overview

Admins review photos from untrusted users, edit/approve tags, delete bad uploads, and manage user trust levels. The admin system is the quality gate between user submissions and public data.

Scale context: OLM currently has 1–3 admins. The spec below is designed for that reality. Multi-admin concurrency, claim queues, and workload distribution are noted as future considerations but not implemented.


Roles & Permissions

Uses Spatie Laravel Permission 6 on web guard.

Existing roles

IDRoleAccess level
1superadminFull access — all admin actions + trust management + Horizon
2adminPhoto review — approve, edit tags, delete
3helperTag editing only — update tags permission, no delete/trust

Existing permissions

IDPermissionGranted to
1update tagssuperadmin, admin, helper
2create boxessuperadmin, admin
3update boxessuperadmin, admin, helper
4view horizonsuperadmin
5verify boxessuperadmin, admin

New permissions needed (Phase 2)

PermissionGranted toPurpose
approve photossuperadmin, adminApprove photos in queue
delete photossuperadmin, adminDelete photos from queue
manage user trustsuperadminToggle verification_required on users

Phase 1: Continue using the existing admin middleware (checks hasRole('admin') or hasRole('superadmin')). Permission-granular access is Phase 2.

Authorization boundaries

  • Admins cannot view or act on school team photos (is_public = false). School photos go through teacher approval via the Facilitator Queue — a parallel 3-panel UI with the same tagging components, scoped to the teacher's team. See readme/Teams.md → Facilitator Queue.
  • Admins can re-edit tags on already-approved photos (re-triggers MetricsService via fingerprint delta).
  • Admins can delete any photo (including approved), but MetricsService reversal runs first.

Verification State Machine

VerificationStatus enum values

ValueNameMeaningWho sets it
0UNVERIFIEDUploaded, no tags yetSystem (on upload)
1VERIFIEDTagged by userAddTagsToPhotoAction
2ADMIN_APPROVEDVerified by admin, trusted user, or teacherAdmin/trusted auto-verify/teacher
3BBOX_APPLIEDBounding boxes drawnOpenLitterAI pipeline
4BBOX_VERIFIEDBounding boxes verifiedAdmin
5AI_READYReady for model trainingAdmin

Allowed transitions (admin actions)

UNVERIFIED (0) ── approve ───→ ADMIN_APPROVED (2)  (untrusted non-school user tagged photo)
VERIFIED (1) ──── approve ───→ ADMIN_APPROVED (2)  (school student tagged photo)
UNVERIFIED (0) ── edit+approve → ADMIN_APPROVED (2)
VERIFIED (1) ──── edit+approve → ADMIN_APPROVED (2)
any status ─── delete ──────→ soft deleted (deleted_at set)
ADMIN_APPROVED+ ── edit tags ──→ ADMIN_APPROVED (stays, re-processed via delta)

Note: Untrusted non-school users' photos stay at UNVERIFIED (0) after tagging (only verification is set to 0.1, verified is never changed). School students' photos are set to VERIFIED (1). Both appear in the admin queue via WHERE verified < ADMIN_APPROVED AND summary IS NOT NULL.

Prohibited

  • is_public = false photos never enter the admin queue
  • Photos with summary IS NULL (untagged) cannot be approved — admin must add tags first

Relationship to processed_at

Stateverifiedprocessed_atIn metrics?
Uploaded, no tags0nullNo
Tagged by untrusted user1nullNo — awaiting admin
Admin approved2set by MetricsServiceYes
Admin re-edited tags2updated (fingerprint delta)Yes (corrected)
Soft deletedanycleared by MetricsServiceNo (reversed)

Current State

Status: Complete — atomic approve/reject, 3-panel verification queue UI (filters | photo viewer | tag editor), tag editing, hard-delete with metrics reversal, user management, username moderation, school-manager toggle, and stats dashboard. The queue reuses the tagging components (PhotoViewer, UnifiedTagSearch, ActiveTagsList, TagCard) and the admin.js Pinia store. 51 admin tests passing across AdminVerificationTest, AdminQueueTest, AdminResetTagsTest, AdminUsersTest, AdminTrustTest, AdminUsernameModerationTest, and AdminStatsTest.


v5 Admin Features

1. Photo Review Queue

Queue query

Photo::where('verified', '<', VerificationStatus::ADMIN_APPROVED)
    ->where('is_public', true)           // ENFORCED: excludes school/private
    ->whereNotNull('summary')            // only tagged photos
    ->whereNull('deleted_at')            // excludes soft-deleted
    ->orderBy('created_at', 'asc')       // oldest first

Hard rule: The is_public = true filter is non-negotiable. School team photos are invisible to the admin queue regardless of verification status.

Filtering

FilterParameterQuery addition
Countrycountry_idWHERE country_id = ?
Statestate_idWHERE state_id = ?
Citycity_idWHERE city_id = ?
Useruser_idWHERE user_id = ?

No filtering by verification float values (0.1 etc.) — v5 uses enum values only. The queue shows all photos where verified < ADMIN_APPROVED.

Skip (MVP — single admin)

Frontend-only. Skipped photo IDs tracked in browser session state. Not persisted server-side.

Declared limitation: This works for 1–3 admins. If multiple admins work the queue simultaneously, they may review the same photo. The atomic WHERE verified < ADMIN_APPROVED on approve makes this safe (second admin gets approved_count: 0, not a double-process). For workload distribution across many admins, a server-side claim system would be needed — that's Phase 3.

Batch approve

POST /api/admin/photos/batch-approve
{ photo_ids: [1, 2, 3, ...] }

Limits: Max 200 photo_ids per request. Each fires TagsVerifiedByAdmin. Uses the same atomic WHERE is_public = true AND verified < ADMIN_APPROVED pattern as TeamPhotosController::approve().

2. Approve

POST /api/admin/photos/{photo}/approve

Idempotency rule: Atomic update with WHERE verified < ADMIN_APPROVED AND is_public = true. If the photo was already approved (by this admin, another admin, or a trust change), the WHERE matches zero rows and no event fires. Response returns { success: true, approved: false, reason: 'already_approved' }.

Flow:

  1. Precondition check: summary must not be null → 422 if null
  2. Atomic update: verified = ADMIN_APPROVED WHERE verified < ADMIN_APPROVED AND is_public = true
  3. If row updated: fire TagsVerifiedByAdmin($photo->id, $photo->user_id, $photo->country_id, $photo->state_id, $photo->city_id, $photo->team_id)
  4. rewardXpToAdmin() — only if row was updated (no XP for no-op)
  5. logAdminAction()

S3 policy: No deletion on approve. Photos remain viewable after approval. The v4 behaviour of replacing filenames with /assets/verified.jpg is removed.

3. Edit Tags + Approve

PATCH /api/admin/photos/{photo}/tags
{ tags: [...v5 format...] }

Transaction boundary: Tag deletion + creation + summary regeneration wrapped in DB::transaction().

Flow:

  1. DB::transaction(): a. Delete existing PhotoTags for this photo b. Create new PhotoTags via AddTagsToPhotoAction::run() (resolves string keys → FK IDs) c. GeneratePhotoSummaryService runs (summary + XP written)
  2. Set verified = ADMIN_APPROVED
  3. Fire TagsVerifiedByAdmin → MetricsService
    • If processed_at was null: MetricsService does a "create" (first processing)
    • If processed_at was set: MetricsService computes fingerprint delta (corrects metrics)
  4. rewardXpToAdmin()
  5. logAdminAction()

Post-conditions:

  • photo.summary is not null
  • photo.xp matches summary calculation
  • PhotoTags reflect the new tag set
  • verified == ADMIN_APPROVED

Tag format: Same as TeamPhotosController::updateTags() — accepts category/object as string keys, resolves to FK IDs via Category::where('key', ...) and LitterObject::where('key', ...).

4. Delete Photo

DELETE /api/admin/photos/{photo}

Hard sequencing rule: MetricsService::deletePhoto() runs BEFORE $photo->delete(). If metrics reversal fails, the delete is aborted (exception propagates).

Flow:

  1. If processed_at is not null: MetricsService::deletePhoto($photo) — reverses all metrics
  2. Detach Littercoin if linked (littercoin.photo_id = null)
  3. $photo->delete() — soft delete (SoftDeletes trait, row persists with deleted_at)
  4. rewardXpToAdmin()
  5. logAdminAction()

S3 policy: Deferred deletion. S3 image cleanup runs as a queued job, not synchronously. Images are retained for 30 days after soft delete for audit purposes. This can be implemented as a scheduled command that purges S3 files for photos soft-deleted > 30 days ago.

No ImageDeleted event. MetricsService handles all metric reversal directly. The old event dispatch is removed.

5. User Trust Management

POST /api/admin/users/{user}/trust
{ trusted: true }

Access: superadmin role only.

Flow:

  1. Set user.verification_required = !trusted
  2. logAdminAction() with target_type: 'user'

Trust changes do NOT retroactively approve existing photos. If the admin wants to approve the user's backlog, they use approve-all as a separate action:

POST /api/admin/users/{user}/approve-all

Same as batch-approve, filtered to WHERE user_id = ? AND verified < ADMIN_APPROVED AND is_public = true AND summary IS NOT NULL. Max 500 per call.


Admin XP Policy

ActionXP awardedCondition
Approve1Only if photo was actually approved (WHERE matched)
Edit + approve1 + tag XP bonusOnly if photo was actually approved
Delete1Always (admin did work)
Trust change0Administrative action, no XP
Batch approve1 per photo approvedSum of actual approvals

Admin XP appears in the global all-time leaderboard only. It does not appear in time-filtered leaderboards (no per-user metrics rows written). This is by design — admin verification XP is supplementary, not competitive. See Leaderboards.md for details on rewardXpToAdmin().

Anti-abuse: Idempotent approve (atomic WHERE) prevents double-awarding. Soft-deleted photos cannot be re-approved. rewardXpToAdmin() only called when an action actually changed state.


Audit Logging

logAdminAction() already exists and is called from all admin methods. The spec requires:

Required fields

FieldSourceNotes
admin_idauth()->id()Who performed the action
photo_id$photo->idTarget photo (or null for user actions)
actionRoute method nameverify, destroy, updateDelete, trust, batchApprove
tag_updatesDiff arrayBefore/after tag summary (for edit actions)
routeCurrent routeFull method name via Route::getCurrentRoute()

Phase 2 additions: target_type (photo/user), target_id, before/after verified status, XP awarded. For now, the existing logAdminAction($photo, $actionMethod, $tagUpdates) signature is sufficient.


API Routes

All under admin middleware (checks hasRole('admin') or hasRole('superadmin')). Auth guard: same as rest of API (resolve in auth guard audit — currently auth:api).

Implemented routes

MethodRouteActionControllerAuth
GET/api/admin/photosPaginated queue with filtersAdminQueueControlleradmin
POST/api/admin/verifyApprove photoAdminController@verifyadmin
POST/api/admin/destroyDelete photoAdminController@destroyadmin
POST/api/admin/contentsupdatedeleteEdit tags + approveAdminController@updateDeleteadmin
GET/api/admin/get-countries-with-photosCountries with pending countsAdminController@getCountriesWithPhotosadmin

Phase 3 routes (IMPLEMENTED)

MethodRouteActionControllerAuth
GET/api/admin/statsDashboard statsAdminStatsControlleradmin
GET/api/admin/usersList/search/filter usersAdminUsersController@indexadmin
POST/api/admin/users/{user}/trustToggle trustAdminUsersController@trustsuperadmin
POST/api/admin/users/{user}/approve-allBulk approve user's photosAdminUsersController@approveAllsuperadmin
PATCH/api/admin/users/{user}/usernameModerate usernameAdminUsersController@updateUsernamesuperadmin
POST/api/admin/users/{user}/school-managerToggle school_manager roleAdminUsersController@toggleSchoolManagersuperadmin

Error responses

CodeMeaning
401Not authenticated
403Not admin / not superadmin (for trust endpoints)
404Photo or user not found
422Validation error (null summary on approve, invalid tag payload, batch over 200)

No 409 Conflict — idempotent approve returns success with approved: false instead.


Dashboard Stats

GET /api/admin/stats
{
    "queue_total": 342,
    "by_country": [
        { "country_id": 105, "country": "Ireland", "count": 42 },
        { "country_id": 1, "country": "United States", "count": 128 }
    ],
    "by_verification": {
        "unverified": 50,
        "verified": 292
    }
}

by_verification uses enum names only — no float values (0.1 etc.).


Database Indices

Queue query

CREATE INDEX idx_admin_queue ON photos (is_public, verified, deleted_at, created_at);

Queue with location filter

CREATE INDEX idx_admin_queue_country ON photos (is_public, verified, country_id, created_at);

Stats (country grouping)

The stats query groups by country_id — the queue index covers this. No separate index needed at OLM scale.


What to Reuse

ComponentStatusNotes
AddTagsToPhotoAction✅ Use as-isv5 tag pipeline — creates PhotoTags, summary, XP
GeneratePhotoSummaryService✅ Use as-isCalled by AddTagsToPhotoAction
MetricsService::processPhoto()✅ Use as-isCalled via TagsVerifiedByAdmin event
MetricsService::deletePhoto()✅ Use as-isCalled before soft delete
TagsVerifiedByAdmin event✅ Use as-isFull constructor with location IDs
rewardXpToAdmin()✅ Use as-isFixed — updates MySQL + Redis
TeamPhotosController::approve() pattern✅ ReferenceAtomic approve with idempotent WHERE
TeamPhotosController::updateTags() pattern✅ ReferenceString key → FK resolution
logAdminAction()✅ Use as-isAudit logging

What to Delete (Phase 1)

TargetReason
use AddTagsTrait in AdminControllerWrites to v4 category tables
S3 deletion calls in verify() and updateDelete()Photos should remain viewable
$photo->filename = '/assets/verified.jpg'No longer replacing images
ImageDeleted event dispatch in destroy()MetricsService handles reversal directly
Manual $user->total_images decrement in destroy()MetricsService handles this

Post-migration cleanup (deleting AddTagsTrait, CalculateTagsDifferenceAction, etc.) is tracked in PostMigrationCleanup.md.


Future Work

The admin actions, queue UI, and user management (stats, username moderation, school-manager toggle) are complete (see Current State). Not yet implemented:

  • AI-assisted pre-tagging (OpenLitterAI)
  • Multi-admin claim queue with TTL
  • Confidence scoring for auto-approval
  • Admin workload distribution
  • Review audit trail / history
  • Permission-granular access (approve photos, delete photos, manage user trust via Spatie)
  • Batch approve endpoint (POST /api/admin/photos/batch-approve)

School photos bypass admin entirely (teacher approval via facilitator queue). Community trusted users bypass admin (auto-verify). The admin queue is for individual untrusted users uploading outside of teams.


DocumentCovers
Upload.mdTagsVerifiedByAdmin pipeline, EventServiceProvider
SchoolPipeline.mdTeacher approval (separate from admin)
Teams.mdTrust model, is_trusted flag
Metrics.mdMetricsService processPhoto/deletePhoto
Tags.mdv5 tag format, summary JSON, XP
Leaderboards.mdrewardXpToAdmin() scope and behaviour
PostMigrationCleanup.mdAddTagsTrait and category tables to delete