# 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`) | | Vœu « je veux voir cet artiste » | `VolunteerArtist` (N-N `Volunteer` ↔ `Artist`) | | Vœu « je voudrais ce poste » | `VolunteerPositionWish` (N-N `Volunteer` ↔ `Position`) | | 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` (unique **par édition**, nullable pour les bénévoles créés à la main — plusieurs `NULL` autorisés), `first_name`, `last_name`, `phone`, `comment`. Pas de date de naissance, taille de t-shirt, régime, ni compétences. Porte deux listes de vœux, exposées par la fiche (`artist_ids`, `wished_position_ids` ; `None`/absent = inchangé, `[]` = vider). - **`Artist`** : programmation de l'édition (`name`, `performance_date`). 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`). 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). - **`Position`** : `name`, `location`, `category` (`PositionCategory`), `lead_account_id` (→ `Account`, `SET NULL` à la suppression du compte). - **`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. - **`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) 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 `, 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) : 3. `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`). 4. **Envoi d'email stubé** : le lien est écrit dans les logs (`app.auth`, INFO, `jeton=…` — identifiants seulement, aucune donnée nominative). 5. `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`. ### 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 ` — 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`), au dépassement du cumul de 8 h ce jour-là, et si le repos avec une affectation déjà décidée serait `< 8 h 00` ; - léger malus pour les bénévoles déjà bien chargés (répartition de la charge). - Simplification assumée : cumul de 8 h et repos ne sont vérifiés que contre les affectations **déjà décidées**, pas entre deux suggestions du même calcul — la validation finale à la confirmation reste seule juge de ce cas limite. --- ## 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` ; 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. 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 ├── .github/workflows/deploy.yml # CI/CD : build → GHCR → SSH sur le VPS ├── deploy/ │ ├── docker-compose.prod.yml │ └── README.md # secrets requis, setup 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 GitHub. VPS avec Docker. - `.github/workflows/deploy.yml` : sur chaque push `main` → build de l'image backend → push sur `ghcr.io//-backend` → SSH sur le VPS → `docker compose -f docker-compose.prod.yml pull && up -d`. - Secrets requis (voir `deploy/README.md`) : `SSH_HOST`, `SSH_USER`, `SSH_KEY`, `DEPLOY_PATH`, `GHCR_TOKEN`. - 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`). --- ## 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 | **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/`).