| 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
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
- 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).
- All public queries use
Photo::public() or where('is_public', true). Missing this leaks school data to maps, clusters, exports, and points API.
- School teams must NOT be
is_trusted. Trust bypasses the teacher approval step entirely. School teams default to is_trusted = false.
- Teacher approval is atomic and idempotent. The
WHERE is_public = false clause prevents double-processing of already-approved photos.
- Safeguarding uses deterministic numbering. Student names are masked based on
team_user.id (creation order), not photo data or pagination.
- SchoolDataApproved broadcasts on a private channel (
team.{id}). School team names (e.g., "St. X 1st Years 2026") must never appear on public channels.
- Admin queue excludes school photos.
is_public = false photos never appear in /api/admin/photos. School photos go through teacher approval only.
- Participant photos:
user_id = facilitator. MetricsService, XP, leaderboards are untouched. participant_id is for attribution only.
- Participant isolation.
PhotoTagsRequest::authorize() checks $photo->participant_id === $participant->id to prevent cross-participant tagging (all photos share user_id = facilitator).
hasParticipantSessions() returns participant_sessions_enabled && isSchool() — community teams can never have participant sessions.
- Privacy defaults. All new teams:
leaderboards = false. School teams: safeguarding = true enforced, is_trusted = false enforced.
- SchoolManagerInvite email. Queued on role grant (both artisan command and admin toggle). Not sent on revoke.
Patterns
PhotoObserver — automatic privacy
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
DB::transaction(function () {
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,
]);
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
Photo::public()
Photo::forTeam($teamId)
Photo::pendingTeamApproval($teamId)
Photo::teamApproved($teamId)
Safeguarding identity masking
if ($team->hasSafeguarding() && !$team->isLeader($viewer->id)
&& !$viewer->hasPermissionTo('view student identities')) {
}
Team model key methods
$team->isSchool()
$team->isLeader($userId)
$team->hasSafeguarding()
Database indexes for team photo queries
INDEX photos_team_approval_idx ON photos(team_id, is_public, verified, created_at)
INDEX photos_team_public_idx ON photos(team_id, is_public)
INDEX photos_public_verified_idx ON photos(is_public, verified)
Teacher delete flow
Teacher revoke flow
Safeguarding on global map (PointsController)
if ($photo->team_id && $photo->team && $photo->team->hasSafeguarding()) {
$properties['name'] = null;
$properties['username'] = null;
$properties['social'] = null;
}
Facilitator Queue — CLO-based tag editing
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
Member stats endpoint
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.