--- name: teams-safeguarding description: Teams, school teams, team photos, approval flow, TeamPhotosController, privacy, is_public, PhotoObserver, MasksStudentIdentity, and safeguarding. --- # Teams & Safeguarding School teams enforce a private-by-default pipeline. Photos are invisible to the public until a teacher approves them. This protects minors and ensures data quality. ## Key Files ### Backend - `app/Http/Controllers/Teams/TeamPhotosController.php` — Photo listing (with `new_tags`), approval, CLO-based tag editing, map points, delete, revoke, member stats - `app/Observers/PhotoObserver.php` — Sets `is_public = false` on school team photo creation - `app/Traits/MasksStudentIdentity.php` — Masks student names as "Student N" - `app/Models/Teams/Team.php` — `isSchool()`, `isLeader()`, `hasSafeguarding()`, `hasParticipantSessions()` - `app/Models/Teams/TeamType.php` — `team` column: `'school'` or `'community'` - `app/Models/Teams/Participant.php` — Participant slot model (token, activation) - `app/Http/Middleware/ParticipantAuth.php` — Token auth middleware for participant workspace - `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 - `app/Actions/Teams/CreateTeamAction.php` — Team creation with school-specific fields - `app/Http/Requests/Teams/CreateTeamRequest.php` — Validation + `school_manager` role check - `app/Mail/SchoolManagerInvite.php` — Queued email sent when `school_manager` role is granted (two CTAs: Upload + Create Team) - `app/Events/SchoolDataApproved.php` — Private broadcast on `team.{id}` channel - `app/Listeners/NotifyTeamOfApproval.php` — Notifies team members after approval ### Frontend (Facilitator Queue) - `resources/js/views/Teams/FacilitatorQueue.vue` — 3-panel layout (filters | PhotoViewer | tag editor) - `resources/js/views/Teams/components/FacilitatorQueueHeader.vue` — Navigation, action buttons (Approve/Save Edits/Revoke/Delete) - `resources/js/views/Teams/components/FacilitatorQueueFilters.vue` — Status toggle (pending/approved/all), date range - `resources/js/views/Teams/components/TeamMembersList.vue` — Per-student stats table - `resources/js/stores/teamPhotos.js` — Team photos Pinia store (CRUD, approve, revoke, delete, updateTags, memberStats) - `resources/js/views/Teams/TeamsHub.vue` — Main teams page (replaces TeamsLayout sidebar + TeamDashboard) - `resources/js/views/Teams/TeamOverview.vue` — Overview tab (stats, team info, all teams list) - `resources/js/views/Teams/TeamSettingsTab.vue` — Consolidated settings tab (privacy, edit, download, leave) - `resources/js/views/Teams/components/ParticipantGrid.vue` — Participant slot management grid - `resources/js/views/Teams/ParticipantEntry.vue` — Token entry page (`/session`) - `resources/js/views/Teams/ParticipantWorkspace.vue` — Participant upload/photos/tag workspace ### Tests - `tests/Feature/Teams/TeamPhotosTest.php` — 35 tests (new_tags, CLO tag edits, member stats, safeguarding, delete, revoke, approval, map) - `tests/Feature/Teams/ParticipantSessionTest.php` — 28 tests (slots, token auth, photos, queue, metrics) ## Authorization ### Facilitator Queue access control ```php // TeamPhotosController authorization pattern: // Team leader OR user with 'manage school team' permission if (! $team->isLeader($user->id) && ! $user->can('manage school team')) { return response()->json(['success' => false, 'message' => 'unauthorized'], 403); } ``` ### Roles involved | Role | How assigned | Facilitator access | |------|-------------|-------------------| | Team leader | `team.leader = user_id` | Yes — `$team->isLeader($userId)` | | `school_manager` | `php artisan school:assign-manager {email}` or admin toggle | Yes — has `manage school team` permission | **Critical:** School managers are NOT admins. They cannot access `/api/admin/*` endpoints. The admin queue and facilitator queue are completely separate systems with no overlap. ## Invariants 1. **School photos start private.** `PhotoObserver::creating()` sets `is_public = false` when `team.isSchool()`. This is non-negotiable and cannot be overridden by the user. Community team photos respect the uploading user's `public_photos` default (`users.public_photos`, boolean, default `true`). 2. **All public queries use `Photo::public()` or `where('is_public', true)`.** Missing this leaks school data to maps, clusters, exports, and points API. 3. **School teams must NOT be `is_trusted`.** Trust bypasses the teacher approval step entirely. School teams default to `is_trusted = false`. 4. **Teacher approval is atomic and idempotent.** The `WHERE is_public = false` clause prevents double-processing of already-approved photos. 5. **Safeguarding uses deterministic numbering.** Student names are masked based on `team_user.id` (creation order), not photo data or pagination. 6. **SchoolDataApproved broadcasts on a private channel** (`team.{id}`). School team names (e.g., "St. X 1st Years 2026") must never appear on public channels. 7. **Admin queue excludes school photos.** `is_public = false` photos never appear in `/api/admin/photos`. School photos go through teacher approval only. 8. **Participant photos: `user_id = facilitator`.** MetricsService, XP, leaderboards are untouched. `participant_id` is for attribution only. 9. **Participant isolation.** `PhotoTagsRequest::authorize()` checks `$photo->participant_id === $participant->id` to prevent cross-participant tagging (all photos share `user_id = facilitator`). 10. **`hasParticipantSessions()`** returns `participant_sessions_enabled && isSchool()` — community teams can never have participant sessions. 11. **Privacy defaults.** All new teams: `leaderboards = false`. School teams: `safeguarding = true` enforced, `is_trusted = false` enforced. 12. **SchoolManagerInvite email.** Queued on role grant (both artisan command and admin toggle). Not sent on revoke. ## Patterns ### PhotoObserver — automatic privacy ```php // app/Observers/PhotoObserver.php public function creating(Photo $photo): void { if (! $photo->team_id) { return; } $team = Team::find($photo->team_id); if ($team && $team->isSchool()) { $photo->is_public = false; } } ``` ### Teacher approval flow ```php // TeamPhotosController::approve() DB::transaction(function () { // Atomic update — WHERE is_public = false prevents double-processing Photo::whereIn('id', $approvedIds) ->where('is_public', false) ->update([ 'is_public' => true, 'verified' => VerificationStatus::ADMIN_APPROVED->value, 'team_approved_at' => now(), 'team_approved_by' => $user->id, ]); // Fire metrics for each newly-approved photo foreach ($affectedPhotos as $photo) { 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 )); } event(new SchoolDataApproved($team, $teacher, $count)); }); ``` ### Photo scopes for team queries ```php // All public photos (excludes unapproved school photos + soft-deleted) Photo::public() // ->where('is_public', true) // All photos for a team (private view — members see everything) Photo::forTeam($teamId) // Pending teacher approval Photo::pendingTeamApproval($teamId) // ->where('team_id', $teamId)->where('is_public', false) // ->where('verified', '>=', VERIFIED)->whereNull('team_approved_at') // Already approved by teacher Photo::teamApproved($teamId) // ->where('team_id', $teamId)->whereNotNull('team_approved_at') ``` ### Safeguarding identity masking ```php // MasksStudentIdentity trait // Builds stable mapping: user_id -> "Student N" from team_user.id order if ($team->hasSafeguarding() && !$team->isLeader($viewer->id) && !$viewer->hasPermissionTo('view student identities')) { // Mask names to "Student 1", "Student 2", etc. } ``` ### Team model key methods ```php $team->isSchool() // type_name === 'school' $team->isLeader($userId) // leader === $userId $team->hasSafeguarding() // (bool) safeguarding ``` ### Database indexes for team photo queries ```sql -- Approval queue: team_id + is_public + verified + created_at INDEX photos_team_approval_idx ON photos(team_id, is_public, verified, created_at) -- Team photo listing INDEX photos_team_public_idx ON photos(team_id, is_public) -- Public queries INDEX photos_public_verified_idx ON photos(is_public, verified) ``` ### Teacher delete flow ```php // TeamPhotosController::destroy() // DELETE /api/teams/photos/{photo}?team_id=X // 1. Check authorization (leader or 'manage school team') // 2. If processed: MetricsService::deletePhoto() → reverse metrics // 3. DeletePhotoAction → S3 cleanup // 4. $photo->delete() → soft-delete // 5. Decrement photo owner's XP ``` ### Teacher revoke flow ```php // TeamPhotosController::revoke() // POST /api/teams/photos/revoke { team_id, photo_ids? | revoke_all? } // 1. Check authorization (leader or 'manage school team') // 2. Query: is_public = true AND team_approved_at IS NOT NULL // 3. For each processed photo: MetricsService::deletePhoto() // 4. Atomic update: is_public=false, verified=VERIFIED, clear approval timestamps // Idempotent: already-private photos filtered by WHERE clause ``` ### Safeguarding on global map (PointsController) ```php // PointsController::formatFeatures() // After building properties array: if ($photo->team_id && $photo->team && $photo->team->hasSafeguarding()) { $properties['name'] = null; $properties['username'] = null; $properties['social'] = null; } // popup.js shows "Contributed by [Team Name]" when name/username are null but team exists ``` ### Facilitator Queue — CLO-based tag editing ```php // TeamPhotosController::updateTags() // PATCH /api/teams/photos/{photo}/tags // Accepts CLO payload: { tags: [{ category_litter_object_id, litter_object_type_id?, quantity, picked_up?, materials?, brands?, custom_tags? }] } DB::transaction(function () use ($request, $photo, $user) { $photo->photoTags()->each(function ($tag) { $tag->extraTags()->delete(); $tag->delete(); }); $photo->update(['summary' => null, 'xp' => 0, 'verified' => VerificationStatus::UNVERIFIED->value]); app(AddTagsToPhotoAction::class)->run($user->id, $photo->id, $request->tags); }); ``` ### Facilitator Queue — new_tags response format ```php // TeamPhotosController::index() and show() return new_tags // Same format as UsersUploadsController::getNewTags() and AdminQueueController // Includes: category_litter_object_id, litter_object_type_id, category, object, extra_tags ``` ### Member stats endpoint ```php // TeamPhotosController::memberStats() // GET /api/teams/photos/member-stats?team_id=X // Returns per-student: total_photos, pending, approved, litter_count, last_active // Applies safeguarding pseudonyms via MasksStudentIdentity trait // Leader or 'manage school team' permission required ``` ### Keyboard shortcuts (FacilitatorQueue.vue) | Key | Action | |-----|--------| | A | Approve current photo | | D | Delete (with confirmation) | | E | Save edits (when modified) | | R | Revoke approval (with confirmation) | | S / K / ArrowRight | Next photo | | J / ArrowLeft | Previous photo | | Escape | Clear search | ### Controllers/queries that must use `is_public = true` - `Maps/GlobalMapController` — global map points - `HomeController` — homepage stats - `CommunityController` — community page - `Leaderboard/LeaderboardController` — leaderboards - `DisplayTagsOnMapController` — tag map - `History/GetPaginatedHistoryController` — public history - `Points/PointsController` — points API - `MapController` — map clusters - `User/ProfileController` — public profile ## Common Mistakes - **Querying photos without `Photo::public()` scope on public-facing endpoints.** This leaks school team photos. - **Setting `is_trusted = true` on school teams.** Trusted teams bypass teacher approval. School teams must always be `is_trusted = false`. - **Broadcasting school data on public channels.** `SchoolDataApproved` must use private channel `team.{id}`. - **Using non-deterministic ordering for safeguarding masks.** Masks must be based on `team_user.id` (join order), not photo data. - **Forgetting `PhotoObserver` when creating photos in tests.** The observer auto-fires on `Photo::create()`. If testing non-school behavior, ensure `team_id` is null or team is community type. - **Double-approving photos.** The `WHERE is_public = false` clause in the approval query prevents this, but don't remove it. - **Allowing per-photo visibility toggle on school photos.** `PATCH /api/v3/photos/{id}/visibility` must return 403 for photos belonging to a school team. School photo privacy is teacher-controlled only (approve/revoke), never user-controlled. - **Using old category/object string format in updateTags.** `TeamPhotosController::updateTags()` uses CLO format (same as `PhotoTagsController::update`). Payload uses `category_litter_object_id`, NOT category/object key strings. - **Forgetting `new_tags` in team photo responses.** Both `index()` and `show()` must include `new_tags` with `category_litter_object_id`, `litter_object_type_id`, and `extra_tags` for the facilitator queue tag editor to work.