Compare commits
10
Commits
166e2c3366
...
0ede409841
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ede409841 | ||
|
|
89e74e5ec7 | ||
|
|
ade59b3dc4 | ||
|
|
9e0be07726 | ||
|
|
a78aad58e2 | ||
|
|
8fcc22115f | ||
|
|
db11d9a3ba | ||
|
|
9871785d89 | ||
|
|
efa652fffb | ||
|
|
11bc8cbe36 |
@@ -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 +
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 #
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)."""
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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/ {
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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' }),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 } : {}),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
|
||||||
}
|
|
||||||
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 (
|
|
||||||
<div className="flex flex-wrap items-start gap-[26px] p-6">
|
|
||||||
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VolunteerHomeView volunteerId={volunteerId} fallbackName={account?.email.split('@')[0]} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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')}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user