Skill v1.0.1
currentAutomated scan100/100+3 new
version: "1.0.1" name: api-endpoints description: REST API endpoints, route structure, auth guards, request/response contracts, error patterns, and the full API surface for web SPA and mobile clients.
API Endpoints
All API routes live in routes/api.php. The API serves two clients: the Vue 3 SPA (session auth) and a mobile app (Bearer token auth). Current version is v3. All legacy v1/v2 endpoints have been removed (2026-03-01).
Key Files
routes/api.php— All route definitions (~146 active routes)readme/API.md— Comprehensive endpoint documentation with request/response contracts for every endpoint (2080+ lines). READ THIS FIRST before modifying any API endpoint.app/Http/Controllers/Auth/AuthTokenController.php— Mobile token login (Sanctum)app/Http/Controllers/Auth/LoginController.php— SPA session loginapp/Http/Controllers/Auth/RegisterController.php— User registration (both clients)app/Http/Controllers/Uploads/UploadPhotoController.php— v3 upload (web EXIF + mobile explicit coords)app/Http/Controllers/API/Tags/PhotoTagsController.php— v3 tag CRUD (POST add, PUT replace)app/Http/Controllers/API/Tags/GetTagsController.php— Public tag catalogapp/Http/Controllers/User/ProfileController.php— Profile (auth + public)app/Http/Controllers/User/Photos/UsersUploadsController.php— v3 user photos (paginated, filterable)app/Http/Controllers/Leaderboard/LeaderboardController.php— Leaderboard (time-filtered, location-scoped)app/Http/Controllers/Points/PointsController.php— Map points GeoJSONapp/Http/Controllers/Points/PointsStatsController.php— Map viewport statsapp/Http/Controllers/Clusters/ClusterController.php— Map clusters GeoJSON (ETag cached)app/Http/Controllers/Location/LocationController.php— Location hierarchy APIapp/Http/Controllers/Location/TagController.php— Location tag analyticsapp/Http/Controllers/API/TeamsController.php— Team CRUD + membershipapp/Http/Controllers/Teams/TeamPhotosController.php— Team photo management + school approvalapp/Http/Controllers/Teams/TeamsLeaderboardController.php— Teams leaderboardapp/Http/Controllers/Teams/TeamsClusterController.php— Team map clusters/pointsapp/Http/Controllers/API/DeleteAccountController.php— GDPR account deletionapp/Http/Controllers/UsersController.php— Settings, privacy toggles, detailsapp/Http/Controllers/ApiSettingsController.php— Privacy settings (maps/leaderboard/createdby)app/Http/Controllers/Achievements/AchievementsController.php— User achievementsapp/Http/Controllers/API/GlobalStatsController.php— World totals (total_tags, total_images, total_users, new_users_*)app/Http/Controllers/CommunityController.php— Community statsapp/Http/Controllers/Cleanups/— 4 cleanup controllers (create, list, join, leave)app/Http/Requests/— Form request validation classes (one per endpoint that needs validation)
Route Groups
| Prefix | Middleware | Purpose | |
|---|---|---|---|
/api/v3 | auth:sanctum | Current API (upload, tags, user photos) | |
/api (public) | none | Tags catalog, points, stats, locations, clusters, leaderboard | |
/api/auth | varies | Login, register, logout, password reset | |
/api (auth) | auth:sanctum | Profile, settings, achievements | |
/api/teams | auth:sanctum | Team CRUD, photos, approval, leaderboard | |
/api/admin | admin | Admin queue, verify, reset | |
/api/bbox | can_bbox | Bounding box annotation | |
/api/participant | varies | School participant session endpoints |
Invariants
- Dual auth: session + token. All
auth:sanctumroutes accept both SPA session cookies and mobile Bearer tokens. The SPA usesPOST /api/auth/login(session), mobile usesPOST /api/auth/token(Sanctum token). - Token name is `mobile`.
AuthTokenControllercreates tokens namedmobileand revokes previousmobiletokens on each login (prevents buildup). - Registration always sets `name = NULL`. Ignores any
namefield in the request. Auto-generates username if omitted (pattern:{adjective}-{noun}-{number}). - `readme/API.md` is the source of truth. Every endpoint's exact request/response contract is documented there. Always consult it before modifying an endpoint.
- Error responses are inconsistent. Some controllers return
{ "msg": "..." }, others return{ "message": "..." }. This is a known legacy issue — match the existing pattern for each controller.UploadPhotoRequestoverridesfailedValidation()to return{ success, error, message, errors }with a typederrorcode field (no_exif,no_gps,no_datetime,invalid_coordinates,validation_error). Duplicates are no longer a validation error — the controller returns idempotent 200 success with the existingphoto_id.Handler::unauthenticated()returns{ message: "Unauthenticated." }without anerrorcode (known inconsistency).PhotoTagsRequestuses Laravel's defaultfailedValidation()(no custom error codes). Admin routes (/api/admin/*) require theadminmiddleware which already wrapsauth:sanctum— do not add a redundantauth:sanctumwrapper. - Public endpoints must filter `is_public = true`. All map/points/global/community endpoints use
Photo::public()scope or explicitwhere('is_public', true). - v3 is the current API version. New endpoints go in the v3 group. All legacy v1/v2 endpoints have been removed.
- Location API uses `locations`/`location_type` keys. Not
children/children_type. The{type}parameter acceptscountry,state, orcity. - Consistent API field naming convention. All list/leaderboard endpoints (teams, locations, global stats) use:
total_tags,total_photos,total_members,created_at,updated_at. Never use old names liketotal_litter,total_images,tags,photos,contributors. - Points API returns `page` (not `current_page`). Frontend normalizes to
current_pageinpointsHelper.getPaginationData(). - Delete account is GDPR-compliant. Photos are preserved as anonymous contributions (
user_idset to NULL via DB CASCADE). Redis leaderboards and per-user metrics are cleaned up.
Patterns
Auth flow (Mobile)
POST /api/auth/token → { token, user }// Include on all subsequent requests:Authorization: Bearer <token>// Validate token is still valid:POST /api/validate-token → { message: "valid" }
Auth flow (SPA)
GET /sanctum/csrf-cookie → Sets XSRF-TOKEN cookiePOST /api/auth/login → Sets session cookie, returns { success, user }GET /api/user/profile/index → Returns user + stats + level + rankPOST /api/auth/logout → Destroys session
Photo lifecycle endpoints
POST /api/v3/upload → Upload photo (web: EXIF; mobile: explicit lat/lon/date)Idempotent: a duplicate (user_id+datetime) returns 200{ success, photo_id: <existing>, already_uploaded: true,tagged: <bool>, xp_awarded: 0 } — NOT a 422.On validation failure: { success: false, error: <code>, message, errors }Error codes: no_exif | no_gps | no_datetime |invalid_coordinates | validation_errorGET /api/v3/user/photos → List user's photos (paginated, filterable, per_page up to 100)Response includes `is_public` (boolean) and `school_team` (boolean)for each photo. Owner sees all photos including private ones.GET /api/v3/user/photos/stats → Aggregate counts (totalPhotos, totalTags, leftToTag)POST /api/v3/tags → Add tags to untagged photo (PhotoTagsRequest — default Laravel errors)PUT /api/v3/tags → Replace all tags on tagged photo (edit mode, accepts empty tags: [])PATCH /api/v3/photos/{id}/visibility → Toggle is_public for a single photo (owner only, auth:sanctum).Returns { is_public: bool }. Blocked for school team photos (403).PhotoObserver marks dirty tiles on change.POST /api/profile/photos/delete → Delete single photo { "photoid": 123 } (soft delete)
Leaderboard query parameters
GET /api/leaderboard?timeFilter=all-time&locationType=country&locationId=1&page=1// timeFilter: all-time | today | yesterday | this-month | last-month | this-year | last-year// locationType: country | state | city (optional)// locationId: numeric ID (required if locationType set)// All time filters use MySQL metrics table. Per page hardcoded to 100.// Public endpoint (no auth required). Optional auth adds currentUserRank.
Team photo management (school teams)
GET /api/teams/photos?team_id=X&status=pending → List photos (with new_tags CLO format)GET /api/teams/photos/{photo}?team_id=X → Single photo (with new_tags)GET /api/teams/photos/member-stats?team_id=X → Per-student stats (leader only, safeguarding)GET /api/teams/photos/map?team_id=X → Map points (up to 5000)POST /api/teams/photos/approve { photo_ids, team_id } → Approve (fires TagsVerifiedByAdmin)POST /api/teams/photos/revoke { photo_ids, team_id } → Revoke approval (reverses metrics)PATCH /api/teams/photos/{photo}/tags { tags: [...] } → Edit tags (CLO format, leader/school_manager)DELETE /api/teams/photos/{photo}?team_id=X → Delete (reverses metrics first)# Participant management (auth:sanctum, team leader only)GET /api/teams/{team}/participants → List slots with photo_countPOST /api/teams/{team}/participants { count: N } → Create slots in bulkPOST /api/teams/{team}/participants/{id}/deactivate → Revoke sessionPOST /api/teams/{team}/participants/{id}/activate → Re-enable sessionPOST /api/teams/{team}/participants/{id}/reset-token → Regenerate token (returns new token)DELETE /api/teams/{team}/participants/{id} → Hard delete slot# Participant session (public/token auth)POST /api/participant/session { token: "64-char" } → Validate token, return session info (public)POST /api/participant/upload → Upload photo (X-Participant-Token header)POST /api/participant/tags { photo_id, tags } → Tag own photo (X-Participant-Token header)GET /api/participant/photos → List own photos (X-Participant-Token header)DELETE /api/participant/photos/{photo} → Delete own pre-approval photo
Public profile (no auth required)
GET /api/user/profile/{id}// Returns stats, level, rank, achievements, recent locations// Respects privacy: returns { public: false } if profile is private// Respects show_name/show_username flags
User photos filtering
GET /api/v3/user/photos?tagged=false&per_page=100&page=1&picked_up=true// tagged: true | false (omit for all)// picked_up: true | false (omit for all) — filters by photo-level picked_up status// per_page: 1-100 (default 8)// Untagged = WHERE summary IS NULL (NOT doesntHave('photoTags'))// Returns: { photos: [...], pagination: { current_page, last_page, per_page, total }, user }
Response includes picked_up (boolean, never null) and remaining (deprecated inverse). Use picked_up. Also includes is_public (boolean) and school_team (boolean) — use these to show visibility state and to gate the per-photo toggle (PATCH /api/v3/photos/{id}/visibility is blocked for school_team = true).
`new_tags` response shape: Each tag includes category_litter_object_id, litter_object_type_id, quantity, picked_up (bool, cast with fallback to photo-level), category (object or null), object (object or null), extra_tags (array). For loose/extra-tag-only tags, category, object, and category_litter_object_id are null. filename field on photo is a full URL, usable directly as image source.
PUT /api/v3/tags accepts empty tags. ReplacePhotoTagsRequest validates tags as present|array (not required|array|min:1). Sending tags: [] clears all tags from a photo (resets summary, XP, verified to untagged state).
GeoJSON response format (points/clusters)
{"type": "FeatureCollection","features": [{"type": "Feature","geometry": { "type": "Point", "coordinates": [lon, lat] },"properties": { "id": 1, "summary": {...}, "username": "..." }}]}
ETag caching (clusters)
GET /api/clusters?zoom=5&bbox=-180,-90,180,90// Response includes ETag header// Client sends If-None-Match on next request// Returns 304 Not Modified if unchanged
Common Mistakes
- Not reading `readme/API.md` before modifying an endpoint. The full request/response contract is documented there. Changing a response shape without updating the docs breaks the mobile agent.
- Using `auth:api` instead of `auth:sanctum`. Passport guards (
auth:api) are legacy. New routes useauth:sanctumwhich supports both session and token auth. - Returning `'tags'` instead of `'new_tags'` from UsersUploadsController. The frontend reads
photo.new_tagsfor tag counts. Wrong key = broken UI. - Omitting `litter_object_type_id` from `new_tags` response.
UsersUploadsController::getNewTags()must include it so the frontend can preserve the type dimension on edit round-trips. - Forgetting to update `readme/API.md` when changing an endpoint. The API docs must stay in sync with actual controller behavior.
- Adding public endpoints without `is_public` filtering. Any query that returns photo data to unauthenticated users MUST use
Photo::public()scope. - Comparing VerificationStatus enum to int in controllers. Use
->valuefor comparisons:$photo->verified->value >= VerificationStatus::ADMIN_APPROVED->value. - Including `geom` in API responses. Binary spatial data — keep it in Photo model's
$hiddenarray. - Using `doesntHave('photoTags')` or `WHERE verified = 0` for untagged filter. Use
whereNull('summary')— summary is set by GeneratePhotoSummaryService when tags are added, regardless of verification status. - Assuming consistent error response keys. Some controllers use
msg, others usemessage. Check the specific controller before asserting response keys in tests. - Missing `team_id` query parameter on team endpoints. Most team endpoints require
?team_id=X— forgetting it returns 422 or wrong team's data. - Not handling the `flag` field on points responses.
GET /api/points/{id}returns aflagfield from the user's settings. Mobile clients display this. - Using removed legacy endpoints. All v1/v2 endpoints (
/api/photos/submit,/api/add-tags,/api/v2/*) were removed 2026-03-01. Mobile uses v3 endpoints only. - Using old category/object strings in team tag edits.
PATCH /api/teams/photos/{photo}/tagsuses CLO format (category_litter_object_id), same asPOST /api/v3/tags. Not the old{ category, object }string format. - Forgetting `new_tags` in team photo responses. Both
index()andshow()returnnew_tagswith CLO IDs + extra_tags for the facilitator queue tag editor. - Reading `remaining` instead of `picked_up`.
remainingis deprecated (inverse boolean). Usepicked_up(boolean, never null at photo level). Per-tagnew_tags[].picked_upis separate and nullable (true/false/null). - Sending Unix milliseconds for upload `date` field. Backend expects seconds:
Carbon::createFromTimestamp((int) $dateInput). JSDate.now()returns milliseconds — divide by 1000. - Wrong delete param name.
POST /api/profile/photos/deleteexpectsphotoid(all lowercase, no underscore). NotphotoIdorphoto_id.