OpenLitterMap v5

June 8, 2026 · View on GitHub

Overview

Teams allow groups of users to collaborate on litter mapping. Two team types exist:

TypeTrustSafeguardingPhoto PrivacyApproval Required
CommunityConfigurableOff by defaultPublicNo
SchoolAlways untrustedAlways onPrivate until approvedYes (teacher)

Golden rule: School team photos are always private (is_public = false) until a teacher approves them — this is enforced by PhotoObserver regardless of user settings. Community team photos respect the uploading user's public_photos default (and any per-photo is_public override), so a community team member who has set public_photos=false will have their uploads hidden from the map while still receiving full metrics. This prevents student data from appearing on the public map or in aggregate metrics before adult review.


Team Types

Stored in the team_types table. Seeded by migrations:

teampriceDescription
community0General-purpose team
school0LitterWeek / environmental education

The Team model resolves its type name via getTypeNameAttribute(), which reads from the teamType relationship. Do NOT hardcode type_id-to-name mappings — IDs vary between environments.


Database Schema

teams table

ColumnTypeNotes
idBIGINTPK
nameVARCHARUnique
identifierVARCHARUnique join code
type_idINT FK→ team_types.id
type_nameVARCHARDenormalized type name
leaderINT FK→ users.id
created_byINT FK→ users.id
membersINTCounter (default 1)
is_trustedBOOLEANWhether tags auto-verify
safeguardingBOOLEANStudent identity masking
leaderboardsBOOLEANWhether team appears on leaderboards
contact_emailVARCHARSchool-specific
academic_yearVARCHAR(20)School-specific
class_groupVARCHAR(100)School-specific
countyVARCHAR(100)School-specific
logoVARCHARPath on logos disk (S3)
max_participantsINT UNSIGNEDMax students for school teams
participant_sessions_enabledBOOLEANEnable token-based participant sessions

participants table

ColumnTypeNotes
idBIGINT PKAuto-increment
team_idINT FK→ teams.id (CASCADE)
slot_numberSMALLINTUnique per team
display_nameVARCHAR(100)Student label
session_tokenCHAR(64)Unique, hidden from JSON
is_activeBOOLEANDefault true
last_active_atTIMESTAMPLast authenticated request

photos table (participant column)

ColumnTypeNotes
participant_idBIGINT FK→ participants.id (SET NULL)

team_user pivot table

ColumnTypeNotes
team_idINT FK→ teams.id
user_idINT FK→ users.id
show_name_mapsBOOLEANPrivacy preference
show_username_mapsBOOLEANPrivacy preference
show_name_leaderboardsBOOLEANPrivacy preference
show_username_leaderboardsBOOLEANPrivacy preference
ColumnTypeNotes
active_teamINT FKCurrently active team (nullable)
remaining_teamsINTHow many more teams user can create (new signups get 1 via RegisterController; school managers granted 1)

Key Files

Models

  • app/Models/Teams/Team.php — Team model with type accessor, relationships, hasParticipantSessions()
  • app/Models/Teams/TeamType.php — Team type lookup
  • app/Models/Teams/Participant.php — Participant slot model (token, activation, relationships)

Controllers

  • app/Http/Controllers/Teams/TeamsController.php — Web routes (create, join, leave, members)
  • app/Http/Controllers/API/TeamsController.php — API routes (same ops, JSON responses)
  • app/Http/Controllers/Teams/TeamPhotosController.php — Photo listing, approval, tag editing, map, delete, revoke
  • app/Http/Controllers/Teams/TeamsDataController.php — Dashboard stats + verification breakdown
  • app/Http/Controllers/Teams/TeamsLeaderboardController.php — Team leaderboard
  • app/Http/Controllers/Teams/TeamsSettingsController.php — Privacy settings per team
  • app/Http/Controllers/Teams/TeamsClusterController.php — Map clustering for team photos
  • app/Http/Controllers/Teams/ParticipantController.php — Facilitator CRUD for participant slots
  • app/Http/Controllers/Teams/ParticipantSessionController.php — Token validation + session entry
  • app/Http/Controllers/Teams/ParticipantPhotoController.php — Participant's own photos (list, delete)

Middleware

  • app/Http/Middleware/ParticipantAuth.php — Token auth for participant workspace routes

Actions

  • app/Actions/Teams/CreateTeamAction.php — Creates team, dispatches TeamCreated
  • app/Actions/Teams/JoinTeamAction.php — Join by identifier
  • app/Actions/Teams/LeaveTeamAction.php — Leave team, clears active_team
  • app/Actions/Teams/SetActiveTeamAction.php — Set user's active team
  • app/Actions/Teams/UpdateTeamAction.php — Update name/identifier (leader only)
  • app/Actions/Teams/DownloadTeamDataAction.php — Export team data

Validation

  • app/Http/Requests/Teams/CreateTeamRequest.php — School teams require school_manager role + extra fields
  • app/Http/Requests/Teams/JoinTeamRequest.php
  • app/Http/Requests/Teams/LeaveTeamRequest.php
  • app/Http/Requests/Teams/UpdateTeamRequest.php

Events

  • app/Events/TeamCreated.php(Team $team) — broadcasts to private channel for schools
  • app/Events/SchoolDataApproved.php(Team $team, User $approvedBy, int $photoCount) — broadcasts approval notification
  • app/Events/TagsVerifiedByAdmin.php(photo_id, user_id, country_id, state_id, ?city_id, ?team_id) — triggers MetricsService

Observer

  • app/Observers/PhotoObserver.php — Sets is_public = false on creating() for school team photos

Traits

  • app/Traits/MasksStudentIdentity.php — Deterministic pseudonym masking ("Student 1", "Student 2", etc.)

Commands

  • app/Console/Commands/Teams/AssignSchoolManager.phpphp artisan school:assign-manager {email} (queues SchoolManagerInvite email)

Mailables

  • app/Mail/SchoolManagerInvite.php — Queued email sent when school_manager role is granted (from artisan command or admin toggle). Two CTAs: "Upload Your First Photos" → /upload, "Create Your School Team" → /teams/create

Tests

  • tests/Feature/Teams/TeamsTest.php — Core CRUD, events, types, members, privacy (17 tests)
  • tests/Feature/Teams/TeamPhotosTest.php — Photo listing, approval, CLO tag editing, new_tags format, member stats, map, dashboard, delete, revoke, safeguarding (35 tests)
  • tests/Feature/Teams/SchoolApprovalPipelineTest.php — End-to-end approval pipeline (7 tests)
  • tests/Feature/Teams/SchoolPhotoPipelineTest.php — Full photo pipeline integration (4 tests)
  • tests/Feature/Teams/CreateTeamTest.php — Team creation validation
  • tests/Feature/Teams/JoinTeamTest.php — Join flow
  • tests/Feature/Teams/SafeguardingTest.php — Identity masking
  • tests/Feature/Teams/ParticipantSessionTest.php — Participant slots, token auth, photos, metrics (28 tests)

API Routes

All under /api/teams, most require auth:api middleware.

Team Management

MethodRouteActionAuth
GET/teams/typesList team typesPublic
GET/teams/joinedUser's teamsRequired
GET/teams/listUser's teams (API)Required
GET/teams/membersPaginated members (with safeguarding)Required
POST/teams/createCreate teamRequired
POST/teams/joinJoin by identifierRequired
POST/teams/leaveLeave teamRequired
POST/teams/activeSet active teamRequired
POST/teams/inactivateClear active teamRequired
PATCH/teams/update/{team}Update name/identifier (leader only)Required
POST/teams/settingsPrivacy settingsRequired

Team Photos (school approval pipeline)

MethodRouteActionAuth
GET/teams/photos?team_id=X&status=pending|approved|allList photos (with new_tags)Required
GET/teams/photos/map?team_id=XMap points (up to 5000)Required
GET/teams/photos/member-stats?team_id=XPer-student stats (leader only)Required
GET/teams/photos/{photo}Single photo with tags (with new_tags)Required
PATCH/teams/photos/{photo}/tagsEdit tags — CLO format (leader/school_manager)Required
POST/teams/photos/approveApprove photos (leader/school_manager)Required
DELETE/teams/photos/{photo}?team_id=XDelete photo (leader/school_manager)Required
POST/teams/photos/revokeRevoke approval (leader/school_manager)Required

Participant Management (leader only)

MethodRouteActionAuth
GET/teams/{team}/participantsList participant slotsRequired
POST/teams/{team}/participantsCreate slots in bulkRequired
POST/teams/{team}/participants/{id}/deactivateRevoke sessionRequired
POST/teams/{team}/participants/{id}/activateRe-enable sessionRequired
POST/teams/{team}/participants/{id}/reset-tokenRegenerate tokenRequired
DELETE/teams/{team}/participants/{id}Delete slotRequired

Participant Session (token auth)

MethodRouteActionAuth
POST/participant/sessionValidate tokenPublic
POST/participant/uploadUpload photoToken
POST/participant/tagsTag own photoToken
GET/participant/photosList own photosToken
DELETE/participant/photos/{photo}Delete own photoToken

Dashboard & Leaderboard

MethodRouteActionAuth
GET/teams/data?team_id=X&period=all|today|week|month|yearDashboard statsRequired
GET/teams/leaderboardTeam leaderboardRequired
POST/teams/leaderboard/visibilityToggle visibilityRequired

Team Response Shape (list + leaderboard)

Both GET /api/teams/list and GET /api/teams/leaderboard return teams with consistent field naming:

{
    "id": 1,
    "name": "Team Name",
    "type_name": "community",
    "total_members": 5,
    "total_tags": 1200,
    "total_images": 300,
    "created_at": "2025-01-15T10:00:00.000000Z",
    "updated_at": "2026-02-28T14:30:00.000000Z"
}

The list endpoint also includes identifier (join code). Uses total_tags/total_images/total_members — never total_litter/members.


Permissions & Roles

Uses Spatie Laravel Permission 6. All on web guard.

Permissions

PermissionPurpose
create school teamCreate a school-type team
manage school teamApprove photos, edit tags, manage team
toggle safeguardingEnable/disable safeguarding on a team
view student identitiesSee real student names even with safeguarding

Roles

RolePermissions
school_managerAll four above

Assignment

php artisan school:assign-manager user@example.com

Both the artisan command and AdminUsersController::toggleSchoolManager() queue a SchoolManagerInvite email when the role is granted. Revoking the role does not send an email.


Trust Model

Team PropertyEffect
is_trusted = trueTags auto-verify → TagsVerifiedByAdmin fires immediately → MetricsService processes
is_trusted = falseTags stay at VERIFIED (1) — no metrics event until approval

School teams MUST be is_trusted = false. If a school team were trusted, student tags would immediately flow through MetricsService into public aggregate data (country totals, leaderboards) before teacher review. The photo would be hidden from the map (is_public = false), but aggregate data would leak.

Teacher approval IS the verification event for school photos. See readme/SchoolPipeline.md.


Safeguarding (Identity Masking)

When team.safeguarding = true, student names are replaced with deterministic pseudonyms in API responses.

Who sees what

ViewerNames visible?
Team leaderReal names
User with view student identities permissionReal names
Students / other membersMasked ("Student 1", "Student 2")

How masking works

The MasksStudentIdentity trait builds a stable mapping from team_user.id ordering:

$memberOrder = DB::table('team_user')
    ->where('team_id', $team->id)
    ->where('user_id', '!=', $team->leader)
    ->orderBy('id')
    ->pluck('user_id')
    ->flip()
    ->map(fn ($index) => 'Student ' . ($index + 1));

Numbering is deterministic — "Student 3" is always the same person regardless of which page or endpoint.

Where masking is applied

  • TeamPhotosController::index() — photo listing
  • TeamPhotosController::memberStats() — per-student stats
  • TeamsController::members() — member listing
  • Any endpoint that uses the MasksStudentIdentity trait

Delete & Revoke (Teacher Actions)

Delete Photo

DELETE /api/teams/photos/{photo}?team_id=X — Teacher permanently removes a photo.

  1. Validates authorization (leader or manage school team permission)
  2. If photo was processed (processed_at set): calls MetricsService::deletePhoto() to reverse all metrics
  3. Runs DeletePhotoAction to clean up S3 files
  4. Soft-deletes the photo
  5. Decrements the photo owner's XP and total_images (not the teacher's)
  6. Returns updated team stats

Revoke Approval

POST /api/teams/photos/revoke — Teacher un-publishes approved photos.

Accepts { team_id, photo_ids: [...] } or { team_id, revoke_all: true }.

  1. Validates authorization (leader or manage school team)
  2. Builds query: photos WHERE team_id = X AND is_public = true AND team_approved_at IS NOT NULL
  3. For each processed photo: calls MetricsService::deletePhoto() to reverse metrics
  4. Atomic UPDATE: is_public = false, verified = VERIFIED, team_approved_at = null, team_approved_by = null
  5. Returns { success, revoked_count }

Idempotent: Already-private photos are filtered out by the WHERE clause. Revoking twice is a no-op.

Safeguarding on Global Map

When a school team has safeguarding = true, the PointsController::formatFeatures() method masks student identity in map popups:

  • Sets name, username, and social to null
  • Preserves team name for attribution: "Contributed by [Team Name]"
  • Implemented via team:id,name,safeguarding eager load in PointsController

Facilitator Queue (3-Panel Verification UI)

School team leaders have access to a full verification queue similar to the admin queue, scoped to their team.

Frontend Components

  • FacilitatorQueue.vue — 3-panel layout (filters | photo viewer | tag editor)
  • FacilitatorQueueHeader.vue — Navigation, action buttons (Approve, Save Edits, Revoke, Delete)
  • FacilitatorQueueFilters.vue — Status toggle (pending/approved/all), date range
  • TeamMembersList.vue — Per-student stats table

Reused Components (from Tagging v2)

  • PhotoViewer.vue — Photo display with zoom/pan
  • UnifiedTagSearch.vue — Fuzzy tag search (objects, types, brands, materials, custom)
  • ActiveTagsList.vue — Active tags with quantity, picked_up, brands, materials, custom tags
  • TagCard.vue — Individual tag display

Tag Format

Both index and show endpoints return new_tags — the CLO-based format that hydrateTagsForPhoto() uses:

{
    "new_tags": [
        {
            "id": 123,
            "category_litter_object_id": 45,
            "litter_object_type_id": null,
            "quantity": 3,
            "picked_up": true,
            "category": { "id": 1, "key": "smoking" },
            "object": { "id": 10, "key": "cigarette_butt" },
            "extra_tags": [
                { "type": "brand", "quantity": 1, "tag": { "id": 5, "key": "marlboro" } }
            ]
        }
    ]
}

Tag Editing (CLO Format)

PATCH /api/teams/photos/{photo}/tags now accepts CLO-based payload (same as PhotoTagsController::store):

{
    "tags": [
        {
            "category_litter_object_id": 45,
            "quantity": 3,
            "picked_up": true,
            "materials": [{ "id": 1, "quantity": 1 }],
            "brands": [{ "id": 5, "quantity": 1 }],
            "custom_tags": [{ "tag": "stained", "quantity": 1 }]
        }
    ]
}

Internally deletes existing tags, resets summary/xp/verified, then calls AddTagsToPhotoAction::run().

Keyboard Shortcuts

KeyAction
AApprove current photo
DDelete (with confirmation)
ESave edits (when modified)
RRevoke approval (with confirmation)
S / K / ArrowRightNext photo
J / ArrowLeftPrevious photo
EscapeClear search

Member Stats

GET /api/teams/photos/member-stats?team_id=X — Leader/school_manager only.

Returns per-student stats:

{
    "members": [
        {
            "user_id": 42,
            "name": "Student 1",
            "username": null,
            "total_photos": 15,
            "pending": 3,
            "approved": 12,
            "litter_count": 87,
            "last_active": "2026-02-28 14:30:00"
        }
    ]
}

When safeguarding is enabled, names are deterministic pseudonyms and usernames are null.


Team Creation Validation

Community teams

  • name: required, 3-100 chars, unique
  • identifier: required, 3-100 chars, unique
  • teamType: required, must exist in team_types

School teams (additional)

  • User must have school_manager role (403 otherwise)
  • contact_email: required, valid email
  • school_roll_number: optional, max 50 chars
  • county: required, max 100 chars
  • academic_year: optional, max 20 chars
  • class_group: optional, max 100 chars

Logo Storage

School logos use a separate S3 disk (logos) from photo uploads, for public direct-URL access:

DiskBucket envPurpose
s3AWS_BUCKETUser photo uploads
logosAWS_LOGOS_BUCKETSchool team logos (school-logos/ prefix, visibility: public)

Config: config/filesystems.phpdisks.logos. Local dev (MinIO): create an openlittermap-logos bucket or override AWS_LOGOS_BUCKET.


Participant Sessions

Let students participate without real user accounts. Facilitators pre-create numbered slots with 64-char session tokens; students authenticate by token and all photos are owned by the facilitator. (DB: participants table + photos.participant_id; routes under /api/teams/{team}/participants and /api/participant/* — see Database Schema and API Routes above.)

Flow:

  1. Facilitator enables participant_sessions_enabled at creation
  2. Facilitator creates slots in bulk — each gets a unique 64-char session_token
  3. Student enters their code at /session (stored in localStorage)
  4. Requests carry X-Participant-Token; ParticipantAuth middleware resolves the facilitator and calls Auth::setUser($facilitator) (stateless, not Auth::login())
  5. Photos created with user_id = facilitator, participant_id = slot

Key invariant: photos.user_id = team.leader for ALL participant photos. participant_id is attribution only — MetricsService, XP, and leaderboards are untouched and accrue to the facilitator. hasParticipantSessions() = participant_sessions_enabled && isSchool() (community teams can't have sessions).

Frontend: ParticipantEntry.vue (/session, public token entry), ParticipantWorkspace.vue (/session/workspace, upload/photos/tag tabs), ParticipantGrid.vue (slot management tab in the team dashboard).


Dashboard Stats

TeamsDataController::index() returns live stats from the photos table:

{
    "photos_count": 42,
    "litter_count": 185,
    "members_count": 7,
    "verification": {
        "unverified": 5,
        "verified": 12,
        "admin_approved": 20,
        "bbox_applied": 3,
        "bbox_verified": 2,
        "ai_ready": 0
    }
}
  • litter_count sums total_tags for ADMIN_APPROVED+ photos only
  • members_count is distinct user_ids in the period
  • Supports period filtering: all, today, week, month, year

Frontend Architecture

TeamsHub (/teams route)

The teams frontend uses a single-page hub pattern. TeamsHub.vue replaces the old TeamsLayout.vue sidebar navigation.

Three states:

  1. No teams → Landing page with Create/Join actions
  2. Has team(s) → Active team dashboard with header (team name, type badge, team switcher), stats row, and tab navigation
  3. No active team but has teams → Prompts to pick an active team

Tabs: Overview | Photos | Map | Members | Settings | Leaderboard | Approval Queue (school+leader) | Participants (school+sessions+leader)

Key files:

  • resources/js/views/Teams/TeamsHub.vue — Main hub component
  • resources/js/views/Teams/TeamOverview.vue — Overview tab (stats, team info, all teams list)
  • resources/js/views/Teams/TeamSettingsTab.vue — Consolidated settings tab
  • resources/js/views/Teams/CreateTeam.vue — Standalone creation page at /teams/create

Routes:

PathComponentPurpose
/teamsTeamsHub.vueTeam-centric hub
/teams/createCreateTeam.vueStandalone create page

Privacy Defaults

  • All new teams: leaderboards = false by default (opt-in via settings)
  • School teams: safeguarding = true enforced on creation (cannot be disabled)
  • School teams: is_trusted = false enforced on creation (cannot be changed)

School Manager Onboarding

When a school team is created, the leader sees a "Getting Started" checklist in the Overview tab covering: upload photos, create participant sessions (if enabled), review the approval queue. The facilitator queue includes an explainer for first-time users. Nav badge shows pending photo count.


DocumentCovers
SchoolPipeline.mdFull school approval pipeline (the critical data flow)
Upload.mdHow photos enter the system, when MetricsService runs
Metrics.mdHow MetricsService processes approved photos
Leaderboards.mdLeaderboard system — Redis ZSETs + MySQL per-user metrics
Tags.mdTag hierarchy, summary JSON, XP calculation