Compare commits

...
10 Commits
Author SHA1 Message Date
AdamThomasandClaude Sonnet 5 0ede409841 Limiteur de tentatives sur /auth/login et /auth/magic-link
Suite de la revue de sécurité (commit précédent) : ni /auth/login (mot
de passe COORDINATION) ni /auth/magic-link n'avaient de limite de
tentatives — aucun verrou de compte, aucun throttling, avant ouverture
au public.

`app/auth/rate_limit.py` : limiteur en mémoire (un seul processus
uvicorn, cf. Dockerfile), à créneau glissant, par clé (édition, email)
plutôt que par IP — le VPS ne transmet pas encore l'IP réelle du
client à travers Caddy, et le risque réel ici est de deviner le mot de
passe d'un compte connu (peu de comptes protégés par mot de passe),
pas une attaque distribuée sur des emails au hasard.

- `/auth/login` : 5 tentatives/minute → 429 `error.too_many_requests`.
- `/auth/magic-link` : 3 demandes/5 min (évite aussi le spam d'emails
  vers une victime ou vers le SMTP).

Fixture `_reset_rate_limiters` (autouse) pour ne pas faire fuiter l'état
d'un test à l'autre. Testé : dépassement, indépendance des clés,
expiration de la fenêtre, scénarios bout en bout sur les deux
endpoints.

Non inclus dans ce commit (édités en parallèle par l'utilisateur, laissés
en l'état) : backend/app/seed.py, frontend/tailwind.config.js.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
2026-09-16 08:19:27 +02:00
AdamThomasandClaude Sonnet 5 89e74e5ec7 Revue de sécurité avant ouverture au public
Audit de l'application avant sa première publication sur internet.
Correctifs immédiats, peu coûteux et sans changement de comportement
en dev :

- Le backend refuse de démarrer en `ENVIRONMENT=production` si
  `JWT_SECRET` est vide, trop court ou égal à la valeur par défaut
  versionnée dans `.env.example` — sinon n'importe qui pourrait forger
  un jeton de session valide (y compris pour un compte COORDINATION)
  sans jamais s'authentifier. Testé (`test_config.py`).
- Avertissement au démarrage si `EMAIL_ENABLED=false` en production :
  dans ce cas le jeton de lien magique est journalisé en clair (confort
  de dev, cf. `api/auth.py`) — un identifiant de connexion en clair dans
  les logs du conteneur en public serait un vrai problème.
- `frontend/nginx.conf` : plus de journal d'accès (la route
  `/connexion?token=…` y ferait apparaître le jeton de lien magique en
  clair), en-têtes `X-Content-Type-Options`, `X-Frame-Options`,
  `Referrer-Policy` (empêche aussi la fuite de ce même jeton vers
  Google Fonts via l'en-tête Referer), `server_tokens off`.
- `deploy/Caddyfile.benevoles` : mêmes en-têtes côté reverse proxy
  public, plus `Strict-Transport-Security`.
- `deploy/README.md` : check-list sécurité avant ouverture (secret,
  SMTP, ne jamais mettre en production la base de démo `app.seed` — le
  mot de passe du compte coordination y est versionné en clair —,
  limites connues comme l'absence de rate-limiting sur `/auth/*`).

Points vérifiés sans modification nécessaire : autorisation par rôle et
cloisonnement par édition cohérents sur tous les endpoints (`owned_or_404`
+ vérification d'édition partout), mots de passe bcryptés, jetons de
lien magique à usage unique haché en base (256 bits d'entropie), pas de
PII/jeton dans les logs applicatifs (RGPD déjà en place), pas
d'injection de gabarit dans les emails (substitution sur liste blanche,
pas de `.format()`), pas d'injection d'en-tête SMTP (`EmailMessage` du
socle Python), pas de `dangerouslySetInnerHTML` côté front, jeton JWT
en `Authorization: Bearer` (pas de cookie ⇒ pas de CSRF), `.env` jamais
versionné, dépendances à jour.

Restent des choix à trancher avec l'utilisateur plutôt qu'à décider
seul (compromis effort/risque) : rate-limiting sur les endpoints
d'authentification, désactivation de `/docs` en production, conteneurs
non-root.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
2026-09-16 08:14:00 +02:00
AdamThomasandClaude Sonnet 5 ade59b3dc4 Écran référent : repli sur la vue bénévole, tous les créneaux du poste
Deux bugs remontés sur l'écran référent (/mobile-referent), tous deux
révélés par le repli documenté « décocher un poste ne rétrograde jamais
le compte » (§2) :

- Un compte LEAD qui ne référence plus aucun poste (ex. décoché) reste
  LEAD et atterrit donc sur /mobile-referent après son lien magique —
  qui affichait un écran référent vide au lieu de sa vue de bénévole
  classique. `MobileLeadPage` détecte maintenant ce cas et affiche,
  quand le compte est rattaché à une fiche bénévole, le même contenu
  que `/mobile-benevole` (planning : poste, jour, horaire, lieu,
  responsable, téléphone) — extrait dans un composant partagé
  `VolunteerHomeView`.

- Un poste avec plusieurs créneaux n'en montrait qu'un seul (le plus
  proche de l'heure réelle) : le référent ne pouvait pas voir son
  équipe sur les autres créneaux. La carte de chaque poste liste
  maintenant tous ses créneaux, chacun avec sa propre équipe, avec un
  repère « MAINTENANT » (composant partagé `NowLine`, déjà utilisé côté
  bénévole) pour se situer parmi eux.

Non inclus dans ce commit (édités en parallèle par l'utilisateur, laissés
en l'état) : backend/app/seed.py, frontend/tailwind.config.js.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
2026-09-16 07:50:00 +02:00
AdamThomasandClaude Sonnet 5 9e0be07726 Écran référent : vraie page responsive, référent correctement calculé
Deux bugs sur l'écran référent (/mobile-referent) :

- Il était resté à l'étape maquette (`PhoneFrame`/`DesignNote`, cadre de
  téléphone à taille fixe) alors que la vue bénévole avait déjà été
  convertie en vraie page responsive. Repli sur `positions.data[0]` en
  l'absence de poste géré par le compte connecté : un référent qui
  n'est encore désigné sur aucun poste se voyait quand même affiché
  comme référent du premier poste de l'édition (« Accueil artistes »
  dans les données de démo) au lieu de voir qu'il n'en a aucun.

Réécriture de `MobileLeadPage.tsx` en page responsive (mobile d'abord,
`sm:` pour l'élargissement, comme `MobileVolunteerPage.tsx`) : plus de
maquette de téléphone, une carte par poste réellement géré
(`Position.lead_account_id === account.id`, sans repli), créneau en
cours ou à venir calculé sur l'heure réelle (nouveau hook partagé
`useNowMinutes`, extrait de la vue bénévole), équipe affectée avec
pointage présent/absent (toujours local à l'écran : le pointage sur
site reste hors périmètre, CLAUDE.md §5). État vide explicite si le
compte n'est référent d'aucun poste.

`PhoneFrame`/`DesignNote` n'avaient plus d'autre utilisateur : supprimés
avec leurs clés i18n devenues mortes.

Non inclus dans ce commit (édités en parallèle par l'utilisateur, laissés
en l'état) : backend/app/seed.py, frontend/tailwind.config.js.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
2026-09-15 23:42:48 +02:00
AdamThomasandClaude Sonnet 5 a78aad58e2 Vue bénévole mobile : responsable, téléphone et repère « maintenant »
La vue bénévole reprenait les infos essentielles (poste, jour, horaire,
lieu) mais affichait un badge de validation factice (« TU ES ATTENDU
SUR CE CRÉNEAU ») sans plus-value, sans jamais donner le nom ni le
téléphone du référent du poste à contacter — pourtant déjà modélisés
(`Position.lead_account_id`) depuis la gestion des référents.

Un compte VOLUNTEER n'a pas accès à `positions`/`shifts` (403, cf. § 2
matrice des accès) : ces infos ne pouvaient donc pas être jointes côté
front à partir des listes existantes. Nouvel endpoint dédié et
minimal, réservé à la lecture du planning propre du bénévole :

  GET /editions/{id}/volunteers/{vid}/planning

Renvoie, par affectation : poste, lieu, et référent (nom + téléphone,
`null` si le poste n'a pas de référent ou si son compte n'est pas
rattaché à une fiche bénévole) — sans exposer la liste complète des
postes/créneaux de l'édition. Tests : accès (soi-même / autre
bénévole 403 / COORD+LEAD), présence/absence du référent, tri par
heure de début.

Front : retrait du badge de validation, ajout des lignes responsable/
téléphone par créneau, et un repère horizontal « MAINTENANT » qui se
positionne dans la liste triée des créneaux selon l'heure réelle (juste
avant le prochain créneau à venir) pour situer le bénévole dans son
planning.

Non inclus dans ce commit (édités en parallèle par l'utilisateur,
laissés en l'état) : backend/app/seed.py, frontend/tailwind.config.js.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
2026-09-15 23:33:06 +02:00
AdamThomasandClaude Sonnet 5 8fcc22115f Gestion des référents depuis la fiche bénévole
Un poste avait bien un `lead_account_id`, lu par l'écran référent
(`/mobile-referent`) et par les droits d'écriture (`ensure_can_write_position`),
mais rien ne permettait de le renseigner : aucun écran ne l'exposait, et
l'API ne validait même pas que la valeur envoyée désignait un compte référent
existant de la bonne édition.

Backend :
- `PATCH /editions/{id}/accounts/{account_id}` (nouveau) : change le `role`
  d'un compte, seulement entre LEAD et VOLUNTEER (lien magique dans les deux
  cas) — 422 `account.role_change_not_allowed` dès que COORDINATOR est
  impliqué (mot de passe en jeu, jamais touché ici). C'est ce qui permet de
  faire passer le compte d'un bénévole en référent.
- `POST`/`PATCH /positions` : `lead_account_id` vérifié — doit désigner un
  compte `Role.LEAD` de la même édition, sinon 422
  `position.lead_must_be_referent_account` (silencieusement accepté
  jusqu'ici, y compris un compte d'une autre édition ou du mauvais rôle).

Frontend (`ConfigVolunteers.tsx`) : nouvelle section « Référent des postes »
sur la fiche du bénévole, juste après l'accès de connexion — mêmes badges à
cocher que les vœux de poste/artiste. Cocher un poste crée ou met à niveau
(LEAD) l'accès de connexion du bénévole automatiquement ; décocher retire
seulement le poste, sans jamais rétrograder l'accès. Un bénévole dont le
compte est déjà COORDINATOR ne peut pas être désigné référent (il a déjà
accès à tout). Miroir complet côté mock (`accounts.update`,
`checkLeadAccount`) pour que la démonstration se comporte pareil.

Vérifié : 204 tests backend pertinents (14 nouveaux : 6 sur `PATCH accounts`,
8 sur la validation `lead_account_id`), ruff, alembic check ; typecheck/
lint/build/smoke frontend ; parcours complet testé en direct sur le jeu de
démo (création de compte, promotion LEAD, rejet COORDINATOR, affectation
référent) avant nettoyage des données de test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
2026-09-15 23:19:56 +02:00
AdamThomasandClaude Sonnet 5 db11d9a3ba Page bénévole : vraie page responsive, plus une maquette de téléphone
C'est l'écran d'accueil réel d'un compte bénévole (`homePath`) — consulté sur
son propre téléphone la plupart du temps, mais la page rendait un cadre de
téléphone factice à taille fixe (376×772 px, fausse barre de statut) pensé
pour être visionné sur un écran d'ordinateur, plus une note de choix de
conception destinée à l'équipe, pas au bénévole. Sur un vrai téléphone,
c'était un téléphone dessiné à l'intérieur du vrai téléphone.

- Retiré le cadre (`PhoneFrame`) et la note de conception (`DesignNote`) —
  toujours utilisés par la page référent (`MobileLeadPage.tsx`, non touchée
  ici), donc le composant partagé reste en place.
- Le contenu (bandeau d'accueil + cartes de créneaux) devient la page
  elle-même : pleine largeur sur mobile, centrée avec une largeur de lecture
  confortable (max 640 px) et un peu plus d'air à partir de `sm:` pour un
  écran d'ordinateur. Mêmes repères de conception qu'avant (fonds opaques,
  cibles tactiles ≥ 52 px), déplacés en commentaire de code.
- `AppShell` : la barre d'onglets ne s'affiche plus quand il n'y a qu'un seul
  écran accessible (bénévole, référent) — elle n'a de sens que pour la
  coordination (3 écrans) ; simplifie l'en-tête sur les rôles qui n'ont nulle
  part où naviguer.

Vérifié : typecheck, lint, build, smoke frontend OK.

À noter : `MobileLeadPage.tsx` (référent) a exactement le même défaut
(maquette de téléphone + note de conception) — pas touchée ici, la demande
portait sur la vue bénévole.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
2026-09-15 23:03:59 +02:00
AdamThomasandClaude Sonnet 5 9871785d89 Grille : aperçu des bénévoles affectés au survol d'une case
Chaque case ne montrait qu'un nombre (affecté/idéal) ; il fallait cliquer et
ouvrir le panneau pour savoir qui exactement. Une bulle au survol affiche
maintenant les noms des bénévoles déjà affectés à ce créneau, sans clic.

- Construit une table (créneau → noms) à partir des affectations et des
  bénévoles de l'édition (mêmes requêtes déjà utilisées par le panneau
  d'affectation, donc pas de coût réseau supplémentaire une fois en cache).
- Remplace l'ancien `title` natif (lent, sans style) par la même bulle que
  `HelpTip` (bordures franches, apparaît sous la case) ; garde le rappel
  affecté/idéal/minimum en tête de bulle, puis la liste triée des noms, ou
  le message « personne d'affecté » sinon.

Vérifié : typecheck, lint, build, smoke frontend OK.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
2026-09-15 22:54:31 +02:00
AdamThomasandClaude Sonnet 5 efa652fffb Panneau d'affectation : barre de recherche pour retrouver un bénévole
La liste des candidats se limitait aux 10 mieux classés par le score de
chance — un bénévole précis pouvait ne jamais y apparaître. Un champ de
recherche (nom/prénom, insensible aux accents/casse) permet maintenant de le
retrouver et de l'affecter directement, sans être limité au classement.

- Recherche vide : comportement inchangé (top 10 par score de chance).
- Recherche non vide : jusqu'à 20 bénévoles non affectés dont le nom
  correspond, triés par le même score ; message dédié si aucun ne correspond.
- `foldAccents` (accents/casse) déplacé de `ConfigVolunteers.tsx` vers
  `lib/planning.ts` pour être partagé avec le panneau d'affectation.
- `GrillePage.tsx` : `key={shift.shift_id}` sur `AssignmentPanel` pour que la
  recherche (et tout futur état local) reparte à zéro en changeant de
  créneau, plutôt que de persister sur le suivant.

Vérifié : typecheck, lint, build, smoke frontend OK.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
2026-09-15 22:43:10 +02:00
AdamThomasandClaude Sonnet 5 11bc8cbe36 Vœu de compagnon : traité comme réciproque par le solveur
Le vœu reste à sens unique en base (A choisit B sur sa fiche, B n'a rien à
faire) mais si A souhaite B, B doit désormais profiter aussi de la présence
de A — dans les deux sens : affecter A d'abord fait remonter B comme
candidat, et affecter B d'abord fait remonter A, même si B n'a rien demandé.

- Solveur (`loader.py`) : le vœu (A → B) alimente maintenant l'ensemble
  effectif des deux bénévoles (A et B), pas seulement celui du demandeur.
  Aucun changement dans `engine.py` : il ne fait que lire un ensemble déjà
  symétrisé.
- Mock (`buildEffectiveCompanions`) et classement par défaut
  (`buildEffectiveCompanionIds` dans `lib/planning.ts`, utilisé par
  `AssignmentPanel.tsx`) : même symétrisation, pour rester cohérents avec le
  vrai solveur.
- La fiche bénévole (vœux affichés/édités) n'est pas touchée : c'est le seul
  endroit qui reste à sens unique, par construction (la réciprocité n'est
  qu'une lecture, jamais une donnée enregistrée — sinon éditer sa propre
  liste risquerait d'effacer le vœu de quelqu'un d'autre).
- Nouveau test d'intégration : le bénévole qui n'a rien demandé profite quand
  même de la présence de celui qui l'a choisi.
- CLAUDE.md à jour.

Vérifié : 193 tests backend pertinents (dont le nouveau), ruff, alembic
check ; typecheck/lint/build/smoke frontend.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
2026-09-15 22:32:02 +02:00
41 changed files with 1554 additions and 323 deletions
+44 -10
View File
@@ -85,13 +85,27 @@ les tables sauf les tables d'association.
voudrait tenir »), distinct de `Assignment` (décision) et de `Position. voudrait tenir »), distinct de `Assignment` (décision) et de `Position.
lead_account_id` (référent). lead_account_id` (référent).
- Vœux de compagnon : `volunteer_companion_wish` (« bénévoles avec qui je - Vœux de compagnon : `volunteer_companion_wish` (« bénévoles avec qui je
voudrais être affecté », 0 à N, à sens unique — A peut souhaiter B sans que voudrais être affecté », 0 à N). **Saisi à sens unique** (A souhaite B sans
B ne le souhaite en retour). `CHECK (volunteer_id <> companion_id)` : un que B fasse le geste en retour) **mais traité comme réciproque par le
bénévole ne peut pas se souhaiter lui-même. Le solveur (section 4) solveur** (section 4) : si A souhaite B, B profite aussi de la présence de
favorise un candidat dont l'un des compagnons souhaités est déjà affecté A, même s'il ne l'a rien demandé — la réciprocité est artificielle, calculée
au créneau. à la volée (le loader du solveur, pas la table elle-même, qui reste à sens
unique et éditable indépendamment par chacun). `CHECK (volunteer_id <>
companion_id)` : un bénévole ne peut pas se souhaiter lui-même.
- **`Position`** : `name`, `location`, `category` (`PositionCategory`), - **`Position`** : `name`, `location`, `category` (`PositionCategory`),
`lead_account_id` (→ `Account`, `SET NULL` à la suppression du compte). `lead_account_id` (→ `Account`, `SET NULL` à la suppression du compte) —
doit désigner un compte `Role.LEAD` de la même édition (`POST`/`PATCH`
`/positions` le vérifient, 422 `position.lead_must_be_referent_account`
sinon). Se gère depuis la fiche du bénévole (`ConfigVolunteers.tsx` :
section « Référent des postes ») plutôt que depuis le poste : cocher un
poste y crée ou met à niveau (`PATCH /accounts/{id}` : voir plus bas) son
compte en `LEAD` — à sa prochaine connexion il voit l'écran référent
(`/mobile-referent`) plutôt que l'écran bénévole. Décocher ne fait que
retirer le poste, sans jamais rétrograder le compte automatiquement : un
compte `LEAD` qui ne référence plus aucun poste (tous décochés) reste donc
`LEAD`, mais `/mobile-referent` détecte ce cas et affiche alors, si le
compte est rattaché à une fiche bénévole, sa vue de bénévole classique
(`VolunteerHomeView`) plutôt qu'un écran référent vide.
- **`Shift`** : `position_id`, `day_part_id` (nullable ; le créneau type dont - **`Shift`** : `position_id`, `day_part_id` (nullable ; le créneau type dont
il est issu, `SET NULL` si le type est supprimé), `starts_at`, `ends_at` il est issu, `SET NULL` si le type est supprimé), `starts_at`, `ends_at`
(peut finir le lendemain), `min_staff`, `ideal_staff`. À la création d'un (peut finir le lendemain), `min_staff`, `ideal_staff`. À la création d'un
@@ -110,6 +124,12 @@ les tables sauf les tables d'association.
défaut `MANUAL`), `locked` (défaut `false`). Unique (`volunteer_id`, défaut `MANUAL`), `locked` (défaut `false`). Unique (`volunteer_id`,
`shift_id`). **Pas de statut** : l'affectation est valide dès sa création `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. (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`, - **`Account`** : `email` (unique par édition), `role` (`Role`), `edition_id`,
`volunteer_id` (nullable, unique). `volunteer_id` (nullable, unique).
- **`MagicLinkToken`** : `account_id`, `token_hash` (unique), `expires_at`, - **`MagicLinkToken`** : `account_id`, `token_hash` (unique), `expires_at`,
@@ -159,7 +179,10 @@ Le compte porte `password_hash` : défini ⇒ mot de passe, `NULL` ⇒ lien magi
*Mot de passe* — comptes `COORDINATOR` : *Mot de passe* — comptes `COORDINATOR` :
1. `POST /editions/{id}/auth/login` `{email, password}` → JWT + `AccountRead`. 1. `POST /editions/{id}/auth/login` `{email, password}` → JWT + `AccountRead`.
Échec = `401 auth.invalid_credentials` (aucune divulgation). Échec = `401 auth.invalid_credentials` (aucune divulgation). Limité à 5
tentatives/minute par (édition, email) — `429 error.too_many_requests`
au-delà (`app/auth/rate_limit.py`, en mémoire, clé par email — pas par IP,
voir le commentaire du module).
2. `POST /editions/{id}/auth/change-password` `{current_password, new_password}` 2. `POST /editions/{id}/auth/change-password` `{current_password, new_password}`
(compte courant). Hachage **bcrypt**, longueur mini `MIN_LENGTH` (10). (compte courant). Hachage **bcrypt**, longueur mini `MIN_LENGTH` (10).
@@ -168,6 +191,8 @@ Le compte porte `password_hash` : défini ⇒ mot de passe, `NULL` ⇒ lien magi
3. `POST /editions/{id}/auth/magic-link` `{email}` → `202` **constant**. Si un 3. `POST /editions/{id}/auth/magic-link` `{email}` → `202` **constant**. Si un
compte **sans mot de passe** correspond, un `MagicLinkToken` est créé compte **sans mot de passe** correspond, un `MagicLinkToken` est créé
(jeton aléatoire, **haché SHA-256** en base, TTL `magic_link_ttl_minutes`). (jeton aléatoire, **haché SHA-256** en base, TTL `magic_link_ttl_minutes`).
Limité à 3 demandes/5 min par (édition, email) — même
`429 error.too_many_requests`.
4. **Envoi d'email stubé** : le lien est écrit dans les logs (`app.auth`, INFO, 4. **Envoi d'email stubé** : le lien est écrit dans les logs (`app.auth`, INFO,
`jeton=…` — identifiants seulement, aucune donnée nominative). `jeton=…` — identifiants seulement, aucune donnée nominative).
5. `POST /editions/{id}/auth/verify` `{token}` → JWT + `AccountRead`. Jeton à 5. `POST /editions/{id}/auth/verify` `{token}` → JWT + `AccountRead`. Jeton à
@@ -186,6 +211,13 @@ pour ne jamais révéler si un compte existe). Front : bouton « Donner un accè
de connexion » sur la fiche du bénévole (`ConfigVolunteers.tsx`), qui appelle de connexion » sur la fiche du bénévole (`ConfigVolunteers.tsx`), qui appelle
cet endpoint avec `role=VOLUNTEER`. cet endpoint avec `role=VOLUNTEER`.
`PATCH /editions/{id}/accounts/{account_id}` change uniquement le `role`, et
seulement entre `LEAD` et `VOLUNTEER` (lien magique dans les deux cas) — 422
`account.role_change_not_allowed` si `COORDINATOR` est impliqué dans un sens
ou l'autre (mot de passe en jeu, jamais touché par cette route). C'est ce qui
met à niveau le compte d'un bénévole en référent quand on le désigne
référent d'un poste (voir `Position.lead_account_id` plus haut).
### Emails (`app/email/`) ### Emails (`app/email/`)
Envoi par **SMTP** (auto-hébergé). `EMAIL_ENABLED=false` (défaut dev/tests) ⇒ Envoi par **SMTP** (auto-hébergé). `EMAIL_ENABLED=false` (défaut dev/tests) ⇒
@@ -291,9 +323,9 @@ affectation n'est jamais persistée par cet endpoint.
- malus si le créneau chevauche le passage d'un artiste que le bénévole - malus si le créneau chevauche le passage d'un artiste que le bénévole
veut voir (`VolunteerArtist` + `Artist.start_time`/`end_time`) — lui veut voir (`VolunteerArtist` + `Artist.start_time`/`end_time`) — lui
ferait manquer le passage ; ferait manquer le passage ;
- bonus par compagnon souhaité (`VolunteerCompanionWish`) déjà affecté - bonus par compagnon souhaité (`VolunteerCompanionWish`, **réciproque** :
(affectation décidée) au créneau — plusieurs compagnons présents si A souhaite B, B en profite aussi) déjà affecté (affectation décidée)
cumulent le bonus ; au créneau — plusieurs compagnons présents cumulent le bonus ;
- léger malus pour les bénévoles déjà bien chargés (répartition de la - léger malus pour les bénévoles déjà bien chargés (répartition de la
charge). charge).
- **Pas de plafond de cumul journalier côté solveur** : contrairement à la - **Pas de plafond de cumul journalier côté solveur** : contrairement à la
@@ -493,6 +525,8 @@ Aucune dépendance locale requise hors Docker.
| 15 | Solveur de suggestions OR-Tools CP-SAT (section 4) : `POST /editions/{id}/solver/suggestions`, candidats remontés en tête de liste dans la grille des besoins — aucune affectation automatique | à valider | | 15 | Solveur de suggestions OR-Tools CP-SAT (section 4) : `POST /editions/{id}/solver/suggestions`, candidats remontés en tête de liste dans la grille des besoins — aucune affectation automatique | à valider |
| 16 | Bénévole : l'email n'est plus unique (couple, famille) — identification par (email, nom + prénom) dans l'import et l'API, plus de rejet 409 à la création manuelle | à valider | | 16 | Bénévole : l'email n'est plus unique (couple, famille) — identification par (email, nom + prénom) dans l'import et l'API, plus de rejet 409 à la création manuelle | à valider |
| 17 | Vœu de compagnon (`VolunteerCompanionWish`, 0 à N, à sens unique) : fiche bénévole, pris en compte par le solveur (bonus) et le classement par défaut | à valider | | 17 | Vœu de compagnon (`VolunteerCompanionWish`, 0 à N, à sens unique) : fiche bénévole, pris en compte par le solveur (bonus) et le classement par défaut | à valider |
| 18 | Gestion des référents depuis la fiche bénévole : `PATCH /accounts` (rôle LEAD ↔ VOLUNTEER), `lead_account_id` vérifié (doit être un compte référent de l'édition) | à valider |
| 19 | Revue de sécurité avant ouverture au public : refus de démarrer en production avec un `JWT_SECRET` par défaut/vide/court, avertissement si `EMAIL_ENABLED=false` en production, en-têtes de sécurité (nginx + Caddy), pas de jeton dans les logs d'accès du frontend, limiteur de tentatives sur `/auth/login` et `/auth/magic-link` | à valider |
**Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur **Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans + de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +
+25 -1
View File
@@ -9,7 +9,7 @@ from sqlmodel import Session, select
from app.api._common import owned_or_404 from app.api._common import owned_or_404
from app.api.deps import get_edition from app.api.deps import get_edition
from app.api.schemas import AccountCreate, AccountRead from app.api.schemas import AccountCreate, AccountRead, AccountUpdate
from app.auth.deps import require_coordinator from app.auth.deps import require_coordinator
from app.auth.passwords import MIN_LENGTH, hash_password from app.auth.passwords import MIN_LENGTH, hash_password
from app.db import get_session from app.db import get_session
@@ -85,6 +85,30 @@ def list_accounts(
) )
@router.patch("/{account_id}", response_model=AccountRead)
def update_account(
account_id: uuid.UUID,
payload: AccountUpdate,
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> Account:
"""Seul le rôle change de main ici, et seulement entre LEAD et VOLUNTEER
(lien magique dans les deux cas) : devenir référent d'un poste (voir
`positions.py`) met à niveau le compte bénévole existant, sans toucher au
mot de passe d'un compte COORDINATION."""
account = owned_or_404(session.get(Account, account_id), edition.id)
if payload.role is not None and payload.role != account.role:
if Role.COORDINATOR in (account.role, payload.role):
raise HTTPException(
status_code=422, detail={"code": "account.role_change_not_allowed"}
)
account.role = payload.role
session.commit()
session.refresh(account)
return account
@router.delete("/{account_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{account_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_account( def delete_account(
account_id: uuid.UUID, account_id: uuid.UUID,
+3
View File
@@ -25,6 +25,7 @@ from app.api.schemas import (
from app.auth.deps import get_current_account from app.auth.deps import get_current_account
from app.auth.magic_link import build_magic_link_url, issue_magic_link_token from app.auth.magic_link import build_magic_link_url, issue_magic_link_token
from app.auth.passwords import MIN_LENGTH, hash_password, verify_password from app.auth.passwords import MIN_LENGTH, hash_password, verify_password
from app.auth.rate_limit import login_limiter, magic_link_limiter
from app.auth.tokens import create_access_token, hash_token from app.auth.tokens import create_access_token, hash_token
from app.config import get_settings from app.config import get_settings
from app.db import get_session from app.db import get_session
@@ -76,6 +77,7 @@ def login(
payload: LoginRequest, payload: LoginRequest,
session: Session = Depends(get_session), session: Session = Depends(get_session),
) -> TokenResponse: ) -> TokenResponse:
login_limiter.hit(f"{edition_id}:{payload.email.strip().lower()}")
account = _account_by_email(session, edition_id, payload.email) account = _account_by_email(session, edition_id, payload.email)
if ( if (
account is None account is None
@@ -131,6 +133,7 @@ def request_magic_link(
"""Émet un lien magique si un compte **sans mot de passe** correspond. """Émet un lien magique si un compte **sans mot de passe** correspond.
Réponse identique dans tous les cas (pas de divulgation). L'email part en Réponse identique dans tous les cas (pas de divulgation). L'email part en
tâche de fond (une erreur SMTP est journalisée, pas remontée).""" tâche de fond (une erreur SMTP est journalisée, pas remontée)."""
magic_link_limiter.hit(f"{edition_id}:{payload.email.strip().lower()}")
account = _account_by_email(session, edition_id, payload.email) account = _account_by_email(session, edition_id, payload.email)
if account is not None and account.password_hash is None: if account is not None and account.password_hash is None:
settings = get_settings() settings = get_settings()
+27 -4
View File
@@ -1,8 +1,13 @@
"""CRUD des postes.""" """CRUD des postes.
`lead_account_id` (référent) ne peut désigner qu'un compte `Role.LEAD` de la
même édition — voir `_check_lead_account`. C'est le seul moyen de donner à un
bénévole une vue référent (`/mobile-referent`) à la connexion ; voir
`api/accounts.py:update_account` pour le faire passer de VOLUNTEER à LEAD."""
import uuid import uuid
from fastapi import APIRouter, Depends, Response, status from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlmodel import Session, select from sqlmodel import Session, select
from app.api._common import owned_or_404 from app.api._common import owned_or_404
@@ -21,13 +26,28 @@ from app.auth.deps import (
require_staff, require_staff,
) )
from app.db import get_session from app.db import get_session
from app.models import Account, Edition, Position from app.models import Account, Edition, Position, Role
router = APIRouter( router = APIRouter(
prefix="/editions/{edition_id}/positions", tags=["positions"] prefix="/editions/{edition_id}/positions", tags=["positions"]
) )
def _check_lead_account(
session: Session, edition: Edition, lead_account_id: uuid.UUID | None
) -> None:
"""`lead_account_id` doit désigner un compte RÉFÉRENT (`Role.LEAD`) de
cette même édition — sinon le poste se retrouve sans référent utilisable
(cf. `ensure_can_write_position`, `auth/deps.py`)."""
if lead_account_id is None:
return
lead = session.get(Account, lead_account_id)
if lead is None or lead.edition_id != edition.id or lead.role is not Role.LEAD:
raise HTTPException(
status_code=422, detail={"code": "position.lead_must_be_referent_account"}
)
@router.post("", response_model=PositionRead, status_code=status.HTTP_201_CREATED) @router.post("", response_model=PositionRead, status_code=status.HTTP_201_CREATED)
def create_position( def create_position(
payload: PositionCreate, payload: PositionCreate,
@@ -35,6 +55,7 @@ def create_position(
_guard: Account = Depends(require_coordinator), _guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session), session: Session = Depends(get_session),
) -> Position: ) -> Position:
_check_lead_account(session, edition, payload.lead_account_id)
position = Position(edition_id=edition.id, **payload.model_dump()) position = Position(edition_id=edition.id, **payload.model_dump())
session.add(position) session.add(position)
session.flush() session.flush()
@@ -80,7 +101,9 @@ def update_position(
) -> Position: ) -> Position:
position = owned_or_404(session.get(Position, position_id), edition.id) position = owned_or_404(session.get(Position, position_id), edition.id)
ensure_can_write_position(account, position) ensure_can_write_position(account, position)
for field, value in payload.model_dump(exclude_unset=True).items(): changes = payload.model_dump(exclude_unset=True)
_check_lead_account(session, edition, changes.get("lead_account_id"))
for field, value in changes.items():
setattr(position, field, value) setattr(position, field, value)
session.commit() session.commit()
session.refresh(position) session.refresh(position)
+24
View File
@@ -84,6 +84,24 @@ class VolunteerRead(ApiModel):
updated_at: dt.datetime 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) # # Artist (programmation) #
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -363,6 +381,12 @@ class AccountCreate(ApiModel):
password: str | None = None password: str | None = None
class AccountUpdate(ApiModel):
# Seul le rôle est modifiable, et seulement entre LEAD et VOLUNTEER (pas
# de mot de passe en jeu) — voir `api/accounts.py:update_account`.
role: Role | None = None
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Notifications par email # # Notifications par email #
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
+54 -1
View File
@@ -6,11 +6,17 @@ L'email n'est pas unique : plusieurs bénévoles peuvent en partager un même
import uuid import uuid
from fastapi import APIRouter, Depends, HTTPException, Response, status from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy.orm import aliased
from sqlmodel import Session, select from sqlmodel import Session, select
from app.api._common import owned_or_404, replace_links from app.api._common import owned_or_404, replace_links
from app.api.deps import get_edition 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 ( from app.auth.deps import (
current_account_in_edition, current_account_in_edition,
require_coordinator, require_coordinator,
@@ -20,9 +26,11 @@ from app.db import get_session
from app.models import ( from app.models import (
Account, Account,
Artist, Artist,
Assignment,
Edition, Edition,
Position, Position,
Role, Role,
Shift,
Volunteer, Volunteer,
VolunteerArtist, VolunteerArtist,
VolunteerCompanionWish, VolunteerCompanionWish,
@@ -177,6 +185,8 @@ def create_volunteer(
_guard: Account = Depends(require_coordinator), _guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session), session: Session = Depends(get_session),
) -> VolunteerRead: ) -> VolunteerRead:
payload.first_name = payload.first_name.title()
payload.last_name = payload.last_name.title()
volunteer = Volunteer( volunteer = Volunteer(
edition_id=edition.id, edition_id=edition.id,
**payload.model_dump( **payload.model_dump(
@@ -219,6 +229,49 @@ def get_volunteer(
return _read(session, 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) @router.patch("/{volunteer_id}", response_model=VolunteerRead)
def update_volunteer( def update_volunteer(
volunteer_id: uuid.UUID, volunteer_id: uuid.UUID,
+62
View File
@@ -0,0 +1,62 @@
"""Limiteur de tentatives, en mémoire — protège `/auth/login` (force brute
sur le mot de passe COORDINATION) et `/auth/magic-link` (spam d'emails vers
une victime, ou vers le SMTP) faute d'un reverse-proxy qui le ferait à notre
place.
Clé par **email seul**, pas par IP : le service tourne derrière Caddy sur le
VPS (voir `deploy/`), qui ne transmet pas l'IP réelle du client par défaut —
il faudrait faire confiance à `X-Forwarded-For` en configurant un proxy de
confiance, ce que ce déploiement ne fait pas encore. Limiter par email suffit
au risque réel ici : il n'y a qu'un très petit nombre de comptes protégés par
mot de passe (la coordination), donc l'attaque à contrer est « deviner le
mot de passe d'un compte connu », pas une attaque distribuée sur des emails
au hasard.
En mémoire, un seul processus uvicorn (voir `Dockerfile`, pas de
`--workers`) : un compteur partagé entre threads (les endpoints sont
synchrones, exécutés dans un pool de threads par Starlette) suffit, pas
besoin de Redis pour ce volume."""
import threading
import time
from fastapi import HTTPException, status
class RateLimiter:
def __init__(self, max_attempts: int, window_seconds: float) -> None:
self.max_attempts = max_attempts
self.window_seconds = window_seconds
self._hits: dict[str, list[float]] = {}
self._lock = threading.Lock()
def hit(self, key: str) -> None:
"""Enregistre une tentative pour `key` ; lève 429 si la limite du
créneau glissant est dépassée."""
now = time.monotonic()
with self._lock:
recent = [
t for t in self._hits.get(key, []) if now - t < self.window_seconds
]
if len(recent) >= self.max_attempts:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail={"code": "error.too_many_requests"},
)
recent.append(now)
self._hits[key] = recent
def reset(self) -> None:
"""Tests uniquement."""
with self._lock:
self._hits.clear()
# 5 tentatives/minute : large pour un vrai utilisateur qui se trompe, serré
# pour de la force brute (bcrypt ralentit déjà chaque essai, ceci coupe
# l'automatisation par-dessus).
login_limiter = RateLimiter(max_attempts=5, window_seconds=60)
# 3 demandes/5 min : un bénévole qui n'a pas reçu son email réessaie deux ou
# trois fois ; au-delà c'est soit un abus, soit un email qui n'arrivera pas
# plus vite en insistant.
magic_link_limiter = RateLimiter(max_attempts=3, window_seconds=300)
+29 -1
View File
@@ -1,7 +1,14 @@
from functools import lru_cache from functools import lru_cache
from pydantic import model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
# Valeur de développement uniquement (versionnée dans `.env.example`) : un
# secret connu de quiconque lit le dépôt ne protège plus rien. Voir le
# contrôle dans `Settings.check_production_secrets`.
_DEV_JWT_SECRET = "dev-insecure-secret-change-me-please-32b"
_MIN_JWT_SECRET_LENGTH = 32
class Settings(BaseSettings): class Settings(BaseSettings):
"""Configuration applicative, alimentée par l'environnement.""" """Configuration applicative, alimentée par l'environnement."""
@@ -20,7 +27,7 @@ class Settings(BaseSettings):
) )
# Authentification (lien magique + session JWT). Secret >= 32 octets. # Authentification (lien magique + session JWT). Secret >= 32 octets.
jwt_secret: str = "dev-insecure-secret-change-me-please-32b" jwt_secret: str = _DEV_JWT_SECRET
magic_link_ttl_minutes: int = 30 magic_link_ttl_minutes: int = 30
session_ttl_hours: int = 12 session_ttl_hours: int = 12
@@ -49,6 +56,27 @@ class Settings(BaseSettings):
def is_production(self) -> bool: def is_production(self) -> bool:
return self.environment == "production" return self.environment == "production"
@model_validator(mode="after")
def check_production_secrets(self) -> "Settings":
"""Un secret JWT par défaut, vide ou trop court n'importe pas en dev
(confort), mais en production il permettrait de forger un jeton pour
n'importe quel compte (y compris COORDINATION) — donc de fabriquer
soi-même un jeton valide sans jamais s'authentifier. On refuse de
démarrer plutôt que de tourner silencieusement sans protection."""
if not self.is_production:
return self
if (
not self.jwt_secret
or self.jwt_secret == _DEV_JWT_SECRET
or len(self.jwt_secret) < _MIN_JWT_SECRET_LENGTH
):
raise ValueError(
"JWT_SECRET manquant, par défaut ou trop court (>= "
f"{_MIN_JWT_SECRET_LENGTH} caractères) en production. "
"Générer une vraie valeur : openssl rand -hex 32"
)
return self
@lru_cache @lru_cache
def get_settings() -> Settings: def get_settings() -> Settings:
+3
View File
@@ -5,11 +5,13 @@
"error.unauthorized": "Authentification requise", "error.unauthorized": "Authentification requise",
"error.validation_failed": "La validation a échoué", "error.validation_failed": "La validation a échoué",
"error.conflict": "Conflit avec une ressource existante.", "error.conflict": "Conflit avec une ressource existante.",
"error.too_many_requests": "Trop de tentatives, réessayez dans un instant",
"edition.dates_inconsistent": "La date de fin doit être postérieure ou égale à la date de début.", "edition.dates_inconsistent": "La date de fin doit être postérieure ou égale à la date de début.",
"day_part.code_required": "Le code du créneau type ne peut pas être vide.", "day_part.code_required": "Le code du créneau type ne peut pas être vide.",
"day_part.duplicate_code": "Un créneau type porte déjà ce code dans l'édition.", "day_part.duplicate_code": "Un créneau type porte déjà ce code dans l'édition.",
"shift.ends_not_after_starts": "La fin du créneau doit être postérieure à son début.", "shift.ends_not_after_starts": "La fin du créneau doit être postérieure à son début.",
"shift.position_wrong_edition": "Le poste n'appartient pas à cette édition.", "shift.position_wrong_edition": "Le poste n'appartient pas à cette édition.",
"position.lead_must_be_referent_account": "Le référent d'un poste doit être un compte référent (rôle LEAD) de cette édition.",
"artist.name_required": "Le nom de l'artiste ne peut pas être vide.", "artist.name_required": "Le nom de l'artiste ne peut pas être vide.",
"artist.duplicate": "Un artiste porte déjà ce nom à cette date dans l'édition.", "artist.duplicate": "Un artiste porte déjà ce nom à cette date dans l'édition.",
"artist.unknown": "Un des artistes choisis n'appartient pas à cette édition.", "artist.unknown": "Un des artistes choisis n'appartient pas à cette édition.",
@@ -25,6 +27,7 @@
"account.volunteer_wrong_edition": "Le bénévole n'appartient pas à cette édition.", "account.volunteer_wrong_edition": "Le bénévole n'appartient pas à cette édition.",
"account.password_required": "Un compte coordination requiert un mot de passe d'au moins {min} caractères.", "account.password_required": "Un compte coordination requiert un mot de passe d'au moins {min} caractères.",
"account.password_not_allowed": "Un compte référent ou bénévole se connecte par lien magique, sans mot de passe.", "account.password_not_allowed": "Un compte référent ou bénévole se connecte par lien magique, sans mot de passe.",
"account.role_change_not_allowed": "Un compte coordination ne peut pas changer de rôle ainsi.",
"import.error.empty_file": "Le fichier est vide.", "import.error.empty_file": "Le fichier est vide.",
"import.error.encoding": "L'encodage du fichier n'est pas reconnu.", "import.error.encoding": "L'encodage du fichier n'est pas reconnu.",
"import.error.unreadable_file": "Fichier illisible ou format non pris en charge.", "import.error.unreadable_file": "Fichier illisible ou format non pris en charge.",
+13
View File
@@ -1,3 +1,5 @@
import logging
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
@@ -23,6 +25,17 @@ configure_logging()
settings = get_settings() settings = get_settings()
if settings.is_production and not settings.email_enabled:
# Avec EMAIL_ENABLED=false, le jeton de lien magique est journalisé en
# clair (confort de dev, voir `api/auth.py`) — acceptable en local, jamais
# en production : quiconque lit les logs du conteneur peut alors se
# connecter à la place de n'importe quel bénévole ou référent.
logging.getLogger("app.main").warning(
"EMAIL_ENABLED=false en production : les liens magiques sont "
"journalisés en clair au lieu d'être envoyés par email. Configurer "
"le SMTP avant l'ouverture au public."
)
app = FastAPI(title="Gestion des bénévoles", version="0.1.0") app = FastAPI(title="Gestion des bénévoles", version="0.1.0")
app.add_middleware( app.add_middleware(
+2 -1
View File
@@ -49,7 +49,8 @@ _ARTIST_CONFLICT_PENALTY = 80
# n'importe quel poste) : limite les enchaînements de shifts plutôt que de # n'importe quel poste) : limite les enchaînements de shifts plutôt que de
# faire tourner un poste en particulier. # faire tourner un poste en particulier.
_CHAIN_PENALTY = 40 _CHAIN_PENALTY = 40
# Par compagnon souhaité déjà affecté au créneau (0 à N vœux par bénévole). # Par compagnon souhaité déjà affecté au créneau (0 à N vœux par bénévole,
# rendus réciproques par le loader — voir `SolverVolunteer.wished_companion_ids`).
_COMPANION_BONUS_PER_MATCH = 25 _COMPANION_BONUS_PER_MATCH = 25
+4
View File
@@ -102,6 +102,9 @@ def build_solver_input(*, session: Session, edition: Edition) -> SolverInput:
): ):
wishes[w.volunteer_id].add(str(w.position_id)) wishes[w.volunteer_id].add(str(w.position_id))
# Traité comme réciproque pour le solveur : si A souhaite B, B profite
# aussi de la présence de A, même s'il ne l'a pas demandé (voir CLAUDE.md
# section 4).
companion_wishes: dict = defaultdict(set) companion_wishes: dict = defaultdict(set)
for w in session.exec( for w in session.exec(
select(VolunteerCompanionWish).join( select(VolunteerCompanionWish).join(
@@ -109,6 +112,7 @@ def build_solver_input(*, session: Session, edition: Edition) -> SolverInput:
).where(Volunteer.edition_id == edition.id) ).where(Volunteer.edition_id == edition.id)
): ):
companion_wishes[w.volunteer_id].add(str(w.companion_id)) companion_wishes[w.volunteer_id].add(str(w.companion_id))
companion_wishes[w.companion_id].add(str(w.volunteer_id))
artists_by_id = { artists_by_id = {
a.id: a a.id: a
+3 -1
View File
@@ -41,7 +41,9 @@ class SolverVolunteer:
id: str id: str
availability: tuple[AvailabilityWindow, ...] = () availability: tuple[AvailabilityWindow, ...] = ()
wished_position_ids: frozenset[str] = frozenset() wished_position_ids: frozenset[str] = frozenset()
# Bénévoles (0 à N) avec qui il voudrait être affecté — à sens unique. # Bénévoles (0 à N) avec qui il voudrait être affecté. Le vœu est saisi à
# sens unique (`VolunteerCompanionWish`) mais rendu réciproque ici par le
# loader : si A souhaite B, l'ensemble de B contient aussi A.
wished_companion_ids: frozenset[str] = frozenset() wished_companion_ids: frozenset[str] = frozenset()
# Passages des artistes que le bénévole veut voir (heure de passage), # Passages des artistes que le bénévole veut voir (heure de passage),
# sous forme de fenêtres datées : un créneau qui chevauche l'une d'elles # sous forme de fenêtres datées : un créneau qui chevauche l'une d'elles
+11
View File
@@ -7,6 +7,7 @@ from sqlmodel import Session, SQLModel, create_engine, select
from app import models # noqa: F401 (enregistre les tables sur SQLModel.metadata) from app import models # noqa: F401 (enregistre les tables sur SQLModel.metadata)
from app.auth.deps import get_current_account from app.auth.deps import get_current_account
from app.auth.rate_limit import login_limiter, magic_link_limiter
from app.config import get_settings from app.config import get_settings
from app.db import get_session from app.db import get_session
from app.main import app from app.main import app
@@ -23,6 +24,16 @@ def _never_use_real_smtp(monkeypatch):
get_settings.cache_clear() get_settings.cache_clear()
@pytest.fixture(autouse=True)
def _reset_rate_limiters():
"""Les limiteurs de `/auth/login` et `/auth/magic-link` sont des
singletons en mémoire (voir `app/auth/rate_limit.py`) : un test qui en
déclenche un ne doit pas polluer les suivants."""
login_limiter.reset()
magic_link_limiter.reset()
yield
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def engine(): def engine():
"""Vraie base PostgreSQL de test (voir docker/postgres-init).""" """Vraie base PostgreSQL de test (voir docker/postgres-init)."""
+51
View File
@@ -0,0 +1,51 @@
import uuid
from app.models import Role
def _url(edition, suffix=""):
return f"/editions/{edition.id}/accounts{suffix}"
def test_promotes_a_volunteer_account_to_lead(client, edition, make_account):
account = make_account(Role.VOLUNTEER, email="v@ex.fr")
resp = client.patch(_url(edition, f"/{account.id}"), json={"role": "LEAD"})
assert resp.status_code == 200
assert resp.json()["role"] == "LEAD"
assert resp.json()["auth_method"] == "magic_link"
def test_demotes_a_lead_account_to_volunteer(client, edition, make_account):
account = make_account(Role.LEAD, email="l@ex.fr")
resp = client.patch(_url(edition, f"/{account.id}"), json={"role": "VOLUNTEER"})
assert resp.status_code == 200
assert resp.json()["role"] == "VOLUNTEER"
def test_cannot_change_role_of_a_coordinator_account(client, edition, make_account):
account = make_account(Role.COORDINATOR, email="c@ex.fr", password="password123")
resp = client.patch(_url(edition, f"/{account.id}"), json={"role": "LEAD"})
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "account.role_change_not_allowed"
def test_cannot_promote_a_volunteer_to_coordinator(client, edition, make_account):
account = make_account(Role.VOLUNTEER, email="v2@ex.fr")
resp = client.patch(_url(edition, f"/{account.id}"), json={"role": "COORDINATOR"})
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "account.role_change_not_allowed"
def test_unknown_account_is_404(client, edition):
assert (
client.patch(_url(edition, f"/{uuid.uuid4()}"), json={"role": "LEAD"}).status_code
== 404
)
def test_volunteer_role_forbidden(edition, make_account, as_account):
volunteer_account = make_account(Role.VOLUNTEER, email="v3@ex.fr")
other = make_account(Role.VOLUNTEER, email="v4@ex.fr")
api = as_account(volunteer_account)
resp = api.patch(_url(edition, f"/{other.id}"), json={"role": "LEAD"})
assert resp.status_code == 403
+74
View File
@@ -1,5 +1,7 @@
import uuid import uuid
from app.models import Role
def _url(edition, suffix=""): def _url(edition, suffix=""):
return f"/editions/{edition.id}/positions{suffix}" return f"/editions/{edition.id}/positions{suffix}"
@@ -35,3 +37,75 @@ def test_unknown_edition_is_404(client):
assert ( assert (
client.get(f"/editions/{uuid.uuid4()}/positions").status_code == 404 client.get(f"/editions/{uuid.uuid4()}/positions").status_code == 404
) )
def test_sets_a_referent(client, edition, make_account):
lead = make_account(Role.LEAD, email="referent@ex.fr")
created = client.post(
_url(edition),
json={"name": "Bar", "category": "BAR"},
)
pid = created.json()["id"]
resp = client.patch(_url(edition, f"/{pid}"), json={"lead_account_id": str(lead.id)})
assert resp.status_code == 200
assert resp.json()["lead_account_id"] == str(lead.id)
def test_referent_must_have_the_lead_role(client, edition, make_account):
volunteer_account = make_account(Role.VOLUNTEER, email="v@ex.fr")
created = client.post(_url(edition), json={"name": "Bar", "category": "BAR"})
pid = created.json()["id"]
resp = client.patch(
_url(edition, f"/{pid}"), json={"lead_account_id": str(volunteer_account.id)}
)
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "position.lead_must_be_referent_account"
def test_referent_must_belong_to_the_same_edition(client, edition, session, make_account):
import datetime as dt
from app.models import Edition
other_edition = Edition(year=2027, start_date=dt.date(2027, 7, 1), end_date=dt.date(2027, 7, 4))
session.add(other_edition)
session.commit()
session.refresh(other_edition)
other_lead = make_account(Role.LEAD, email="stranger@ex.fr")
other_lead.edition_id = other_edition.id
session.add(other_lead)
session.commit()
created = client.post(_url(edition), json={"name": "Bar", "category": "BAR"})
pid = created.json()["id"]
resp = client.patch(
_url(edition, f"/{pid}"), json={"lead_account_id": str(other_lead.id)}
)
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "position.lead_must_be_referent_account"
def test_unknown_referent_is_422(client, edition):
created = client.post(_url(edition), json={"name": "Bar", "category": "BAR"})
pid = created.json()["id"]
resp = client.patch(
_url(edition, f"/{pid}"), json={"lead_account_id": str(uuid.uuid4())}
)
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "position.lead_must_be_referent_account"
def test_clearing_the_referent_is_allowed(client, edition, make_account):
lead = make_account(Role.LEAD, email="referent2@ex.fr")
created = client.post(_url(edition), json={"name": "Bar", "category": "BAR"})
pid = created.json()["id"]
client.patch(_url(edition, f"/{pid}"), json={"lead_account_id": str(lead.id)})
resp = client.patch(_url(edition, f"/{pid}"), json={"lead_account_id": None})
assert resp.status_code == 200
assert resp.json()["lead_account_id"] is None
+32
View File
@@ -102,6 +102,38 @@ def test_prefers_volunteer_whose_wished_companion_is_already_on_the_shift(
assert str(stranger.id) not in suggestion["volunteer_ids"] assert str(stranger.id) not in suggestion["volunteer_ids"]
def test_companion_bonus_applies_even_to_the_one_who_did_not_ask(client, edition, session):
# Le vœu de compagnon est à sens unique en base, mais traité comme
# réciproque par le solveur : « wisher » (déjà affecté) a souhaité être
# avec « wished », qui lui n'a rien demandé — il en profite quand même.
from app.models import Assignment
position = make_position(session, edition)
shift = make_shift(
session, edition, position,
start=dt_at(10, 14), end=dt_at(10, 18), min_staff=1, ideal_staff=2,
)
wisher = make_volunteer(session, edition, email="wisher@ex.fr")
session.add(
Assignment(edition_id=edition.id, volunteer_id=wisher.id, shift_id=shift.id)
)
session.commit()
wished = make_volunteer(session, edition, email="wished@ex.fr")
assert client.patch(
f"/editions/{edition.id}/volunteers/{wisher.id}",
json={"wished_companion_ids": [str(wished.id)]},
).status_code == 200
stranger = make_volunteer(session, edition, email="stranger@ex.fr")
resp = client.post(_url(edition, "/suggestions"))
suggestion = next(
s for s in resp.json()["suggestions"] if s["shift_id"] == str(shift.id)
)
assert suggestion["volunteer_ids"] == [str(wished.id)]
assert str(stranger.id) not in suggestion["volunteer_ids"]
def test_chain_penalty_applies_across_different_positions(client, edition, session): def test_chain_penalty_applies_across_different_positions(client, edition, session):
from app.models import Assignment, PositionCategory from app.models import Assignment, PositionCategory
+158 -1
View File
@@ -3,7 +3,7 @@ import uuid
from sqlmodel import select from sqlmodel import select
from app.models import Assignment, Availability, Volunteer from app.models import Assignment, Availability, Role, Volunteer
from tests.factories import ( from tests.factories import (
dt_at, dt_at,
make_artist, make_artist,
@@ -232,3 +232,160 @@ def test_delete_is_a_full_cascade(client, edition, session):
from app.models import Shift from app.models import Shift
assert session.get(Shift, shift.id) is not None 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
+40
View File
@@ -0,0 +1,40 @@
"""Garde-fou : un secret JWT par défaut/vide/trop court ne doit jamais tourner
en production (n'importe qui pourrait forger un jeton, y compris pour un
compte COORDINATION) — voir `app/config.py:check_production_secrets`."""
import pytest
from pydantic import ValidationError
from app.config import Settings
_OK_SECRET = "x" * 32
def test_dev_secret_refused_in_production():
with pytest.raises(ValidationError):
Settings(
environment="production",
jwt_secret="dev-insecure-secret-change-me-please-32b",
)
def test_empty_secret_refused_in_production():
with pytest.raises(ValidationError):
Settings(environment="production", jwt_secret="")
def test_short_secret_refused_in_production():
with pytest.raises(ValidationError):
Settings(environment="production", jwt_secret="trop-court")
def test_proper_secret_accepted_in_production():
settings = Settings(environment="production", jwt_secret=_OK_SECRET)
assert settings.jwt_secret == _OK_SECRET
def test_dev_secret_allowed_outside_production():
# Confort de dev : le défaut versionné ne doit pas empêcher de démarrer
# en local ou en CI.
settings = Settings(environment="development")
assert settings.is_production is False
+87
View File
@@ -0,0 +1,87 @@
"""Limiteur de tentatives sur `/auth/login` et `/auth/magic-link` — voir
`app/auth/rate_limit.py`."""
import pytest
from fastapi import HTTPException
from app.auth.rate_limit import RateLimiter
from app.models import Role
PASSWORD = "coordination-2026"
def _url(edition, suffix):
return f"/editions/{edition.id}/auth/{suffix}"
def test_rate_limiter_blocks_beyond_max_attempts():
limiter = RateLimiter(max_attempts=3, window_seconds=60)
for _ in range(3):
limiter.hit("k")
with pytest.raises(HTTPException) as exc_info:
limiter.hit("k")
assert exc_info.value.status_code == 429
def test_rate_limiter_keys_are_independent():
limiter = RateLimiter(max_attempts=1, window_seconds=60)
limiter.hit("a")
# Une autre clé n'est pas affectée par les tentatives sur "a".
limiter.hit("b")
def test_rate_limiter_forgets_hits_outside_the_window(monkeypatch):
limiter = RateLimiter(max_attempts=1, window_seconds=10)
now = [1000.0]
monkeypatch.setattr("app.auth.rate_limit.time.monotonic", lambda: now[0])
limiter.hit("k")
now[0] += 11 # au-delà de la fenêtre de 10s
limiter.hit("k") # ne lève pas : la première tentative est oubliée
def test_login_is_rate_limited_after_five_attempts(raw_client, edition, make_account):
make_account(Role.COORDINATOR, email="admin@e.fr", password=PASSWORD)
for _ in range(5):
resp = raw_client.post(
_url(edition, "login"),
json={"email": "admin@e.fr", "password": "faux"},
)
assert resp.status_code == 401
resp = raw_client.post(
_url(edition, "login"),
json={"email": "admin@e.fr", "password": "faux"},
)
assert resp.status_code == 429
assert resp.json()["detail"]["code"] == "error.too_many_requests"
# Même le bon mot de passe est bloqué tant que la fenêtre n'est pas passée.
resp = raw_client.post(
_url(edition, "login"),
json={"email": "admin@e.fr", "password": PASSWORD},
)
assert resp.status_code == 429
def test_login_rate_limit_is_scoped_per_email(raw_client, edition, make_account):
make_account(Role.COORDINATOR, email="admin@e.fr", password=PASSWORD)
make_account(Role.COORDINATOR, email="autre@e.fr", password=PASSWORD)
for _ in range(5):
raw_client.post(
_url(edition, "login"),
json={"email": "admin@e.fr", "password": "faux"},
)
# Un autre compte n'est pas affecté par les tentatives sur le premier.
resp = raw_client.post(
_url(edition, "login"),
json={"email": "autre@e.fr", "password": PASSWORD},
)
assert resp.status_code == 200
def test_magic_link_is_rate_limited_after_three_requests(raw_client, edition, make_account):
make_account(Role.VOLUNTEER, email="v@e.fr")
for _ in range(3):
resp = raw_client.post(_url(edition, "magic-link"), json={"email": "v@e.fr"})
assert resp.status_code == 202
resp = raw_client.post(_url(edition, "magic-link"), json={"email": "v@e.fr"})
assert resp.status_code == 429
assert resp.json()["detail"]["code"] == "error.too_many_requests"
+10
View File
@@ -9,6 +9,16 @@
benevoles.mon-domaine.fr { benevoles.mon-domaine.fr {
encode zstd gzip encode zstd gzip
# En-têtes de sécurité de base pour une appli publique. HSTS : Caddy gère
# le certificat et la redirection HTTP→HTTPS, mais n'ajoute pas ce header
# tout seul.
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
}
# API : même préfixe `/api` que le proxy Vite en développement, retiré # API : même préfixe `/api` que le proxy Vite en développement, retiré
# avant de transmettre au backend (qui expose ses routes sous # avant de transmettre au backend (qui expose ses routes sous
# `/editions/...`, pas `/api`). # `/editions/...`, pas `/api`).
+28 -1
View File
@@ -15,6 +15,30 @@ serveur** (service système, hors de ce dépôt) reçoit le trafic public sur
443 et reverse-proxy vers les deux conteneurs, qui n'écoutent que sur 443 et reverse-proxy vers les deux conteneurs, qui n'écoutent que sur
`127.0.0.1`. `127.0.0.1`.
## Avant d'ouvrir au public — check-list sécurité
- `JWT_SECRET` : une vraie valeur générée (`openssl rand -hex 32`), jamais la
valeur par défaut de `.env.example`. Le backend refuse de démarrer en
`ENVIRONMENT=production` sinon (`app/config.py:check_production_secrets`).
- `EMAIL_ENABLED=true` avec un SMTP qui fonctionne (`docker compose run --rm
backend uv run python -m app.email.check <destinataire>` pour vérifier) —
sinon les liens magiques sont journalisés en clair au lieu d'être envoyés
(le backend avertit au démarrage tant que c'est le cas, voir plus bas).
- Édition créée via `python -m app.bootstrap` (étape 3), **jamais** via
`python -m app.seed` : ce dernier crée un compte de démonstration
(`coordination@lucioles.test`) avec un mot de passe versionné dans le
dépôt — un vrai identifiant de connexion COORDINATION public si la base de
démo devient la base de production.
- `CORS_ORIGINS` : sans objet ici tant que le frontend appelle l'API en
même origine via `/api/*` (voir le vhost Caddy, étape 5) — à ne renseigner
que si un autre client (domaine séparé, appli mobile) doit appeler l'API
directement.
- `/auth/login` et `/auth/magic-link` sont limités en tentatives par email
(5/min et 3/5 min) — voir `app/auth/rate_limit.py`. Limité par email et
non par IP (le VPS ne transmet pas encore l'IP réelle du client à travers
Caddy) : suffisant contre la force brute sur un compte connu, pas contre
une attaque distribuée sur de nombreux emails différents.
## Étape 0 — pousser le dépôt sur GitLab ## Étape 0 — pousser le dépôt sur GitLab
```bash ```bash
@@ -54,7 +78,10 @@ PUBLIC_BASE_URL=https://benevoles.mon-domaine.fr
# Emails (récapitulatifs de planning + liens de connexion). Laisser # Emails (récapitulatifs de planning + liens de connexion). Laisser
# EMAIL_ENABLED=false tant que le SMTP n'est pas prêt : les emails sont alors # EMAIL_ENABLED=false tant que le SMTP n'est pas prêt : les emails sont alors
# seulement journalisés. # seulement journalisés — un jeton de lien magique en clair dans les logs du
# conteneur permet de se connecter à la place de n'importe qui, donc à
# repasser à true avant l'ouverture au public (le backend affiche un
# avertissement au démarrage tant que c'est le cas).
EMAIL_ENABLED=true EMAIL_ENABLED=true
SMTP_HOST=smtp.mon-domaine.fr SMTP_HOST=smtp.mon-domaine.fr
SMTP_PORT=587 SMTP_PORT=587
+18
View File
@@ -4,6 +4,24 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
# Pas de version nginx dans les en-têtes/pages d'erreur (surface de
# reconnaissance en moins).
server_tokens off;
# RGPD / sécurité : les liens magiques (`/connexion?token=…`, un jeton de
# connexion à usage unique) transitent par cette route. Pas de journal
# d'accès ici — un jeton en clair dans un log de conteneur serait un vrai
# moyen de se connecter à la place de quelqu'un pendant sa durée de vie.
access_log off;
# Postures de base pour une appli publique : pas de sniffing de type MIME,
# jamais dans une frame étrangère (l'appli n'a aucune raison d'y être),
# et un Referer minimal vers les tiers (police Google Fonts) — l'URL
# complète (avec un éventuel ?token=) ne doit jamais leur être transmise.
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Fichiers hachés par Vite (contenu du nom = jamais modifiés une fois # Fichiers hachés par Vite (contenu du nom = jamais modifiés une fois
# publiés) : cache long côté navigateur. # publiés) : cache long côté navigateur.
location /assets/ { location /assets/ {
+19
View File
@@ -8,6 +8,7 @@ import {
import { EDITION_ID, api } from './index'; import { EDITION_ID, api } from './index';
import type { import type {
AccountCreate, AccountCreate,
AccountUpdate,
ArtistCreate, ArtistCreate,
ArtistUpdate, ArtistUpdate,
AssignmentCreate, AssignmentCreate,
@@ -82,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) { export function useShifts(positionId?: string) {
return useQuery({ return useQuery({
queryKey: queryKeys.shifts(positionId), queryKey: queryKeys.shifts(positionId),
@@ -253,6 +263,15 @@ export function useCreateAccount() {
}); });
} }
export function useUpdateAccount() {
const invalidate = useInvalidate();
return useMutation({
mutationFn: ({ id, body }: { id: string; body: AccountUpdate }) =>
api.accounts.update(edition, id, body),
onSuccess: invalidate.accounts,
});
}
export function useDeleteAccount() { export function useDeleteAccount() {
const invalidate = useInvalidate(); const invalidate = useInvalidate();
return useMutation({ return useMutation({
+5
View File
@@ -1,5 +1,6 @@
import type { import type {
AccountCreate, AccountCreate,
AccountUpdate,
ArtistCreate, ArtistCreate,
ArtistUpdate, ArtistUpdate,
AssignmentCreate, AssignmentCreate,
@@ -109,11 +110,15 @@ export function createHttpApi(baseUrl: string): PlanningApi {
request(`${base(editionId)}/volunteers/${id}`, { method: 'PATCH', body: JSON.stringify(body) }), request(`${base(editionId)}/volunteers/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
remove: (editionId, id) => remove: (editionId, id) =>
request(`${base(editionId)}/volunteers/${id}`, { method: 'DELETE' }), request(`${base(editionId)}/volunteers/${id}`, { method: 'DELETE' }),
planning: (editionId, id) =>
request(`${base(editionId)}/volunteers/${id}/planning`),
}, },
accounts: { accounts: {
list: (editionId) => request(`${base(editionId)}/accounts`), list: (editionId) => request(`${base(editionId)}/accounts`),
create: (editionId, body: AccountCreate) => create: (editionId, body: AccountCreate) =>
request(`${base(editionId)}/accounts`, { method: 'POST', body: JSON.stringify(body) }), request(`${base(editionId)}/accounts`, { method: 'POST', body: JSON.stringify(body) }),
update: (editionId, id, body: AccountUpdate) =>
request(`${base(editionId)}/accounts/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
remove: (editionId, id) => remove: (editionId, id) =>
request(`${base(editionId)}/accounts/${id}`, { method: 'DELETE' }), request(`${base(editionId)}/accounts/${id}`, { method: 'DELETE' }),
}, },
+77 -4
View File
@@ -129,6 +129,16 @@ function resolveCompanionWishes(ids: string[], selfId?: string): string[] {
return resolved; return resolved;
} }
/** `lead_account_id` d'un poste : doit désigner un compte référent (LEAD)
* existant — sinon le poste se retrouve sans référent utilisable. */
function checkLeadAccount(leadAccountId: string | null | undefined): void {
if (leadAccountId == null) return;
const lead = state.accounts.find((a) => a.id === leadAccountId);
if (!lead || lead.role !== 'LEAD') {
throw new ApiError(422, 'position.lead_must_be_referent_account');
}
}
function shiftWindow(day: string, dp: DayPartRead): [string, string] { function shiftWindow(day: string, dp: DayPartRead): [string, string] {
const endDay = dp.end_time <= dp.start_time ? addDays(day, 1) : day; const endDay = dp.end_time <= dp.start_time ? addDays(day, 1) : day;
return [`${day}T${dp.start_time}`, `${endDay}T${dp.end_time}`]; return [`${day}T${dp.start_time}`, `${endDay}T${dp.end_time}`];
@@ -405,7 +415,9 @@ const SOLVER_FAIRNESS_PENALTY_PER_HOUR = 2;
// n'importe quel poste) : limite les enchaînements de shifts plutôt que de // n'importe quel poste) : limite les enchaînements de shifts plutôt que de
// faire tourner un poste en particulier. // faire tourner un poste en particulier.
const SOLVER_CHAIN_PENALTY = 40; const SOLVER_CHAIN_PENALTY = 40;
// Par compagnon souhaité déjà affecté au créneau (0 à N vœux par bénévole). // Par compagnon souhaité déjà affecté au créneau (0 à N vœux par bénévole),
// traité comme réciproque : si A souhaite B, B en profite aussi (voir
// `buildEffectiveCompanions`).
const SOLVER_COMPANION_BONUS_PER_MATCH = 25; const SOLVER_COMPANION_BONUS_PER_MATCH = 25;
type TimeWindow = { starts_at: string; ends_at: string }; type TimeWindow = { starts_at: string; ends_at: string };
@@ -428,6 +440,22 @@ function assignedVolunteerIds(shiftId: string): Set<string> {
); );
} }
/** Vœu de compagnon traité comme réciproque : si A souhaite B, B profite
* aussi de la présence de A (même s'il ne l'a pas demandé). Indexé par id de
* bénévole — sert au bonus de compagnon de route (voir `solverScore`). */
function buildEffectiveCompanions(): Map<string, Set<string>> {
const effective = new Map<string, Set<string>>();
for (const v of state.volunteers) {
effective.set(v.id, new Set(v.wished_companion_ids));
}
for (const v of state.volunteers) {
for (const companionId of v.wished_companion_ids) {
effective.get(companionId)?.add(v.id);
}
}
return effective;
}
/** Créneau(x) — n'importe quel poste — dont la fin coïncide avec le début de /** Créneau(x) — n'importe quel poste — dont la fin coïncide avec le début de
* chaque créneau, le même jour (indexé par id de créneau) — sert au malus de * chaque créneau, le même jour (indexé par id de créneau) — sert au malus de
* chaînage (voir `solverScore`), pour limiter les enchaînements de shifts * chaînage (voir `solverScore`), pour limiter les enchaînements de shifts
@@ -480,6 +508,7 @@ function solverScore(
wishedArtists: TimeWindow[], wishedArtists: TimeWindow[],
chainedFromPredecessor: boolean, chainedFromPredecessor: boolean,
alreadyAssignedIds: Set<string>, alreadyAssignedIds: Set<string>,
effectiveCompanionIds: Set<string>,
): number { ): number {
let score = assignedCount < shift.min_staff ? SOLVER_URGENT_BASE : SOLVER_NORMAL_BASE; let score = assignedCount < shift.min_staff ? SOLVER_URGENT_BASE : SOLVER_NORMAL_BASE;
if (volunteer.wished_position_ids.includes(shift.position_id)) score += SOLVER_WISH_BONUS; if (volunteer.wished_position_ids.includes(shift.position_id)) score += SOLVER_WISH_BONUS;
@@ -487,9 +516,10 @@ function solverScore(
score -= SOLVER_ARTIST_CONFLICT_PENALTY; score -= SOLVER_ARTIST_CONFLICT_PENALTY;
} }
if (chainedFromPredecessor) score -= SOLVER_CHAIN_PENALTY; if (chainedFromPredecessor) score -= SOLVER_CHAIN_PENALTY;
const companionsPresent = volunteer.wished_companion_ids.filter((id) => let companionsPresent = 0;
alreadyAssignedIds.has(id), for (const id of alreadyAssignedIds) {
).length; if (effectiveCompanionIds.has(id)) companionsPresent += 1;
}
score += SOLVER_COMPANION_BONUS_PER_MATCH * companionsPresent; score += SOLVER_COMPANION_BONUS_PER_MATCH * companionsPresent;
const windows = state.availability.get(volunteer.id) ?? []; const windows = state.availability.get(volunteer.id) ?? [];
@@ -535,6 +565,7 @@ function runSolver(): SolverRunResult {
artistWindowsByVolunteer.set(v.id, wishedArtistWindows(v)); artistWindowsByVolunteer.set(v.id, wishedArtistWindows(v));
} }
const predecessorByShift = buildPredecessorMap(); const predecessorByShift = buildPredecessorMap();
const effectiveCompanions = buildEffectiveCompanions();
// Suggestions déjà retenues dans ce calcul, pour ne jamais proposer deux // Suggestions déjà retenues dans ce calcul, pour ne jamais proposer deux
// fois un même bénévole sur des créneaux qui se chevauchent. // fois un même bénévole sur des créneaux qui se chevauchent.
const takenByVolunteer = new Map<string, ShiftRead[]>(); const takenByVolunteer = new Map<string, ShiftRead[]>();
@@ -571,6 +602,7 @@ function runSolver(): SolverRunResult {
artistWindowsByVolunteer.get(v.id) ?? [], artistWindowsByVolunteer.get(v.id) ?? [],
chained, chained,
alreadyAssignedIds, alreadyAssignedIds,
effectiveCompanions.get(v.id) ?? new Set(),
), ),
}; };
}) })
@@ -803,6 +835,33 @@ export function createMockApi(): PlanningApi {
}); });
return delay(undefined); 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: { accounts: {
@@ -837,6 +896,18 @@ export function createMockApi(): PlanningApi {
state.accounts.push(account); state.accounts.push(account);
return delay(account); return delay(account);
}, },
update: (_edition, id, body) => {
const account = state.accounts.find((a) => a.id === id) ?? notFound();
if (body.role != null && body.role !== account.role) {
if (account.role === 'COORDINATOR' || body.role === 'COORDINATOR') {
throw new ApiError(422, 'account.role_change_not_allowed');
}
account.role = body.role;
account.auth_method = 'magic_link';
}
account.updated_at = touch();
return delay(account);
},
remove: (_edition, id) => { remove: (_edition, id) => {
state.accounts = state.accounts.filter((a) => a.id !== id); state.accounts = state.accounts.filter((a) => a.id !== id);
return delay(undefined); return delay(undefined);
@@ -1068,6 +1139,7 @@ export function createMockApi(): PlanningApi {
delay([...state.positions].sort((a, b) => a.name.localeCompare(b.name, 'fr'))), delay([...state.positions].sort((a, b) => a.name.localeCompare(b.name, 'fr'))),
get: (_edition, id) => delay(state.positions.find((x) => x.id === id) ?? notFound()), get: (_edition, id) => delay(state.positions.find((x) => x.id === id) ?? notFound()),
create: (_edition, body) => { create: (_edition, body) => {
checkLeadAccount(body.lead_account_id);
const now = touch(); const now = touch();
const position = { const position = {
id: `p${state.seq++}`, id: `p${state.seq++}`,
@@ -1085,6 +1157,7 @@ export function createMockApi(): PlanningApi {
}, },
update: (_edition, id, body) => { update: (_edition, id, body) => {
const p = state.positions.find((x) => x.id === id) ?? notFound(); const p = state.positions.find((x) => x.id === id) ?? notFound();
if ('lead_account_id' in body) checkLeadAccount(body.lead_account_id);
Object.assign(p, { Object.assign(p, {
...('name' in body ? { name: body.name } : {}), ...('name' in body ? { name: body.name } : {}),
...('location' in body ? { location: body.location ?? null } : {}), ...('location' in body ? { location: body.location ?? null } : {}),
+23
View File
@@ -135,6 +135,24 @@ export interface VolunteerUpdate {
wished_companion_ids?: string[] | null; 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) // // Availability (disponibilités déclarées par le bénévole) //
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
@@ -421,6 +439,11 @@ export interface AccountCreate {
password?: string | null; password?: string | null;
} }
export interface AccountUpdate {
/** Seulement entre LEAD et VOLUNTEER — jamais vers/depuis COORDINATOR. */
role?: Role;
}
export interface TokenResponse { export interface TokenResponse {
access_token: string; access_token: string;
token_type: string; token_type: string;
+6
View File
@@ -1,6 +1,7 @@
import type { import type {
AccountCreate, AccountCreate,
AccountRead, AccountRead,
AccountUpdate,
ArtistCreate, ArtistCreate,
ArtistRead, ArtistRead,
ArtistUpdate, ArtistUpdate,
@@ -37,6 +38,7 @@ import type {
VerifyRequest, VerifyRequest,
Violation, Violation,
VolunteerCreate, VolunteerCreate,
VolunteerPlanningEntry,
VolunteerRead, VolunteerRead,
VolunteerUpdate, VolunteerUpdate,
} from './schema'; } from './schema';
@@ -68,11 +70,15 @@ export interface PlanningApi {
create(editionId: string, body: VolunteerCreate): Promise<VolunteerRead>; create(editionId: string, body: VolunteerCreate): Promise<VolunteerRead>;
update(editionId: string, id: string, body: VolunteerUpdate): Promise<VolunteerRead>; update(editionId: string, id: string, body: VolunteerUpdate): Promise<VolunteerRead>;
remove(editionId: string, id: string): Promise<void>; remove(editionId: string, id: string): Promise<void>;
/** Planning enrichi (poste, lieu, référent) — écran bénévole. */
planning(editionId: string, id: string): Promise<VolunteerPlanningEntry[]>;
}; };
accounts: { accounts: {
/** Comptes de connexion (mot de passe ou lien magique) de l'édition. */ /** Comptes de connexion (mot de passe ou lien magique) de l'édition. */
list(editionId: string): Promise<AccountRead[]>; list(editionId: string): Promise<AccountRead[]>;
create(editionId: string, body: AccountCreate): Promise<AccountRead>; create(editionId: string, body: AccountCreate): Promise<AccountRead>;
/** Changement de rôle seulement (LEAD ↔ VOLUNTEER) — voir `AccountUpdate`. */
update(editionId: string, id: string, body: AccountUpdate): Promise<AccountRead>;
remove(editionId: string, id: string): Promise<void>; remove(editionId: string, id: string): Promise<void>;
}; };
availability: { availability: {
+3 -1
View File
@@ -36,7 +36,9 @@ export function AppShell() {
)} )}
</div> </div>
<nav className="flex overflow-x-auto"> {/* Rien à choisir avec un seul écran (bénévole, référent) : la
barre d'onglets n'a de sens que pour la coordination. */}
<nav className={cx('flex overflow-x-auto', tabs.length <= 1 && 'hidden')}>
{tabs.map((tab) => ( {tabs.map((tab) => (
<NavLink <NavLink
key={tab.to} key={tab.to}
+16
View File
@@ -0,0 +1,16 @@
import { t } from '@/i18n';
/** Repère horizontal « MAINTENANT », à insérer dans une liste triée
* chronologiquement (créneaux d'un bénévole, créneaux d'un poste…) au point
* correspondant à l'heure réelle. Purement visuel. */
export function NowLine() {
return (
<div className="flex items-center gap-2 px-1" aria-hidden="true">
<div className="h-[3px] flex-1 bg-orange" />
<div className="bg-orange px-[7px] py-[3px] font-mono text-[10px] font-bold text-white">
{t('common.now')}
</div>
<div className="h-[3px] flex-1 bg-orange" />
</div>
);
}
+16 -12
View File
@@ -5,20 +5,24 @@
"action.remove": "Retirer", "action.remove": "Retirer",
"action.add": "Ajouter", "action.add": "Ajouter",
"app.loading": "Chargement…", "app.loading": "Chargement…",
"common.now": "MAINTENANT",
"app.error.generic": "Une erreur est survenue", "app.error.generic": "Une erreur est survenue",
"error.generic": "Erreur inattendue", "error.generic": "Erreur inattendue",
"error.not_found": "Ressource introuvable", "error.not_found": "Ressource introuvable",
"error.forbidden": "Accès refusé", "error.forbidden": "Accès refusé",
"error.unauthorized": "Authentification requise", "error.unauthorized": "Authentification requise",
"error.validation_failed": "La validation a échoué (règle bloquante)", "error.validation_failed": "La validation a échoué (règle bloquante)",
"error.too_many_requests": "Trop de tentatives, réessayez dans un instant",
"assignment.duplicate": "Ce bénévole est déjà affecté à ce créneau", "assignment.duplicate": "Ce bénévole est déjà affecté à ce créneau",
"assignment.wrong_edition": "Bénévole ou créneau hors de cette édition", "assignment.wrong_edition": "Bénévole ou créneau hors de cette édition",
"shift.ends_not_after_starts": "La fin doit être après le début", "shift.ends_not_after_starts": "La fin doit être après le début",
"shift.position_wrong_edition": "Le poste n'appartient pas à cette édition", "shift.position_wrong_edition": "Le poste n'appartient pas à cette édition",
"position.lead_must_be_referent_account": "Le référent d'un poste doit être un compte référent de cette édition",
"account.duplicate": "Un compte existe déjà pour cet email ou ce bénévole", "account.duplicate": "Un compte existe déjà pour cet email ou ce bénévole",
"account.volunteer_wrong_edition": "Le bénévole n'appartient pas à cette édition", "account.volunteer_wrong_edition": "Le bénévole n'appartient pas à cette édition",
"account.password_required": "Un compte coordination requiert un mot de passe", "account.password_required": "Un compte coordination requiert un mot de passe",
"account.password_not_allowed": "Un compte référent ou bénévole se connecte par lien magique, sans mot de passe", "account.password_not_allowed": "Un compte référent ou bénévole se connecte par lien magique, sans mot de passe",
"account.role_change_not_allowed": "Un compte coordination ne peut pas changer de rôle ainsi",
"auth.invalid_credentials": "Email ou mot de passe incorrect", "auth.invalid_credentials": "Email ou mot de passe incorrect",
"auth.invalid_token": "Ce lien de connexion est invalide ou expiré", "auth.invalid_token": "Ce lien de connexion est invalide ou expiré",
"role.coordinator": "Coordination", "role.coordinator": "Coordination",
@@ -81,6 +85,7 @@
"grille.positionsCount": "{count} POSTES", "grille.positionsCount": "{count} POSTES",
"grille.assignedRequired": "AFFECTÉS / IDÉAL", "grille.assignedRequired": "AFFECTÉS / IDÉAL",
"grille.footnote": "Chaque cellule est un créneau. Nombre = bénévoles affectés, / = effectif idéal. Cliquer ouvre le panneau d'affectation à droite.", "grille.footnote": "Chaque cellule est un créneau. Nombre = bénévoles affectés, / = effectif idéal. Cliquer ouvre le panneau d'affectation à droite.",
"grille.cellPreview.staffing": "{assigned}/{ideal} affecté(s) · min {min}",
"grille.empty": "Aucun créneau pour le moment. Créez des postes dans Configuration → « Postes & créneaux » — un créneau est généré par jour et par créneau type.", "grille.empty": "Aucun créneau pour le moment. Créez des postes dans Configuration → « Postes & créneaux » — un créneau est généré par jour et par créneau type.",
"grille.panel.empty.title": "Cliquez une cellule de la grille pour affecter des bénévoles.", "grille.panel.empty.title": "Cliquez une cellule de la grille pour affecter des bénévoles.",
"grille.panel.empty.body": "Le panneau s'ouvre ici, sans quitter la grille : candidats triés par charge, alertes bloquantes et avertissements du moteur de validation.", "grille.panel.empty.body": "Le panneau s'ouvre ici, sans quitter la grille : candidats triés par charge, alertes bloquantes et avertissements du moteur de validation.",
@@ -92,7 +97,8 @@
"grille.panel.noneAssigned": "Personne n'est encore affecté sur ce créneau.", "grille.panel.noneAssigned": "Personne n'est encore affecté sur ce créneau.",
"grille.panel.hoursTotal": "{hours} H AU TOTAL", "grille.panel.hoursTotal": "{hours} H AU TOTAL",
"grille.panel.candidates": "CANDIDATS", "grille.panel.candidates": "CANDIDATS",
"grille.panel.candidatesHint": "LES MOINS CHARGÉS D'ABORD", "grille.panel.searchVolunteer": "Rechercher un bénévole…",
"grille.panel.searchNoMatch": "Aucun bénévole ne correspond à cette recherche.",
"grille.panel.hoursAssigned": "{hours} H · {slots} CRÉNEAUX", "grille.panel.hoursAssigned": "{hours} H · {slots} CRÉNEAUX",
"grille.panel.assign": "AFFECTER", "grille.panel.assign": "AFFECTER",
"grille.panel.blocked": "BLOQUÉ", "grille.panel.blocked": "BLOQUÉ",
@@ -112,31 +118,25 @@
"volunteers.empty": "Aucun bénévole ne correspond à ce filtre — bonne nouvelle. Repassez sur « TOUS » pour revoir l'ensemble de l'équipe.", "volunteers.empty": "Aucun bénévole ne correspond à ce filtre — bonne nouvelle. Repassez sur « TOUS » pour revoir l'ensemble de l'équipe.",
"volunteers.noData": "Aucun bénévole pour le moment. Ajoutez-les dans Configuration → « Bénévoles & import ».", "volunteers.noData": "Aucun bénévole pour le moment. Ajoutez-les dans Configuration → « Bénévoles & import ».",
"volunteers.footnote": "Surcharge = plus de 8 h sur une même journée ou moins de 8 h entre deux créneaux. Oublié = aucune affectation.", "volunteers.footnote": "Surcharge = plus de 8 h sur une même journée ou moins de 8 h entre deux créneaux. Oublié = aucune affectation.",
"design.noteTitle": "CHOIX DE CONCEPTION",
"mobileVol.statusTime": "9:41",
"mobileVol.kicker": "MON PLANNING BÉNÉVOLE", "mobileVol.kicker": "MON PLANNING BÉNÉVOLE",
"mobileVol.greeting": "SALUT {name}", "mobileVol.greeting": "SALUT {name}",
"mobileVol.slots": "{count} CRÉNEAUX", "mobileVol.slots": "{count} CRÉNEAUX",
"mobileVol.hours": "{count} HEURES", "mobileVol.hours": "{count} HEURES",
"mobileVol.location": "📍 {location}", "mobileVol.location": "📍 {location}",
"mobileVol.slotAssigned": "✓ TU ES ATTENDU SUR CE CRÉNEAU", "mobileVol.referent": "👤 Responsable : {name}",
"mobileVol.phone": "📞 {phone}",
"mobileVol.noSlots": "Aucun créneau pour l'instant. La coordination t'affectera bientôt.", "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.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.",
"mobileVol.designNote": "Lisibilité plein soleil : fonds opaques, texte noir sur jaune ou blanc, aucun gris clair. Cibles tactiles à 52–62 px de haut. Une seule action par créneau. Les données proviennent des affectations réelles de l'API.",
"mobileLead.statusTime": "18:07",
"mobileLead.kicker": "RÉFÉRENT DE POSTE", "mobileLead.kicker": "RÉFÉRENT DE POSTE",
"mobileLead.inProgress": "CRÉNEAU · {hours}", "mobileLead.positionsCount": "{count} POSTE(S) DONT VOUS ÊTES RÉFÉRENT",
"mobileLead.noPosition": "Vous n'êtes actuellement référent d'aucun poste.",
"mobileLead.noShift": "Aucun créneau sur ce poste.",
"mobileLead.present": "{present}/{total} POINTÉS PRÉSENTS", "mobileLead.present": "{present}/{total} POINTÉS PRÉSENTS",
"mobileLead.absenceAlert": "⚠ {count} ABSENCE(S) SUR LE CRÉNEAU",
"mobileLead.requestBackup": "DEMANDER UN RENFORT À LA COORDINATION",
"mobileLead.teamTitle": "POINTAGE DE L'ÉQUIPE",
"mobileLead.badgePresent": "✓ PRÉSENT", "mobileLead.badgePresent": "✓ PRÉSENT",
"mobileLead.badgeAbsent": "✕ ABSENT", "mobileLead.badgeAbsent": "✕ ABSENT",
"mobileLead.markPresent": "PRÉSENT", "mobileLead.markPresent": "PRÉSENT",
"mobileLead.markAbsent": "ABSENT", "mobileLead.markAbsent": "ABSENT",
"mobileLead.emptyTeam": "Aucun bénévole affecté à ce créneau.", "mobileLead.emptyTeam": "Aucun bénévole affecté à ce créneau.",
"mobileLead.designNote": "Le référent ne voit que son poste et un créneau. Le pointage est un geste unique par personne. Il est local à cette maquette : le pointage sur site est hors périmètre (CLAUDE.md §4).",
"config.admin": "ADMINISTRATION", "config.admin": "ADMINISTRATION",
"config.reserved": "RÉSERVÉ À LA COORDINATION", "config.reserved": "RÉSERVÉ À LA COORDINATION",
"config.tab.grid": "POSTES & CRÉNEAUX", "config.tab.grid": "POSTES & CRÉNEAUX",
@@ -234,6 +234,10 @@
"config.vol.access.revoke": "Révoquer", "config.vol.access.revoke": "Révoquer",
"config.vol.access.revokeConfirm": "Révoquer l'accès de connexion de ce bénévole ?", "config.vol.access.revokeConfirm": "Révoquer l'accès de connexion de ce bénévole ?",
"config.vol.access.needEmail": "Renseignez un email ci-dessus pour pouvoir donner un accès.", "config.vol.access.needEmail": "Renseignez un email ci-dessus pour pouvoir donner un accès.",
"config.vol.field.referentPositions": "Référent des postes",
"config.vol.referentHint": "Cocher un poste crée ou met à niveau son accès de connexion en référent : à sa prochaine connexion, il verra l'écran référent (son équipe, le pointage) au lieu de l'écran bénévole. Décocher retire seulement le poste, sans jamais rétrograder l'accès automatiquement.",
"config.vol.referentIsCoordinator": "Ce bénévole a un compte coordination : il a déjà accès à tous les postes, inutile de le désigner référent.",
"config.vol.referentNeedsEmail": "Renseignez un email sur la fiche pour pouvoir désigner ce bénévole référent.",
"config.vol.addTitle": "NOUVEAU BÉNÉVOLE", "config.vol.addTitle": "NOUVEAU BÉNÉVOLE",
"config.vol.addHint": "Renseigner un email ne donne pas d'accès à lui seul : une fois le bénévole créé, utilisez « Donner un accès de connexion » sur sa fiche.", "config.vol.addHint": "Renseigner un email ne donne pas d'accès à lui seul : une fois le bénévole créé, utilisez « Donner un accès de connexion » sur sa fiche.",
"config.vol.create": "CRÉER LE BÉNÉVOLE", "config.vol.create": "CRÉER LE BÉNÉVOLE",
+31
View File
@@ -6,6 +6,7 @@ import type {
PositionCategory, PositionCategory,
ShiftRead, ShiftRead,
Violation, Violation,
VolunteerRead,
} from '@/api/schema'; } from '@/api/schema';
import { t } from '@/i18n'; import { t } from '@/i18n';
@@ -15,6 +16,14 @@ export function categoryLabel(category: PositionCategory): string {
return t(`position.category.${category.toLowerCase()}`); return t(`position.category.${category.toLowerCase()}`);
} }
/** Normalise pour une recherche insensible aux accents et à la casse. */
export function foldAccents(value: string): string {
return value
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase();
}
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
// Disponibilités : couverture d'un créneau par les fenêtres déclarées // // Disponibilités : couverture d'un créneau par les fenêtres déclarées //
// (portage de `backend/app/validation/engine.py`). // // (portage de `backend/app/validation/engine.py`). //
@@ -112,6 +121,28 @@ export function windowsOverlap(a: TimeWindow, b: TimeWindow): boolean {
return a.starts_at < b.ends_at && b.starts_at < a.ends_at; return a.starts_at < b.ends_at && b.starts_at < a.ends_at;
} }
/**
* Vœu de compagnon traité comme réciproque : si A souhaite B, B profite
* aussi de la présence de A, même s'il ne l'a pas demandé (le vœu reste à
* sens unique en base — voir `VolunteerCompanionWish` côté back-end — cette
* réciprocité n'est calculée qu'à l'usage, jamais enregistrée). Indexé par
* id de bénévole.
*/
export function buildEffectiveCompanionIds(
volunteers: Pick<VolunteerRead, 'id' | 'wished_companion_ids'>[],
): Map<string, Set<string>> {
const effective = new Map<string, Set<string>>();
for (const v of volunteers) {
effective.set(v.id, new Set(v.wished_companion_ids));
}
for (const v of volunteers) {
for (const companionId of v.wished_companion_ids) {
effective.get(companionId)?.add(v.id);
}
}
return effective;
}
const CHANCE_WISH_POSITION_BONUS = 30; const CHANCE_WISH_POSITION_BONUS = 30;
const CHANCE_AVAIL_PENALTY_PER_HALF_HOUR = 5; const CHANCE_AVAIL_PENALTY_PER_HALF_HOUR = 5;
const CHANCE_ARTIST_CONFLICT_PENALTY = 80; const CHANCE_ARTIST_CONFLICT_PENALTY = 80;
+11
View File
@@ -51,3 +51,14 @@ export function longDayLabel(iso: string): string {
export function hourRangeLabel(startIso: string, endIso: string): string { export function hourRangeLabel(startIso: string, endIso: string): string {
return `${timePart(startIso)} → ${timePart(endIso)}`; 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
);
}
+18
View File
@@ -0,0 +1,18 @@
import { useEffect, useState } from 'react';
import { nowNaiveMinutes } from '@/lib/time';
// Rafraîchit « maintenant » assez souvent pour qu'un repère qui en dépend
// bouge de façon perceptible sans re-rendre à outrance.
const REFRESH_MS = 30_000;
/** Minutes « murales » actuelles (cf. `nowNaiveMinutes`), retenues en state et
* rafraîchies périodiquement pour rester réactif sans requête réseau. */
export function useNowMinutes(): number {
const [minutes, setMinutes] = useState(() => nowNaiveMinutes());
useEffect(() => {
const id = setInterval(() => setMinutes(nowNaiveMinutes()), REFRESH_MS);
return () => clearInterval(id);
}, []);
return minutes;
}
+42 -4
View File
@@ -1,6 +1,6 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useMatrix, useRunSolver } from '@/api/hooks'; import { useAssignments, useMatrix, useRunSolver, useVolunteers } from '@/api/hooks';
import type { MatrixPosition, MatrixShift, PositionCategory } from '@/api/schema'; import type { MatrixPosition, MatrixShift, PositionCategory } from '@/api/schema';
import { Chip, HelpTip } from '@/components/ui'; import { Chip, HelpTip } from '@/components/ui';
import { EmptyNote, ErrorNote, Loading } from '@/components/state'; import { EmptyNote, ErrorNote, Loading } from '@/components/state';
@@ -13,6 +13,8 @@ import { cx } from '@/lib/cx';
export function GrillePage() { export function GrillePage() {
const matrix = useMatrix(); const matrix = useMatrix();
const runSolver = useRunSolver(); const runSolver = useRunSolver();
const assignments = useAssignments();
const volunteers = useVolunteers();
const [dayFilter, setDayFilter] = useState<'all' | string>('all'); const [dayFilter, setDayFilter] = useState<'all' | string>('all');
const [categoryFilter, setCategoryFilter] = useState<'all' | PositionCategory>('all'); const [categoryFilter, setCategoryFilter] = useState<'all' | PositionCategory>('all');
const [selectedShiftId, setSelectedShiftId] = useState<string | null>(null); const [selectedShiftId, setSelectedShiftId] = useState<string | null>(null);
@@ -22,6 +24,24 @@ export function GrillePage() {
[matrix.data], [matrix.data],
); );
// Noms des bénévoles affectés, par créneau — pour l'aperçu au survol d'une
// case (voir `PositionRow`). Pas bloquant : peut rester vide un instant le
// temps que ces requêtes (déjà utilisées par le panneau d'affectation)
// reviennent du cache.
const volunteerNamesByShift = useMemo(() => {
const volunteersById = new Map((volunteers.data ?? []).map((v) => [v.id, v]));
const map = new Map<string, string[]>();
for (const a of assignments.data ?? []) {
const v = volunteersById.get(a.volunteer_id);
if (!v) continue;
const list = map.get(a.shift_id) ?? [];
list.push(`${v.first_name} ${v.last_name}`);
map.set(a.shift_id, list);
}
for (const list of map.values()) list.sort((a, b) => a.localeCompare(b, 'fr'));
return map;
}, [assignments.data, volunteers.data]);
if (matrix.isLoading) return <Loading />; if (matrix.isLoading) return <Loading />;
if (matrix.error) return <ErrorNote error={matrix.error} />; if (matrix.error) return <ErrorNote error={matrix.error} />;
if (!matrix.data) return null; if (!matrix.data) return null;
@@ -167,6 +187,7 @@ export function GrillePage() {
columns={shownColumns} columns={shownColumns}
selectedShiftId={selectedShiftId} selectedShiftId={selectedShiftId}
onSelect={setSelectedShiftId} onSelect={setSelectedShiftId}
volunteerNamesByShift={volunteerNamesByShift}
/> />
))} ))}
</div> </div>
@@ -175,6 +196,7 @@ export function GrillePage() {
<aside className="max-h-[calc(100vh-118px)] w-[392px] flex-none overflow-auto border-l-[3px] border-ink bg-paper"> <aside className="max-h-[calc(100vh-118px)] w-[392px] flex-none overflow-auto border-l-[3px] border-ink bg-paper">
{selected ? ( {selected ? (
<AssignmentPanel <AssignmentPanel
key={selected.shift.shift_id}
positionId={selected.position.id} positionId={selected.position.id}
positionName={selected.position.name} positionName={selected.position.name}
positionLocation={selected.position.location} positionLocation={selected.position.location}
@@ -228,11 +250,13 @@ function PositionRow({
columns, columns,
selectedShiftId, selectedShiftId,
onSelect, onSelect,
volunteerNamesByShift,
}: { }: {
position: MatrixPosition; position: MatrixPosition;
columns: LatticeColumn[]; columns: LatticeColumn[];
selectedShiftId: string | null; selectedShiftId: string | null;
onSelect: (id: string) => void; onSelect: (id: string) => void;
volunteerNamesByShift: Map<string, string[]>;
}) { }) {
const byColumn = new Map<string, MatrixShift>(); const byColumn = new Map<string, MatrixShift>();
for (const shift of position.shifts) { for (const shift of position.shifts) {
@@ -253,7 +277,7 @@ function PositionRow({
{columns.map((column) => { {columns.map((column) => {
const shift = byColumn.get(column.key); const shift = byColumn.get(column.key);
if (!shift) { if (!shift) {
return <div key={column.key} className="h-9 border-b border-r border-ink bg-hatch-sand" />; return <div key={column.key} className="h-9 border-b border-r border-ink" />;
} }
const assigned = shift.assigned; const assigned = shift.assigned;
const undefinedNeed = shift.min_staff === 0 && shift.ideal_staff === 0; const undefinedNeed = shift.min_staff === 0 && shift.ideal_staff === 0;
@@ -271,25 +295,39 @@ function PositionRow({
? '◧' ? '◧'
: '✓'; : '✓';
const selected = selectedShiftId === shift.shift_id; const selected = selectedShiftId === shift.shift_id;
const names = volunteerNamesByShift.get(shift.shift_id) ?? [];
return ( return (
<div <div
key={column.key} key={column.key}
onClick={() => onSelect(shift.shift_id)} onClick={() => onSelect(shift.shift_id)}
className={cx( className={cx(
'relative flex h-9 cursor-pointer items-center justify-center gap-[3px] border-b border-r border-ink', 'group relative flex h-9 cursor-pointer items-center justify-center gap-[3px] border-b border-r border-ink',
undefinedNeed && 'bg-sand-dark hover:bg-sand', undefinedNeed && 'bg-sand-dark hover:bg-sand',
isCrit && 'bg-hatch-orange', isCrit && 'bg-hatch-orange',
isOver && 'bg-hatch-mauve hover:bg-mauve-hover', isOver && 'bg-hatch-mauve hover:bg-mauve-hover',
isInc && 'bg-yellow-soft hover:bg-yellow', isInc && 'bg-yellow-soft hover:bg-yellow',
isOk && 'bg-paper hover:bg-green/10', isOk && 'bg-paper hover:bg-green/10',
)} )}
title={`${assigned} affecté(s) · min ${shift.min_staff} · idéal ${shift.ideal_staff}`}
> >
<span className="font-mono text-[13px] font-bold">{assigned}</span> <span className="font-mono text-[13px] font-bold">{assigned}</span>
<span className="font-mono text-[9px] opacity-50">/{shift.ideal_staff}</span> <span className="font-mono text-[9px] opacity-50">/{shift.ideal_staff}</span>
<span className={cx('font-mono text-[10px] font-bold', isOk && 'text-green')}> <span className={cx('font-mono text-[10px] font-bold', isOk && 'text-green')}>
{glyph} {glyph}
</span> </span>
<div className="pointer-events-none absolute left-1/2 top-full z-20 mt-[4px] w-max max-w-[220px] -translate-x-1/2 border-2 border-ink bg-ink px-[9px] py-[7px] text-left font-mono text-[10px] font-normal normal-case leading-relaxed tracking-normal text-cream opacity-0 shadow-brutal-md transition-opacity duration-100 group-hover:opacity-100">
<div className="mb-[3px] font-display text-[9px] font-extrabold uppercase tracking-wide text-yellow">
{t('grille.cellPreview.staffing', {
assigned,
ideal: shift.ideal_staff,
min: shift.min_staff,
})}
</div>
{names.length > 0 ? (
names.map((name, i) => <div key={i}>{name}</div>)
) : (
<div className="opacity-70">{t('grille.panel.noneAssigned')}</div>
)}
</div>
{selected ? ( {selected ? (
<span className="pointer-events-none absolute inset-0 border-[3px] border-blue-strong" /> <span className="pointer-events-none absolute inset-0 border-[3px] border-blue-strong" />
) : null} ) : null}
+132 -62
View File
@@ -1,12 +1,30 @@
import { useMemo, useState } from 'react'; import { Fragment, useMemo, useState } from 'react';
import type { ShiftRead } from '@/api/schema';
import { useAssignments, usePositions, useShifts, useVolunteers } from '@/api/hooks'; import { useAssignments, usePositions, useShifts, useVolunteers } from '@/api/hooks';
import { useAuth } from '@/auth/auth-context'; import { useAuth } from '@/auth/auth-context';
import { ErrorNote, Loading } from '@/components/state'; import { ErrorNote, Loading } from '@/components/state';
import { DesignNote, PhoneFrame } from '@/pages/mobile/PhoneFrame'; import { NowLine } from '@/components/NowLine';
import { hourRangeLabel } from '@/lib/time'; import { hourRangeLabel, longDayLabel, naiveMinutes } from '@/lib/time';
import { useNowMinutes } from '@/lib/useNowMinutes';
import { t } from '@/i18n'; import { t } from '@/i18n';
import { cx } from '@/lib/cx'; import { cx } from '@/lib/cx';
import { VolunteerHomeView } from '@/pages/VolunteerHomeView';
// Page réellement consultée par le référent (accueil de son rôle LEAD, cf.
// `auth/roles.ts:homePath`) — page responsive (mobile d'abord, élargie à
// partir de `sm:`), plus de maquette de téléphone. Un compte peut être
// référent de plusieurs postes (`Position.lead_account_id`) : chacun a sa
// propre carte, listant TOUS ses créneaux (pas seulement le plus proche) avec
// un repère « MAINTENANT » pour se situer. Aucun repli vers « le premier
// poste de l'édition » si le compte n'est référent d'aucun poste — ce serait
// montrer un poste qui n'est pas le sien ; s'il est par ailleurs rattaché à
// une fiche bénévole (cas courant : « Donner un accès de connexion » promu
// en référent), il voit alors sa vue de bénévole classique plutôt qu'un
// écran référent vide (`VolunteerHomeView`).
//
// Le pointage présent/absent reste local à l'écran (état React, jamais
// envoyé au serveur) : le pointage sur site est hors périmètre (CLAUDE.md §5).
type Presence = Record<string, 'present' | 'absent'>; type Presence = Record<string, 'present' | 'absent'>;
@@ -15,19 +33,26 @@ export function MobileLeadPage() {
const positions = usePositions(); const positions = usePositions();
const shifts = useShifts(); const shifts = useShifts();
const volunteers = useVolunteers(); const volunteers = useVolunteers();
const assignments = useAssignments();
const nowMinutes = useNowMinutes();
const [presence, setPresence] = useState<Presence>({}); const [presence, setPresence] = useState<Presence>({});
// Poste du référent connecté (repli : le premier, pour la démo coordination).
const myPositions = (positions.data ?? []).filter( const myPositions = (positions.data ?? []).filter(
(p) => account && p.lead_account_id === account.id, (p) => account && p.lead_account_id === account.id,
); );
const position = myPositions[0] ?? (positions.data ?? [])[0];
const positionShifts = useMemo( const shiftsByPosition = useMemo(() => {
() => (shifts.data ?? []).filter((s) => s.position_id === position?.id), const map = new Map<string, ShiftRead[]>();
[shifts.data, position?.id], for (const s of shifts.data ?? []) {
); const list = map.get(s.position_id) ?? [];
const shift = positionShifts[Math.floor(positionShifts.length / 2)] ?? positionShifts[0]; list.push(s);
const shiftAssignments = useAssignments(shift ? { shiftId: shift.id } : undefined); map.set(s.position_id, list);
}
for (const list of map.values()) {
list.sort((a, b) => naiveMinutes(a.starts_at) - naiveMinutes(b.starts_at));
}
return map;
}, [shifts.data]);
const volunteersById = useMemo( const volunteersById = useMemo(
() => new Map((volunteers.data ?? []).map((v) => [v.id, v])), () => new Map((volunteers.data ?? []).map((v) => [v.id, v])),
@@ -35,68 +60,100 @@ export function MobileLeadPage() {
); );
if (positions.isLoading || shifts.isLoading) return <Loading />; if (positions.isLoading || shifts.isLoading) return <Loading />;
const error = positions.error ?? shifts.error ?? volunteers.error; const error = positions.error ?? shifts.error ?? volunteers.error ?? assignments.error;
if (error) return <ErrorNote error={error} />; if (error) return <ErrorNote error={error} />;
if (!position || !shift) return null;
const team = (shiftAssignments.data ?? []).map((a) => { if (myPositions.length === 0) {
if (account?.volunteer_id) {
return (
<VolunteerHomeView
volunteerId={account.volunteer_id}
fallbackName={account.email.split('@')[0]}
/>
);
}
return (
<div className="mx-auto w-full max-w-[640px] p-4 sm:p-6">
<div className="border-[3px] border-dashed border-ink p-4 font-mono text-[11px] leading-relaxed opacity-70">
{t('mobileLead.noPosition')}
</div>
</div>
);
}
return (
<div className="mx-auto flex w-full max-w-[640px] flex-1 flex-col">
<div className="bg-ink px-4 py-5 text-cream sm:px-6 sm:py-7">
<div className="font-mono text-[10px] text-yellow">{t('mobileLead.kicker')}</div>
<div className="mt-[5px] font-display text-[28px] font-black leading-tight tracking-tight sm:text-[34px]">
{t('mobileLead.positionsCount', { count: myPositions.length })}
</div>
</div>
<div className="flex flex-1 flex-col gap-[22px] p-4 sm:p-6">
{myPositions.map((position) => {
const positionShifts = shiftsByPosition.get(position.id) ?? [];
const nowIndex = positionShifts.findIndex((s) => naiveMinutes(s.starts_at) > nowMinutes);
const nowInsertAt = nowIndex === -1 ? positionShifts.length : nowIndex;
return (
<div key={position.id} className="border-[3px] border-ink bg-paper shadow-brutal-lg">
<div className="bg-ink px-[14px] pb-[11px] pt-[11px] text-cream">
<div className="font-display text-[19px] font-black leading-tight tracking-tight">
{position.name.toUpperCase()}
</div>
{position.location ? (
<div className="font-display text-[13px] font-semibold opacity-80">
{position.location}
</div>
) : null}
</div>
<div className="flex flex-col gap-[14px] px-[14px] pb-[18px] pt-[13px]">
{positionShifts.length === 0 ? (
<div className="font-mono text-[11px] leading-relaxed opacity-70">
{t('mobileLead.noShift')}
</div>
) : null}
{positionShifts.map((shift, i) => {
const team = (assignments.data ?? [])
.filter((a) => a.shift_id === shift.id)
.map((a) => {
const volunteer = volunteersById.get(a.volunteer_id); const volunteer = volunteersById.get(a.volunteer_id);
return { return {
id: a.id, id: a.id,
name: volunteer ? `${volunteer.first_name} ${volunteer.last_name}` : a.volunteer_id, name: volunteer
initials: volunteer ? `${volunteer.first_name[0]}${volunteer.last_name[0]}` : '?', ? `${volunteer.first_name} ${volunteer.last_name}`
: a.volunteer_id,
initials: volunteer
? `${volunteer.first_name[0]}${volunteer.last_name[0]}`
: '?',
phone: volunteer?.phone ?? '', phone: volunteer?.phone ?? '',
present: presence[a.id] === 'present', present: presence[a.id] === 'present',
absent: presence[a.id] === 'absent', absent: presence[a.id] === 'absent',
}; };
}); });
const presentCount = team.filter((m) => m.present).length; const presentCount = team.filter((m) => m.present).length;
const absentCount = team.filter((m) => m.absent).length;
return ( return (
<div className="flex flex-wrap items-start gap-[26px] p-6"> <Fragment key={shift.id}>
<PhoneFrame time={t('mobileLead.statusTime')}> {i === nowInsertAt ? <NowLine /> : null}
<div className="bg-ink px-4 pb-[15px] pt-3 text-cream"> <div className="border-2 border-ink">
<div className="font-mono text-[10px] text-yellow">{t('mobileLead.kicker')}</div> <div className="flex flex-wrap items-stretch gap-2 border-b-2 border-ink bg-sand px-[11px] py-[9px]">
<div className="mt-[5px] font-display text-[25px] font-black leading-tight tracking-tight">
{position.name.toUpperCase()}
</div>
{position.location ? (
<div className="font-display text-[13px] font-semibold opacity-80">{position.location}</div>
) : null}
<div className="mt-3 flex items-stretch gap-2">
<div className="bg-yellow px-[9px] py-[7px] font-display text-[12px] font-extrabold text-ink"> <div className="bg-yellow px-[9px] py-[7px] font-display text-[12px] font-extrabold text-ink">
{t('mobileLead.inProgress', { {longDayLabel(shift.starts_at)} ·{' '}
hours: hourRangeLabel(shift.starts_at, shift.ends_at), {hourRangeLabel(shift.starts_at, shift.ends_at)}
})}
</div> </div>
<div className="border-2 border-cream px-[9px] py-[6px] font-display text-[12px] font-extrabold"> <div className="border-2 border-ink bg-cream px-[9px] py-[6px] font-display text-[12px] font-extrabold">
{t('mobileLead.present', { present: presentCount, total: team.length })} {t('mobileLead.present', { present: presentCount, total: team.length })}
</div> </div>
</div> </div>
</div> <div className="flex flex-col gap-[11px] p-[11px]">
{absentCount > 0 ? (
<div className="border-b-[3px] border-ink bg-hatch-orange px-[14px] py-[11px]">
<div className="font-display text-[15px] font-black leading-tight">
{t('mobileLead.absenceAlert', { count: absentCount })}
</div>
<button
type="button"
className="mt-[9px] min-h-[52px] w-full cursor-pointer border-[3px] border-ink bg-ink font-display text-[14px] font-black text-cream hover:bg-yellow hover:text-ink"
>
{t('mobileLead.requestBackup')}
</button>
</div>
) : null}
<div className="flex flex-col gap-[11px] px-[14px] pb-[26px] pt-[13px]">
<div className="font-display text-[12px] font-black uppercase tracking-widest">
{t('mobileLead.teamTitle')}
</div>
{team.map((member) => ( {team.map((member) => (
<div key={member.id} className="border-[3px] border-ink bg-paper shadow-brutal-md"> <div
key={member.id}
className="border-[3px] border-ink bg-cream shadow-brutal-sm"
>
<div className="flex items-center gap-[10px] px-[11px] py-[10px]"> <div className="flex items-center gap-[10px] px-[11px] py-[10px]">
<span className="flex h-[38px] w-[38px] flex-none items-center justify-center border-[3px] border-ink bg-yellow font-display text-[13px] font-extrabold"> <span className="flex h-[38px] w-[38px] flex-none items-center justify-center border-[3px] border-ink bg-yellow font-display text-[13px] font-extrabold">
{member.initials} {member.initials}
@@ -123,17 +180,21 @@ export function MobileLeadPage() {
<div className="grid grid-cols-2 border-t-[3px] border-ink"> <div className="grid grid-cols-2 border-t-[3px] border-ink">
<button <button
type="button" type="button"
onClick={() => setPresence((p) => ({ ...p, [member.id]: 'present' }))} onClick={() =>
setPresence((p) => ({ ...p, [member.id]: 'present' }))
}
className={cx( className={cx(
'min-h-[56px] cursor-pointer border-r-[3px] border-ink bg-cream font-display text-[15px] font-black hover:bg-green hover:text-white', 'min-h-[52px] cursor-pointer border-r-[3px] border-ink bg-paper font-display text-[14px] font-black hover:bg-green hover:text-white',
)} )}
> >
{t('mobileLead.markPresent')} {t('mobileLead.markPresent')}
</button> </button>
<button <button
type="button" type="button"
onClick={() => setPresence((p) => ({ ...p, [member.id]: 'absent' }))} onClick={() =>
className="min-h-[56px] cursor-pointer bg-cream font-display text-[15px] font-black hover:bg-orange" setPresence((p) => ({ ...p, [member.id]: 'absent' }))
}
className="min-h-[52px] cursor-pointer bg-paper font-display text-[14px] font-black hover:bg-orange"
> >
{t('mobileLead.markAbsent')} {t('mobileLead.markAbsent')}
</button> </button>
@@ -141,14 +202,23 @@ export function MobileLeadPage() {
</div> </div>
))} ))}
{team.length === 0 ? ( {team.length === 0 ? (
<div className="border-[3px] border-dashed border-ink p-[11px] font-mono text-[11px] leading-relaxed opacity-70"> <div className="font-mono text-[11px] leading-relaxed opacity-70">
{t('mobileLead.emptyTeam')} {t('mobileLead.emptyTeam')}
</div> </div>
) : null} ) : null}
</div> </div>
</PhoneFrame> </div>
</Fragment>
<DesignNote>{t('mobileLead.designNote')}</DesignNote> );
})}
{nowInsertAt === positionShifts.length && positionShifts.length > 0 ? (
<NowLine />
) : null}
</div>
</div>
);
})}
</div>
</div> </div>
); );
} }
+13 -116
View File
@@ -1,133 +1,30 @@
import { useMemo } from 'react';
import { ApiError } from '@/api';
import { useAssignments, usePositions, useShifts, useVolunteer } from '@/api/hooks';
import { useAuth } from '@/auth/auth-context'; import { useAuth } from '@/auth/auth-context';
import { ErrorNote, Loading } from '@/components/state';
import { DesignNote, PhoneFrame } from '@/pages/mobile/PhoneFrame';
import { hourRangeLabel, hoursBetween, longDayLabel, naiveMinutes } from '@/lib/time';
import { t } from '@/i18n'; import { t } from '@/i18n';
import { VolunteerHomeView } from '@/pages/VolunteerHomeView';
// Page réellement consultée par le bénévole (accueil de son rôle, cf.
// `auth/roles.ts:homePath`) — sur son propre téléphone la plupart du temps,
// mais aussi possible depuis un ordinateur : le rendu s'adapte (mobile
// d'abord, une seule colonne pleine largeur ; élargi et centré à partir de
// `sm:`), sans jamais simuler un cadre de téléphone. Le contenu lui-même vit
// dans `VolunteerHomeView`, également montré à un compte RÉFÉRENT sans poste
// actuellement assigné (voir `MobileLeadPage.tsx`).
export function MobileVolunteerPage() { export function MobileVolunteerPage() {
const { account } = useAuth(); const { account } = useAuth();
const volunteerId = account?.volunteer_id ?? null; const volunteerId = account?.volunteer_id ?? null;
const meVolunteer = useVolunteer(volunteerId);
const assignments = useAssignments(volunteerId ? { volunteerId } : undefined, {
enabled: Boolean(volunteerId),
});
const shifts = useShifts();
const positions = usePositions();
const shiftsById = useMemo(
() => new Map((shifts.data ?? []).map((s) => [s.id, s])),
[shifts.data],
);
const positionsById = useMemo(
() => new Map((positions.data ?? []).map((p) => [p.id, p])),
[positions.data],
);
if (!volunteerId) { if (!volunteerId) {
return ( return (
<div className="m-6 max-w-[560px] border-[3px] border-dashed border-ink bg-paper p-4 font-display text-[13px] font-bold"> <div className="mx-auto w-full max-w-[640px] p-4 sm:p-6">
<div className="border-[3px] border-dashed border-ink bg-paper p-4 font-display text-[13px] font-bold">
{t('mobileVol.noAccount')} {t('mobileVol.noAccount')}
</div> </div>
</div>
); );
} }
if (assignments.isLoading) return <Loading />;
if (assignments.error) return <ErrorNote error={assignments.error} />;
const firstName = meVolunteer.data?.first_name ?? account?.email.split('@')[0] ?? '';
// shifts / positions peuvent être refusés (403) à un bénévole côté back-end réel :
// on affiche alors les créneaux sans détail plutôt que de tomber en erreur.
const detailsUnavailable =
(shifts.error instanceof ApiError && shifts.error.status === 403) ||
(positions.error instanceof ApiError && positions.error.status === 403);
const slots = (assignments.data ?? [])
.map((a) => ({ assignment: a, shift: shiftsById.get(a.shift_id) ?? null }))
.sort((a, b) => {
const av = a.shift ? naiveMinutes(a.shift.starts_at) : 0;
const bv = b.shift ? naiveMinutes(b.shift.starts_at) : 0;
return av - bv;
});
const totalHours =
Math.round(
slots.reduce(
(sum, s) => sum + (s.shift ? hoursBetween(s.shift.starts_at, s.shift.ends_at) : 0),
0,
) * 10,
) / 10;
return ( return (
<div className="flex flex-wrap items-start gap-[26px] p-6"> <VolunteerHomeView volunteerId={volunteerId} fallbackName={account?.email.split('@')[0]} />
<PhoneFrame time={t('mobileVol.statusTime')}>
<div className="bg-ink px-4 pb-4 pt-3 text-cream">
<div className="font-mono text-[10px] text-yellow">{t('mobileVol.kicker')}</div>
<div className="mt-[5px] font-display text-[30px] font-black leading-tight tracking-tight">
{t('mobileVol.greeting', { name: firstName.toUpperCase() })}
</div>
<div className="mt-3 flex gap-2">
<div className="bg-yellow px-[9px] py-[7px] font-display text-[12px] font-extrabold text-ink">
{t('mobileVol.slots', { count: slots.length })}
</div>
{!detailsUnavailable ? (
<div className="border-2 border-cream px-[9px] py-[6px] font-display text-[12px] font-extrabold">
{t('mobileVol.hours', { count: totalHours })}
</div>
) : null}
</div>
</div>
<div className="flex flex-col gap-[14px] px-[14px] pb-[26px] pt-[14px]">
{detailsUnavailable ? (
<div className="border-2 border-dashed border-ink bg-yellow-soft p-3 font-mono text-[10px] leading-relaxed">
{t('mobileVol.detailsUnavailable')}
</div>
) : null}
{slots.map(({ assignment, shift }) => {
const position = shift ? positionsById.get(shift.position_id) : undefined;
return (
<div key={assignment.id} className="border-[3px] border-ink bg-paper shadow-brutal-lg">
{shift ? (
<div className="flex items-baseline gap-2 bg-ink px-[11px] py-2 text-cream">
<span className="font-display text-[15px] font-black">
{longDayLabel(shift.starts_at)}
</span>
<span className="ml-auto font-mono text-[16px] font-bold text-yellow">
{hourRangeLabel(shift.starts_at, shift.ends_at)}
</span>
</div>
) : null}
<div className="p-[11px]">
{position ? (
<div className="font-display text-[23px] font-black leading-tight tracking-tight">
{position.name.toUpperCase()}
</div>
) : null}
{position?.location ? (
<div className="mt-[9px] border-t-2 border-ink pt-[9px] font-mono text-[12px]">
{t('mobileVol.location', { location: position.location })}
</div>
) : null}
<div className="mt-[11px] flex min-h-[52px] items-center justify-center border-[3px] border-ink bg-green font-display text-[15px] font-black text-white">
{t('mobileVol.slotAssigned')}
</div>
</div>
</div>
);
})}
{slots.length === 0 ? (
<div className="border-[3px] border-dashed border-ink p-4 font-mono text-[11px] leading-relaxed opacity-70">
{t('mobileVol.noSlots')}
</div>
) : null}
</div>
</PhoneFrame>
<DesignNote>{t('mobileVol.designNote')}</DesignNote>
</div>
); );
} }
+117
View File
@@ -0,0 +1,117 @@
import { Fragment, useMemo } from 'react';
import { useVolunteer, useVolunteerPlanning } from '@/api/hooks';
import { ErrorNote, Loading } from '@/components/state';
import { NowLine } from '@/components/NowLine';
import { hourRangeLabel, longDayLabel, naiveMinutes } from '@/lib/time';
import { useNowMinutes } from '@/lib/useNowMinutes';
import { t } from '@/i18n';
/**
* Contenu de l'écran bénévole (planning : poste, jour, horaire, lieu,
* responsable et téléphone à contacter, repère « MAINTENANT ») — extrait de
* `MobileVolunteerPage` pour être également montré à un compte RÉFÉRENT
* (`LEAD`) qui n'est actuellement désigné référent d'aucun poste : dans ce
* cas il doit voir sa vue de bénévole classique plutôt qu'un écran référent
* vide (voir `MobileLeadPage.tsx`).
*/
export function VolunteerHomeView({
volunteerId,
fallbackName,
}: {
volunteerId: string;
/** Utilisé si la fiche bénévole n'est pas encore chargée (ex. email). */
fallbackName?: string;
}) {
const meVolunteer = useVolunteer(volunteerId);
const planning = useVolunteerPlanning(volunteerId);
const nowMinutes = useNowMinutes();
// Hooks appelés inconditionnellement avant les retours anticipés ci-dessous.
const slots = useMemo(
() =>
[...(planning.data ?? [])].sort(
(a, b) => naiveMinutes(a.starts_at) - naiveMinutes(b.starts_at),
),
[planning.data],
);
if (planning.isLoading) return <Loading />;
if (planning.error) return <ErrorNote error={planning.error} />;
const firstName = meVolunteer.data?.first_name ?? fallbackName ?? '';
const totalHours =
Math.round(
slots.reduce((sum, s) => sum + (naiveMinutes(s.ends_at) - naiveMinutes(s.starts_at)) / 60, 0) *
10,
) / 10;
// Le repère « maintenant » se place juste avant le premier créneau pas
// encore commencé (le prochain à venir) ; en fin de liste si tous sont déjà
// passés.
const nowIndex = slots.findIndex((s) => naiveMinutes(s.starts_at) > nowMinutes);
const nowInsertAt = nowIndex === -1 ? slots.length : nowIndex;
return (
<div className="mx-auto flex w-full max-w-[640px] flex-1 flex-col">
<div className="bg-ink px-4 py-5 text-cream sm:px-6 sm:py-7">
<div className="font-mono text-[10px] text-yellow">{t('mobileVol.kicker')}</div>
<div className="mt-[5px] font-display text-[28px] font-black leading-tight tracking-tight sm:text-[34px]">
{t('mobileVol.greeting', { name: firstName.toUpperCase() })}
</div>
<div className="mt-3 flex flex-wrap gap-2">
<div className="bg-yellow px-[9px] py-[7px] font-display text-[12px] font-extrabold text-ink">
{t('mobileVol.slots', { count: slots.length })}
</div>
<div className="border-2 border-cream px-[9px] py-[6px] font-display text-[12px] font-extrabold">
{t('mobileVol.hours', { count: totalHours })}
</div>
</div>
</div>
<div className="flex flex-1 flex-col gap-[14px] p-4 sm:p-6">
{slots.map((entry, i) => (
<Fragment key={entry.assignment_id}>
{i === nowInsertAt ? <NowLine /> : null}
<div className="border-[3px] border-ink bg-paper shadow-brutal-lg">
<div className="flex items-baseline gap-2 bg-ink px-[11px] py-2 text-cream">
<span className="font-display text-[15px] font-black">
{longDayLabel(entry.starts_at)}
</span>
<span className="ml-auto font-mono text-[16px] font-bold text-yellow">
{hourRangeLabel(entry.starts_at, entry.ends_at)}
</span>
</div>
<div className="p-[11px]">
<div className="font-display text-[23px] font-black leading-tight tracking-tight">
{entry.position_name.toUpperCase()}
</div>
{entry.position_location ? (
<div className="mt-[9px] border-t-2 border-ink pt-[9px] font-mono text-[12px]">
{t('mobileVol.location', { location: entry.position_location })}
</div>
) : null}
{entry.referent_name ? (
<div className="mt-[9px] font-mono text-[12px]">
{t('mobileVol.referent', { name: entry.referent_name })}
</div>
) : null}
{entry.referent_phone ? (
<div className="mt-[3px] font-mono text-[12px]">
{t('mobileVol.phone', { phone: entry.referent_phone })}
</div>
) : null}
</div>
</div>
</Fragment>
))}
{nowInsertAt === slots.length && slots.length > 0 ? <NowLine /> : null}
{slots.length === 0 ? (
<div className="border-[3px] border-dashed border-ink p-4 font-mono text-[11px] leading-relaxed opacity-70">
{t('mobileVol.noSlots')}
</div>
) : null}
</div>
</div>
);
}
+119 -6
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { ApiError } from '@/api'; import { ApiError } from '@/api';
import { roleLabelKey } from '@/auth/roles';
import { import {
useAccounts, useAccounts,
useAnalyzeImport, useAnalyzeImport,
@@ -13,6 +14,8 @@ import {
useExecuteImport, useExecuteImport,
usePositions, usePositions,
useReplaceAvailability, useReplaceAvailability,
useUpdateAccount,
useUpdatePosition,
useUpdateVolunteer, useUpdateVolunteer,
useVolunteerAvailability, useVolunteerAvailability,
useVolunteers, useVolunteers,
@@ -30,6 +33,7 @@ import type {
} from '@/api/schema'; } from '@/api/schema';
import { Chip, FieldLabel, HelpTip, TextInput } from '@/components/ui'; import { Chip, FieldLabel, HelpTip, TextInput } from '@/components/ui';
import { ErrorNote, Loading } from '@/components/state'; import { ErrorNote, Loading } from '@/components/state';
import { foldAccents } from '@/lib/planning';
import { t } from '@/i18n'; import { t } from '@/i18n';
import { shortDayLabel } from '@/lib/time'; import { shortDayLabel } from '@/lib/time';
import { cx } from '@/lib/cx'; import { cx } from '@/lib/cx';
@@ -114,6 +118,9 @@ function VolunteerAccessSection({
<div className="flex items-center justify-between gap-2 border-2 border-ink bg-cream p-[9px]"> <div className="flex items-center justify-between gap-2 border-2 border-ink bg-cream p-[9px]">
<span className="min-w-0 truncate font-mono text-[10px]"> <span className="min-w-0 truncate font-mono text-[10px]">
{t('config.vol.access.active', { email: account.email })} {t('config.vol.access.active', { email: account.email })}
<span className="ml-[6px] border-[1.5px] border-ink bg-yellow px-1 py-[1px] font-display text-[8px] font-extrabold uppercase tracking-wide">
{t(roleLabelKey(account.role))}
</span>
</span> </span>
<button <button
type="button" type="button"
@@ -151,11 +158,114 @@ function VolunteerAccessSection({
); );
} }
function foldAccents(value: string): string { /**
return value * Postes dont ce bénévole est référent (`Position.lead_account_id`). Cocher
.normalize('NFD') * un poste crée ou met à niveau son accès de connexion en RÉFÉRENT (LEAD) —
.replace(/[\u0300-\u036f]/g, '') * à sa prochaine connexion il verra l'écran référent, pas l'écran bénévole
.toLowerCase(); * (voir `auth/roles.ts:homePath`). Décocher retire juste son nom du poste,
* sans jamais rétrograder son accès automatiquement.
*/
function VolunteerReferentSection({
volunteerId,
email,
}: {
volunteerId: string;
email: string | null;
}) {
const accounts = useAccounts();
const positions = usePositions();
const createAccount = useCreateAccount();
const updateAccount = useUpdateAccount();
const updatePosition = useUpdatePosition();
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const account = accounts.data?.find((a) => a.volunteer_id === volunteerId);
const isCoordinator = account?.role === 'COORDINATOR';
const options = useMemo(() => positionOptions(positions.data), [positions.data]);
const referentIds = (positions.data ?? [])
.filter((p) => account && p.lead_account_id === account.id)
.map((p) => p.id);
const ensureLeadAccountId = async (): Promise<string> => {
if (account) {
if (account.role !== 'LEAD') {
await updateAccount.mutateAsync({ id: account.id, body: { role: 'LEAD' } });
}
return account.id;
}
if (!email) throw new ApiError(422, 'config.vol.referentNeedsEmail');
const created = await createAccount.mutateAsync({
email,
role: 'LEAD',
volunteer_id: volunteerId,
});
return created.id;
};
const handleChange = (nextIds: string[]) => {
const added = nextIds.filter((id) => !referentIds.includes(id));
const removed = referentIds.filter((id) => !nextIds.includes(id));
if (added.length === 0 && removed.length === 0) return;
setError(null);
setPending(true);
void (async () => {
try {
let leadAccountId: string | null = null;
if (added.length > 0) leadAccountId = await ensureLeadAccountId();
for (const id of added) {
await updatePosition.mutateAsync({ id, body: { lead_account_id: leadAccountId } });
}
for (const id of removed) {
await updatePosition.mutateAsync({ id, body: { lead_account_id: null } });
}
} catch (err) {
setError(err instanceof ApiError ? t(err.code) : t('error.generic'));
} finally {
setPending(false);
}
})();
};
const label = (
<>
{t('config.vol.field.referentPositions')} <HelpTip text={t('config.vol.referentHint')} />
</>
);
if (isCoordinator) {
return (
<div>
<FieldLabel>{label}</FieldLabel>
<div className="font-mono text-[10px] opacity-60">
{t('config.vol.referentIsCoordinator')}
</div>
</div>
);
}
return (
<div>
<BadgeWishPicker
label={label}
options={options}
selected={referentIds}
onChange={handleChange}
emptyLabel={t('config.vol.noPositions')}
disabled={pending}
/>
{referentIds.length === 0 && !email ? (
<div className="mt-1 font-mono text-[9.5px] opacity-60">
{t('config.vol.access.needEmail')}
</div>
) : null}
{error ? (
<div className="mt-1 border-2 border-ink bg-orange p-[7px] font-display text-[10px] font-bold">
{error}
</div>
) : null}
</div>
);
} }
/** Sélecteur de vœux avec recherche (bénévoles : liste potentiellement /** Sélecteur de vœux avec recherche (bénévoles : liste potentiellement
@@ -241,12 +351,14 @@ function BadgeWishPicker({
selected, selected,
onChange, onChange,
emptyLabel, emptyLabel,
disabled,
}: { }: {
label: ReactNode; label: ReactNode;
options: WishOption[]; options: WishOption[];
selected: string[]; selected: string[];
onChange: (ids: string[]) => void; onChange: (ids: string[]) => void;
emptyLabel: string; emptyLabel: string;
disabled?: boolean;
}) { }) {
const toggle = (id: string) => const toggle = (id: string) =>
onChange(selected.includes(id) ? selected.filter((x) => x !== id) : [...selected, id]); onChange(selected.includes(id) ? selected.filter((x) => x !== id) : [...selected, id]);
@@ -256,7 +368,7 @@ function BadgeWishPicker({
{options.length === 0 ? ( {options.length === 0 ? (
<span className="font-mono text-[10px] opacity-55">{emptyLabel}</span> <span className="font-mono text-[10px] opacity-55">{emptyLabel}</span>
) : ( ) : (
<div className="flex flex-wrap gap-[6px]"> <div className={cx('flex flex-wrap gap-[6px]', disabled && 'pointer-events-none opacity-50')}>
{options.map((o) => ( {options.map((o) => (
<Chip key={o.id} active={selected.includes(o.id)} onClick={() => toggle(o.id)}> <Chip key={o.id} active={selected.includes(o.id)} onClick={() => toggle(o.id)}>
{o.hint ? `${o.hint} · ${o.label}` : o.label} {o.hint ? `${o.hint} · ${o.label}` : o.label}
@@ -495,6 +607,7 @@ export function ConfigVolunteers() {
</label> </label>
<VolunteerAccessSection volunteerId={selected.id} email={selected.email} /> <VolunteerAccessSection volunteerId={selected.id} email={selected.email} />
<VolunteerReferentSection volunteerId={selected.id} email={selected.email} />
<BadgeWishPicker <BadgeWishPicker
label={t('config.vol.field.artists')} label={t('config.vol.field.artists')}
+48 -12
View File
@@ -1,4 +1,4 @@
import { useMemo } from 'react'; import { useMemo, useState } from 'react';
import { ApiError } from '@/api'; import { ApiError } from '@/api';
import { import {
@@ -24,8 +24,10 @@ import { Loading } from '@/components/state';
import { import {
artistPerformanceWindow, artistPerformanceWindow,
availabilityCoverage, availabilityCoverage,
buildEffectiveCompanionIds,
candidateChanceScore, candidateChanceScore,
categoryLabel, categoryLabel,
foldAccents,
hoursByVolunteer, hoursByVolunteer,
uncoveredMinutes, uncoveredMinutes,
violationText, violationText,
@@ -45,6 +47,7 @@ interface Props {
} }
const MAX_CANDIDATES = 10; const MAX_CANDIDATES = 10;
const MAX_SEARCH_RESULTS = 20;
export function AssignmentPanel({ export function AssignmentPanel({
positionId, positionId,
@@ -54,6 +57,7 @@ export function AssignmentPanel({
shift, shift,
onClose, onClose,
}: Props) { }: Props) {
const [search, setSearch] = useState('');
const volunteers = useVolunteers(); const volunteers = useVolunteers();
const allAssignments = useAssignments(); const allAssignments = useAssignments();
const shiftAssignments = useAssignments({ shiftId: shift.shift_id }); const shiftAssignments = useAssignments({ shiftId: shift.shift_id });
@@ -126,6 +130,11 @@ export function AssignmentPanel({
const durationHours = hoursBetween(shift.starts_at, shift.ends_at); const durationHours = hoursBetween(shift.starts_at, shift.ends_at);
const assigned = useMemo(() => shiftAssignments.data ?? [], [shiftAssignments.data]); const assigned = useMemo(() => shiftAssignments.data ?? [], [shiftAssignments.data]);
const effectiveCompanionIds = useMemo(
() => buildEffectiveCompanionIds(volunteers.data ?? []),
[volunteers.data],
);
// Raisons du score, par bénévole : vœu de poste, conflit avec un artiste // Raisons du score, par bénévole : vœu de poste, conflit avec un artiste
// qu'il veut voir sur ce créneau (indépendant de sa disponibilité déclarée), // qu'il veut voir sur ce créneau (indépendant de sa disponibilité déclarée),
// compagnons souhaités déjà affectés à ce créneau. // compagnons souhaités déjà affectés à ce créneau.
@@ -140,14 +149,21 @@ export function AssignmentPanel({
const artist = artistsById.get(id); const artist = artistsById.get(id);
return artist ? windowsOverlap(shift, artistPerformanceWindow(artist)) : false; return artist ? windowsOverlap(shift, artistPerformanceWindow(artist)) : false;
}); });
const effective = effectiveCompanionIds.get(v.id);
let companionMatches = 0;
if (effective) {
for (const id of assignedIds) {
if (effective.has(id)) companionMatches += 1;
}
}
map.set(v.id, { map.set(v.id, {
wishesPosition: v.wished_position_ids.includes(positionId), wishesPosition: v.wished_position_ids.includes(positionId),
conflictsWishedArtist, conflictsWishedArtist,
companionMatches: v.wished_companion_ids.filter((id) => assignedIds.has(id)).length, companionMatches,
}); });
} }
return map; return map;
}, [volunteers.data, artistsById, shift, positionId, assigned]); }, [volunteers.data, artistsById, shift, positionId, assigned, effectiveCompanionIds]);
// Score de chance de chaque candidat pour CE créneau : vœu de poste, // Score de chance de chaque candidat pour CE créneau : vœu de poste,
// disponibilité (dont conflit avec un artiste qu'il veut voir), compagnons // disponibilité (dont conflit avec un artiste qu'il veut voir), compagnons
@@ -175,20 +191,29 @@ export function AssignmentPanel({
return map; return map;
}, [volunteers.data, detailsByVolunteer, windowsByVolunteer, hours, shift]); }, [volunteers.data, detailsByVolunteer, windowsByVolunteer, hours, shift]);
const needle = foldAccents(search.trim());
const candidates = useMemo(() => { const candidates = useMemo(() => {
const assignedIds = new Set(assigned.map((a) => a.volunteer_id)); const assignedIds = new Set(assigned.map((a) => a.volunteer_id));
const suggestedRank = new Map(suggestedIds.map((id, i) => [id, i])); const suggestedRank = new Map(suggestedIds.map((id, i) => [id, i]));
return (volunteers.data ?? []) const notAssigned = (volunteers.data ?? []).filter((v) => !assignedIds.has(v.id));
.filter((v) => !assignedIds.has(v.id)) // Score de chance décroissant (vœu de poste, disponibilité, compagnons,
// Le solveur d'abord (dans son ordre, s'il a tourné), puis par score de // charge) — le solveur d'abord (dans son ordre, s'il a tourné), sauf
// chance décroissant (vœu de poste, disponibilité, charge). // pendant une recherche : on veut alors trouver un bénévole précis, pas
.sort((a, b) => { // se fier au classement.
const byChance = (a: VolunteerRead, b: VolunteerRead) => {
const ra = suggestedRank.get(a.id) ?? Infinity; const ra = suggestedRank.get(a.id) ?? Infinity;
const rb = suggestedRank.get(b.id) ?? Infinity; const rb = suggestedRank.get(b.id) ?? Infinity;
return ra - rb || (scoreByVolunteer.get(b.id) ?? 0) - (scoreByVolunteer.get(a.id) ?? 0); return ra - rb || (scoreByVolunteer.get(b.id) ?? 0) - (scoreByVolunteer.get(a.id) ?? 0);
}) };
.slice(0, MAX_CANDIDATES); if (needle) {
}, [volunteers.data, assigned, scoreByVolunteer, suggestedIds]); return notAssigned
.filter((v) => foldAccents(`${v.first_name} ${v.last_name}`).includes(needle))
.sort(byChance)
.slice(0, MAX_SEARCH_RESULTS);
}
return notAssigned.sort(byChance).slice(0, MAX_CANDIDATES);
}, [volunteers.data, assigned, scoreByVolunteer, suggestedIds, needle]);
if (volunteers.isLoading || shiftAssignments.isLoading) { if (volunteers.isLoading || shiftAssignments.isLoading) {
return <Loading />; return <Loading />;
@@ -274,8 +299,19 @@ export function AssignmentPanel({
<span className="font-display text-[10px] font-extrabold uppercase tracking-widest"> <span className="font-display text-[10px] font-extrabold uppercase tracking-widest">
{t('grille.panel.candidates')} {t('grille.panel.candidates')}
</span> </span>
<span className="font-mono text-[9px] opacity-60">{t('grille.panel.candidatesHint')}</span>
</div> </div>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('grille.panel.searchVolunteer')}
className="mb-[9px] w-full border-2 border-ink bg-cream p-[7px] font-mono text-[11px]"
/>
{needle && candidates.length === 0 ? (
<div className="mb-[6px] font-mono text-[10.5px] opacity-60">
{t('grille.panel.searchNoMatch')}
</div>
) : null}
{candidates.map((volunteer) => ( {candidates.map((volunteer) => (
<CandidateRow <CandidateRow
key={volunteer.id} key={volunteer.id}
-29
View File
@@ -1,29 +0,0 @@
import type { ReactNode } from 'react';
import { t } from '@/i18n';
/** Maquette de téléphone (mêmes proportions que le design). */
export function PhoneFrame({ time, children }: { time: string; children: ReactNode }) {
return (
<div className="flex-none rounded-[36px] border-[3px] border-ink bg-ink p-[9px] shadow-[9px_9px_0_rgba(20,17,14,0.22)]">
<div className="h-[772px] w-[376px] overflow-auto rounded-[27px] bg-cream">
<div className="flex justify-between bg-ink px-5 pt-[7px] font-mono text-[10px] font-medium text-cream">
<span>{time}</span>
<span>◗ ▮▮▮</span>
</div>
{children}
</div>
</div>
);
}
export function DesignNote({ children }: { children: ReactNode }) {
return (
<div className="min-w-[250px] max-w-[420px] flex-1 border-[3px] border-ink bg-paper p-[15px] shadow-brutal-lg">
<div className="mb-[9px] font-display text-[13px] font-black uppercase tracking-widest">
{t('design.noteTitle')}
</div>
<p className="font-display text-[12.5px] leading-relaxed">{children}</p>
</div>
);
}