diff --git a/CLAUDE.md b/CLAUDE.md index eb2ba3e..8361ee8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -120,6 +120,12 @@ les tables sauf les tables d'association. défaut `MANUAL`), `locked` (défaut `false`). Unique (`volunteer_id`, `shift_id`). **Pas de statut** : l'affectation est valide dès sa création (aucune étape de confirmation) ; la retirer, c'est supprimer la ligne. + `GET /editions/{id}/volunteers/{vid}/planning` (lecture : COORD / LEAD ou le + bénévole lui-même) renvoie son planning enrichi (poste, lieu, référent du + poste + son téléphone si son compte est rattaché à une fiche bénévole) sans + jamais donner à un compte VOLUNTEER l'accès en lecture à `positions`/ + `shifts` de toute l'édition — c'est l'écran bénévole (`/mobile-benevole`) + qui en a besoin. - **`Account`** : `email` (unique par édition), `role` (`Role`), `edition_id`, `volunteer_id` (nullable, unique). - **`MagicLinkToken`** : `account_id`, `token_hash` (unique), `expires_at`, diff --git a/backend/app/api/schemas.py b/backend/app/api/schemas.py index e8c4640..02324d4 100644 --- a/backend/app/api/schemas.py +++ b/backend/app/api/schemas.py @@ -84,6 +84,24 @@ class VolunteerRead(ApiModel): updated_at: dt.datetime +class VolunteerPlanningEntry(ApiModel): + """Un créneau affecté, enrichi (poste, lieu, référent à contacter) — pour + l'écran bénévole (`GET .../volunteers/{id}/planning`), sans jamais + exposer la liste complète des postes/créneaux de l'édition à un compte + VOLUNTEER (lecture restreinte à sa propre fiche, cf. § 2 matrice).""" + + assignment_id: uuid.UUID + shift_id: uuid.UUID + starts_at: dt.datetime + ends_at: dt.datetime + position_name: str + position_location: str | None + # None si le poste n'a pas (encore) de référent, ou si son compte n'est + # pas rattaché à une fiche bénévole (pas de téléphone à donner alors). + referent_name: str | None + referent_phone: str | None + + # --------------------------------------------------------------------------- # # Artist (programmation) # # --------------------------------------------------------------------------- # diff --git a/backend/app/api/volunteers.py b/backend/app/api/volunteers.py index 1cf9554..a84ab6a 100644 --- a/backend/app/api/volunteers.py +++ b/backend/app/api/volunteers.py @@ -6,11 +6,17 @@ L'email n'est pas unique : plusieurs bénévoles peuvent en partager un même import uuid from fastapi import APIRouter, Depends, HTTPException, Response, status +from sqlalchemy.orm import aliased from sqlmodel import Session, select from app.api._common import owned_or_404, replace_links from app.api.deps import get_edition -from app.api.schemas import VolunteerCreate, VolunteerRead, VolunteerUpdate +from app.api.schemas import ( + VolunteerCreate, + VolunteerPlanningEntry, + VolunteerRead, + VolunteerUpdate, +) from app.auth.deps import ( current_account_in_edition, require_coordinator, @@ -20,9 +26,11 @@ from app.db import get_session from app.models import ( Account, Artist, + Assignment, Edition, Position, Role, + Shift, Volunteer, VolunteerArtist, VolunteerCompanionWish, @@ -177,6 +185,8 @@ def create_volunteer( _guard: Account = Depends(require_coordinator), session: Session = Depends(get_session), ) -> VolunteerRead: + payload.first_name = payload.first_name.title() + payload.last_name = payload.last_name.title() volunteer = Volunteer( edition_id=edition.id, **payload.model_dump( @@ -219,6 +229,49 @@ def get_volunteer( return _read(session, volunteer) +@router.get("/{volunteer_id}/planning", response_model=list[VolunteerPlanningEntry]) +def get_volunteer_planning( + volunteer_id: uuid.UUID, + edition: Edition = Depends(get_edition), + account: Account = Depends(current_account_in_edition), + session: Session = Depends(get_session), +) -> list[VolunteerPlanningEntry]: + """Planning enrichi (poste, lieu, référent + téléphone à contacter) d'un + bénévole — permet à l'écran bénévole (`/mobile-benevole`) d'afficher ces + infos sans jamais donner à un compte VOLUNTEER un accès en lecture aux + postes/créneaux de toute l'édition (réservé à COORDINATOR/LEAD).""" + _ensure_can_read(account, volunteer_id) + owned_or_404(session.get(Volunteer, volunteer_id), edition.id) + + Referent = aliased(Volunteer) + rows = session.exec( + select(Assignment, Shift, Position, Referent) + .join(Shift, Shift.id == Assignment.shift_id) + .join(Position, Position.id == Shift.position_id) + .join(Account, Account.id == Position.lead_account_id, isouter=True) + .join(Referent, Referent.id == Account.volunteer_id, isouter=True) + .where( + Assignment.volunteer_id == volunteer_id, + Assignment.edition_id == edition.id, + ) + .order_by(Shift.starts_at) + ).all() + + return [ + VolunteerPlanningEntry( + assignment_id=a.id, + shift_id=s.id, + starts_at=s.starts_at, + ends_at=s.ends_at, + position_name=p.name, + position_location=p.location, + referent_name=f"{r.first_name} {r.last_name}" if r else None, + referent_phone=r.phone if r else None, + ) + for a, s, p, r in rows + ] + + @router.patch("/{volunteer_id}", response_model=VolunteerRead) def update_volunteer( volunteer_id: uuid.UUID, diff --git a/backend/tests/test_api_volunteers.py b/backend/tests/test_api_volunteers.py index de402ed..9d2cd67 100644 --- a/backend/tests/test_api_volunteers.py +++ b/backend/tests/test_api_volunteers.py @@ -3,7 +3,7 @@ import uuid from sqlmodel import select -from app.models import Assignment, Availability, Volunteer +from app.models import Assignment, Availability, Role, Volunteer from tests.factories import ( dt_at, make_artist, @@ -232,3 +232,160 @@ def test_delete_is_a_full_cascade(client, edition, session): from app.models import Shift assert session.get(Shift, shift.id) is not None + + +# --------------------------------------------------------------------------- # +# Planning enrichi (poste, lieu, référent) — écran bénévole # +# --------------------------------------------------------------------------- # +def test_planning_is_empty_without_assignment(client, edition, session): + volunteer = make_volunteer(session, edition, email="v@ex.fr") + resp = client.get(_url(edition, f"/{volunteer.id}/planning")) + assert resp.status_code == 200 + assert resp.json() == [] + + +def test_planning_includes_position_and_referent(client, edition, session): + from app.models import Account + + volunteer = make_volunteer(session, edition, email="v@ex.fr") + referent_volunteer = make_volunteer( + session, edition, first_name="Rita", last_name="Referente", email="r@ex.fr" + ) + referent_volunteer.phone = "0600000000" + session.add(referent_volunteer) + referent_account = Account( + edition_id=edition.id, + email="rita@ex.fr", + role=Role.LEAD, + volunteer_id=referent_volunteer.id, + ) + session.add(referent_account) + session.commit() + session.refresh(referent_account) + + position = make_position(session, edition, name="Bar central") + position.location = "Chapiteau" + position.lead_account_id = referent_account.id + session.add(position) + session.commit() + + shift = make_shift( + session, edition, position, start=dt_at(10, 9), end=dt_at(10, 12) + ) + session.add( + Assignment(edition_id=edition.id, volunteer_id=volunteer.id, shift_id=shift.id) + ) + session.commit() + + resp = client.get(_url(edition, f"/{volunteer.id}/planning")) + assert resp.status_code == 200 + body = resp.json() + assert len(body) == 1 + entry = body[0] + assert entry["shift_id"] == str(shift.id) + assert entry["position_name"] == "Bar central" + assert entry["position_location"] == "Chapiteau" + assert entry["referent_name"] == "Rita Referente" + assert entry["referent_phone"] == "0600000000" + + +def test_planning_referent_fields_are_none_without_referent( + client, edition, session +): + volunteer = make_volunteer(session, edition, email="v@ex.fr") + position = make_position(session, edition) + shift = make_shift( + session, edition, position, start=dt_at(10, 9), end=dt_at(10, 12) + ) + session.add( + Assignment(edition_id=edition.id, volunteer_id=volunteer.id, shift_id=shift.id) + ) + session.commit() + + resp = client.get(_url(edition, f"/{volunteer.id}/planning")) + entry = resp.json()[0] + assert entry["referent_name"] is None + assert entry["referent_phone"] is None + + +def test_planning_referent_fields_are_none_when_lead_has_no_volunteer_record( + client, edition, session +): + """Un référent peut être un compte LEAD sans fiche bénévole associée : + pas de nom/téléphone à afficher dans ce cas.""" + from app.models import Account + + volunteer = make_volunteer(session, edition, email="v@ex.fr") + referent_account = Account( + edition_id=edition.id, email="lead@ex.fr", role=Role.LEAD + ) + session.add(referent_account) + session.commit() + session.refresh(referent_account) + + position = make_position(session, edition) + position.lead_account_id = referent_account.id + session.add(position) + session.commit() + + shift = make_shift( + session, edition, position, start=dt_at(10, 9), end=dt_at(10, 12) + ) + session.add( + Assignment(edition_id=edition.id, volunteer_id=volunteer.id, shift_id=shift.id) + ) + session.commit() + + resp = client.get(_url(edition, f"/{volunteer.id}/planning")) + entry = resp.json()[0] + assert entry["referent_name"] is None + assert entry["referent_phone"] is None + + +def test_planning_is_ordered_by_start_time(client, edition, session): + volunteer = make_volunteer(session, edition, email="v@ex.fr") + position = make_position(session, edition) + late = make_shift( + session, edition, position, start=dt_at(11, 14), end=dt_at(11, 18) + ) + early = make_shift( + session, edition, position, start=dt_at(10, 9), end=dt_at(10, 12) + ) + session.add_all( + [ + Assignment(edition_id=edition.id, volunteer_id=volunteer.id, shift_id=late.id), + Assignment(edition_id=edition.id, volunteer_id=volunteer.id, shift_id=early.id), + ] + ) + session.commit() + + resp = client.get(_url(edition, f"/{volunteer.id}/planning")) + body = resp.json() + assert [e["shift_id"] for e in body] == [str(early.id), str(late.id)] + + +def test_planning_is_404_for_unknown_volunteer(client, edition): + resp = client.get(_url(edition, f"/{uuid.uuid4()}/planning")) + assert resp.status_code == 404 + + +def test_volunteer_can_read_own_planning_but_not_anothers( + session, edition, make_account, as_account +): + me = make_volunteer(session, edition, email="me@e.fr") + other = make_volunteer(session, edition, email="other@e.fr") + account = make_account(Role.VOLUNTEER, volunteer_id=me.id, email="me@e.fr") + client = as_account(account) + + assert client.get(_url(edition, f"/{me.id}/planning")).status_code == 200 + assert client.get(_url(edition, f"/{other.id}/planning")).status_code == 403 + + +def test_lead_can_read_any_volunteers_planning( + session, edition, make_account, as_account +): + other = make_volunteer(session, edition, email="other@e.fr") + lead_account = make_account(Role.LEAD) + client = as_account(lead_account) + + assert client.get(_url(edition, f"/{other.id}/planning")).status_code == 200 diff --git a/frontend/src/api/hooks.ts b/frontend/src/api/hooks.ts index a007e33..df8297f 100644 --- a/frontend/src/api/hooks.ts +++ b/frontend/src/api/hooks.ts @@ -83,6 +83,15 @@ export function useVolunteer(id: string | null | undefined) { }); } +/** Planning enrichi (poste, lieu, référent) d'un bénévole — écran bénévole. */ +export function useVolunteerPlanning(volunteerId: string | null | undefined) { + return useQuery({ + queryKey: ['volunteer-planning', edition, volunteerId ?? null], + queryFn: () => api.volunteers.planning(edition, volunteerId as string), + enabled: Boolean(volunteerId), + }); +} + export function useShifts(positionId?: string) { return useQuery({ queryKey: queryKeys.shifts(positionId), diff --git a/frontend/src/api/http-client.ts b/frontend/src/api/http-client.ts index c5f4b63..112359b 100644 --- a/frontend/src/api/http-client.ts +++ b/frontend/src/api/http-client.ts @@ -110,6 +110,8 @@ export function createHttpApi(baseUrl: string): PlanningApi { request(`${base(editionId)}/volunteers/${id}`, { method: 'PATCH', body: JSON.stringify(body) }), remove: (editionId, id) => request(`${base(editionId)}/volunteers/${id}`, { method: 'DELETE' }), + planning: (editionId, id) => + request(`${base(editionId)}/volunteers/${id}/planning`), }, accounts: { list: (editionId) => request(`${base(editionId)}/accounts`), diff --git a/frontend/src/api/mock/mock-client.ts b/frontend/src/api/mock/mock-client.ts index 8ea987a..14a6c41 100644 --- a/frontend/src/api/mock/mock-client.ts +++ b/frontend/src/api/mock/mock-client.ts @@ -835,6 +835,33 @@ export function createMockApi(): PlanningApi { }); return delay(undefined); }, + planning: (_edition, id) => { + if (!state.volunteers.some((v) => v.id === id)) return notFound(); + const entries = state.assignments + .filter((a) => a.volunteer_id === id) + .map((a) => { + const shift = state.shifts.find((s) => s.id === a.shift_id); + const position = shift ? state.positions.find((p) => p.id === shift.position_id) : undefined; + const referentAccount = position?.lead_account_id + ? state.accounts.find((acc) => acc.id === position.lead_account_id) + : undefined; + const referent = referentAccount?.volunteer_id + ? state.volunteers.find((v) => v.id === referentAccount.volunteer_id) + : undefined; + return { + assignment_id: a.id, + shift_id: a.shift_id, + starts_at: shift?.starts_at ?? '', + ends_at: shift?.ends_at ?? '', + position_name: position?.name ?? '', + position_location: position?.location ?? null, + referent_name: referent ? `${referent.first_name} ${referent.last_name}` : null, + referent_phone: referent?.phone ?? null, + }; + }) + .sort((a, b) => a.starts_at.localeCompare(b.starts_at)); + return delay(entries); + }, }, accounts: { diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index b775e5f..c3b57c6 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -135,6 +135,24 @@ export interface VolunteerUpdate { wished_companion_ids?: string[] | null; } +/** + * Un créneau affecté, enrichi (poste, lieu, référent à contacter) — pour + * l'écran bénévole. Évite de donner à un compte VOLUNTEER un accès en + * lecture aux postes/créneaux de toute l'édition (réservé à COORD/LEAD). + */ +export interface VolunteerPlanningEntry { + assignment_id: string; + shift_id: string; + starts_at: string; + ends_at: string; + position_name: string; + position_location: string | null; + /** null si le poste n'a pas de référent, ou si son compte n'est pas + * rattaché à une fiche bénévole (pas de téléphone à donner alors). */ + referent_name: string | null; + referent_phone: string | null; +} + // --------------------------------------------------------------------------- // // Availability (disponibilités déclarées par le bénévole) // // --------------------------------------------------------------------------- // diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index f4e1bc6..0a23469 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -38,6 +38,7 @@ import type { VerifyRequest, Violation, VolunteerCreate, + VolunteerPlanningEntry, VolunteerRead, VolunteerUpdate, } from './schema'; @@ -69,6 +70,8 @@ export interface PlanningApi { create(editionId: string, body: VolunteerCreate): Promise; update(editionId: string, id: string, body: VolunteerUpdate): Promise; remove(editionId: string, id: string): Promise; + /** Planning enrichi (poste, lieu, référent) — écran bénévole. */ + planning(editionId: string, id: string): Promise; }; accounts: { /** Comptes de connexion (mot de passe ou lien magique) de l'édition. */ diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index 867f1a7..b7a7e83 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -122,10 +122,11 @@ "mobileVol.slots": "{count} CRÉNEAUX", "mobileVol.hours": "{count} HEURES", "mobileVol.location": "📍 {location}", - "mobileVol.slotAssigned": "✓ TU ES ATTENDU SUR CE CRÉNEAU", + "mobileVol.referent": "👤 Responsable : {name}", + "mobileVol.phone": "📞 {phone}", + "mobileVol.now": "MAINTENANT", "mobileVol.noSlots": "Aucun créneau pour l'instant. La coordination t'affectera bientôt.", "mobileVol.noAccount": "Ce compte n'est rattaché à aucun bénévole. Contactez la coordination.", - "mobileVol.detailsUnavailable": "Le détail des créneaux (poste, horaires) n'est pas encore exposé aux bénévoles par l'API.", "mobileLead.statusTime": "18:07", "mobileLead.kicker": "RÉFÉRENT DE POSTE", "mobileLead.inProgress": "CRÉNEAU · {hours}", diff --git a/frontend/src/lib/time.ts b/frontend/src/lib/time.ts index 2fac711..52a6915 100644 --- a/frontend/src/lib/time.ts +++ b/frontend/src/lib/time.ts @@ -51,3 +51,14 @@ export function longDayLabel(iso: string): string { export function hourRangeLabel(startIso: string, endIso: string): string { return `${timePart(startIso)} → ${timePart(endIso)}`; } + +/** Instant présent, dans la même unité que `naiveMinutes` (minutes « murales » + * sans fuseau) — suppose que l'appareil est à l'heure Europe/Paris, ce qui + * est le cas sur place pendant le festival. Sert à situer « maintenant » par + * rapport aux créneaux naïfs de l'API, jamais à un vrai instant UTC. */ +export function nowNaiveMinutes(): number { + const d = new Date(); + return ( + Date.UTC(d.getFullYear(), d.getMonth(), d.getDate(), d.getHours(), d.getMinutes()) / 60000 + ); +} diff --git a/frontend/src/pages/MobileVolunteerPage.tsx b/frontend/src/pages/MobileVolunteerPage.tsx index 7eee9b2..098d5cb 100644 --- a/frontend/src/pages/MobileVolunteerPage.tsx +++ b/frontend/src/pages/MobileVolunteerPage.tsx @@ -1,10 +1,9 @@ -import { useMemo } from 'react'; +import { Fragment, useEffect, useMemo, useState } from 'react'; -import { ApiError } from '@/api'; -import { useAssignments, usePositions, useShifts, useVolunteer } from '@/api/hooks'; +import { useVolunteer, useVolunteerPlanning } from '@/api/hooks'; import { useAuth } from '@/auth/auth-context'; import { ErrorNote, Loading } from '@/components/state'; -import { hourRangeLabel, hoursBetween, longDayLabel, naiveMinutes } from '@/lib/time'; +import { hourRangeLabel, longDayLabel, naiveMinutes, nowNaiveMinutes } from '@/lib/time'; import { t } from '@/i18n'; // Page réellement consultée par le bénévole (accueil de son rôle, cf. @@ -16,24 +15,45 @@ import { t } from '@/i18n'; // de gris clair, lisibilité plein soleil), cibles tactiles ≥ 52 px de haut, // une seule action par créneau. +// Rafraîchit « maintenant » assez souvent pour que le repère bouge de façon +// perceptible sans re-rendre à outrance. +const NOW_REFRESH_MS = 30_000; + +function useNowMinutes(): number { + const [minutes, setMinutes] = useState(() => nowNaiveMinutes()); + useEffect(() => { + const id = setInterval(() => setMinutes(nowNaiveMinutes()), NOW_REFRESH_MS); + return () => clearInterval(id); + }, []); + return minutes; +} + +function NowLine() { + return ( +