name: olm-architecture description: > OpenLitterMap v5 architecture reference. Use this skill whenever working on OLM backend code, Laravel controllers, services, events, tests, Redis, metrics, tags, photos, teams, admin, leaderboards, or any part of the OpenLitterMap codebase. Also trigger when the user mentions MetricsService, VerificationStatus, PhotoTags, AddTagsToPhotoAction, TagsVerifiedByAdmin, rewardXpToAdmin, school pipeline, teacher approval, clustering, or any OLM-specific concept. This skill should be used BEFORE writing any OLM code to avoid architectural mistakes. When in doubt about OLM patterns, read this skill.
OpenLitterMap v5 — Architecture Reference
This is the canonical reference for OLM's v5 architecture. Read this before writing any backend code.
Core Principle: Every Tag Change Flows Through MetricsService
Nothing writes metrics directly. Every tag creation, edit, or deletion flows through a pipeline that ensures metrics, leaderboards, Redis, and the database stay in sync.
The Tag Pipeline
User/Admin/Teacher tags a photo
↓
AddTagsToPhotoAction::run($userId, $photoId, $tags) ← wrapped in DB::transaction()
├── Resolves tags:
│ ├── Object tags: uses category_litter_object_id (CLO ID), auto-resolves category
│ ├── Custom-only: $tag['custom'] = true, $tag['key'] = "dirty-bench"
│ ├── Brand-only: $tag['brand_only'] = true
│ └── Material-only: $tag['material_only'] = true
├── Creates PhotoTag + PhotoTagExtraTags rows
├── Calls GeneratePhotoSummaryService
│ ├── Builds summary JSON from PhotoTags (numeric ID keys)
│ ├── Calculates XP via XpScore enum multipliers
│ └── Writes photo.summary, photo.xp, photo.total_tags
↓
updateVerification() — WHO tagged it?
├── Trusted user → verified = ADMIN_APPROVED, fires TagsVerifiedByAdmin
├── Non-trusted user → verified = UNVERIFIED (0), fires TagsVerifiedByAdmin
│ (Photo NOT on map, but user gets immediate leaderboard credit)
├── School student → verified = VERIFIED (1), does NOT fire event
│ (Waits for teacher approval — safeguarding invariant)
↓
TagsVerifiedByAdmin event (fires for ALL non-school users)
→ ProcessPhotoMetrics listener
→ MetricsService::processPhoto($photo)
├── Computes fingerprint from summary JSON
├── If new (processed_at null): creates metric rows
├── If re-edit (processed_at set): computes delta
├── Upserts metrics table (5 timescales × 4 location scopes × 2 user modes)
├── Updates Redis (stats hashes, XP ZSETs, bitmaps, HLLs)
└── Sets photo.processed_at, processed_fp, processed_tags, processed_xp
Key distinction: TagsVerifiedByAdmin fires for ALL non-school users at tag time. Trusted users also get verified = ADMIN_APPROVED (photo visible on map). Non-trusted users stay at verified = 0 (photo NOT on map, but user IS on leaderboard). Only school students' photos wait for teacher approval.
VerificationStatus Enum
enum VerificationStatus: int
{
case UNVERIFIED = 0; // Uploaded, no tags (also: tagged by non-trusted non-school user)
case VERIFIED = 1; // Tagged by school student (awaiting teacher)
case ADMIN_APPROVED = 2; // Approved by admin/trusted/teacher
case BBOX_APPLIED = 3; // Bounding boxes drawn
case BBOX_VERIFIED = 4; // Bounding boxes verified
case AI_READY = 5; // Ready for model training
}
Critical rule: Use ->value >= VerificationStatus::ADMIN_APPROVED->value (not == 2) when checking if a photo is public-ready. Photos at BBOX_APPLIED+ are also approved.
The enum is cast on the Photo model: 'verified' => VerificationStatus::class. Use ->value for >=/< comparisons, direct enum for === equality.
The Atomic Approve Pattern
Used everywhere: admin approve, teacher approve, batch approve.
$updated = Photo::where('id', $photo->id)
->where('is_public', true)
->where('verified', '<', VerificationStatus::ADMIN_APPROVED->value)
->update(['verified' => VerificationStatus::ADMIN_APPROVED->value]);
if ($updated > 0) {
event(new TagsVerifiedByAdmin(
photo_id: $photo->id,
user_id: $photo->user_id,
country_id: $photo->country_id,
state_id: $photo->state_id,
city_id: $photo->city_id,
team_id: $photo->team_id
));
rewardXpToAdmin();
}
Why atomic: Prevents double-processing. If two admins approve simultaneously, the WHERE clause ensures only one succeeds. The second gets $updated = 0 and no event fires.
Always pass all 6 named constructor args to TagsVerifiedByAdmin. Missing args cause listener failures.
MetricsService
Single writer rule. Only MetricsService writes to the metrics table and Redis metric keys. No other code may increment/decrement counters. Never use DB::table('metrics')->increment() or Redis::hincrby() directly.
processPhoto($photo)
- Computes fingerprint from summary JSON
- If
processed_atis null: first processing →doCreate()(creates metrics + incrementsusers.xp) - If
processed_atis set: re-processing →doUpdate()(computes delta, applies corrections tousers.xp) - Upserts
metricstable rows: 5 timescales × 4 location scopes × 2 (aggregate user_id=0 + per-user user_id>0) - Updates Redis via
RedisMetricsCollector(insideDB::afterCommit) - Sets
photo.processed_at,processed_fp,processed_tags,processed_xp
recordUploadMetrics($photo, $uploadXp)
- Gated on
$team->isSchool()check — school photos skip entirely (deferred to approval). The gate is a school team check, NOT anis_publiccheck. - Private-by-choice photos (non-school,
is_public = falseby user preference) still get immediate upload XP - For non-school photos: writes 1 upload + upload XP to metrics/Redis, sets
processed_at - At tag time,
processPhoto()routes todoUpdate()(delta-based) sinceprocessed_atis set
deletePhoto($photo)
- Must run BEFORE
$photo->delete()— hard sequencing rule - Reverses all metrics that processPhoto created
- Only runs if
processed_atis not null - Clears all processed_* columns
- Prunes zero-XP members from Redis leaderboard ZSETs
// CORRECT order
if ($photo->processed_at) {
$this->metricsService->deletePhoto($photo);
}
$photo->delete(); // soft delete
// WRONG — metrics can't read a deleted photo
$photo->delete();
$this->metricsService->deletePhoto($photo); // too late
Photo Summary JSON
Every tagged photo has a summary JSON column — a flat array of tag entries, one per photo_tags row:
{
"tags": [
{
"clo_id": 42,
"category_id": 1,
"object_id": 5,
"type_id": 3,
"quantity": 2,
"picked_up": true,
"materials": [1, 4],
"brands": { "7": 1 },
"custom_tags": [45]
}
],
"totals": {
"litter": 3,
"materials": 3,
"brands": 1,
"custom_tags": 0
},
"keys": {
"categories": { "1": "alcohol" },
"objects": { "5": "bottle" },
"types": { "3": "beer" },
"materials": { "1": "glass" },
"brands": { "7": "heineken" }
}
}
Structure: tags[] — each entry maps 1:1 to a photo_tags row. materials and custom_tags are ID arrays (set membership, qty = parent tag quantity); brands is an {id: quantity} map (independent quantities); totals.litter = sum of object quantities; keys is a reverse-lookup for display (empty sub-maps omitted). Generated by GeneratePhotoSummaryService from PhotoTag rows. If summary is null, the photo has no tags and cannot be approved.
PhotoTags (v5 Tag System)
photo_tags (
id, photo_id,
category_id, -- FK → categories (auto-resolved from object)
litter_object_id, -- FK → litter_objects
category_litter_object_id, -- FK → category_litter_object pivot (CLO ID)
litter_object_type_id, -- FK → litter_object_types (e.g., "beer" in "beer bottle")
custom_tag_primary_id, -- for custom-only tags (no category/object)
quantity, picked_up,
created_at, updated_at
)
photo_tag_extra_tags (
id, photo_tag_id,
tag_type, -- 'material' | 'brand' | 'custom_tag'
tag_type_id, -- FK → materials / brandslist / custom_tags_new
quantity, index,
created_at, updated_at
)
v4 vs v5: v4 stored tags in 16+ separate category tables (smoking_id, food_id, etc on photos). v5 uses the unified PhotoTags table. Legacy v4 endpoints and the ConvertV4TagsAction shim have been removed (2026-03-01). Mobile now uses v3 endpoints with CLO format. Category FK columns on photos are deprecated.
Frontend sends CLO IDs: The web frontend sends category_litter_object_id (pre-resolved from the search index). Backend auto-resolves category_id from the CLO pivot. Category need NOT be sent separately.
No unique constraint on photo_tags: There is no unique constraint on (photo_id, category_litter_object_id, litter_object_type_id). Duplicate CLO+type combinations are possible (each row is a separate PhotoTag). Extra-tag deduplication (materials/brands) is handled via upsert within a single PhotoTag row, not across rows. Do NOT add a unique constraint expecting one row per (photo, CLO, type) combination.
getNewTags() conditional inclusion: UsersUploadsController::getNewTags() conditionally includes category and object only when both resolve (i.e., when both category_id and litter_object_id are non-null). Extra-tag-only PhotoTags (brand/material/custom-only) return category: null and object: null.
Upload error contract: UploadPhotoRequest::failedValidation() returns { success: false, error: <code>, message: <string>, errors: {...} }. resolveErrorCode() maps validation failures to typed string codes: no_exif, no_gps, no_datetime, duplicate, invalid_coordinates, validation_error. Mobile clients should read the error field for programmatic handling.
School Privacy — The is_public Gate
Hard rule: School team photos have is_public = false until a teacher approves them.
// PhotoObserver::creating() — automatic privacy
if ($photo->team_id) {
$team = Team::find($photo->team_id);
if ($team && $team->isSchool()) {
$photo->is_public = false;
}
}
// ALL public-facing queries MUST use this:
Photo::public() // →where('is_public', true)
// or:
->where('is_public', true)
School pipeline: student uploads → is_public = false → NO upload metrics (deferred) → teacher reviews in Facilitator Queue → teacher approves → is_public = true + verified = ADMIN_APPROVED → TagsVerifiedByAdmin fires → processPhoto() → doCreate() handles full XP (upload + tag) in one pass + increments users.xp.
Admin queue never sees school photos (WHERE is_public = true excludes them). Teachers are the sole approvers for their team via the Facilitator Queue (3-panel admin-like UI).
User Photo Visibility
Users can control the public visibility of their photos independently of the school pipeline.
users.public_photos (boolean, default true): A per-user default. New photos inherit this value unless overridden.
Visibility precedence (highest to lowest):
- School team — always
is_public = false(enforced by PhotoObserver, cannot be overridden) - Explicit
is_publicparam in upload request - User's
public_photosdefault - Falls back to
true
Private-by-choice photos (non-school, is_public = false by user preference):
- Hidden from map, clusters, and all public endpoints (same as any
is_public = falsephoto) - Upload metrics are processed immediately (unlike school photos)
- User appears on leaderboard with full XP credit
School photos (is_public = false enforced):
- Hidden from map AND upload metrics are deferred until teacher approval
recordUploadMetrics()is skipped (school team check, notis_publiccheck)
Per-photo toggle: PATCH /api/v3/photos/{id}/visibility — owner-only, requires auth:sanctum. Blocked for school team photos (403). PhotoObserver marks affected tiles dirty on is_public change so the map updates correctly.
Spatie Roles & Permissions
Laravel Permission 6, web guard.
| Role | Access |
|---|---|
superadmin |
Everything + trust management + Horizon |
admin |
Photo review, approve, edit tags, delete |
helper |
Tag editing only |
school_manager |
Manage school team, approve student photos (invite email sent on grant) |
Check with: $user->hasRole('admin') or $user->hasRole('superadmin')
Admin middleware checks: hasRole('admin') || hasRole('superadmin')
Nav.vue isAdmin includes 'superadmin' role (not just 'admin' and 'helper').
Redis Key Patterns
All keys use hash tags {...} for Redis Cluster compatibility:
{g}:stats → HASH (global stats: uploads, tags, litter, xp, ...)
{g}:lb:xp → ZSET (global XP leaderboard, user_id → xp)
{g}:hll → HyperLogLog (contributor count)
{c:ID}:stats → HASH (country stats)
{c:ID}:lb:xp → ZSET (country leaderboard)
{s:ID}:stats → HASH (state stats)
{s:ID}:lb:xp → ZSET (state leaderboard)
{ci:ID}:stats → HASH (city stats)
{ci:ID}:lb:xp → ZSET (city leaderboard)
{u:ID}:stats → HASH (per-user stats: uploads, xp, litter)
{u:ID}:bitmap → Bitmap (activity tracking)
Key builder: RedisKeys::global() → {g}, RedisKeys::country($id) → {c:$id}, etc.
rewardXpToAdmin() updates both MySQL users.xp AND Redis {g}:lb:xp sorted set.
Redis is a derived cache. Rebuildable from the metrics table. Never treat Redis as source of truth.
Soft Deletes
Photo model uses SoftDeletes trait. $photo->delete() sets deleted_at, does not remove the row. Eloquent auto-applies global scope excluding soft-deleted records. Raw queries need explicit whereNull('deleted_at').
Teams
Types: community and school. Stored in team_types table (resolve via $team->getTypeNameAttribute()). Do NOT hardcode type IDs — they vary between environments.
School teams enforce extra safeguarding: MasksStudentIdentity trait masks student names as "Student N" (deterministic, based on team_user.id join order). Student identity never exposed publicly.
School teams must NOT be is_trusted — trust bypasses teacher approval entirely.
Key methods: $team->isSchool(), $team->isLeader($userId), $team->hasSafeguarding()
Facilitator Queue (Teacher's Admin-like UI)
School team leaders have a 3-panel verification queue (same layout as admin queue):
- Left:
FacilitatorQueueFilters(status toggle, date range) - Center:
PhotoViewer(reused from tagging v2) - Right:
UnifiedTagSearch+ActiveTagsList(reused from tagging v2) - Keyboard: A=approve, D=delete, E=save edits, R=revoke, S/K/→=next, J/←=prev
Backend: TeamPhotosController returns new_tags (CLO format), accepts CLO-based tag edits, provides member stats with safeguarding.
Teams Frontend — TeamsHub
/teams route uses TeamsHub.vue (replaces old sidebar layout). Three states: no teams (create/join landing), active team (header + stats + tabs), no active team (team picker). Tabs: Overview, Photos, Map, Members, Settings, Leaderboard, Approval Queue (school), Participants (school+sessions). Privacy defaults: leaderboards = false for all new teams, safeguarding = true enforced for school.
SchoolManagerInvite Email
SchoolManagerInvite mailable queued when school_manager role is granted (artisan command or admin toggle). Two CTAs: Upload → /upload, Create Team → /teams/create. Not sent on revoke.
Level System
Config-driven thresholds in config/levels.php. 12 levels from "Noob" (0 XP) to "SuperIntelligent LitterMaster" (1M+ XP).
LevelService::getUserLevel($xp) returns: level, title, xp_into_level, xp_for_next, xp_remaining, progress_percent.
User model next_level accessor calls LevelService. Frontend reads user.next_level.title.
XP Calculation
AddTagsToPhotoAction::calculateXp() uses XpScore enum multipliers:
| Action | XP | Note |
|---|---|---|
| Upload | 5 | Base per photo |
| Object | 1 | Per item (special objects: small=10, medium=25, large=50, bagsLitter=10) |
| Brand | 3 | Per brand (uses brand's own quantity) |
| Material | 2 | Per material (uses parent tag's quantity — set membership) |
| Custom Tag | 1 | Per custom tag (uses parent tag's quantity) |
| Picked Up | 5 | Per object (×quantity) where photo_tags.picked_up=true. Objects only — no bonus for brand/material/custom-only tags |
API Field Naming Convention
All list/leaderboard endpoints use: total_tags, total_photos, total_members, created_at, updated_at. Never use old names: total_litter, total_images, tags, photos, contributors, members.
Clustering
Grid-based, 9 zoom levels (0,2,4,6,8,10,12,14,16). Two tiers:
- Global (zoom 0-6): single query across all verified photos
- Per-tile (zoom 8-16): pre-computed tile keys, generated columns
Photos need verified >= ADMIN_APPROVED and is_public = true to appear in clusters.
PhotoObserver marks affected tiles dirty when photos are verified/moved. Team dirty tracking was removed — team clustering is on-demand only.
Testing Patterns
- Base
TestCaseusesRefreshDatabase+ Redis flush +TagKeyCache::forgetAll() auth:sanctumroutes: useactingAs($user)with NO guard argauth:apiroutes (legacy mobile): useactingAs($user, 'api')- Photo factory: set
verified,is_public,summary,country_idexplicitly VerificationStatusassertions: compare enum directly or use->valuefor orderingEvent::fake([TagsVerifiedByAdmin::class])— fakes specific events, others still fire- Reset Spatie permissions cache:
app()[PermissionRegistrar::class]->forgetCachedPermissions() TeamType::create(['team' => 'community', 'price' => 0])—pricehas no default
1033+ tests passing (1 skipped). See testing-patterns skill for full details.
Common Mistakes
- Forgetting
is_public = truein admin/public queries — exposes school data - Using
->verified == 2instead of->verified->value >= VerificationStatus::ADMIN_APPROVED->value— misses BBOX+ states and breaks due to enum cast - Deleting before MetricsService — metrics can't reverse what they can't read
- Missing TagsVerifiedByAdmin constructor args — all 6 required (photo_id, user_id, country_id, state_id, city_id, team_id)
- Writing metrics directly instead of going through MetricsService — causes desync
- Forgetting
whereNotNull('summary')in queue queries — untagged photos can't be approved - Using old category relationships (Photo::smoking(), Photo::food()) — v4 deprecated
- Not wrapping tag edits in
DB::transaction()— partial tag state causes summary corruption - Gating summary generation behind trust check — summary MUST be unconditional (null summary = zero metrics at approval)
- Dispatching
TagsVerifiedByAdminfor school students — breaks safeguarding invariant - Using
result_stringortotal_litter— deprecated write-only columns. Usesummaryandtotal_tags - Treating Redis as source of truth — Redis is a derived cache from
metricstable - Using
doesntHave('photoTags')for untagged filter — useWHERE verified = 0 - Mismatching
actingAs()guard with route middleware —actingAs($user)forauth:sanctum,actingAs($user, 'api')forauth:api
Domain Documentation
Read the relevant file before working in that area:
| Document | Covers |
|---|---|
readme/API.md |
Comprehensive API endpoint reference (source of truth) |
readme/Tags.md |
Tag hierarchy, summary JSON, XP calculation |
readme/Teams.md |
Teams architecture, permissions, safeguarding, facilitator queue |
readme/SchoolPipeline.md |
School approval pipeline (critical data flow) |
readme/Metrics.md |
Metrics pipeline and aggregation |
readme/Leaderboards.md |
Leaderboard system (Redis ZSETs + MySQL) |
readme/Admin.md |
Admin verification system, queue UI, roles |
readme/Upload.md |
Photo upload pipeline |
readme/Clustering.md |
Map clustering system |
readme/Locations.md |
Location and geography system |
readme/Mobile.md |
Mobile app & v4-to-v5 tag conversion shim |
readme/Profile.md |
User profile, settings, privacy, public profiles |