Skill v1.0.1
currentAutomated scan100/100+3 new
version: "1.0.1" name: photo-pipeline description: Photo upload, tagging, verification status, summary generation, XP calculation, AddTagsToPhotoAction, UploadPhotoController, and the VerificationStatus enum.
Photo Pipeline
Photos flow through three phases: Upload (observation only) -> Tag (summary + XP) -> Verify (metrics). Each phase is independent and idempotent.
Key Files
app/Http/Controllers/Uploads/UploadPhotoController.php— Web upload entry pointapp/Http/Requests/UploadPhotoRequest.php— Web upload validation (EXIF datetime, GPS). Dedup is NOT here — it's an idempotent lookup in the controller. HEIC files skip theimage/dimensionsrules AND the rawexif_read_data()GPS/datetime checks (both detected viaMakeImageAction::isHeic()) — PHP can't read HEIC EXIF pre-conversion, so that validation is deferred to the controller, which reads the converted JPEG's EXIF.app/Http/Controllers/API/Tags/PhotoTagsController.php— V5 tagging endpoint (POST /api/v3/tagsadd,PUT /api/v3/tagsreplace)app/Actions/Tags/AddTagsToPhotoAction.php— Core tagging logic (v5)app/Actions/Photos/MakeImageAction.php— Image processing + EXIF extraction. Converts HEIC→JPEG by shelling out to `heif-convert` (from libheif; NOT ImageMagick — IM's HEIC delegate is unreliable across variants). The binary path comes fromconfig('services.heif_convert.path')(envHEIF_CONVERT_PATH, defaultheif-convertresolved via PATH). Set an absolute path when the web server's PATH excludes it (e.g. local Valet php-fpm under launchd lacks/opt/homebrew/bin).app/Actions/Photos/UploadPhotoAction.php— S3 storage (requires non-null Carbon datetime)app/Services/Tags/GeneratePhotoSummaryService.php— Builds summary JSON + calculates XPapp/Services/Tags/XpCalculator.php— XP scoring rulesapp/Enums/VerificationStatus.php— Photo verification state machineapp/Enums/XpScore.php— XP values per tag typeapp/Http/Requests/Api/PhotoTagsRequest.php— V5 tag request validation (POST — blocks already-verified photos)app/Http/Requests/Api/ReplacePhotoTagsRequest.php— V5 replace tag request validation (PUT — ownership only, no verification gate)app/Http/Controllers/API/GetUntaggedUploadController.php— Mobile untagged photos (supports?platform=web|mobilefilter)app/Observers/PhotoObserver.php— Setsis_public = falsefor school team photosapp/Helpers/helpers.php—getDateTimeForPhoto(),getCoordinatesFromPhoto(),dmsToDec()tests/Feature/UploadValidationTest.php— 11 tests (EXIF datetime, GPS DMS conversion, edge cases)tests/Feature/Tags/ReplacePhotoTagsTest.php— 5 tests (replace tags, ownership, auth, extra tags cleanup)
Invariants
- Upload creates observation only. No tags, no XP, no summary, no metrics. Just the photo record with location FKs.
- EXIF datetime is required for web uploads.
UploadPhotoRequestrejects images without EXIF datetime. Controller has?? Carbon::now()safety fallback.UploadPhotoAction::run()type-hintsCarbon $datetime— null will crash. Mobile uploads send explicitlat,lon,date— EXIF validation is skipped. - GPS DMS conversion guards against division by zero.
dmsToDec()validates all 6 denominator values before dividing. Returnsnullon malformed data. - (0,0) coordinates rejected for explicit mode. Mobile uploads with
lat=0, lon=0get 422 (Null Island guard). Web uploads accept 0,0 from EXIF. - Summary generation is unconditional.
GeneratePhotoSummaryService::run()MUST run regardless of trust level. School photos need a summary at tag time so it exists when the teacher approves later. Gating summary behind a trust check causes null summary at approval = zero metrics. - XP calculation is unconditional. Runs for all users, before verification.
- `TagsVerifiedByAdmin` fires for ALL non-school users. This ensures all users get immediate leaderboard credit. Trusted users also get
ADMIN_APPROVED(visible on map). Non-trusted users stay atverified=0(not on map). School students' photos stop atVERIFIED(1)and wait for teacher approval — event does NOT fire for them. - VerificationStatus is an enum cast.
$photo->verifiedreturns the enum, not an int. Use->valuefor>=/<comparisons,===for equality checks. Never compare enum to raw int. - `remaining` is deprecated and NO LONGER read for picked-up. DB column is
photos.remaining(tinyint(1) NOT NULL DEFAULT 1). ThePhoto::getPickedUpAttribute()accessor (appended via$appends) now derives picked-up from the first tag —data_get($this->summary, 'tags.0.picked_up')— returning?bool(null for untagged), NOT!$this->remaining. So$photo->picked_upreflects per-tag edits everywhere (map popups, profile, uploads, team views). Requiressummaryto be loaded (all readers select it).photos.remainingis now only written at upload (a user default) and hidden from API serialization ($hidden = ['geom', 'remaining']) — it is no longer returned in any response. Clients usepicked_up. Per-tagphoto_tags.picked_upis the source of truth (nullable true/false/null).users.picked_updefaults totruefor new users; column stays nullable tri-state. - Loose PhotoTags (nullable CLO).
photo_tags.category_litter_object_id,category_id, andlitter_object_idare now NULLABLE. Extra-tag-only tags (brands, materials, custom tags) can exist as standalone PhotoTags without a litter object.AddTagsToPhotoAction::createExtraTagOnly()creates these with null CLO fields.GeneratePhotoSummaryServiceonly counts objects whenobjectId > 0(variable renamed$totalLitter→$totalObjects).XpCalculatoronly awards object XP whenobject_id > 0. - Replace tags accepts empty tags array.
PUT /api/v3/tagswithtags: []clears all tags on a photo (resets summary, XP, verified).ReplacePhotoTagsRequestvalidatestagsaspresent|array(notrequired|array|min:1). - Photo visibility is user-controlled (except school teams). Non-school users can set
is_public = falseper photo or globally viausers.public_photosdefault. Private-by-choice photos still receive immediate upload XP — the metrics gate inrecordUploadMetrics()uses a school team check ($photo->team_id && $team->isSchool()), NOT anis_publiccheck. Never change the gate to$photo->is_public === false— that would incorrectly defer metrics for private-by-choice photos.
VerificationStatus Enum
enum VerificationStatus: int{case UNVERIFIED = 0; // Uploaded, no tagscase VERIFIED = 1; // Tagged (school students land here, awaiting teacher)case ADMIN_APPROVED = 2; // Verified by admin/trusted user OR teacher-approvedcase BBOX_APPLIED = 3; // Bounding boxes drawncase BBOX_VERIFIED = 4; // Bounding boxes verifiedcase AI_READY = 5; // Ready for OpenLitterAI trainingpublic function isPublicReady(): bool // >= ADMIN_APPROVEDpublic function isVerified(): bool // >= VERIFIED}
Patterns
Phase 1: Upload
UploadPhotoRequest::after() validates before controller runs:
- EXIF must exist and be non-empty
- DateTime must exist (DateTimeOriginal → DateTime → FileDateTime fallback)
- GPS fields must exist and
dmsToDec()must succeed (guards zero denominators)
Duplicate handling is NOT validation. Dedup (user_id + datetime) lives in UploadPhotoController::__invoke(), before any S3 write / Photo::create / XP. A duplicate is idempotent success, not a 422: it returns { success: true, photo_id: <existing>, already_uploaded: true, tagged: <bool>, xp_awarded: 0 } (pure lookup, zero side effects). Skipped for participant uploads (students share the facilitator's user_id and may share a datetime). tagged = existing photo has a non-null summary.
Error contract on `UploadPhotoRequest`: failedValidation() returns a structured response:
{ "success": false, "error": "<code>", "message": "<human string>", "errors": {} }
resolveErrorCode() maps failures to typed string codes:
no_exif— image has no readable EXIF datano_gps— image has no GPS coordinatesno_datetime— image has no datetime in EXIFinvalid_coordinates— GPS coordinates failed parsing (zero denominators, etc.)validation_error— generic Laravel validation failure (wrong file type, size, etc.)
Mobile clients should read the error field for programmatic handling. Note: Handler::unauthenticated() returns { message: "Unauthenticated." } without an error code field (inconsistency — not yet fixed).
UploadPhotoController::__invoke() flow:
MakeImageAction::run($file)— extract EXIFgetDateTimeForPhoto($exif) ?? Carbon::now()— EXIF datetime with safety fallbackUploadPhotoAction::run()x2 — S3 full image + bbox thumbnailgetCoordinatesFromPhoto($exif)→ResolveLocationAction::run($lat, $lon)— Country/State/City FKsPhoto::create()— observation record with FKs only. For participant uploads:team_idfrom participant's team,participant_idfrom participant slotevent(new ImageUploaded(...))— real-time broadcast
Phase 2: Tagging
POST is append-only + has an idempotent guard. store() first checks the target photo's summary: if non-null (already tagged), it returns an idempotent no-op { success: true, already_tagged: true, photoTags: [...] } WITHOUT re-adding — because POST appends, a retried POST would otherwise double-tag/double-count (the verified >= 1 authorize gate does NOT catch ordinary users, who stay verified = 0). To re-tag/edit an already-tagged photo, use PUT /api/v3/tags (replace).
PhotoTagsController::store() -> AddTagsToPhotoAction::run():
// AddTagsToPhotoAction::run() is wrapped in DB::transaction() — all tag creation,// summary generation, and verification update are atomic.public function run(int $userId, int $photoId, array $tags): array{$photoTags = $this->addTagsToPhoto($userId, $photoId, $tags);// Creates PhotoTag + PhotoTagExtraTags (materials, brands, custom)// Handles 4 tag types: object, custom-only, brand-only, material-only$photo->generateSummary();// ALWAYS — generates summary JSON from PhotoTag records$photo->xp = $this->calculateXp($photoTags);// ALWAYS — uses XpScore enum multipliers (Upload=5, Object=1, Brand=3, Material=2, Custom=1)$this->updateVerification($userId, $photo);// Routes to trusted path or school-pending path}
Frontend tag types handled by AddTagsToPhotoAction
The web frontend sends 4 distinct tag types. resolveTag() handles each:
- Object tag —
{ object: { id, key }, quantity, materials?, brands? }. Category auto-resolved fromobject->categories()->first(). - Custom-only —
{ custom: true, key: "dirty-bench", quantity }. Uses$tag['key'](not$tag['custom']). - Brand-only —
{ brand_only: true, brand: { id, key }, quantity }. PhotoTag with null category/object. - Material-only —
{ material_only: true, material: { id, key }, quantity }. Same as brand-only pattern.
Verification routing
protected function updateVerification(int $userId, Photo $photo): void{$user = User::find($userId);$isSchoolStudent = false;if ($user->verification_required) {$photo->verification = 0.1;if ($photo->team_id) {$team = Team::find($photo->team_id);if ($team && $team->isSchool()) {$photo->verified = VerificationStatus::VERIFIED->value;$isSchoolStudent = true;}}} else {// Trusted user — immediate approval + map visibility$photo->verification = 1;$photo->verified = VerificationStatus::ADMIN_APPROVED->value;}$photo->save();// ALL users get leaderboard credit immediately (except school students).// Non-trusted photos stay at verified=0 (not on map) but metrics are processed.if (! $isSchoolStudent) {event(new TagsVerifiedByAdmin(...));}}
Key distinction: TagsVerifiedByAdmin fires for ALL non-school users. 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).
XP calculation
// XpScore enum values:Upload => 5 // Base for every photoObject => 1 // Per litter item (default)Material => 2 // Per material tagBrand => 3 // Per brand tagCustomTag => 1 // Per custom tagPickedUp => 5 // Bonus if picked_up = trueSmall => 10 // Special objects: 'dumping_small'Medium => 25 // Special objects: 'dumping_medium'Large => 50 // Special objects: 'dumping_large'BagsLitter => 10 // Special objects: 'bags_litter'
Phase 2b: Replace Tags (edit mode)
PhotoTagsController::update() handles PUT /api/v3/tags for replacing all tags on an already-tagged photo. The entire operation is wrapped in DB::transaction():
- Delete all existing PhotoTags + PhotoTagExtraTags
- Reset photo:
summary=null, xp=0, verified=0 - Call
AddTagsToPhotoAction::run()— regenerates summary, XP, firesTagsVerifiedByAdmin MetricsService::processPhoto()detects prior processing (hasprocessed_at), callsdoUpdate()which calculates deltas between oldprocessed_tagsand new summary, applies adjustments to all metrics- Marks
onboarding_completed_aton first tag submission (parity withstore()), guarded to non-emptytags. PUT-first-time == POST-first-time: on a never-tagged photo the reset is a no-op and the sameAddTagsToPhotoAction::run(..., skipVerification=false)runs → identicalverified/XP/metrics for trusted/school/ordinary users. The mobile auto-upload flow tags exclusively via PUT (idempotent).
Frontend edit mode: /tag?photo=<id> loads a specific photo. If it has existing tags, isEditMode=true → uses PUT. If untagged, uses POST. convertExistingTags() transforms API new_tags format back to frontend format (including litter_object_type_id for the type dimension).
Frontend guards: Double-submit prevention via isSubmitting ref. After success, REFRESH_USER() updates the nav XP bar (non-blocking). Stats and photos refresh in parallel via Promise.all().
Security: ReplacePhotoTagsRequest checks $photo->user_id === $this->user()->id. GET_SINGLE_PHOTO calls /api/v3/user/photos which filters by authenticated user.
result_string and total_litter (v4 compatibility — write-only)
GeneratePhotoSummaryService::run() still populates result_string from the summary keys for backward compatibility. Format: category.object qty,category.object qty,... (e.g., smoking.butts 3,food.wrapper 2,). However, no public-facing endpoint reads `result_string` anymore — all map endpoints (GlobalMapController, DisplayTagsOnMapController, TeamsClusterController, PointsController, FilterPhotosByGeoHashTrait) were updated to select and return summary instead. Both result_string and total_litter columns are now write-only and scheduled for eventual removal.
`total_litter` → `total_tags`: All active endpoints now read total_tags instead of total_litter. Fixed: CommunityController, ContributorAggregator, TimeSeriesAggregator, ProfileController (global litter fallback), JoinTeamAction (team pivot). Safe (location-level Redis, not photo column): GlobalStatsController, WorldCupController. Safe (correct fallback): CreateCSVExport. Console commands that read these columns (CompileResultsString, ResetResultString) have been deleted. Dead jobs deleted: Api/AddTags, Photos/AddTagsToPhoto (both wrote total_litter + verification float).
Summary JSON structure
{"tags": {"2": {"65": {"quantity": 5,"materials": {"16": 3, "15": 2},"brands": {"12": 3}}}},"totals": {"total_tags": 15, "total_objects": 5,"by_category": {"2": 10},"materials": 8, "brands": 3, "custom_tags": 0},"keys": {"categories": {"2": "smoking"},"objects": {"65": "wrapper"},"materials": {"16": "plastic"},"brands": {"12": "marlboro"}}}
Photo model hidden attribute
protected $hidden = ['geom']; // Binary spatial data — breaks JSON serialization
Always ensure geom stays in $hidden. If you need coordinates, use lat/lon columns.
Common Mistakes
- Gating summary generation behind trust check. Summary MUST be unconditional. This is the #1 cause of broken metrics for school photos.
- Comparing VerificationStatus enum to int.
$photo->verified >= 2fails. Use$photo->verified->value >= VerificationStatus::ADMIN_APPROVED->value. - Dispatching `TagsVerifiedByAdmin` for school students. School photos must wait for teacher approval. Only trusted users get immediate dispatch.
- Including `geom` in API responses. Binary spatial data. Keep it in
$hidden. - Using `$photo->toArray()` for queue responses. The Location model's
updatedAtDiffForHumansaccessor crashes on nullupdated_at. Build response arrays manually when including country relation. SeeAdminQueueControllerfor pattern. - Passing null datetime to `UploadPhotoAction::run()`. The method type-hints
Carbon $datetime. If EXIF has no datetime,getDateTimeForPhoto()returns null. Validation must reject first; controller has?? Carbon::now()safety fallback. - Not guarding `dmsToDec()` against zero denominators. EXIF GPS values are
"numerator/denominator"format. If denominator is 0 in any of the 6 components (degrees/minutes/seconds for lat and lon), division crashes. The function now returnsnullinstead. - Rejecting 0,0 coordinates. Photos at latitude 0, longitude 0 are valid (Gulf of Guinea). Do not reject
0,0— only rejectnull. - Forgetting `city_id` in factory. PhotoFactory doesn't include
city_idby default. Add'city_id' => City::factory()when testing location-dependent features. - Confusing `category_litter_object_id` with `category_id`. Phase 1 adds
category_litter_object_id(FK tocategory_litter_objectpivot) andlitter_object_type_id(FK tolitter_object_types) tophoto_tags. Both are nullable in Phase 1. The existingcategory_idandlitter_object_idcolumns remain and are still the authoritative source until Phase 3. - Returning `'tags'` instead of `'new_tags'` in upload controller.
UsersUploadsControllermust return tags under the key'new_tags'— theUploads.vuefrontend readsphoto.new_tagsfor tag counts and objects list. - Using `where('verified', 0)` or `doesntHave('photoTags')` for untagged filter. Use
whereNull('summary')— summary is set byGeneratePhotoSummaryServicewhen tags are added, regardless of verification status. After "leaderboard immediate credit," untrusted users'verifiedstays at 0 after tagging, sowhere('verified', 0)includes tagged photos. - Not including `litter_object_type_id` in photo response.
UsersUploadsController::getNewTags()must includelitter_object_type_idso the frontend can preserve the type dimension on edit round-trips. - Replace tags without `DB::transaction()`. If
AddTagsToPhotoAction::run()fails after old tags are deleted, the photo loses all tag data. The entire delete-reset-add sequence must be atomic.AddTagsToPhotoAction::run()itself is also wrapped in a transaction — both operations are independently protected. - `getNewTags()` conditionally includes `category`/`object`. For extra-tag-only PhotoTags (brand/material/custom-only),
categoryandobjectfields arenull. The serializer only includes them when bothcategory_idandlitter_object_idare non-null. Frontend must handlenullcategory/object gracefully. - Expecting `UploadPhotoRequest` errors to match Laravel's default shape.
UploadPhotoRequestoverridesfailedValidation()to return a custom{ success, error, message, errors }shape with typederrorcodes. Do not assert the standard Laravel{ errors: { field: [...] } }shape for upload failures. - Adding `image`/`dimensions` back unconditionally, running raw `exif_read_data()` on HEIC, or swapping the converter to ImageMagick. Laravel's
imagerule excludes HEIC anddimensions(getimagesize()) returns false for HEIC —UploadPhotoRequest::rules()drops both for HEIC (detected viaMakeImageAction::isHeic()), keepingmimes+max.after()also skips the rawexif_read_data()GPS/datetime checks for HEIC (PHP can't read HEIC EXIF pre-conversion → falseno_exif); the controller validates GPS from the converted JPEG instead and returnsno_gpsif genuinely missing. AndMakeImageActionconverts via `heif-convert` (libheif; path fromconfig('services.heif_convert.path')), NOT ImageMagickconvert/magick— IM's HEIC delegate is unreliable across variants. Reintroducing any of these breaks HEIC upload (silent client/server rejection, or conversion failure).