Files
benevoles/CLAUDE.md
T
AdamThomasandClaude Sonnet 5 89e74e5ec7 Revue de sécurité avant ouverture au public
Audit de l'application avant sa première publication sur internet.
Correctifs immédiats, peu coûteux et sans changement de comportement
en dev :

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
2026-09-16 08:14:00 +02:00

32 KiB
Raw Blame History

CLAUDE.md — Gestion des bénévoles

Application web de gestion des bénévoles pour un festival associatif (50–200 bénévoles, 4 jours montage/démontage inclus). Interface en français, code en anglais.

Ce fichier est le contrat de travail. Il est tenu à jour à chaque étape.


1. Vocabulaire du domaine (glossaire FR ↔ EN)

Le métier parle français, le code et la base parlent anglais. Toute entité, colonne, route et variable utilise la colonne « Code / base ».

Métier (brief) Code / base
Édition Edition
Bénévole Volunteer
Poste Position
Référent d'un poste Position.lead_account_id → Account
Créneau Shift
Disponibilité (fenêtre horaire libre) Availability (date, start_time, end_time)
Affectation Assignment
Artiste programmé Artist (name, performance_date, start_time, end_time)
Vœu « je veux voir cet artiste » VolunteerArtist (N-N Volunteer ↔ Artist)
Vœu « je voudrais ce poste » VolunteerPositionWish (N-N Volunteer ↔ Position)
Vœu « je voudrais être avec ce bénévole » VolunteerCompanionWish (volunteer_id, companion_id, à sens unique)
Compte (utilisateur connecté) Account
Découpage de journée (paramétrable) DayPart (code, label, start_time, end_time, order)
Lien magique MagicLinkToken

Énumérations :

Métier Code
Origine d'affectation SOLVER / MANUAL
Verrouillée Assignment.locked: bool
Rôle COORDINATOR / LEAD / VOLUNTEER
Catégorie de poste BAR / TICKETING / WELCOME / PARKING / CLEANING / CATERING / SETUP / STAGE / OTHER

Distinction critique (ne jamais confondre, ni en base ni dans l'UI)

  • Availability = une déclaration du bénévole : « le samedi, je peux venir de 14 h à 23 h » — fenêtre horaire libre, plusieurs par jour.
  • Assignment = une décision de l'organisation : « tu tiens le bar samedi 18 h – 22 h ».

Deux tables distinctes, deux écrans distincts, deux vocabulaires distincts.


2. Modèle de domaine

Défini dans backend/app/models/ (enums.py, base.py, domain.py). Clés primaires UUID. Champs d'audit created_at / updated_at sur toutes les tables sauf les tables d'association.

  • Edition : year, name (opt.), start_date, end_date, retention_days (durée de rétention des données nominatives en jours, NULL = pas de purge).
  • DayPart (« créneau type ») : découpage d'une journée propre à l'édition (nombre et bornes libres). code, label, start_time, end_time (end_time < start_time ⇒ passe minuit), order. Unique (edition_id, code) ; code figé après création. Sert à générer les créneaux 0/0 des postes (pas les disponibilités, qui sont des fenêtres libres). CRUD GET/POST/PATCH/DELETE /editions/{id}/day-parts (COORDINATION pour l'écriture).
  • Volunteer : email (nullable, pas unique — plusieurs bénévoles peuvent partager une même adresse, ex. un couple ou une famille ; c'est le couple (email, nom + prénom) qui identifie un bénévole, pas l'email seul), first_name, last_name, phone, comment. Pas de date de naissance, taille de t-shirt, régime, ni compétences. Porte trois listes de vœux, exposées par la fiche (artist_ids, wished_position_ids, wished_companion_ids ; None/absent = inchangé, [] = vider).
  • Artist : programmation de l'édition (name, performance_date, start_time, end_time — end_time <= start_time ⇒ le passage finit le lendemain). Unique (edition_id, name, performance_date). La coordination gère la liste ; elle est présentée par jour. Les bénévoles y choisissent les artistes qu'ils veulent voir (volunteer_artist) — le solveur (section 4) évite de les affecter pendant ce passage. CRUD GET/POST/PATCH/DELETE /editions/{id}/artists (lecture : tout compte de l'édition ; écriture : COORDINATION).
  • Vœux de postes : volunteer_position_wish (« postes que le bénévole voudrait tenir »), distinct de Assignment (décision) et de Position. lead_account_id (référent).
  • Vœux de compagnon : volunteer_companion_wish (« bénévoles avec qui je voudrais être affecté », 0 à N). Saisi à sens unique (A souhaite B sans que B fasse le geste en retour) mais traité comme réciproque par le solveur (section 4) : si A souhaite B, B profite aussi de la présence de A, même s'il ne l'a rien demandé — la réciprocité est artificielle, calculée à 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), 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 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 Position, un Shift à 0/0 est généré par jour de l'édition et par DayPart ; POST /editions/{id}/positions/{pid}/generate-shifts recrée les manquants (après ajout d'un créneau type ou extension des dates).
  • Availability : volunteer_id, date, start_time, end_time (end_time <= start_time ⇒ la fenêtre se termine le lendemain). Fenêtre horaire libre ; plusieurs par (volunteer, date). Unique (volunteer_id, date, start_time, end_time). Éditée en bloc : GET/PUT /editions/{id}/volunteers/{vid}/availability (lecture : COORD / LEAD ou le bénévole lui-même ; PUT : COORDINATION, remplace tout). GET /editions/{id}/availability liste celles de toute l'édition (COORD / LEAD) — pour la grille des besoins.
  • Assignment : volunteer_id, shift_id, source (AssignmentSource, défaut MANUAL), locked (défaut false). Unique (volunteer_id, shift_id). Pas de statut : l'affectation est valide dès sa création (aucune étape de confirmation) ; la retirer, c'est supprimer la ligne. GET /editions/{id}/volunteers/{vid}/planning (lecture : COORD / LEAD ou le bénévole lui-même) renvoie son planning enrichi (poste, lieu, référent du poste + son téléphone si son compte est rattaché à une fiche bénévole) sans jamais donner à un compte VOLUNTEER l'accès en lecture à positions/ shifts de toute l'édition — c'est l'écran bénévole (/mobile-benevole) qui en a besoin.
  • Account : email (unique par édition), role (Role), edition_id, volunteer_id (nullable, unique).
  • MagicLinkToken : account_id, token_hash (unique), expires_at, used_at.

Conventions techniques :

  • Multi-édition : toute entité métier porte un edition_id.
  • Énumérations stockées en VARCHAR + CHECK (native_enum=False), pas de type ENUM natif PostgreSQL.
  • Suppression en cascade au niveau base (ON DELETE CASCADE) sur tous les edition_id et sur les liens vers volunteer, shift, position, artist, account. La suppression complète d'un bénévole (RGPD) efface donc ses Availability, Assignment, vœux (artistes, postes, compagnons — dans les deux sens : ceux qu'il a émis et ceux qui le visaient) et son Account.
  • Dates/heures naïves, heure locale Europe/Paris (TZ du conteneur + tzdata). app.models.local_now() pour les horodatages.

Rôles et accès (app/auth/)

  • COORDINATOR : accès complet, construit et publie les plannings.
  • LEAD (référent) : lecture sur toute l'édition, écriture limitée aux postes dont il est lead_account_id (et à leurs créneaux et affectations).
  • VOLUNTEER : lecture de sa seule fiche et de ses seules affectations ; aucune écriture.

Matrice appliquée par les endpoints :

Ressource COORDINATOR LEAD VOLUNTEER
volunteers liste/écriture ✔ liste seule —
volunteers/{id} lecture ✔ ✔ soi-même
positions/shifts lecture ✔ ✔ —
positions/shifts écriture ✔ ses postes —
assignments écriture ✔ ses postes —
assignments lecture ✔ ✔ les siennes
artists lecture / écriture ✔ / ✔ ✔ / — ✔ / —
availability lecture / écriture ✔ / ✔ ✔ / — la sienne / —
matrix, import, accounts ✔ / ✔ / ✔ ✔ / — / — —

Authentification — deux systèmes selon le compte, une même session JWT HS256 (Authorization: Bearer <jwt>, TTL session_ttl_hours) à l'arrivée. Le compte porte password_hash : défini ⇒ mot de passe, NULL ⇒ lien magique (Account.auth_method = "password" / "magic_link").

Mot de passe — comptes COORDINATOR :

  1. POST /editions/{id}/auth/login {email, password} → JWT + AccountRead. Échec = 401 auth.invalid_credentials (aucune divulgation).
  2. POST /editions/{id}/auth/change-password {current_password, new_password} (compte courant). Hachage bcrypt, longueur mini MIN_LENGTH (10).

Lien magique — comptes LEAD / VOLUNTEER (sans mot de passe) :

  1. POST /editions/{id}/auth/magic-link {email} → 202 constant. Si un compte sans mot de passe correspond, un MagicLinkToken est créé (jeton aléatoire, haché SHA-256 en base, TTL magic_link_ttl_minutes).
  2. Envoi d'email stubé : le lien est écrit dans les logs (app.auth, INFO, jeton=… — identifiants seulement, aucune donnée nominative).
  3. POST /editions/{id}/auth/verify {token} → JWT + AccountRead. Jeton à usage unique.

GET /auth/me → compte courant.

La coordination crée les comptes via POST /editions/{id}/accounts : un COORDINATOR exige un password (≥ 10) ; un LEAD / VOLUNTEER le refuse (lien magique). Un bénévole sans email n'a pas de Account — et inversement, renseigner un email sur la fiche d'un bénévole ne crée aucun compte : c'est un champ de la fiche, pas une inscription. Tant qu'aucun Account ne lui est explicitement donné, auth/magic-link ne trouve personne à qui écrire pour cet email et ne fait rien (202 quoi qu'il arrive, pour ne jamais révéler si un compte existe). Front : bouton « Donner un accès de connexion » sur la fiche du bénévole (ConfigVolunteers.tsx), qui appelle 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/)

Envoi par SMTP (auto-hébergé). EMAIL_ENABLED=false (défaut dev/tests) ⇒ les emails sont seulement journalisés (app.email, INFO, sans destinataire ni corps — RGPD). Config : SMTP_HOST/PORT/USER/PASSWORD/STARTTLS/SSL/FROM, PUBLIC_BASE_URL (pour les liens). Ne jamais mettre de vraies valeurs dans config.py : elles vivent dans .env (non versionné).

  • Remise « best effort » : une erreur SMTP (connexion, auth, rejet) est journalisée avec le message du serveur mais ne fait pas échouer l'appel HTTP.

  • Le port 465 implique le SSL (SMTPS) : open_smtp bascule automatiquement, même si SMTP_SSL=false / SMTP_STARTTLS=true. Port 587 = STARTTLS.

  • Beaucoup de fournisseurs (OVH…) exigent une From = boîte authentifiée ou alias validé.

  • Diagnostic : docker compose run --rm backend uv run python -m app.email.check <destinataire> — affiche la config effective et la cause exacte d'un échec.

  • Lien magique : POST .../auth/magic-link envoie l'email contenant PUBLIC_BASE_URL + magic_link_path + ?token=. Sans SMTP, le jeton (non nominatif) reste journalisé pour le dev.

  • Récapitulatif de planning : POST /editions/{id}/notifications/planning (COORDINATION), corps { subject?, body?, volunteer_ids? }. subject/body sont des modèles avec {prenom}, {nom}, {nb_creneaux}, {nb_heures}, {creneaux}, {lien_connexion} (si présent ⇒ un lien magique personnel est émis pour le compte du bénévole). volunteer_ids absent ⇒ tous les bénévoles de l'édition qui ont un email. Envoi en tâche de fond (BackgroundTasks, une connexion SMTP réutilisée). Réponse : { queued, skipped_no_email, recipients }.


3. Moteur de validation des affectations — cœur du produit

Module isolé backend/app/validation/. Tests écrits avant l'implémentation, un cas passant + un cas déclenchant par règle.

Retourne une liste de violations typées BLOCKING ou WARNING. Chaque violation est un code + des paramètres structurés, jamais une phrase (la traduction se fait via locales/fr.json).

Code Sévérité Règle
SHIFT_OVERLAP BLOCKING Deux créneaux d'un même bénévole se chevauchent.
OUTSIDE_AVAILABILITY WARNING Le créneau n'est pas entièrement couvert par les fenêtres de disponibilité déclarées ce jour-là (params : date, uncovered_minutes).
DAILY_HOURS_EXCEEDED WARNING > 8 h 00 d'affectations cumulées sur une même journée.
INSUFFICIENT_REST WARNING < 8 h 00 entre la fin d'un créneau et le début du suivant.

Sémantique figée :

  • Toutes les affectations du bénévole comptent pour chevauchement / cumul / repos (une affectation = une décision, toujours active).
  • OUTSIDE_AVAILABILITY : on prend l'union des fenêtres Availability du bénévole (converties en instants, minuit géré) ; les minutes du créneau hors de cette union déclenchent l'avertissement. Aucune fenêtre déclarée ⇒ créneau entièrement à découvert.
  • « Journée » (cumul 8 h) : du début du premier DayPart du jour au début du premier DayPart du lendemain (le DayPart ne sert plus qu'à ça).
  • Cumul : > 8 h 00 strict (8 h 00 pile passe).
  • Repos : < 8 h 00 strict.
  • Créneaux jointifs (end_at == start_at) : pas un chevauchement.
  • Une violation BLOCKING empêche l'enregistrement, sans dérogation.
  • Une violation WARNING est retournée à l'appelant mais n'empêche rien (l'équipe connaît des exceptions légitimes).

4. Solveur de suggestions (OR-Tools CP-SAT)

Module isolé backend/app/solver/ (mêmes conventions que validation/ : models.py types purs, engine.py fonction pure solve(), loader.py seul point de contact avec la base). Ne place jamais personne : il calcule, pour chaque créneau encore sous son effectif idéal, une liste ordonnée de bénévoles candidats. La coordination affecte elle-même dans l'écran habituel (grille des besoins) ; l'appli fait simplement remonter ces candidats en tête de la colonne des choix quand on ouvre le détail d'un poste. Aucune affectation n'est jamais persistée par cet endpoint.

  • POST /editions/{id}/solver/suggestions (COORDINATION ou référent) : recalcule à la demande (rien n'est mis en cache côté serveur) et renvoie { suggestions: [{ shift_id, volunteer_ids }], shifts_considered, volunteers_considered }, volunteer_ids du meilleur au moins bon.
  • Seule contrainte dure : aucun chevauchement de créneaux pour un même bénévole (règle SHIFT_OVERLAP) — à la fois contre les affectations déjà décidées et entre deux créneaux nouvellement suggérés. L'effectif idéal est un plafond dur (le solveur ne sur-staffe jamais).
  • Tout le reste est un terme de l'objectif (maximisation), pas une contrainte — cohérent avec le moteur de validation, qui reste seul juge au moment où la coordination confirme une affectation :
    • un créneau encore sous son effectif minimal prime largement sur tout le reste (le solveur doit presque toujours proposer quelqu'un pour un trou urgent, quitte à ce que ce soit un pis-aller signalé ensuite par un avertissement) ;
    • bonus si le poste fait partie des vœux du bénévole (VolunteerPositionWish) ;
    • malus proportionnel aux minutes hors des fenêtres de disponibilité déclarées (réutilise validation.engine.uncovered_minutes), et si le repos avec une affectation déjà décidée serait < 8 h 00 ;
    • malus si le bénévole a déjà (affectation décidée) le créneau immédiatement précédent, le même jour — n'importe quel poste (SolverShift.predecessor_shift_ids) : limite les enchaînements de shifts plutôt que de faire tourner un poste en particulier ;
    • 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 ferait manquer le passage ;
    • bonus par compagnon souhaité (VolunteerCompanionWish, réciproque : si A souhaite B, B en profite aussi) déjà affecté (affectation décidée) 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 charge).
    • Pas de plafond de cumul journalier côté solveur : contrairement à la validation (DAILY_HOURS_EXCEEDED, § 3), le solveur ne pénalise pas un bénévole qui dépasserait 8 h sur la journée — seule la validation le signale, à la confirmation.
    • Simplification assumée : repos, chaînage et compagnons ne sont vérifiés que contre les affectations déjà décidées, pas entre deux suggestions du même calcul (deux compagnons suggérés ensemble dans le même calcul ne se renforcent pas mutuellement) — la validation finale à la confirmation reste seule juge de ce cas limite.

Classement par défaut, sans lancer le solveur — frontend/src/lib/planning.ts (candidateChanceScore) porte une version simplifiée du même scoring (vœu de poste, disponibilité y compris conflit d'artiste, compagnons souhaités déjà présents sur le créneau, charge déjà affectée ; pas de cumul 8 h / repos / chaînage, déjà couverts par les badges de validation). C'est ce qui classe la colonne des candidats dans le panneau d'affectation dès l'ouverture du détail d'un poste, sans requête réseau. Si le solveur a effectivement tourné (POST .../solver/suggestions), ses bénévoles retenus passent malgré tout en tête (ce sont eux le résultat d'une vraie optimisation jointe, pas juste un score local).

Colonnes de la grille des besoins — une colonne représente un créneau distinct (starts_at + ends_at), pas seulement une heure de début : deux créneaux qui démarrent en même temps mais durent différemment ont chacun leur colonne (buildLattice / LatticeColumn dans frontend/src/lib/planning.ts). Largeur adaptative par jour (columnWidthPx) : peu de créneaux dans une journée ⇒ colonnes larges, beaucoup de créneaux ⇒ largeur compacte avec défilement horizontal — jamais de colonnes étroites par défaut.


5. Périmètre

Premier incrément (en cours)

  1. Modèle de données complet + migrations Alembic.
  2. Import CSV/Excel (identité des bénévoles : nom, email, téléphone, commentaire — pas les disponibilités, qui se déclarent dans l'app), en deux temps : analyse (colonnes détectées, aperçu) puis exécution avec mapping validé. Rapport ligne par ligne. Détection des doublons : upsert sur (email, last_name + first_name) — l'email seul ne suffit pas, plusieurs bénévoles peuvent le partager (couple, famille) ; un même email revu avec un nom différent crée un bénévole de plus (signalé, jamais ignoré) ; sans email, détection sur (last_name, first_name) signalée sans upsert automatique. Mode simulation qui ne persiste rien.
  3. API CRUD sur Position, Shift, Volunteer, Assignment.
  4. Moteur de validation (section 3).
  5. Endpoint matrice d'une édition : [{ position, shifts: [{ shift, min_staff, ideal_staff, assigned }] }].
  6. Jeu de données de démonstration : 120 bénévoles, 15 postes, 4 jours, quelques trous d'effectif, 2–3 avertissements actifs, programmation (artistes les jours de festival) et vœux des bénévoles (postes, artistes) pour que le solveur ait matière à arbitrer.
  7. Solveur de suggestions OR-Tools CP-SAT (section 4) — en cours.

Hors périmètre (le modèle doit juste pouvoir les accueillir sans refonte)

SMS · constructeur de formulaire · pointage sur site · fonctions d'IA. (L'envoi d'emails par SMTP est en place — voir § 2.)


6. Arborescence

.
├── CLAUDE.md
├── README.md
├── docker-compose.yml              # dev : db + backend (hot reload)
├── .env.example
├── .gitlab-ci.yml                  # CI/CD : tests → build (registre GitLab) → SSH sur le VPS
├── deploy/
│   ├── docker-compose.prod.yml     # db + backend + frontend, ports en 127.0.0.1
│   ├── Caddyfile.benevoles         # bloc à ajouter au Caddy du VPS (reverse proxy)
│   └── README.md                   # variables CI/CD requises, mise en service du VPS
├── docker/postgres-init/           # création de la base de test au 1er boot
├── backend/
│   ├── pyproject.toml              # géré par uv
│   ├── Dockerfile
│   ├── alembic.ini
│   ├── app/
│   │   ├── main.py                 # instance FastAPI
│   │   ├── config.py               # settings (pydantic-settings)
│   │   ├── db.py                   # engine + session
│   │   ├── logging_config.py       # RGPD : pas de PII dans les logs
│   │   ├── api/                    # routers + schemas Pydantic + deps
│   │   │                           #   health, auth, accounts, editions,
│   │   │                           #   day_parts, imports, artists, volunteers,
│   │   │                           #   availability, positions, shifts,
│   │   │                           #   assignments, solver, matrix, notifications
│   │   ├── auth/                   # mot de passe + lien magique + JWT + rôles
│   │   ├── email/                  # SMTP (sender) + rendu des emails (templates)
│   │   ├── importing/              # import CSV/Excel (parser, text, service)
│   │   ├── models/                 # tables SQLModel
│   │   ├── validation/             # moteur pur (models, engine) + loader (base)
│   │   ├── solver/                 # suggestions OR-Tools (models, engine, loader)
│   │   ├── bootstrap.py            # instance vierge : édition + 1er compte
│   │   ├── seed.py                 # jeu de démonstration (python -m app.seed)
│   │   ├── i18n/
│   │   │   ├── __init__.py         # t(key, **params)
│   │   │   └── locales/fr.json
│   │   └── migrations/             # Alembic (env.py, versions/)
│   └── tests/
│       ├── conftest.py             # vraie base de test PostgreSQL
│       └── ...
└── frontend/                       # React + Vite + TS + Tailwind (étape ultérieure)

7. Commandes

Aucune dépendance locale requise hors Docker.

Action Commande
Lancer en dev docker compose up --build
API + docs interactives http://localhost:8000/docs
Tests backend docker compose run --rm backend uv run pytest
Couverture docker compose run --rm backend uv run pytest --cov=app
Lint docker compose run --rm backend uv run ruff check .
Format docker compose run --rm backend uv run ruff format .
Créer une migration docker compose run --rm backend uv run alembic revision --autogenerate -m "..."
Appliquer les migrations docker compose run --rm backend uv run alembic upgrade head
Vérifier migration ↔ modèle docker compose run --rm backend uv run alembic check (après upgrade head)
Instance vierge (édition + 1er compte) docker compose run --rm backend uv run python -m app.bootstrap --name … --start … --end … --email … --password …
Charger le jeu de démo docker compose run --rm backend uv run python -m app.seed (après alembic upgrade head)

8. Conventions

  • Code, base, noms de fichiers en anglais. Le français vit dans les fichiers de traduction et les commits.
  • Aucun texte visible par l'utilisateur en dur. Backend : app.i18n.t(key)
    • locales/fr.json. Frontend : fichier i18n dédié. Vrai même en monolingue.
  • RGPD :
    • Pas de log des noms, emails, téléphones. On logue des identifiants.
    • Edition.retention_days : durée de rétention paramétrable.
    • Endpoint de suppression complète d'un bénévole : hard delete en cascade.
  • Dates et heures : stockées naïves, interprétées en heure locale Europe/Paris (heure murale). Les règles « 8 h », « journée », « demi-journée » sont toutes en heure locale.
  • Validation TDD : tests avant implémentation, un cas passant + un cas déclenchant par règle.
  • Git : un commit atomique par étape validée, message en français.
  • Étapes courtes : après chaque étape, montrer le résultat et attendre la validation avant de continuer.

9. Déploiement

  • Dépôt GitLab. VPS avec Docker + un Caddy déjà installé en service système (hors de ce dépôt), qui reçoit tout le trafic public en HTTPS et fait reverse proxy vers les conteneurs.
  • .gitlab-ci.yml : à chaque push, tests (backend + frontend) ; sur main seulement, build des images backend et frontend (registre de conteneurs du projet GitLab) puis SSH sur le VPS → copie de deploy/docker-compose.prod.yml → pull → migration Alembic → up -d.
  • Le frontend est un build statique (frontend/Dockerfile.prod, servi par nginx) : VITE_API_MODE/VITE_EDITION_ID sont figées au moment du build, pas à l'exécution — l'édition de production doit donc exister avant la première construction de cette image (voir deploy/README.md).
  • Variables CI/CD requises (voir deploy/README.md) : SSH_HOST, SSH_USER, SSH_KEY, DEPLOY_PATH, VITE_EDITION_ID.
  • Le VPS porte un fichier .env (non versionné) avec POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB, JWT_SECRET, PUBLIC_BASE_URL et les variables EMAIL_ENABLED / SMTP_* (voir deploy/README.md).
  • backend et frontend n'écoutent que sur 127.0.0.1 (voir docker-compose.prod.yml) : deploy/Caddyfile.benevoles est le bloc à ajouter au Caddyfile du serveur pour les exposer publiquement (/api/* vers le backend avec le préfixe retiré, le reste vers le frontend).

10. Journal des étapes

Étape Description État
1 Squelette : monorepo, Docker, CI/CD, backend qui démarre validée
2 Modèle de données complet + migration Alembic initiale validée
3 Import CSV/Excel (analyse + exécution, rapport, doublons, simulation) validée
4 Moteur de validation des affectations (pur, TDD, 5 règles) validée
5 API CRUD (Position, Shift, Volunteer, Assignment) + matrice + validation branchée validée
6 Jeu de données de démonstration (app/seed.py) validée
7 Authentification : lien magique + JWT + application des rôles validée
8 Second système : connexion mot de passe (bcrypt) pour la coordination validée
9 Suppression du statut d'affectation (plus d'étape de confirmation) validée
10 Envoi d'emails par SMTP : lien magique + récapitulatif de planning validée
11 GET/PATCH /editions/{id} (nom, dates) — ajouter/retirer des jours ; UX config front validée
12 Créneaux types (DayPart CRUD) + génération auto des créneaux 0/0 à la création d'un poste ; case « à définir » / « sureffectif » sur la grille à valider
13 Bénévole : retrait taille de t-shirt / régime ; entité Artist (CRUD, par jour) + vœux des bénévoles (artistes, postes) à valider
14 Retrait des compétences (Skill et règle MISSING_REQUIRED_SKILL) ; disponibilités = fenêtres horaires libres par jour (GET/PUT availability), prises en compte par OUTSIDE_AVAILABILITY à 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
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 à valider

Reste hors périmètre (modèle prêt à les accueillir) : SMS, constructeur de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans + connexion en place (voir frontend/).