From 822fdc1bc43cd87544f71d6340d13a3049e8a0f9 Mon Sep 17 00:00:00 2001 From: Corentin JOGUET Date: Mon, 15 Jun 2026 12:01:31 +0200 Subject: [PATCH 01/93] ci: add Forgejo Actions pipeline + gitleaks secret-scan (#2) --- .forgejo/workflows/ci.yml | 84 +++++++++++++++++++++++++++++++++++++++ .gitleaks.toml | 31 +++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 .forgejo/workflows/ci.yml create mode 100644 .gitleaks.toml diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml new file mode 100644 index 0000000..b42f73d --- /dev/null +++ b/.forgejo/workflows/ci.yml @@ -0,0 +1,84 @@ +name: CI +# CI Wakdo - Forgejo Actions (runner stark-wakdo, label `docker`). +# Strategie solo dev : PR obligatoire + auto-merge sur CI verte (voir SECURITY.md). +# +# Etat des jobs selon la phase projet : +# - secret-scan : fonctionnel des maintenant (gitleaks scanne tout le depot) +# - php-lint : fonctionnel sur les fichiers PHP presents (stubs P1, code P2+) +# - static-tests: PHPStan + PHPUnit GARDES - s'activent quand P2 ajoute +# composer.json / phpstan.neon / tests + phpunit.xml + +on: + pull_request: + branches: [dev, main] + push: + # dev/main : porte de merge. feat|fix|ci|refactor : feedback avant la PR. + branches: [dev, main, 'feat/**', 'fix/**', 'ci/**', 'refactor/**'] + +jobs: + secret-scan: + runs-on: docker + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Install tools + run: | + apt-get update -qq + apt-get install -y -qq curl ca-certificates tar >/dev/null + - name: Install gitleaks + run: | + VER=8.21.2 + curl -sSL "https://github.com/gitleaks/gitleaks/releases/download/v${VER}/gitleaks_${VER}_linux_x64.tar.gz" -o /tmp/gl.tgz + tar -xzf /tmp/gl.tgz -C /usr/local/bin gitleaks + gitleaks version + - name: Scan for secrets + run: gitleaks detect --config .gitleaks.toml --redact --no-banner --verbose + + php-lint: + runs-on: docker + steps: + - uses: actions/checkout@v4 + - name: Install PHP CLI + run: | + apt-get update -qq + apt-get install -y -qq php-cli >/dev/null + php --version + - name: Lint all PHP files + run: | + set -eu + files=$(find . -path ./node_modules -prune -o -name '*.php' -print) + if [ -z "$files" ]; then echo "No PHP files yet - skip"; exit 0; fi + echo "$files" | while IFS= read -r f; do + [ -z "$f" ] && continue + php -l "$f" + done + + static-tests: + runs-on: docker + steps: + - uses: actions/checkout@v4 + - name: PHPStan (guarded) + run: | + if [ -f composer.json ] && [ -f phpstan.neon ]; then + echo "phpstan config detected - running" + apt-get update -qq && apt-get install -y -qq php-cli unzip git >/dev/null + curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer + composer install --no-interaction --no-progress + vendor/bin/phpstan analyse --no-progress + else + echo "PHPStan skipped: no composer.json/phpstan.neon yet (activates in P2)" + fi + - name: PHPUnit (guarded) + run: | + if [ -d tests ] && [ -f phpunit.xml ]; then + echo "phpunit config detected - running" + apt-get update -qq && apt-get install -y -qq php-cli >/dev/null + if [ -f vendor/bin/phpunit ]; then vendor/bin/phpunit; \ + elif [ -f phpunit.phar ]; then php phpunit.phar; \ + else echo "phpunit binary missing despite config" && exit 1; fi + else + echo "PHPUnit skipped: no tests/ + phpunit.xml yet (activates in P2)" + fi + +# (CI pipeline validee le 2026-06-15 - test auto-merge) diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..211c793 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,31 @@ +# Wakdo - configuration gitleaks (secret-scan) +# +# Utilise par : +# - le hook pre-commit local (defense en profondeur) +# - le job CI Forgejo Actions (.forgejo/workflows/, lot D) sur chaque PR -> dev +# +# Principe : etendre le jeu de regles par defaut de gitleaks, puis ne tolerer +# QUE les faux positifs explicitement justifies ci-dessous (placeholders de doc). + +[extend] +useDefault = true + +[allowlist] +description = "Faux positifs documentes - placeholders de configuration, jamais des secrets reels" + +# Fichiers de template / doc : ne contiennent que des placeholders RFC 2606 / change_me. +paths = [ + '''\.env\.example$''', + '''\.gitleaks\.toml$''', + '''docs/.*\.md$''', +] + +# Valeurs placeholder explicites tolerees ou qu'elles apparaissent. +regexes = [ + '''change_me_strong_password''', + '''change_me_root_password''', + '''example\.com''', +] + +# Note : le vrai .env est gitignore et ne doit jamais etre commite. Ce scan est +# une defense en profondeur, pas un substitut a l'hygiene .gitignore. -- 2.45.3 From 32ff6a63bab3e6e77eb27c99cd39119e1ef8c079 Mon Sep 17 00:00:00 2001 From: Corentin JOGUET Date: Mon, 15 Jun 2026 12:16:11 +0200 Subject: [PATCH 02/93] P1 conception: security-by-design layer (Merise 21 entities, Forgejo CI/CD, hardening) (#3) --- .env.example | 44 +++ .forgejo/workflows/ci.yml | 2 - .gitea/PULL_REQUEST_TEMPLATE.md | 11 + SECURITY.md | 55 +++ docker/php-fpm/php.ini | 25 +- docs/PROJECT_CONTEXT.md | 218 ++++++++-- docs/architecture/forgejo-actions-runner.md | 102 +++++ docs/merise/_diagrams/mcd-catalogue.drawio | 67 ---- docs/merise/_diagrams/mcd-catalogue.mmd | 51 +++ docs/merise/_diagrams/mcd-catalogue.svg | 5 +- docs/merise/_diagrams/mcd-commande.drawio | 93 ----- docs/merise/_diagrams/mcd-commande.svg | 4 - docs/merise/_diagrams/mcd-global.drawio | 182 --------- docs/merise/_diagrams/mcd-global.svg | 4 - .../_diagrams/mcd-ingredients-stock.mmd | 61 +++ .../_diagrams/mcd-ingredients-stock.svg | 1 + docs/merise/_diagrams/mcd-order.mmd | 67 ++++ docs/merise/_diagrams/mcd-order.svg | 1 + docs/merise/_diagrams/mcd-rbac.drawio | 57 --- docs/merise/_diagrams/mcd-rbac.mmd | 64 +++ docs/merise/_diagrams/mcd-rbac.svg | 5 +- docs/merise/_diagrams/mld-catalogue.drawio | 59 --- docs/merise/_diagrams/mld-commande.drawio | 78 ---- docs/merise/_diagrams/mld-rbac.drawio | 56 --- docs/merise/dictionary.md | 158 +++++++- docs/merise/mcd.md | 144 +++++-- docs/merise/mct.md | 85 +++- docs/merise/mld.md | 187 +++++++-- docs/merise/mlt.md | 124 ++++-- docs/uml/security-sequence.md | 232 +++++++++++ docs/uml/sequence-passer-commande.md | 151 ++++--- docs/uml/state-commande.md | 152 +++---- docs/uml/use-cases.md | 374 +++++++++++------- scripts/forgejo-branch-protection.sh | 64 +++ scripts/forgejo-pr-automerge.sh | 59 +++ 35 files changed, 2016 insertions(+), 1026 deletions(-) create mode 100644 SECURITY.md create mode 100644 docs/architecture/forgejo-actions-runner.md delete mode 100644 docs/merise/_diagrams/mcd-catalogue.drawio create mode 100644 docs/merise/_diagrams/mcd-catalogue.mmd delete mode 100644 docs/merise/_diagrams/mcd-commande.drawio delete mode 100644 docs/merise/_diagrams/mcd-commande.svg delete mode 100644 docs/merise/_diagrams/mcd-global.drawio delete mode 100644 docs/merise/_diagrams/mcd-global.svg create mode 100644 docs/merise/_diagrams/mcd-ingredients-stock.mmd create mode 100644 docs/merise/_diagrams/mcd-ingredients-stock.svg create mode 100644 docs/merise/_diagrams/mcd-order.mmd create mode 100644 docs/merise/_diagrams/mcd-order.svg delete mode 100644 docs/merise/_diagrams/mcd-rbac.drawio create mode 100644 docs/merise/_diagrams/mcd-rbac.mmd delete mode 100644 docs/merise/_diagrams/mld-catalogue.drawio delete mode 100644 docs/merise/_diagrams/mld-commande.drawio delete mode 100644 docs/merise/_diagrams/mld-rbac.drawio create mode 100644 docs/uml/security-sequence.md create mode 100755 scripts/forgejo-branch-protection.sh create mode 100755 scripts/forgejo-pr-automerge.sh diff --git a/.env.example b/.env.example index 0e2cb55..10d3c84 100644 --- a/.env.example +++ b/.env.example @@ -66,6 +66,50 @@ CORS_ALLOWED_ORIGIN=https://kiosk.example.com # argon2id recommande depuis PHP 7.3 pour les nouveaux projets. PASSWORD_ALGO=argon2id +# Parametres de cout argon2id (password_hash options). +# Defauts alignes sur les recommandations OWASP Password Storage Cheat Sheet +# (memoire >= 19 MiB, >= 2 iterations). 65536 KiB = 64 MiB, marge confortable. +# Ces valeurs servent aussi au hash du PIN equipier (pin_hash, actions sensibles). +ARGON2_MEMORY_COST=65536 # KiB (64 MiB) +ARGON2_TIME_COST=4 # nombre d'iterations +ARGON2_THREADS=1 # parallelisme (1 = portable, deterministe) + +# =================================================================== +# Anti brute-force - throttling de connexion (security-by-design) +# =================================================================== +# Deux gardes complementaires (cf. docs/merise/mld.md 4.21 + PROJECT_CONTEXT 19) : +# 1. par compte : colonnes user.failed_login_attempts / user.lockout_until +# 2. par IP source : table login_throttle (entite 21) +# Backoff degressif (pas de lock definitif) : evite le DoS sur compte legitime. + +# Compteur par compte : nombre d'echecs avant declenchement du backoff. +ACCOUNT_LOCKOUT_THRESHOLD=5 +# Duree de base du backoff (secondes). Croit de facon degressive a chaque +# palier d'echecs supplementaires (ex : 60 -> 300 -> 900). +ACCOUNT_LOCKOUT_BASE_SECONDS=60 +ACCOUNT_LOCKOUT_MAX_SECONDS=900 # plafond du backoff (15 min) + +# Gate par IP : fenetre glissante et plafond de tentatives sur cette fenetre. +IP_THROTTLE_WINDOW_SECONDS=900 # 15 min +IP_THROTTLE_MAX_ATTEMPTS=20 # par IP sur la fenetre + +# PIN equipier pour actions sensibles (annulation, override). Longueur minimale. +STAFF_PIN_MIN_LENGTH=4 + +# Expiration du token de reinitialisation de mot de passe (secondes). +PASSWORD_RESET_TTL=3600 # 1h + +# =================================================================== +# Retention des donnees (RGPD - minimisation et limitation de conservation) +# =================================================================== +# Purges executees par le service cron (docker/cron/crontab). +# Justification documentee : interet legitime / obligations probatoires. + +AUDIT_LOG_RETENTION_DAYS=365 # journal d'audit ~12 mois +THROTTLE_PURGE_AFTER_HOURS=24 # login_throttle : lignes sans lockout actif > 24h +ORDER_RETENTION_DAYS=1095 # commandes (historique/stats) ~3 ans +# Purge des sessions expirees : deja geree par le cron */15 (voir crontab). + # =================================================================== # Upload images produits # =================================================================== diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index b42f73d..63f73b2 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -80,5 +80,3 @@ jobs: else echo "PHPUnit skipped: no tests/ + phpunit.xml yet (activates in P2)" fi - -# (CI pipeline validee le 2026-06-15 - test auto-merge) diff --git a/.gitea/PULL_REQUEST_TEMPLATE.md b/.gitea/PULL_REQUEST_TEMPLATE.md index 9905ed8..0b72345 100644 --- a/.gitea/PULL_REQUEST_TEMPLATE.md +++ b/.gitea/PULL_REQUEST_TEMPLATE.md @@ -24,6 +24,17 @@ Remplis les sections, coche ce qui s'applique, supprime ce qui ne sert pas. - [ ] Docs Merise / dictionnaire a jour si le modele de donnees change - [ ] Tests ajoutes et passants si du code est touche (unit > integration > e2e) +## Checklist securite (security-by-design) + + + +- [ ] Aucun secret commite (CI gitleaks verte) ; `.env` reste gitignore +- [ ] Entrees utilisateur validees ; requetes SQL en prepared statements (anti-injection) +- [ ] Mots de passe / PIN en argon2id ; pas de donnee sensible en clair ni dans les logs +- [ ] Sorties HTML echappees (anti-XSS) ; CSRF gere sur les formulaires d'etat +- [ ] Permissions RBAC verifiees cote serveur pour toute action sensible +- [ ] Impact RGPD evalue si nouvelles donnees personnelles (retention, droit a l'effacement) + ## Bloc RNCP impacte diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..5c6aa3f --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,55 @@ +# Politique de securite - Wakdo + +Wakdo est un projet de fin de formation (RNCP 37805) construit en +**security-by-design** : la menace est modelisee avant le code. Ce document +resume la posture, le signalement de vulnerabilites et les garde-fous CI. + +## Modele de menace + +Le modele STRIDE complet, le registre des risques et la classification des +donnees (4 niveaux) vivent dans `docs/PROJECT_CONTEXT.md` section 19, et le flux +d'authentification durci dans `docs/uml/security-sequence.md`. + +## Mesures en place (resume) + +| Domaine | Mesure | +|---|---| +| Mots de passe | `password_hash` argon2id (cout configurable, defauts OWASP) | +| Actions sensibles | PIN equipier hashe argon2id (`pin_hash`) | +| Brute-force | double throttle : compteur par compte (`user`) + par IP (`login_throttle`), backoff degressif | +| Sessions | cookies `HttpOnly` + `Secure` + `SameSite=Strict`, regeneration d'ID a la connexion (anti-fixation), idle 4h / absolu 10h | +| Injection | PDO prepared statements exclusivement | +| Upload | validation MIME + taille, stockage hors webroot | +| En-tetes / PHP | `expose_php=Off`, `allow_url_fopen/include=Off`, `cgi.fix_pathinfo=0`, fonctions d'execution systeme desactivees | +| RGPD | retention limitee (audit ~12 mois, throttle 24h, commandes ~3 ans), droit consultation/modif/suppression | +| Secrets | `.env` gitignore, tenu hors de `.git/config` (credential helper lisant `.env`), secret-scan gitleaks en CI | + +Les seuils operationnels (couts argon2, lockout, throttle, retention) sont +documentes dans `.env.example`. + +## Garde-fous CI (Forgejo Actions) + +Chaque PR vers `dev` ou `main` declenche `.forgejo/workflows/ci.yml` : + +- **secret-scan** (gitleaks) : empeche un secret d'entrer dans l'historique +- **php-lint** : `php -l` sur tous les fichiers PHP +- **static-tests** : PHPStan + PHPUnit (s'activent quand le code PHP arrive en P2) + +La strategie de merge est **PR + auto-merge sur CI verte** (travail solo) : la +PR est obligatoire (trace de gouvernance), le merge se declenche automatiquement +une fois les checks au vert. Voir `scripts/forgejo-pr-automerge.sh` et +`scripts/forgejo-branch-protection.sh`. + +## Signaler une vulnerabilite + +Projet pedagogique non destine a la production publique. Pour signaler un +probleme de securite : ouvrir une issue sur le depot Forgejo +(`https://git.acadenice.com/AcadeNice/corentin_wakdo`) ou contacter l'auteur. +Merci de ne pas divulguer publiquement un detail exploitable avant correction. + +## Perimetre + +Couvert : authentification, autorisation (RBAC), gestion de session, validation +d'entree, integrite des donnees de commande, hygiene des secrets. +Hors perimetre : paiement reel (remplace par numero de commande), durcissement +OS de l'hote, securite physique de la borne. diff --git a/docker/php-fpm/php.ini b/docker/php-fpm/php.ini index bc210d1..dc53cee 100644 --- a/docker/php-fpm/php.ini +++ b/docker/php-fpm/php.ini @@ -41,8 +41,31 @@ session.cookie_secure = 1 ; Persistance inter-container non necessaire : chaque session est liee a une ; instance unique du service wakdo-app (pas de scale horizontal pour ce projet). -; --- Expose_php = Off : ne pas leak la version PHP dans l'entete HTTP --- +; session.gc_maxlifetime : filet de securite cote serveur (l'idle reel est +; pilote par l'appli via SESSION_LIFETIME_IDLE). 4h. +session.gc_maxlifetime = 14400 +; IDs de session longs et a forte entropie (anti-prediction/fixation). +session.sid_length = 48 +session.sid_bits_per_character = 6 +; Pas de cache navigateur sur les pages avec session (anti-fuite via cache). +session.cache_limiter = nocache + +; --- Durcissement general (security-by-design, cf. PROJECT_CONTEXT 19) --- +; Expose_php = Off : ne pas leak la version PHP dans l'entete HTTP. expose_php = Off +; Anti RFI/SSRF : interdire l'ouverture d'URL distantes et leur inclusion. +allow_url_fopen = Off +allow_url_include = Off +; FPM : ne pas deviner le script a partir du PATH_INFO (anti exploitation +; d'upload mal route vers l'interpreteur). Le routage passe par le front controller. +cgi.fix_pathinfo = 0 +; Interdire le chargement dynamique d'extensions au runtime. +enable_dl = Off +; Ne pas inclure les arguments dans les stack traces (anti-fuite de secrets). +zend.exception_ignore_args = On +; Desactiver les fonctions d'execution systeme : l'appli n'en a aucun usage +; legitime (anti-RCE en cas d'injection). Les scripts d'ops vivent cote cron/host. +disable_functions = exec,passthru,shell_exec,system,proc_open,popen ; --- OPcache (perf + stabilite) --- [opcache] diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index 2efbaaf..674c287 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -26,12 +26,19 @@ Wakdo est une **borne de commande tactile** pour un restaurant de restauration r ### Acteurs -| Acteur | Role | Interface | +| Acteur | Role RBAC | Interface | |---|---|---| -| **Client** | Passe sa commande sur la borne | Borne tactile (Bloc 1) | -| **Accueil** | Saisit commandes au **comptoir** (client au guichet) ou au **drive** (client en voiture via intercom + casque equipier), remet les commandes livrees aux clients | Back-office (Bloc 2) | -| **Preparation** | Voit les commandes a preparer triees par heure croissante, les declare "preparees" | Back-office (Bloc 2) | -| **Administration** | CRUD sur donnees (produits, menus, prix, images) + gestion utilisateurs + stats | Back-office (Bloc 2) | +| **Client** | (non authentifie) | Borne tactile (Bloc 1, canal `kiosk`) | +| **Counter** | `counter` | Back-office : saisit les commandes au **comptoir**, les remet au client, peut annuler | +| **Drive** | `drive` | Back-office : saisit les commandes au **drive** (intercom + casque), les remet, peut annuler | +| **Kitchen** | `kitchen` | Back-office : voit la file des commandes `paid` triees par `paid_at` croissant, en **lecture seule** (KDS visuel, aucune transition) | +| **Manager** | `manager` | Back-office : catalogue (create/update), stock/reappro, statistiques | +| **Administration** | `admin` | Back-office : catalogue complet (+ suppressions), gestion utilisateurs, roles et permissions (RBAC), stats | + +> Modele v0.2 : 5 roles RBAC (`admin`, `manager`, `kitchen`, `counter`, `drive`) +> + Customer non authentifie. RBAC permission-driven (le code teste une +> permission, pas un nom de role) ; catalogue de 23 permissions fige au seed. +> Voir `docs/merise/dictionary.md` 3.15-3.18 et `docs/uml/use-cases.md`. ### Processus metier cle @@ -46,21 +53,21 @@ Client Borne (Bloc 1) API (Bloc 2) BDD │ │─POST /api/orders─────▶│───INSERT──────────▶│ │ │◀──────────201─────────│ │ │─recupere au comptoir │ │ │ - Preparation voit commande pending - → declare "preparee" - Accueil voit commande prete - → declare "livree" + Kitchen voit la file des commandes paid (lecture seule, KDS) + Counter / Drive remettent au client + → declarent "livree" (geste unique paid -> delivered) ``` ### Regles metier (MCT - a modeliser en Merise) -- Un **menu** = burger + accompagnement (frites OU salade) + boisson + sauce -- Les **accompagnements** et **boissons** ont **2 tailles** (normale / grande) -- **Grande taille** = +0,50 € sur le prix de base -- Une **commande** a un **numero** saisi par le client (remplace le paiement dans le cadre de l'exam) -- Statuts commande : `pending` -> `preparing` -> `ready` -> `delivered` (ou `cancelled`) +- Un **menu** = burger fixe + slots a choix (boisson, accompagnement, sauce). Modele relationnel `menu_slot` + `menu_slot_option` (voir `dictionary.md` 3.4-3.5) +- Format **Normal / Maxi** au niveau du menu (deux prix : `price_normal_cents`, `price_maxi_cents`) ; le Maxi agrandit accompagnement + boisson uniquement +- **Personnalisation des ingredients** (retirer = gratuit, ajouter = supplement) sur les sandwichs composes, via le configurateur (`ingredient`, `product_ingredient`, `order_item_modifier`) +- **TVA portee par le produit** (`vat_rate` : 10% defaut, 5,5% contenant conservable), calculee ligne par ligne et snapshotee sur `order_item` (fact-check BOFiP, voir `dictionary.md` note 9) +- Une **commande** a un **numero** saisi par le client, prefixe par canal `K`/`C`/`D` (remplace le paiement dans le cadre de l'exam) +- Statuts commande (machine a **4 etats**) : `pending_payment` -> `paid` -> `delivered` (+ `cancelled`). La transition `pending_payment -> paid` est **atomique** a la creation (saisie du numero = substitut de paiement). `cancelled` est atteignable depuis `pending_payment` et `paid` (pas depuis `delivered`). Plus de `preparing` / `ready` : la cuisine est en lecture seule, la remise est un geste unique - **Source commande** (trace sur chaque commande) : `kiosk` (borne autonome) | `counter` (comptoir) | `drive` (drive-thru) -- La preparation voit les commandes triees par **heure de livraison croissante** (tous canaux confondus) +- Le canal de prepa (`kitchen`/`counter`/`drive`) voit la file des commandes `paid` triee par `paid_at` **croissant**, filtree par `role_visible_source` (kitchen voit tout ; counter voit kiosk+counter ; drive voit drive) - **Horaires service** : 10h00 → 01h00 du matin (service continu 15h, pas de fermeture intermediaire) - **Pas de notion de "session de service" a modeliser** : les equipiers se relaient, chacun se connecte a sa prise de poste et se deconnecte a la fin. Pas de "shift" a tracer dans la BDD (hors scope RNCP) - **Fenetre de maintenance systeme** : 01h30 → 09h30 (crons lourds, backups, agregations) — evite toute interference avec le service actif @@ -187,8 +194,8 @@ Reseaux : | TLS | Let's Encrypt via Traefik | auto | `acme.json` existant | | Conteneurisation | Docker + docker compose | v2 | Cr 7.c | | Orchestration locale | Makefile | — | Cr 7.b (script) + Cr 7.c.4 (une commande) | -| CI/CD | GitHub Actions | — | Cr 7.d | -| Versioning | Git + GitHub | — | Cr 4.f (collaboration) | +| CI/CD | Forgejo Actions (act_runner auto-heberge) | — | Cr 7.d | +| Versioning | Git + Forgejo auto-heberge (push-mirror GitHub) | — | Cr 4.f (collaboration) | | Hooks Git | pre-commit + commit-msg | versionnes dans `.githooks/` | Conventional Commits | --- @@ -220,10 +227,11 @@ Reseaux : **IN scope — Back-office :** - Authentification sessions securisees (hash bcrypt/argon2, protection CSRF, fixation session) — duree de session adaptee a un poste complet d'equipier (idle timeout 4h, absolute timeout 10h) -- 3 roles RBAC : `admin`, `preparation`, `accueil` -- **Admin** : CRUD categories, produits (nom, description, prix, image, dispo), menus (composition + options), utilisateurs -- **Preparation** : liste commandes a preparer triees par heure livraison croissante, bouton "declarer preparee" -- **Accueil** : saisir commande manuellement (comptoir ou drive-thru via casque/intercom), bouton "declarer livree" ; champ `source` enregistre sur chaque commande (`counter` ou `drive`) +- 5 roles RBAC seed : `admin`, `manager`, `kitchen`, `counter`, `drive` (RBAC permission-driven, 23 permissions figees au seed ; roles personnalises possibles) +- **Admin** : CRUD complet catalogue (+ suppressions), gestion utilisateurs, roles et permissions (RBAC), stats +- **Manager** : catalogue (create/update), stock (reappro + inventaire), statistiques ; pas d'acces utilisateurs ni RBAC +- **Kitchen** : file des commandes `paid` triee par `paid_at` croissant, en **lecture seule** (KDS visuel) ; inventaire +- **Counter** / **Drive** : saisir une commande (comptoir / drive-thru via casque/intercom), bouton "declarer livree" (geste unique `paid -> delivered`), annuler ; `source` auto-tague depuis `role.order_source` ; inventaire - Upload images produits (validation type MIME + taille + stockage dans volume `wakdo_uploads`) - Historique commandes par statut - Stats de base (commandes du jour, CA jour, produits top) @@ -261,9 +269,10 @@ Reseaux : - `0 3 * * *` — backup BDD quotidien a 03h00 (entre fin service 01h et ouverture 10h) - `*/15 * * * *` — purge sessions expirees toutes les 15 min (leger, peut tourner en service) - `30 4 * * *` — agregation stats commandes a 04h30 sur le **jour de service** ecoule (10h J-1 → 01h J) -- **CI GitHub Actions** : lint PHP + PHPUnit sur PR -> dev -- **CD GitHub Actions** : deploy auto sur merge main (SSH + pull + `make rebuild`) -- `.env.example` documente, secrets hors du repo +- **CI Forgejo Actions** (act_runner auto-heberge) : lint PHP + PHPStan + PHPUnit + secret-scan (gitleaks) sur PR -> dev +- **CD Forgejo Actions** : deploy auto sur merge main (SSH + pull + `make rebuild`) +- `.env.example` documente (parametres securite : argon2id, lockout, seuils throttle, retention RGPD), secrets hors du repo +- `php.ini` durci (expose_php off, session cookies httponly/secure/samesite, upload limite) - Healthcheck Traefik + readiness probes - Logs centralises (stdout des conteneurs) - Documentation deploiement + architecture (schemas dans `docs/`) @@ -327,8 +336,8 @@ Reseaux : | Cr 7.c.3 | App conteneurisee complete | 4 services (web, app, db, cron) | | Cr 7.c.4 | **Une ligne de commande** | `make init` lance toute la stack + migrate + seed | | Cr 7.d.1 | Architecture serveur | Traefik reverse + reseaux segmentes documentes | -| Cr 7.d.2 | Tests avant deploy | CI PHPUnit + lint sur PR | -| Cr 7.d.3 | Integration/deploiement continus | GitHub Actions deploy automatique sur merge main | +| Cr 7.d.2 | Tests avant deploy | CI PHPUnit + PHPStan + secret-scan sur PR (Forgejo Actions) | +| Cr 7.d.3 | Integration/deploiement continus | Forgejo Actions deploy automatique sur merge main | --- @@ -344,13 +353,13 @@ main ← production (tag vX.Y.Z sur chaque release) fix/* ← corrections refactor/* ← refactos docs/* ← doc seulement - ci/* ← GitHub Actions + ci/* ← Forgejo Actions db/* ← migrations / schema BDD chore/* ← tooling, config test/* ← ajout de tests ``` -Les branches `main` et `dev` sont **protegees** cote GitHub. Pas de commit direct autorise. Hook pre-commit local les bloque egalement. +Les branches `main` et `dev` sont **protegees** cote Forgejo (push direct interdit, force-push bloque, PR obligatoire via l'API `branch_protections`). Hook pre-commit local les bloque egalement. **Flow :** 1. `git checkout -b feat/menu-composition` (depuis `dev`) @@ -432,9 +441,10 @@ Les branches `main` et `dev` sont **protegees** cote GitHub. Pas de commit direc | 10 | Service cron dedie | Cr 7.b.3 explicite + realiste prod | | 11 | Makefile avec `make init` | Cr 7.c.4 + demonstration DevOps | | 12 | Conventional Commits + hooks | Cr 4.f.x + discipline de versioning | -| 13 | Branches feat/* -> dev -> main | Pipeline propre pour jury, GitHub PR trace | -| 14 | CI/CD GitHub Actions | Cr 7.d explicite dans referentiel | +| 13 | Branches feat/* -> dev -> main | Pipeline propre pour jury, PR tracee (Forgejo, mirror GitHub) | +| 14 | CI/CD Forgejo Actions (act_runner auto-heberge) | Cr 7.d explicite ; forge + CI maitrisees de bout en bout (argument Bloc 5) | | 15 | RGPD implemente minimal | Cr 3.d.1-4 evaluees meme projet ecole | +| 16 | Security-by-design (threat model STRIDE + classification donnees) | Audit Cr 7.a ; stock en %, throttle brute-force, retention RGPD documentes en amont du code | --- @@ -442,18 +452,18 @@ Les branches `main` et `dev` sont **protegees** cote GitHub. Pas de commit direc | Phase | Scope | Budget (h) | Deadline intermediaire | |---|---|---|---| -| **P0 - Setup** | PC, arborescence, Docker, hooks, CI squelette, init Git/GitHub | 20 | Semaine 1 | -| **P1 - Conception Merise** | Dictionnaire, MCD, MCT, MLD, schemas fonctionnels, DDL | 30 | Semaine 3 | +| **P0 - Setup** | PC, arborescence, Docker, hooks, CI squelette, migration Forgejo + act_runner | 22 | Semaine 1 | +| **P1 - Conception Merise + Security-by-design** | Dictionnaire, MCD, MCT, MLD, schemas fonctionnels, DDL, threat model STRIDE + classification donnees + sequence securite | 38 | Semaine 3 | | **P2 - Back squelette** | POO base (Core, Router, Autoloader, DB), auth + roles | 30 | Semaine 6 | | **P3 - Back CRUD admin** | Produits, menus, utilisateurs, views | 40 | Semaine 10 | | **P4 - API REST** | Endpoints + CORS + tests | 20 | Semaine 12 | | **P5 - Front borne** | Integration maquette, Ajax, accessibilite, responsive | 60 | Semaine 16 | | **P6 - Tests + finition** | PHPUnit, tests E2E borne, corrections | 25 | Semaine 18 | -| **P7 - DevOps finalisation** | CI/CD deploy auto, crons, docs argumentation | 20 | Semaine 19 | +| **P7 - DevOps finalisation** | Forgejo Actions CI/CD (PHPUnit + PHPStan + secret-scan + deploy auto), crons, SECURITY.md, docs argumentation | 22 | Semaine 19 | | **P8 - Prep soutenance** | README pour jury, schemas finaux, repetitions, modifs en direct | 15 | Semaine 20 | -| **TOTAL** | | **260** | **Semaine 20 = fin aout 2026** | +| **TOTAL** | | **272** | **Semaine 20 = fin aout 2026** | -Buffer : ~20 h pour imprevus. Cible effective : ~240 h sur 20 semaines = **12 h/semaine**. +Buffer : ~8 h pour imprevus. Cible effective : ~264 h sur 20 semaines = **~13 h/semaine**. --- @@ -481,7 +491,7 @@ Buffer : ~20 h pour imprevus. Cible effective : ~240 h sur 20 semaines = **12 h/ - `docker-compose.yml` commente - Dockerfiles customs commentes - `Makefile` avec `make help` -- `.github/workflows/` avec CI + CD +- `.forgejo/workflows/` avec CI (PHPUnit + PHPStan + secret-scan) + CD - Crontab documente - Script de backup/restore teste - Architecture serveur decrite (`docs/architecture/deployment.md`) @@ -675,4 +685,138 @@ Ces regles tiennent lieu de garde-fous pendant toute la duree du projet. Les enf --- -*Document vivant — version 1.1 — 2026-04-24 (ajout section 17 transparence IA). A mettre a jour a chaque decision structurante.* +## 19. Security threat model and data classification + +Cette section formalise la couche **security-by-design** ajoutee au modele Merise v0.2 +(voir `docs/merise/dictionary.md` note 13, `docs/merise/mlt.md` section 2 pour les regles +transverses RG-T13 a RG-T21). Elle se lit a deux niveaux : un **registre des risques** de +synthese pour une lecture gestion, suivi d'une **analyse STRIDE par element** pour la +profondeur technique, puis une **matrice de classification des donnees** en 4 niveaux. Tous +les claims securite sont rattaches a un mecanisme concret (une regle RG-T, une colonne, une +entite) plutot qu'enonces comme des absolus. + +### 19.1 Perimetre et frontieres de confiance + +Le systeme expose cinq frontieres de confiance (trust boundaries), correspondant aux points +d'entree analyses ci-dessous : + +- **E1 — Borne kiosk (public anonyme)** : `POST /api/orders`, consultation du catalogue. + Aucune authentification ; la borne est anonyme par conception (les commandes kiosk ont + `customer_order.acting_user_id = NULL`). Surface la plus exposee, donc traitee sans + hypothese de confiance sur l'entree. +- **E2 — Back-office admin (staff authentifie, poste partage + PIN par equipier)** : CRUD + catalogue/menus/ingredients, RBAC, gestion utilisateurs, stock, annulation de commande, + stats. Session partagee par poste pour le flux courant ; un PIN par equipier + (`user.pin_hash`) re-autorise l'ensemble sensible (RG-T13). +- **E3 — Surface d'authentification** : login (`AUTHENTICATE_USER`, op 25, `mlt.md` 12.1) et + reinitialisation de mot de passe (`RESET_PASSWORD`, op 28, `mlt.md` 12.3). +- **E4 — Couche donnees / BDD** : acces PDO, requetes preparees (RG-T06), allowlists + (RG-T16/RG-T17), integrite transactionnelle (RG-T08/RG-T11), snapshots immuables (RG-T05). +- **E5 — Stock / inventaire** : decrement de vente, reappro, comptage d'inventaire, avec un + journal append-only `stock_movement` et attribution de l'acteur ; la correction d'inventaire + est PIN-gated (RG-T13, `mlt.md` 9.2) car elle peut masquer de la demarque (shrinkage). + +Hors perimetre de cette section : la securite reseau/infra (Traefik, segmentation Docker, +TLS), couverte en section 5 ; le durcissement CORS, couvert en section 5. + +### 19.2 Registre des risques (risk register) + +Synthese gestion. Likelihood et residual risk sont evalues a dire d'expert pour ce projet +fictif (`[REASONING]`, non quantifies par benchmark) ; chaque mitigation cite une regle RG-T +et/ou une entite reelle du modele. + +| # | Actif | Menace | Impact | Likelihood | Mitigation (regle / entite) | Risque residuel | +|---|---|---|---|---|---|---| +| R1 | Recette (cash) sur commande payee | Un equipier annule une commande `paid` pour detourner l'encaissement (fraude interne) | Fort | Moyenne | `CANCEL_ORDER` (`mlt.md` 7.1) PIN-gated (RG-T13) + ecriture `audit_log` dans la meme transaction (RG-T14, RG-T11) ; acteur capture via `audit_log.actor_user_id` | Faible — l'annulation reste possible mais devient nominative et tracee ; dissuasion plus que blocage | +| R2 | `product.price_cents` / `vat_rate` / `role_id` | Falsification via un champ de formulaire injecte (mass-assignment) | Fort | Moyenne | Allowlist de colonnes par operation (RG-T16) sur `UPDATE_PRODUCT` (`mlt.md` 8.2) et `UPDATE_USER` (10.2) ; seules les colonnes autorisees sont bindees | Faible — les champs hors allowlist sont ignores ; un changement de prix reste audite (RG-T14) | +| R3 | Comptes back-office (`user.password_hash`) | Brute-force sur le login staff | Moyen | Haute | Backoff degressif par compte (`user.failed_login_attempts` / `lockout_until`) + par IP (`login_throttle`, entite 21) ; gate avant verification (`mlt.md` 12.1 PRE-3, RG-8) | Faible — ralentissement sans lock indefini ; un service de 15h n'est pas bloque par une saisie maladroite | +| R4 | Vues kiosk et admin (texte stocke) | XSS stocke via `product.name` / `ingredient.name` / `user.first_name` | Moyen | Moyenne | Echappement au rendu (RG-T15) : `htmlspecialchars(..., ENT_QUOTES)` cote admin, injection via `textContent` (pas `innerHTML`) cote kiosk vanilla-JS | Faible — l'echappement reduit le risque d'execution de script injecte | +| R5 | `ingredient.stock_quantity` | Survente (oversell) sous concurrence multi-borne | Moyen | Moyenne | Decrement atomique auto-verrouillant (RG-T20) sans read-gate + disponibilite calculee (RG-T21) ; `stock_quantity` signe, la magnitude de survente est remontee aux managers | Moyen accepte — le systeme ne bloque pas une commande sur le stock ; la survente est mesuree, pas empechee (decision metier) | +| R6 | Commande payee | Double-charge sur retry reseau de `POST /api/orders` | Moyen | Moyenne | Idempotence (RG-T19) : `customer_order.idempotency_key` UNIQUE ; un retry renvoie la commande existante au lieu d'en creer une seconde | Faible — la cle UNIQUE deduplique les rejeux ; depend d'une cle client correctement generee | +| R7 | PII utilisateur (`user.email`/`first_name`/`last_name`) | Demande d'effacement RGPD non honoree, ou rupture de l'integrite referentielle a la suppression | Fort (conformite) | Faible | Anonymisation (`ERASE_USER_PII`, `mlt.md` 10.5) : la ligne est conservee, PII remplacees par un placeholder `anon-@wakdo.invalid`, credentials invalides, `anonymized_at` pose ; `audit_log` retient sa propre fenetre | Faible — effacement et tracabilite coexistent ; les FK (`audit_log.actor_user_id`, `customer_order.acting_user_id`, `stock_movement.user_id`) restent valides | +| R8 | Matrice RBAC (`role_permission`) | Elevation de privilege via modification de role non controlee | Fort | Faible | `MANAGE_RBAC` (`mlt.md` 10.4) PIN-gated (RG-T13) + `audit_log` du diff de permissions (RG-T14, RG-6) ; `role_id` derriere l'allowlist (RG-T16) | Faible — tout gain/perte de capacite est nominatif et trace | +| R9 | `stock_movement` (demarque) | Correction d'inventaire masquant une demarque | Moyen | Moyenne | `INVENTORY_COUNT` (`mlt.md` 9.2) PIN-gated (RG-T13) ; le `user_id` capture par PIN est ecrit dans `stock_movement.user_id` (append-only) | Faible — la correction devient attribuable a une personne meme sur poste partage | + +### 19.3 Analyse STRIDE par element + +Un bloc par categorie STRIDE, mappe aux controles reels du modele (verifies contre +`mlt.md` section 2). + +**Spoofing (usurpation d'identite).** L'authentification back-office repose sur argon2id +(`user.password_hash`, `mlt.md` 12.1 RG-2) avec regeneration de session a la connexion +(`session_regenerate(true)`, RG-3) pour contrer la fixation. Le login est enumeration-safe : +meme erreur generique que l'email existe ou non, avec un `password_verify` leurre pour garder +le timing comparable (RG-2). Sur un poste partage, un PIN par equipier (`user.pin_hash`, +RG-T13) re-authentifie l'acteur reel pour les actions sensibles. La reinitialisation de mot de +passe (`RESET_PASSWORD`, `mlt.md` 12.3) stocke le token hashe (`password_reset_token_hash`), +n'envoie le token brut qu'une seule fois, l'expire a 1h et le rend a usage unique (RG-2/RG-3) ; +la phase requete renvoie une reponse neutre identique que le compte existe ou non (RG-1, +enumeration-safe). La borne kiosk est anonyme +par conception, donc hors perimetre d'usurpation (pas de compte a usurper). + +**Tampering (alteration).** L'allowlist de mass-assignment (RG-T16) limite les colonnes +bindees aux champs autorises par operation, protegeant `price_cents`, `vat_rate`, `role_id`, +`is_active`, `status`. La validation cote serveur (RG-T18) re-verifie type, plage, longueur, +appartenance ENUM et existence des FK independamment du client. Les requetes preparees PDO +(RG-T06) traitent les valeurs hors de la chaine SQL, ce qui ferme l'injection SQL par valeur. +Les identifiants SQL dynamiques (colonne et direction d'un `ORDER BY`/`GROUP BY`) sont resolus +contre une allowlist fixe avant construction de la requete (RG-T17), car un identifiant ne +peut pas etre bind comme une valeur. +Les snapshots de commande (`order_item.label_snapshot`, `unit_price_cents_snapshot`, +`vat_rate_snapshot`) sont immuables apres INSERT (RG-T05), preservant l'integrite historique +des commandes placees. La re-validation serveur des modifiers (`mlt.md` 3.3 RG-9) rejette un +`POST` forge ajoutant un ingredient non-`is_addable`. + +**Repudiation (deni d'action).** Le journal `audit_log` (entite 20, RG-T14) enregistre les +actions sensibles non-stock avec `actor_user_id` (capture par PIN, RG-T13), `actor_role_id` +(denormalise pour survivre a l'anonymisation), `action_code`, `entity_type`/`entity_id` et un +`summary` non-personnel ; pas d'UPDATE/DELETE applicatif. L'attribution des commandes +comptoir/drive passe par `customer_order.acting_user_id` (`mlt.md` 4.1 RG-5) et celle du stock +par `stock_movement.user_id` (`mlt.md` 9.1/9.2). Les actions stock ne sont pas doublement +journalisees : `stock_movement` (append-only) fournit deja la piste. + +**Information disclosure (divulgation).** La matrice de classification (19.4) borne ce qui +sort des logs et des reponses API. Les erreurs d'auth sont generiques (RG-2, pas de +distinction email inconnu / mot de passe faux). L'`audit_log` stocke des **noms de champs** +modifies, pas les valeurs PII (`audit_log.details`, RG-T14). L'attribution de stock +(`stock_movement.user_id`) n'est visible que pour manager/admin ; le staff de ligne voit les +deltas sans l'identite de l'acteur (`mlt.md` 9.3 RG-4). Les credentials (`password_hash`, +`pin_hash`, `password_reset_token_hash`) sont tenus hors logs et hors reponses API. + +**Denial of service.** Le throttling de login est degressif (backoff exponentiel plafonne) +plutot qu'un lock indefini, dans les deux dimensions compte (`user.lockout_until`) et IP +(`login_throttle.lockout_until`, `mlt.md` 12.1 RG-8) : une saisie maladroite ne bloque pas une +cuisine en plein service de 15h continu. L'idempotence (RG-T19) absorbe les doubles-soumissions +de retry reseau sur le kiosk anonyme. Le decrement de stock atomique (RG-T20) evite tout +contentieux de verrou (pas de `SELECT ... FOR UPDATE`, pas d'ordre de deadlock). + +**Elevation of privilege.** Le RBAC est permission-driven : le code teste une permission, pas +un nom de role (catalogue de 23 permissions fige au seed, `dictionary.md` 3.17). Les +changements de role passent par `MANAGE_RBAC` (`mlt.md` 10.4) PIN-gated (RG-T13) et audites +avec le diff de permissions (RG-T14, RG-6). `role_id` est derriere l'allowlist de +mass-assignment (RG-T16) sur `UPDATE_USER` (10.2). Les permissions sont rechargees depuis la +BDD a chaque verification (`mlt.md` 10.4 RG-3), donc un changement de droits prend effet sans +re-login force. + +### 19.4 Matrice de classification des donnees (4 niveaux) + +Les 21 entites du modele (`dictionary.md` 3.1-3.21) sont reparties en quatre niveaux. La +classification suit l'entite ; quelques colonnes sont surclassees explicitement (credentials, +PII). + +| Niveau | Definition | Entites / colonnes | Regle de manipulation | +|---|---|---|---| +| **RESTRICTED** (secrets / credentials) | Secrets d'authentification ; tenus hors de toute exposition | Colonnes de `user` (14) : `password_hash`, `pin_hash`, `password_reset_token_hash` | Hors logs et hors reponses API ; argon2id ; invalides a l'anonymisation (`mlt.md` 10.5 RG-1) ; exclus de `audit_log.details` qui ne retient que des noms de champs (RG-T14) | +| **CONFIDENTIAL** (PII, RGPD) | Donnees a caractere personnel d'un staff identifiable | Colonnes de `user` (14) : `email`, `first_name`, `last_name` | Sujet a l'anonymisation a l'effacement (`ERASE_USER_PII`, op 27) ; `audit_log` stocke les noms de champs, pas les valeurs ; echappement au rendu (RG-T15) | +| **INTERNAL** (sensible metier) | Donnees d'exploitation, non publiques, a acces restreint par RBAC | `customer_order` (10), `order_item` (11), `order_item_selection` (12), `order_item_modifier` (13), `stock_movement` (19), `audit_log` (20), `login_throttle` (21, contient l'IP source), `role` (15), `permission` (17), `role_permission` (18), `role_visible_source` (16) ; sorties de stats (`READ_STATS`, op 24) | Acces filtre par permission (RG-T03) ; attribution stock visible manager/admin seulement (`mlt.md` 9.3 RG-4) ; integrite par snapshots (RG-T05) et transactions (RG-T08/RG-T11) | +| **PUBLIC** (catalogue, face kiosk) | Donnees servies a la borne anonyme | `category` (1), `product` (2), `menu` (3), `menu_slot` (4), `menu_slot_option` (5), `ingredient` (6, nom + dispo calculee), `product_ingredient` (7), `allergen` (8), `ingredient_allergen` (9) | Lecture publique via `LOAD_CATALOGUE` (op 1) ; ecriture reservee admin/manager (RG-T03) ; texte echappe au rendu (RG-T15) ; disponibilite calculee (RG-T21) | + +**Couverture** : 21/21 entites classifiees (9 PUBLIC, 11 INTERNAL incluant les deux entites +security-by-design `audit_log` et `login_throttle`, plus `user` dont les colonnes sont +reparties entre RESTRICTED, CONFIDENTIAL et — pour `is_active`, `role_id`, `last_login_at`, +les compteurs de throttle — INTERNAL). L'entite `user` (14) est la seule a porter trois +niveaux simultanement, d'ou son traitement par colonne. + +--- + +*Document vivant — version 1.3 — 2026-06-15 (drift GitHub -> Forgejo Actions corrige, CI securite PHPStan/secret-scan, planning rechiffre pour la couche security-by-design). A mettre a jour a chaque decision structurante.* diff --git a/docs/architecture/forgejo-actions-runner.md b/docs/architecture/forgejo-actions-runner.md new file mode 100644 index 0000000..96a5e6c --- /dev/null +++ b/docs/architecture/forgejo-actions-runner.md @@ -0,0 +1,102 @@ +# Forgejo Actions - runner (act_runner) + +Prerequis d'infrastructure pour la CI/CD Wakdo. Les workflows vivent dans +`.forgejo/workflows/` (lot D) ; ils ne s'executent que si un `act_runner` est +enregistre et en ligne sur le serveur. + +## Pourquoi un runner separe de la stack app + +La stack `docker-compose.yml` de Wakdo = runtime applicatif (web, app, db, cron). +Le runner CI est du **tooling** : il se rattache au depot Forgejo, pas a l'app. +On le fait tourner comme service dedie sur l'hote stark (meme lecon que +"gh dans Docker = mauvaise idee", cf. journal session 6). Cela evite que la CI +puisse impacter le runtime, et garde un cycle de vie independant. + +## 1. Obtenir le token de registration (action manuelle, niveau admin) + +Le token vient de l'instance Forgejo, pas du repo. Dans l'UI Forgejo : + +- niveau **repo** : `Settings > Actions > Runners > Create new runner` +- ou niveau **org/instance** : `Site Administration > Actions > Runners` + +Recuperer le `REGISTRATION_TOKEN` affiche. Il est a usage unique pour +l'enregistrement (pas a versionner). + +## 2. Enregistrer le runner (sur stark) + +Setup reel en place (image `simplyforma/forgejo-runner` deja presente sur +l'hote, data dir sous `$HOME` car `/srv` non inscriptible par `corentin`). +Le conteneur tourne sous l'uid de l'hote (`--user`) pour pouvoir ecrire +`.runner` dans le volume monte. + +```bash +DATA=/home/corentin/forgejo-runner-wakdo +mkdir -p "$DATA" + +docker run --rm \ + --user "$(id -u):$(id -g)" \ + -v "$DATA":/data --workdir /data \ + --entrypoint forgejo-runner \ + simplyforma/forgejo-runner:12.10.2 \ + register --no-interactive \ + --instance https://git.acadenice.com \ + --token "" \ + --name stark-wakdo \ + --labels 'docker:docker://node:20-bookworm,php-ci:docker://php:8.3-cli' +``` + +L'enregistrement ecrit `$DATA/.runner` (contient le secret du runner - ne pas +versionner, ne pas sortir de l'hote). Runner enregistre le 2026-06-15 +(uuid `e4a3dbef-...`, labels `docker` + `php-ci`). + +## 3. Lancer le runner en service + +```bash +DATA=/home/corentin/forgejo-runner-wakdo +DOCKER_GID=$(stat -c '%g' /var/run/docker.sock) + +docker run -d --restart=always \ + --name forgejo-runner-wakdo \ + --user "$(id -u):$(id -g)" \ + --group-add "$DOCKER_GID" \ + -e HOME=/data \ + -v "$DATA":/data --workdir /data \ + -v /var/run/docker.sock:/var/run/docker.sock \ + --entrypoint forgejo-runner \ + simplyforma/forgejo-runner:12.10.2 \ + daemon +``` + +Notes : +- `--group-add $DOCKER_GID` : acces au socket Docker pour executer les jobs + dans des conteneurs (sans tourner en root). +- `-e HOME=/data` : evite l'erreur `mkdir /.cache: permission denied` (le cache + server interne ecrit sous `$HOME`). +- Verifier `docker logs forgejo-runner-wakdo` : `declared successfully` + + `[poller] launched`, et `Settings > Actions > Runners` doit montrer `stark-wakdo` **Idle**. +- Prerequis cote depot : **Actions activees** (`Settings > Actions` du depot). + +## 4. Labels et usage en workflow + +Les jobs ciblent un label via `runs-on`. Pour la CI PHP de Wakdo : + +```yaml +jobs: + ci: + runs-on: docker # image par defaut node:20-bookworm + # les etapes installent/php via le conteneur ou une action setup-php +``` + +## Securite du runner + +- Le `.runner` (secret) reste sur l'hote, hors du repo. +- Le socket Docker monte donne un acces privilegie : le runner ne doit executer + que des workflows du depot Wakdo (runner dedie au repo, pas partage). +- Roter le secret = re-enregistrer avec un nouveau token et supprimer l'ancien + runner dans l'UI. + +## Lien avec les autres lots + +- **Lot C** : ce document + prerequis infra. +- **Lot D** : `.forgejo/workflows/ci.yml` (PHPUnit + PHPStan + secret-scan gitleaks) + et auto-merge des PR sur CI verte (strategie solo dev validee). diff --git a/docs/merise/_diagrams/mcd-catalogue.drawio b/docs/merise/_diagrams/mcd-catalogue.drawio deleted file mode 100644 index c9cf3e8..0000000 --- a/docs/merise/_diagrams/mcd-catalogue.drawio +++ /dev/null @@ -1,67 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/merise/_diagrams/mcd-catalogue.mmd b/docs/merise/_diagrams/mcd-catalogue.mmd new file mode 100644 index 0000000..698a179 --- /dev/null +++ b/docs/merise/_diagrams/mcd-catalogue.mmd @@ -0,0 +1,51 @@ +erDiagram + category { + int id PK + varchar name + varchar slug + varchar image_path + smallint display_order + tinyint is_active + } + product { + int id PK + int category_id FK + varchar name + text description + int price_cents + smallint vat_rate + varchar image_path + tinyint is_available + smallint display_order + } + menu { + int id PK + int category_id FK + int burger_product_id FK + varchar name + text description + int price_normal_cents + int price_maxi_cents + varchar image_path + tinyint is_available + smallint display_order + } + menu_slot { + int id PK + int menu_id FK + varchar name + enum slot_type + tinyint is_required + smallint display_order + } + menu_slot_option { + int menu_slot_id FK + int product_id FK + } + + category ||--o{ product : "groups" + category ||--o{ menu : "groups" + menu ||--|| product : "anchors (burger_product_id)" + menu ||--o{ menu_slot : "defines_slot" + menu_slot ||--o{ menu_slot_option : "lists" + product ||--o{ menu_slot_option : "is_eligible_for" diff --git a/docs/merise/_diagrams/mcd-catalogue.svg b/docs/merise/_diagrams/mcd-catalogue.svg index c3a2016..38c2764 100644 --- a/docs/merise/_diagrams/mcd-catalogue.svg +++ b/docs/merise/_diagrams/mcd-catalogue.svg @@ -1,4 +1 @@ - - - -
CATEGORIE
id : INT (PK)
libelle : VARCHAR (UNIQUE)
slug : VARCHAR (UNIQUE)
image_path : VARCHAR
ordre : SMALLINT
est_actif : BOOLEAN
CATEGORIEid : INT (PK)...
PRODUIT
id : INT (PK)
categorie_id : INT (FK)
libelle : VARCHAR
description : TEXT
prix_ttc_cents : INT
image_path : VARCHAR
est_disponible : BOOLEAN
ordre : SMALLINT
PRODUITid : INT (PK)...
MENU
id : INT (PK)
categorie_id : INT (FK)
libelle : VARCHAR
description : TEXT
prix_ttc_cents : INT
image_path : VARCHAR
est_disponible : BOOLEAN
ordre : SMALLINT
MENUid : INT (PK)...
MENU_PRODUIT (associative)
menu_id : INT (PK, FK)
produit_id : INT (PK, FK)
role : ENUM
position : SMALLINT
MENU_PRODUIT (associative)menu_id : INT (PK,...
regroupe
regroupe
(0,N)
(0,N)
(1,1)
(1,1)
regroupe
regroupe
(0,N)
(0,N)
(1,1)
(1,1)
fait_partie_de
fait_partie_de
(0,N)
(0,N)
(1,1)
(1,1)
compose
compose
(1,N)
(1,N)
(1,1)
(1,1)
Text is not SVG - cannot display
\ No newline at end of file +

groups

groups

anchors (burger_product_id)

defines_slot

lists

is_eligible_for

category

int

id

PK

varchar

name

varchar

slug

varchar

image_path

smallint

display_order

tinyint

is_active

product

int

id

PK

int

category_id

FK

varchar

name

text

description

int

price_cents

smallint

vat_rate

varchar

image_path

tinyint

is_available

smallint

display_order

menu

int

id

PK

int

category_id

FK

int

burger_product_id

FK

varchar

name

text

description

int

price_normal_cents

int

price_maxi_cents

varchar

image_path

tinyint

is_available

smallint

display_order

menu_slot

int

id

PK

int

menu_id

FK

varchar

name

enum

slot_type

tinyint

is_required

smallint

display_order

menu_slot_option

int

menu_slot_id

FK

int

product_id

FK

\ No newline at end of file diff --git a/docs/merise/_diagrams/mcd-commande.drawio b/docs/merise/_diagrams/mcd-commande.drawio deleted file mode 100644 index a6ef277..0000000 --- a/docs/merise/_diagrams/mcd-commande.drawio +++ /dev/null @@ -1,93 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/merise/_diagrams/mcd-commande.svg b/docs/merise/_diagrams/mcd-commande.svg deleted file mode 100644 index 2d4be3b..0000000 --- a/docs/merise/_diagrams/mcd-commande.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - -
COMMANDE
id : INT (PK)
numero : VARCHAR (UNIQUE)
source : ENUM (kiosk|comptoir|drive)
mode_consommation : ENUM (sur_place|a_emporter|drive)
statut : ENUM
total_ht_cents : INT
total_tva_cents : INT
total_ttc_cents : INT
tva_taux_pourmille : SMALLINT
paye_a : DATETIME
USER
id : INT (PK)
(detail dans RBAC)
COMMANDE_EVENT
id : INT (PK)
commande_id : INT (FK)
event_type : ENUM
from_statut : ENUM (NULL)
to_statut : ENUM
user_id : INT (FK, NULL)
payload : JSON (NULL)
created_at : DATETIME
LIGNE_COMMANDE
id : INT (PK)
commande_id : INT (FK)
type_item : ENUM (produit|menu)
produit_id : INT (FK, NULL)
menu_id : INT (FK, NULL)
libelle_snapshot : VARCHAR
prix_unitaire_ttc_cents_snapshot : INT
quantite : SMALLINT
PRODUIT
id : INT (PK)
(detail dans Catalogue)
MENU
id : INT (PK)
(detail dans Catalogue)
contient
(1,N)
(1,1)
refere_si_type_produit
(0,1)
(0,N)
refere_si_type_menu
(0,N)
Polymorphisme
Exactement UNE des deux references est non-nulle.
Discriminateur : type_item ∈ {produit, menu}.
Contrainte CHECK SQL au MLD.
journalise
(1,N)
(1,1)
declenche
(0,N)
(0,1)
(0,1)
Journal d'audit (event sourcing)
Append-only : aucun UPDATE / DELETE applicatif.
user_id NULL si auto-validation kiosk.
ON DELETE CASCADE cote commande_id.
ON DELETE SET NULL cote user_id.
\ No newline at end of file diff --git a/docs/merise/_diagrams/mcd-global.drawio b/docs/merise/_diagrams/mcd-global.drawio deleted file mode 100644 index 962f01b..0000000 --- a/docs/merise/_diagrams/mcd-global.drawio +++ /dev/null @@ -1,182 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/merise/_diagrams/mcd-global.svg b/docs/merise/_diagrams/mcd-global.svg deleted file mode 100644 index 1d26537..0000000 --- a/docs/merise/_diagrams/mcd-global.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - -
CATEGORIE
PRODUIT
MENU_PRODUIT
MENU
LIGNE_COMMANDE
COMMANDE
COMMANDE_EVENT
USER
ROLE
ROLE_PERMISSION
PERMISSION
regroupe
(0,N)
(1,1)
regroupe
(0,N)
(1,1)
fait_partie_de
(0,N)
(1,1)
compose
(1,N)
(1,1)
contient
(1,N)
(1,1)
refere_si_type_produit
(0,1)
(0,N)
refere_si_type_menu
(0,1)
(0,N)
a_pour_role
(1,1)
(0,N)
possede
(0,N)
(1,1)
assignee_a
(0,N)
(1,1)
journalise
(1,N)
(1,1)
declenche
(0,N)
(0,1)
\ No newline at end of file diff --git a/docs/merise/_diagrams/mcd-ingredients-stock.mmd b/docs/merise/_diagrams/mcd-ingredients-stock.mmd new file mode 100644 index 0000000..dff0eee --- /dev/null +++ b/docs/merise/_diagrams/mcd-ingredients-stock.mmd @@ -0,0 +1,61 @@ +erDiagram + product { + int id PK + varchar name + } + ingredient { + int id PK + varchar name + varchar unit + int stock_quantity + int stock_capacity + smallint pack_size + varchar pack_label + smallint low_stock_pct + smallint critical_stock_pct + tinyint is_active + } + product_ingredient { + int product_id FK + int ingredient_id FK + smallint quantity_normal + smallint quantity_maxi + tinyint is_removable + tinyint is_addable + int extra_price_cents + } + allergen { + int id PK + varchar code + varchar name + text description + } + ingredient_allergen { + int ingredient_id FK + int allergen_id FK + } + customer_order { + int id PK + varchar order_number + } + user { + int id PK + varchar email + } + stock_movement { + int id PK + int ingredient_id FK + enum movement_type + int delta + int order_id FK + int user_id FK + varchar note + } + + product ||--o{ product_ingredient : "is_composed_of" + ingredient ||--o{ product_ingredient : "appears_in" + ingredient ||--o{ ingredient_allergen : "contains" + allergen ||--o{ ingredient_allergen : "is_present_in" + ingredient ||--o{ stock_movement : "decrements" + customer_order |o--o{ stock_movement : "triggers" + user |o--o{ stock_movement : "logs" diff --git a/docs/merise/_diagrams/mcd-ingredients-stock.svg b/docs/merise/_diagrams/mcd-ingredients-stock.svg new file mode 100644 index 0000000..615a975 --- /dev/null +++ b/docs/merise/_diagrams/mcd-ingredients-stock.svg @@ -0,0 +1 @@ +

is_composed_of

appears_in

contains

is_present_in

decrements

triggers

logs

product

int

id

PK

varchar

name

ingredient

int

id

PK

varchar

name

varchar

unit

int

stock_quantity

int

stock_capacity

smallint

pack_size

varchar

pack_label

smallint

low_stock_pct

smallint

critical_stock_pct

tinyint

is_active

product_ingredient

int

product_id

FK

int

ingredient_id

FK

smallint

quantity_normal

smallint

quantity_maxi

tinyint

is_removable

tinyint

is_addable

int

extra_price_cents

allergen

int

id

PK

varchar

code

varchar

name

text

description

ingredient_allergen

int

ingredient_id

FK

int

allergen_id

FK

customer_order

int

id

PK

varchar

order_number

user

int

id

PK

varchar

email

stock_movement

int

id

PK

int

ingredient_id

FK

enum

movement_type

int

delta

int

order_id

FK

int

user_id

FK

varchar

note

\ No newline at end of file diff --git a/docs/merise/_diagrams/mcd-order.mmd b/docs/merise/_diagrams/mcd-order.mmd new file mode 100644 index 0000000..be06a07 --- /dev/null +++ b/docs/merise/_diagrams/mcd-order.mmd @@ -0,0 +1,67 @@ +erDiagram + customer_order { + int id PK + varchar order_number + varchar idempotency_key + enum source + int acting_user_id FK + enum service_mode + enum status + int total_ht_cents + int total_vat_cents + int total_ttc_cents + datetime paid_at + datetime delivered_at + datetime cancelled_at + } + order_item { + int id PK + int order_id FK + enum item_type + int product_id FK + int menu_id FK + enum format + varchar label_snapshot + int unit_price_cents_snapshot + smallint vat_rate_snapshot + smallint quantity + } + order_item_selection { + int id PK + int order_item_id FK + int menu_slot_id FK + int product_id FK + varchar label_snapshot + } + order_item_modifier { + int id PK + int order_item_id FK + int ingredient_id FK + enum action + int extra_price_cents + } + product { + int id PK + varchar name + } + menu { + int id PK + varchar name + } + menu_slot { + int id PK + varchar name + } + ingredient { + int id PK + varchar name + } + + customer_order ||--o{ order_item : "contains" + order_item }o--o| product : "references_product" + order_item }o--o| menu : "references_menu" + order_item ||--o{ order_item_selection : "fills_slot" + order_item ||--o{ order_item_modifier : "modifies_ingredient" + menu_slot ||--o{ order_item_selection : "slot_filled_by" + product ||--o{ order_item_selection : "chosen_for_slot" + ingredient ||--o{ order_item_modifier : "modified_by" diff --git a/docs/merise/_diagrams/mcd-order.svg b/docs/merise/_diagrams/mcd-order.svg new file mode 100644 index 0000000..aeb5ac7 --- /dev/null +++ b/docs/merise/_diagrams/mcd-order.svg @@ -0,0 +1 @@ +

contains

references_product

references_menu

fills_slot

modifies_ingredient

slot_filled_by

chosen_for_slot

modified_by

customer_order

int

id

PK

varchar

order_number

varchar

idempotency_key

enum

source

int

acting_user_id

FK

enum

service_mode

enum

status

int

total_ht_cents

int

total_vat_cents

int

total_ttc_cents

datetime

paid_at

datetime

delivered_at

datetime

cancelled_at

order_item

int

id

PK

int

order_id

FK

enum

item_type

int

product_id

FK

int

menu_id

FK

enum

format

varchar

label_snapshot

int

unit_price_cents_snapshot

smallint

vat_rate_snapshot

smallint

quantity

order_item_selection

int

id

PK

int

order_item_id

FK

int

menu_slot_id

FK

int

product_id

FK

varchar

label_snapshot

order_item_modifier

int

id

PK

int

order_item_id

FK

int

ingredient_id

FK

enum

action

int

extra_price_cents

product

int

id

PK

varchar

name

menu

int

id

PK

varchar

name

menu_slot

int

id

PK

varchar

name

ingredient

int

id

PK

varchar

name

\ No newline at end of file diff --git a/docs/merise/_diagrams/mcd-rbac.drawio b/docs/merise/_diagrams/mcd-rbac.drawio deleted file mode 100644 index 31e109d..0000000 --- a/docs/merise/_diagrams/mcd-rbac.drawio +++ /dev/null @@ -1,57 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/merise/_diagrams/mcd-rbac.mmd b/docs/merise/_diagrams/mcd-rbac.mmd new file mode 100644 index 0000000..f3bb49a --- /dev/null +++ b/docs/merise/_diagrams/mcd-rbac.mmd @@ -0,0 +1,64 @@ +erDiagram + user { + int id PK + varchar email + varchar password_hash + varchar pin_hash + varchar first_name + varchar last_name + int role_id FK + tinyint is_active + datetime last_login_at + smallint failed_login_attempts + datetime lockout_until + datetime anonymized_at + } + role { + int id PK + varchar code + varchar label + text description + varchar default_route + enum order_source + tinyint is_active + } + role_visible_source { + int role_id FK + enum source + } + permission { + int id PK + varchar code + varchar label + text description + } + role_permission { + int role_id FK + int permission_id FK + } + audit_log { + int id PK + int actor_user_id FK + int actor_role_id FK + varchar action_code + varchar entity_type + int entity_id + varchar summary + json details + datetime created_at + } + login_throttle { + int id PK + varchar ip_address UK + smallint failed_attempts + datetime window_started_at + datetime lockout_until + datetime last_attempt_at + } + + user }o--|| role : "holds" + role ||--o{ role_visible_source : "sees_source" + role ||--o{ role_permission : "grants" + permission ||--o{ role_permission : "granted_to" + user |o--o{ audit_log : "performs" + role |o--o{ audit_log : "context_of" diff --git a/docs/merise/_diagrams/mcd-rbac.svg b/docs/merise/_diagrams/mcd-rbac.svg index e92d624..688b27f 100644 --- a/docs/merise/_diagrams/mcd-rbac.svg +++ b/docs/merise/_diagrams/mcd-rbac.svg @@ -1,4 +1 @@ - - - -
USER
id : INT (PK)
email : VARCHAR (UNIQUE, RFC 5321)
password_hash : VARCHAR (argon2id)
nom : VARCHAR
prenom : VARCHAR
role_id : INT (FK)
est_actif : BOOLEAN
last_login_at : DATETIME
ROLE
id : INT (PK)
code : VARCHAR (UNIQUE)
libelle : VARCHAR
description : TEXT
est_actif : BOOLEAN
PERMISSION
id : INT (PK)
code : VARCHAR (UNIQUE, resource.action)
libelle : VARCHAR
description : TEXT
ROLE_PERMISSION (associative)
role_id : INT (PK, FK)
permission_id : INT (PK, FK)
a_pour_role
(1,1)
(0,N)
possede
(0,N)
(1,1)
assignee_a
(0,N)
(1,1)
\ No newline at end of file +

holds

sees_source

grants

granted_to

performs

context_of

user

int

id

PK

varchar

email

varchar

password_hash

varchar

pin_hash

varchar

first_name

varchar

last_name

int

role_id

FK

tinyint

is_active

datetime

last_login_at

smallint

failed_login_attempts

datetime

lockout_until

datetime

anonymized_at

role

int

id

PK

varchar

code

varchar

label

text

description

varchar

default_route

enum

order_source

tinyint

is_active

role_visible_source

int

role_id

FK

enum

source

permission

int

id

PK

varchar

code

varchar

label

text

description

role_permission

int

role_id

FK

int

permission_id

FK

audit_log

int

id

PK

int

actor_user_id

FK

int

actor_role_id

FK

varchar

action_code

varchar

entity_type

int

entity_id

varchar

summary

json

details

datetime

created_at

login_throttle

int

id

PK

varchar

ip_address

UK

smallint

failed_attempts

datetime

window_started_at

datetime

lockout_until

datetime

last_attempt_at

\ No newline at end of file diff --git a/docs/merise/_diagrams/mld-catalogue.drawio b/docs/merise/_diagrams/mld-catalogue.drawio deleted file mode 100644 index e292dbc..0000000 --- a/docs/merise/_diagrams/mld-catalogue.drawio +++ /dev/null @@ -1,59 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/merise/_diagrams/mld-commande.drawio b/docs/merise/_diagrams/mld-commande.drawio deleted file mode 100644 index 1a47a5d..0000000 --- a/docs/merise/_diagrams/mld-commande.drawio +++ /dev/null @@ -1,78 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/merise/_diagrams/mld-rbac.drawio b/docs/merise/_diagrams/mld-rbac.drawio deleted file mode 100644 index 0922801..0000000 --- a/docs/merise/_diagrams/mld-rbac.drawio +++ /dev/null @@ -1,56 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/merise/dictionary.md b/docs/merise/dictionary.md index 0dcbf80..cb72788 100644 --- a/docs/merise/dictionary.md +++ b/docs/merise/dictionary.md @@ -1,10 +1,10 @@ # Data Dictionary — Wakdo **Merise phase** : P1 - Conception, step 1 (data dictionary first, mantra #33) -**Version** : v0.2 — prod-like, 19 entities -**Date** : 2026-06-04 +**Version** : v0.2 — prod-like, 21 entities (19 prod-like + security-by-design layer, incl. the new `login_throttle` entity) +**Date** : 2026-06-04 (security-by-design additions 2026-06-11) **Branch** : `feat/p1-conception` -**Status** : prod-like — all D1-D8 + stock decisions applied (see `docs/notes/revue-alignement-p1.md` §7) +**Status** : prod-like — all D1-D8 + stock decisions applied (see `docs/notes/revue-alignement-p1.md` §7); security-by-design layer in progress (see note 13) **Author** : BYAN (methodology layer) --- @@ -193,21 +193,32 @@ Elementary ingredient used in product composition. Carries stock data. | `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | | | `name` | VARCHAR(120) | NO | — | UNIQUE | e.g., "Sesame Bun", "Cheddar Slice", "Ketchup Portion" | | `unit` | VARCHAR(40) | NO | — | — | packaging unit label: piece / portion / sachet 1kg / pot / bottle (free-form label, not an ENUM — units vary per ingredient) | -| `stock_quantity` | INT | NO | 0 | CHECK >= 0 | current stock in units. Signed INT to allow negative detection (alert), but business rule enforces >= 0 | +| `stock_quantity` | INT (signed) | NO | 0 | — | current stock in units. Signed INT with no `CHECK >= 0`: it MAY go negative when sales outrun counted stock (oversell magnitude, surfaced to managers). The system does not block an order on stock. | +| `stock_capacity` | INT | NO | — | CHECK > 0 | reference "full" level in units = the 100% used to compute the stock percentage. The `CHECK > 0` also guards the percentage division against divide-by-zero | | `pack_size` | SMALLINT UNSIGNED | NO | 1 | CHECK > 0 | units per restocking pack (e.g., 100 for a bag of 100 portions) | | `pack_label` | VARCHAR(80) | YES | NULL | — | human label of the pack (e.g., "Sac 100 portions") | -| `low_stock_threshold` | SMALLINT UNSIGNED | NO | 0 | CHECK >= 0 | alert threshold: stock_quantity <= this value triggers low-stock indicator | +| `low_stock_pct` | SMALLINT UNSIGNED | NO | 10 | CHECK BETWEEN 0 AND 100 | warning band, percent of capacity: `stock_quantity <= stock_capacity * low_stock_pct/100` triggers the low-stock indicator | +| `critical_stock_pct` | SMALLINT UNSIGNED | NO | 5 | CHECK BETWEEN 0 AND 100 | auto-out-of-stock floor, percent of capacity: `stock_quantity <= stock_capacity * critical_stock_pct/100` makes the product computed out-of-stock | | `is_active` | TINYINT(1) | NO | 1 | — | deactivate obsolete ingredients without deleting | | `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | — | audit | | `updated_at` | DATETIME | NO | CURRENT_TIMESTAMP ON UPDATE | — | audit | +**Table-level CHECK**: `critical_stock_pct < low_stock_pct` (the critical floor sits below the warning band). + **Stock decrement rule**: at the `paid` transition, each ingredient is decremented by `product_ingredient.quantity_normal` or `quantity_maxi` (selected by `order_item.format`) multiplied by `order_item.quantity`, then adjusted by `order_item_modifier` rows. See note 7. **Restocking rule**: `stock_quantity += N * pack_size` (restocked in full packs). **Cancellation rule**: stock is re-credited when a `paid` order is cancelled. -**Low-stock alert**: computed at display time (`stock_quantity <= low_stock_threshold`); -no additional stored column. +**Stock model (percentage-based, three bands)**: the absolute alert threshold is replaced by a +percentage model anchored on `stock_capacity` (the 100% reference). The stock percentage is +computed, not stored: `stock_pct = ROUND(stock_quantity / stock_capacity * 100)`. The +`CHECK > 0` on `stock_capacity` guards this division against divide-by-zero. Three bands: +- **Normal** — above the low band: nothing flagged. +- **Low** — `stock_quantity <= stock_capacity * low_stock_pct/100`: orderable + manager alert. + The manager either pulls the product via `product.is_available=0`, or restocks to clear the alert. +- **Critical** — `stock_quantity <= stock_capacity * critical_stock_pct/100`: the product + auto-goes out-of-stock (computed availability, see rule RG-T21 in `mlt.md`); no extra stored column. --- @@ -271,7 +282,9 @@ Customer transaction: 1 order = 1 validated cart at a point in time. |---|---|---|---|---|---| | `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | | | `order_number` | VARCHAR(20) | NO | — | UNIQUE | human-readable format: `K`/`C`/`D`-YYYY-MM-DD-NNN. Prefix by channel: K=kiosk, C=counter, D=drive. See note 4. | +| `idempotency_key` | VARCHAR(36) | YES | NULL | UNIQUE | client-generated UUID to deduplicate a retried `POST /api/orders` (anti-double-charge). UNIQUE rejects duplicates; multiple NULLs allowed. Security-by-design, see note 13 | | `source` | ENUM('kiosk','counter','drive') | NO | — | INDEX | input channel (who entered the order). Values in English, see note 5. | +| `acting_user_id` | INT UNSIGNED | YES | NULL | FK -> `user(id)`, ON DELETE SET NULL | back-office staff (counter/drive) who created the order, captured under PIN. NULL for `kiosk` (anonymous). Targeted accountability without forcing per-person login on the kiosk. See note 13 | | `service_mode` | ENUM('dine_in','takeaway','drive') | NO | — | — | consumption mode, retained for stats/KPI only. No fiscal role (see note 9). `drive` source implies `drive` service_mode (cross-constraint enforced at app layer). | | `status` | ENUM('pending_payment','paid','delivered','cancelled') | NO | 'pending_payment' | INDEX | 4-state machine: `pending_payment -> paid -> delivered` (+ `cancelled`). See note 6. | | `total_ht_cents` | INT UNSIGNED | NO | — | CHECK >= 0 | ex-VAT total, snapshot at order validation | @@ -384,6 +397,13 @@ are not authenticated and have no row here. | `role_id` | INT UNSIGNED | NO | — | FK -> `role(id)`, ON DELETE RESTRICT | a user cannot exist without a role | | `is_active` | TINYINT(1) | NO | 1 | — | deactivation without deletion | | `last_login_at` | DATETIME | YES | NULL | — | useful for audit and dormant account detection | +| `pin_hash` | VARCHAR(255) | YES | NULL | — | argon2id hash of the per-staff PIN that authorises sensitive actions (price/RBAC/user/cancel/inventory). NULL = no PIN set. Security-by-design, see note 13 | +| `failed_login_attempts` | SMALLINT UNSIGNED | NO | 0 | — | consecutive failed logins; drives degressive throttling (note 13) | +| `last_failed_login_at` | DATETIME | YES | NULL | — | timestamp of the last failed login | +| `lockout_until` | DATETIME | YES | NULL | — | end of the current throttling window (degressive backoff, not a hard indefinite lock) | +| `password_reset_token_hash` | VARCHAR(255) | YES | NULL | — | hash of the reset token (not the raw token); NULL when no reset pending | +| `password_reset_expires_at` | DATETIME | YES | NULL | — | expiry of the reset token | +| `anonymized_at` | DATETIME | YES | NULL | — | RGPD tombstone marker: when set, PII columns are nulled/replaced (note 13). The row is kept for referential integrity | | `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | — | audit | | `updated_at` | DATETIME | NO | CURRENT_TIMESTAMP ON UPDATE | — | audit | @@ -392,6 +412,10 @@ are not authenticated and have no row here. RFC 5321 email length: local-part <= 64, domain <= 255, total <= 254 (including `@`). VARCHAR(254) is the spec-compliant value. +**PII columns**: `email`, `first_name`, `last_name`. Subject to RGPD anonymisation +(see note 13). `password_hash` and `pin_hash` are credentials, kept out of logs and +API responses. + --- ### 3.15 `role` @@ -539,6 +563,59 @@ Append-only audit log of all stock changes per ingredient. --- +### 3.20 `audit_log` + +Append-only log of **sensitive back-office actions**, for accountability where it matters +(insider threat, money handling, RBAC changes). Complements `stock_movement` (which is +stock-specific); covers catalogue/price, user, role/permission, and order cancellation events. +Security-by-design addition (see note 13). + +| Attribute | Type | NULL | Default | Constraint | Notes | +|---|---|---|---|---|---| +| `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | | +| `actor_user_id` | INT UNSIGNED | YES | NULL | FK -> `user(id)`, ON DELETE SET NULL | staff who performed the action, captured via PIN for sensitive operations. NULL if not attributable to an individual | +| `actor_role_id` | INT UNSIGNED | YES | NULL | FK -> `role(id)`, ON DELETE SET NULL | role context at action time (denormalised so the trail survives user anonymisation) | +| `action_code` | VARCHAR(60) | NO | — | INDEX | MCT operation / permission code, e.g. `product.update`, `order.cancel`, `role.manage`, `user.deactivate` | +| `entity_type` | VARCHAR(40) | YES | NULL | — | affected table name, e.g. `product`, `customer_order`, `role`, `user` | +| `entity_id` | INT UNSIGNED | YES | NULL | — | PK of the affected row | +| `summary` | VARCHAR(255) | YES | NULL | — | short non-personal description, e.g. "price_cents 880 -> 920", "added permission stock.manage" | +| `details` | JSON | YES | NULL | — | optional before/after diff. For user-targeted actions, stores changed **field names**, not PII values | +| `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | INDEX | immutable timestamp | + +**Immutability**: no UPDATE or DELETE at application layer (same discipline as `stock_movement`). +**Indexes**: `(actor_user_id, created_at)`, `(entity_type, entity_id)`, `(action_code, created_at)`. +**Retention**: own window (~12 months, legitimate-interest / fiscal traceability), decoupled +from user PII lifecycle (note 13). A scheduled purge (cron) removes rows past the window. + +**Logged operations** (sensitive set): `UPDATE_PRODUCT` (8.2, incl. price), `DELETE_PRODUCT` +(8.3), `DELETE_MENU` (8.6), `CANCEL_ORDER` (7.1), `RESTOCK` (9.1), `INVENTORY_COUNT` (9.2), +`CREATE_USER` / `UPDATE_USER` / `DEACTIVATE_USER` (10.1-10.3), `MANAGE_RBAC` (10.4). + +**Volume**: low (~10-50 sensitive actions/day) — orders of magnitude below `stock_movement`. + +--- + +### 3.21 `login_throttle` + +Per-source-IP brute-force throttle. Complements the per-account counter already on `user` +(`failed_login_attempts` / `lockout_until`), one row per source IP. Security-by-design addition +(see note 13). + +| Attribute | Type | NULL | Default | Constraint | Notes | +|---|---|---|---|---|---| +| `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | | +| `ip_address` | VARCHAR(45) | NO | — | UNIQUE | source IP, one row per IP, upserted; 45 chars holds a full IPv6 literal | +| `failed_attempts` | SMALLINT UNSIGNED | NO | 0 | — | consecutive failed logins from this IP in the current window | +| `window_started_at` | DATETIME | NO | CURRENT_TIMESTAMP | — | start of the current counting window | +| `lockout_until` | DATETIME | YES | NULL | — | end of the degressive backoff window; NULL = not throttled | +| `last_attempt_at` | DATETIME | NO | CURRENT_TIMESTAMP | — | timestamp of the last failed attempt | + +**No FK**: an IP is not a modelled entity. Rows are appended/upserted by IP; the window resets +when expired. A daily cron purges rows with no active lockout whose `last_attempt_at` is older +than 24h. + +--- + ## 4. Modeling notes ### Note 1 — Why `INT UNSIGNED` in cents for prices @@ -748,6 +825,62 @@ The 4-state machine combined with 3 phase timestamps provides all KPI data neede For stock audit, `stock_movement` (entity 3.19) provides the append-only audit trail where it is genuinely needed (inventory reconciliation). +### Note 13 — Security-by-design data additions (2026-06-11) + +These additions extend the prod-like model with a security-by-design layer. They do not +replace any v0.2 decision; they add accountability, auth lifecycle, and abuse resistance. + +**Accountability — hybrid shared-account + PIN.** Back-office sessions stay shared per +workstation for the routine flow (a fast-food terminal is shared, `equipiers` rotate). A +per-staff PIN (`user.pin_hash`, argon2id) authorises a defined set of **sensitive actions** +(price/menu edits 8.2/8.3/8.6, order cancellation 7.1, inventory correction 9.2, user +management 10.1-10.3, RBAC 10.4). Those actions write the acting `user_id` into `audit_log` +(3.20). This resolves the circular justification that dropped `commande_event` in v0.1 +(events were considered useless because accounts were shared): accountability is recorded +where it matters, at near-zero friction for the routine 95%. `customer_order.acting_user_id` +captures the staff for counter/drive orders taken under PIN; kiosk orders stay anonymous. + +**Auth lifecycle.** `password_reset_token_hash` + `password_reset_expires_at` enable a reset +path (the token is stored hashed, the raw token is e-mailed once). Brute-force resistance uses +degressive throttling rather than a hard indefinite lock: `failed_login_attempts` + +`lockout_until` implement an exponential backoff per (account + source IP), so a fat-finger +streak does not lock out a whole kitchen mid-service (15 h continuous). Failed logins are +written to `audit_log`. + +**RGPD anonymisation vs audit retention.** `user` PII (`email`, `first_name`, `last_name`) +is subject to the right to erasure (Cr 3.d). Erasure **anonymises** rather than hard-deletes: +the row is kept, `email` becomes a non-identifying unique placeholder (`anon-@wakdo.invalid`, +RFC 2606 reserved domain), names are cleared, `password_hash`/`pin_hash` are invalidated, and +`anonymized_at` is set. The `audit_log` retains its own retention window (~12 months, +legitimate-interest / fiscal traceability) and keeps pointing at the anonymised principal, so +erasure and accountability coexist without breaking referential integrity. + +**Abuse resistance on the anonymous kiosk.** `customer_order.idempotency_key` (client UUID, +UNIQUE) deduplicates a retried `POST /api/orders` so a network retry does not create a +duplicate paid order. Stock is decremented with a single atomic statement +(`UPDATE ingredient SET stock_quantity = stock_quantity - :units WHERE id = :id`): no operation +gates on a stock read, so the row self-locks for the duration of the write — no lost update and +no deadlock-ordering concern. This replaces the earlier pessimistic `SELECT ... FOR UPDATE` +approach (treatment-layer rule, see `mlt.md`); it adds no column here. + +**Percentage stock model + computed availability.** `ingredient` carries `stock_capacity` (the +100% reference), `low_stock_pct` (warning band) and `critical_stock_pct` (auto-out-of-stock +floor) — see 3.6. `stock_quantity` is signed and may go negative (oversell magnitude surfaced to +managers); the system does not block an order on stock. Effective product orderability is +computed (rule RG-T21 in `mlt.md`): `product.is_available = 1` AND each non-removable +(`is_removable=0`) ingredient of its `product_ingredient` has +`stock_quantity > stock_capacity * critical_stock_pct/100`. At the critical band a product +auto-goes out-of-stock with no write and no cascade; a manual pull (`product.is_available=0`) is +a hard override; restock above the critical band makes the product orderable again on its own. + +**Per-IP brute-force throttle.** `login_throttle` (3.21) tracks `failed_attempts` and +`lockout_until` per source IP (one upserted row per IP), complementing the per-account counter +on `user`. This adds a second throttling dimension so a single IP hammering many accounts is +slowed independently of any one account's counter. A daily cron purges idle, non-locked rows. + +References: `docs/notes/revue-alignement-p1.md` §7 (D-decisions), security-by-design impact +map (2026-06-11). Threat model and data-classification matrix: `PROJECT_CONTEXT.md` §19 (to come). + --- ## 5. Entity count summary @@ -773,11 +906,20 @@ where it is genuinely needed (inventory reconciliation). | 17 | `permission` | reference | v0.1 `permission` (translated, catalogue frozen) | | 18 | `role_permission` | join | v0.1 `role_permission` (unchanged) | | 19 | `stock_movement` | audit | new — append-only stock audit log | +| 20 | `audit_log` | audit | new (security-by-design) — append-only sensitive-action log | +| 21 | `login_throttle` | security | new (security-by-design) - per-IP brute-force throttle | **Dropped from v0.1**: `commande_event` (replaced by phase timestamps on `customer_order`), `menu_produit` (replaced by `menu_slot` + `menu_slot_option` model). -**Total: 19 entities.** +**Total: 21 entities** (19 prod-like v0.2 + `audit_log` and `login_throttle` from the +security-by-design layer). + +Security-by-design also adds columns (beyond the two new entities): `user` auth-lifecycle + +`pin_hash` + `anonymized_at` (3.14), `customer_order.acting_user_id` + `idempotency_key` (3.10), +and the percentage stock model on `ingredient` (3.6) — `stock_capacity`, `critical_stock_pct`, +plus the rename of `low_stock_threshold` to `low_stock_pct`. `login_throttle` (3.21) is the 21st +entity. See note 13. --- diff --git a/docs/merise/mcd.md b/docs/merise/mcd.md index 80fcbf9..e910e23 100644 --- a/docs/merise/mcd.md +++ b/docs/merise/mcd.md @@ -1,10 +1,10 @@ # Conceptual Data Model (MCD) — Wakdo **Merise phase** : P1 - Conception, step 2 (data dictionary first, mantra #33) -**Version** : v0.2 — prod-like, 19 entities -**Date** : 2026-06-04 +**Version** : v0.2 — prod-like, 21 entities (19 prod-like + security-by-design layer) +**Date** : 2026-06-04 (security-by-design additions 2026-06-11) **Branch** : `feat/p1-conception` -**Status** : prod-like — all D1-D8 + stock decisions applied (see `docs/notes/revue-alignement-p1.md` §7) +**Status** : prod-like — all D1-D8 + stock decisions applied (see `docs/notes/revue-alignement-p1.md` §7); security-by-design layer (audit_log + accountability/auth columns) in progress **Author** : BYAN (methodology layer) --- @@ -21,7 +21,7 @@ structure: how many X per Y, whether participation is mandatory, whether associa their own attributes. **Sources**: -- `docs/merise/dictionary.md` (v0.2 — 19 entities, source of truth for all names, types, ENUMs) +- `docs/merise/dictionary.md` (v0.2 — 21 entities, source of truth for all names, types, ENUMs) - `docs/notes/revue-alignement-p1.md` §7 (decision table D1-D8 + stock) - `docs/PROJECT_CONTEXT.md` (business rules: menu composition, order flow, RBAC, service modes) - `docs/merise/_sources/` (school data: 9 categories, 53 products, 13 menus) @@ -62,7 +62,7 @@ N-N associations that carry their own attributes become **associative entities** ## 3. Decomposition by sub-domain -The 19-entity model is split into 4 sub-domains for readability. Beyond approximately +The 21-entity model is split into 4 sub-domains for readability. Beyond approximately 5 entities, a single flat diagram becomes difficult to read; decomposition is the standard Merise practice for models of this size. @@ -71,12 +71,21 @@ Merise practice for models of this size. | Catalogue | category, product, menu, menu_slot, menu_slot_option | 5 | | Ingredients & Stock | ingredient, product_ingredient, allergen, ingredient_allergen, stock_movement | 5 | | Order | customer_order, order_item, order_item_selection, order_item_modifier | 4 | -| RBAC | user, role, role_visible_source, permission, role_permission | 5 | +| RBAC & Audit | user, role, role_visible_source, permission, role_permission, audit_log, login_throttle | 7 | -**Note on the absence of a global diagram**: a single 19-entity ER diagram would be +> **Security-by-design layer (2026-06-11)**: `audit_log` (entity 20) is a cross-cutting, +> append-only log of sensitive actions; it is placed in the RBAC & Audit sub-domain because +> its references (`actor_user_id`, `actor_role_id`) are RBAC entities. `login_throttle` +> (entity 21) is a per-source-IP brute-force throttle, keyed by IP and carrying no FK; it sits +> in the same sub-domain because it guards the authentication path. New columns on existing +> entities: `user` auth-lifecycle + `pin_hash` + `anonymized_at`, `customer_order.acting_user_id` +> + `idempotency_key`. See dictionary note 13. + +**Note on the absence of a global diagram**: a single 21-entity ER diagram would be unreadable and unmaintainable. The sub-domain decomposition below is the intentional -structural choice. The `.drawio` source files will be regenerated from this document as the -single reference once the MCD is stabilised (regeneration tracked in `docs/notes/`). +structural choice. Each sub-domain is a Mermaid `erDiagram` (authoritative, rendered +natively) with a portable SVG render in `docs/merise/_diagrams/`; see section 11 for the +sources and regeneration command. --- @@ -174,15 +183,18 @@ erDiagram varchar name varchar unit int stock_quantity + int stock_capacity smallint pack_size varchar pack_label - smallint low_stock_threshold + smallint low_stock_pct + smallint critical_stock_pct tinyint is_active } product_ingredient { int product_id FK int ingredient_id FK - smallint quantity + smallint quantity_normal + smallint quantity_maxi tinyint is_removable tinyint is_addable int extra_price_cents @@ -238,13 +250,15 @@ erDiagram ### 5.3 Notes on the Ingredients & Stock sub-domain -**`product_ingredient` as an associative entity**: the N-N association between `product` and `ingredient` carries four attributes (`quantity`, `is_removable`, `is_addable`, `extra_price_cents`). It becomes a join table in the MLD with composite PK `(product_id, ingredient_id)`. +**`product_ingredient` as an associative entity**: the N-N association between `product` and `ingredient` carries five attributes (`quantity_normal`, `quantity_maxi`, `is_removable`, `is_addable`, `extra_price_cents`). It becomes a join table in the MLD with composite PK `(product_id, ingredient_id)`. **`ingredient_allergen` as a pure join table**: no own attributes. The allergen set for a product is computed at query time by joining `product_ingredient -> ingredient_allergen -> allergen`; no manual per-product entry is needed. **`stock_movement` immutability**: this table is append-only. No UPDATE or DELETE is permitted at application layer. Corrections are new rows with `movement_type = 'inventory_correction'` and a signed `delta`. -**Low-stock alert**: computed at display time (`stock_quantity <= low_stock_threshold`); no additional stored column. +**Percentage-based stock model**: stock health is anchored on a per-ingredient `stock_capacity` (the 100% reference, `CHECK > 0`). `stock_quantity` is signed and may go negative when sales outrun counted stock; the system does not block an order on a low stock read. `stock_pct = ROUND(stock_quantity / stock_capacity * 100)` is computed, not stored. Two percentage thresholds drive a three-band behaviour: `low_stock_pct` (warning band, default 10%) and `critical_stock_pct` (auto-out-of-stock floor, default 5%), with the table-level invariant `critical_stock_pct < low_stock_pct`. Above the low band is normal; between critical and low the product stays orderable and a manager alert is raised (the manager either pulls the product via `product.is_available = 0` or restocks to clear the alert); at or below the critical band the product auto-goes out-of-stock (computed, see below). + +**Computed product availability (rule RG-T21, see `mlt.md`)**: effective orderability is derived, not stored. A product is orderable when `product.is_available = 1` AND each non-removable (`is_removable = 0`) ingredient in its `product_ingredient` has `stock_quantity > stock_capacity * critical_stock_pct / 100`. A required ingredient reaching the critical band makes the product auto-out-of-stock with no write and no cascade; a manual pull (`product.is_available = 0`) is a hard override; restock above the critical band makes the product orderable again on its own. A removable/optional ingredient at the critical band does not block the product (only its add-on becomes unavailable). The dashboard distinguishes a manual pull from a stock-driven OOS. --- @@ -257,7 +271,9 @@ erDiagram customer_order { int id PK varchar order_number + varchar idempotency_key enum source + int acting_user_id FK enum service_mode enum status int total_ht_cents @@ -358,6 +374,12 @@ the MLD). `preparing` and `ready` are dropped (decision D4, `revue-alignement-p1.md` §7). KPI timing is `delivered_at - paid_at`; KDS colour coding is computed from `NOW() - paid_at`. +**Security-by-design columns (2026-06-11)**: `idempotency_key` (client UUID, UNIQUE) +deduplicates a retried `POST /api/orders`. `acting_user_id` (FK -> `user`, ON DELETE SET NULL) +records the counter/drive staff who took the order under PIN; NULL for anonymous kiosk orders. +This adds a `customer_order |o--o| user : "taken_by"` association (cardinality: an order is +taken by (0,1) user; a user takes (0,N) orders). See dictionary note 13. + --- ## 7. Sub-domain: RBAC @@ -370,11 +392,15 @@ erDiagram int id PK varchar email varchar password_hash + varchar pin_hash varchar first_name varchar last_name int role_id FK tinyint is_active datetime last_login_at + smallint failed_login_attempts + datetime lockout_until + datetime anonymized_at } role { int id PK @@ -399,13 +425,38 @@ erDiagram int role_id FK int permission_id FK } + audit_log { + int id PK + int actor_user_id FK + int actor_role_id FK + varchar action_code + varchar entity_type + int entity_id + varchar summary + json details + datetime created_at + } + login_throttle { + int id PK + varchar ip_address UK + smallint failed_attempts + datetime window_started_at + datetime lockout_until + datetime last_attempt_at + } user }o--|| role : "holds" role ||--o{ role_visible_source : "sees_source" role ||--o{ role_permission : "grants" permission ||--o{ role_permission : "granted_to" + user |o--o{ audit_log : "performs" + role |o--o{ audit_log : "context_of" ``` +> `login_throttle` is a standalone entity with no association: it is keyed by source IP +> (`ip_address UNIQUE`), not by a modelled actor, so it carries no FK and connects to no +> other entity in the diagram. + ### 7.2 Association cardinalities | # | Association | Side A | Cardinality A | Side B | Cardinality B | Justification | @@ -414,6 +465,8 @@ erDiagram | R2 | sees_source | role | (0,N) | role_visible_source | (1,1) | A role may see 0 or more order sources on the preparation dashboard (admin/manager use a global view with no source filter). Each visibility row belongs to exactly one role. | | R3 | grants | role | (0,N) | role_permission | (1,1) | A role may have no permissions (a newly created role before assignment) or many. Each mapping row belongs to one role. | | R4 | granted_to | permission | (0,N) | role_permission | (1,1) | A permission may be granted to no roles yet (declared at seed, not yet distributed) or to several. Each mapping row references one permission. | +| R5 | performs | user | (0,1) | audit_log | (0,N) | A sensitive action captured under PIN records its acting user; automated/non-attributable entries carry NULL. A user may have logged any number of actions. ON DELETE SET NULL preserves the trail on user anonymisation/removal. | +| R6 | context_of | role | (0,1) | audit_log | (0,N) | Each audit row may denormalise the actor's role at action time (NULL allowed). A role may be the context of many audit rows. ON DELETE SET NULL preserves the trail. | ### 7.3 Notes on the RBAC sub-domain @@ -430,11 +483,28 @@ erDiagram **Seed roles** (5 roles, frozen at DDL; extendable without code change): `admin`, `manager`, `kitchen`, `counter`, `drive`. +**`audit_log` (security-by-design)**: append-only log of sensitive actions, immutable like +`stock_movement`. Both FKs (`actor_user_id`, `actor_role_id`) are nullable with ON DELETE +SET NULL, so the trail survives user anonymisation (RGPD) and role removal. The `actor_role_id` +is denormalised on purpose: even if the user is later anonymised, the role context of the +action is preserved. It carries no PII (the `details` JSON stores changed field names, not +values for user-targeted actions). See dictionary 3.20 and note 13. + +**`login_throttle` (security-by-design)**: per-source-IP brute-force throttle, complementing +the per-account counter already on `user` (`failed_login_attempts` / `lockout_until`). One row +per IP (`ip_address VARCHAR(45) UNIQUE`, 45 chars to hold a full IPv6 literal), upserted on each +failed login: `failed_attempts` counts consecutive failures from this IP in the current window, +`window_started_at` marks the start of that window (which resets when expired), `lockout_until` +holds the end of the degressive backoff (NULL = not throttled), `last_attempt_at` the timestamp +of the last failed attempt. It has no FK (an IP is not a modelled entity) and no association. A +daily cron purges rows with no active lockout whose `last_attempt_at` is older than 24h. See +dictionary 3.21 and note 13. + --- ## 8. Cross-validation MCD <-> dictionary -Verification that all 19 dictionary entities appear in the MCD and vice versa. +Verification that all 21 dictionary entities appear in the MCD and vice versa. | # | Dictionary entity (section 3) | Sub-domain in MCD | Present | |---|---|---|---| @@ -457,17 +527,21 @@ Verification that all 19 dictionary entities appear in the MCD and vice versa. | 17 | `permission` (3.17) | RBAC | Yes | | 18 | `role_permission` (3.18) | RBAC | Yes | | 19 | `stock_movement` (3.19) | Ingredients & Stock | Yes | +| 20 | `audit_log` (3.20) | RBAC & Audit | Yes | +| 21 | `login_throttle` (3.21) | RBAC & Audit | Yes | -**Result**: 19/19 entities traced. No entity from the dictionary is absent from the MCD. -No entity in the MCD falls outside the dictionary. +**Result**: 21/21 entities traced (19 prod-like + `audit_log` and `login_throttle` +security-by-design). No entity from the dictionary is absent from the MCD. No entity in the MCD +falls outside the dictionary. **Entities appearing in multiple sub-domains** (cross-domain shared entities): - `product`: Catalogue (sold item, slot eligibility) + Ingredients (recipe) + Order (line reference, slot choice) - `menu`: Catalogue (definition, slots) + Order (line reference) - `menu_slot`: Catalogue (slot definition) + Order (slot choices via `order_item_selection`) - `ingredient`: Ingredients (recipe, stock) + Order (modifiers) -- `customer_order`: Order (order lifecycle) + Ingredients (stock movement trigger) -- `user`: RBAC (authentication) + Ingredients (stock movement author) +- `customer_order`: Order (order lifecycle) + Ingredients (stock movement trigger) + RBAC & Audit (taken_by staff via `acting_user_id`) +- `user`: RBAC (authentication) + Ingredients (stock movement author) + Order (`acting_user_id` on counter/drive orders) + Audit (actor of `audit_log`) +- `role`: RBAC (permissions, visible sources) + Audit (denormalised `actor_role_id` context on `audit_log`) This is expected in a normalised model. The sub-domain split is for readability; the actual relational schema is a unified graph. @@ -518,16 +592,36 @@ Pre-validation: each entity participates in at least one treatment. | `permission` | Admin permission matrix management | | `role_permission` | Admin permission matrix management | | `stock_movement` | Automatic at `paid` transition; manual restock and inventory correction | +| `audit_log` | Written by sensitive operations: UPDATE/DELETE product/menu (8.2/8.3/8.6), CANCEL_ORDER (7.1), RESTOCK/INVENTORY_COUNT (9.1/9.2), user ops (10.1-10.3), MANAGE_RBAC (10.4), and failed/successful logins (12.1) | +| `login_throttle` | Read and written by AUTHENTICATE_USER (12.1): per-source-IP throttle upserted on each failed login, read to enforce the backoff window, purged by a daily cron | Cross-validation MCD <-> MCT (mantra #34) to be completed exhaustively in `mct.md` -once the MCT is updated to the 4-state machine and 19-entity model. +once the MCT incorporates the security-by-design operations (PIN-gated sensitive actions, +audit writes, reset/lockout, anonymisation). The treatment-layer additions are tracked there. --- -## 11. Note on .drawio diagram regeneration +## 11. Diagram sources and regeneration -The `.drawio` XML sources in `docs/merise/_diagrams/` reflect the v0.1 model (11 entities, -French naming). They are scheduled for regeneration from this v0.2 MCD as a separate task. -Until regenerated, this Markdown document is the authoritative conceptual model. The Mermaid -`erDiagram` blocks in sections 4-7 render natively on GitHub and serve as the interim -graphical reference. +The authoritative graphical model is the set of Mermaid `erDiagram` blocks in sections 4-7, +one per sub-domain. They render natively on Forgejo and GitHub. The MCD is decomposed by +sub-domain on purpose: a single 21-entity diagram cannot be laid out without crossing +relationship lines (intrinsic planarity limit, and `erDiagram` offers no manual layout +control). Each sub-domain stays at 5-8 entities, which auto-layout handles cleanly. The +integrated view across sub-domains is the cross-validation table in section 8. + +Portable SVG renders live in `docs/merise/_diagrams/` (for PDF export / offline viewing): + +| Sub-domain | Source | Render | +|---|---|---| +| Catalogue | `mcd-catalogue.mmd` | `mcd-catalogue.svg` | +| Ingredients & Stock | `mcd-ingredients-stock.mmd` | `mcd-ingredients-stock.svg` | +| Order | `mcd-order.mmd` | `mcd-order.svg` | +| RBAC | `mcd-rbac.mmd` | `mcd-rbac.svg` | + +The `.mmd` files are extracted from the `erDiagram` blocks above; the `.svg` are produced by +`make docs-render` (mmdc). If a block here changes, re-extract the matching `.mmd` and re-run +`make docs-render`. The legacy v0.1 `.drawio` sources have been removed: drawio gave manual +layout control but required hand-editing and did not render in the Markdown previews, whereas +the decomposed Mermaid blocks are version-controlled, render everywhere, and stay in sync with +this document. diff --git a/docs/merise/mct.md b/docs/merise/mct.md index 05b3935..e618deb 100644 --- a/docs/merise/mct.md +++ b/docs/merise/mct.md @@ -1,10 +1,10 @@ # Model of Conceptual Treatments (MCT) — Wakdo **Merise phase** : P1 - Conception, step 3 (after MCD) -**Version** : v0.2 — prod-like, 4-state machine -**Date** : 2026-06-04 +**Version** : v0.2 — prod-like, 4-state machine (+ security-by-design layer 2026-06-11) +**Date** : 2026-06-04 (security-by-design additions 2026-06-11) **Branch** : `feat/p1-conception` -**Status** : prod-like — all D1-D8 + stock decisions applied (see `docs/notes/revue-alignement-p1.md` §7) +**Status** : prod-like — all D1-D8 + stock decisions applied (see `docs/notes/revue-alignement-p1.md` §7); security-by-design ops added (ERASE_USER_PII, RESET_PASSWORD, PIN-gated sensitive set, audit_log writes, auth throttling) — 28 operations **Author** : BYAN (methodology layer) --- @@ -56,6 +56,17 @@ the ticket and act. The single staff gesture is "deliver". KPI is total time and `MARK_READY` (`MARQUER_PRETE`) are removed because their intermediate states no longer exist. `DELIVER_ORDER` becomes the sole status-advancing action for counter/drive staff. +**Security-by-design layer (2026-06-11)**: two operations are added — `RESET_PASSWORD` (12.3) +and `ERASE_USER_PII` (10.5, RGPD anonymisation). A subset of operations is **PIN-gated**: +back-office sessions stay shared per workstation, but a per-staff PIN re-authorises the +sensitive set — `CANCEL_ORDER` (7.1), `UPDATE_PRODUCT`/`DELETE_PRODUCT` (8.2/8.3), +`DELETE_MENU` (8.6), `INVENTORY_COUNT` (9.2), user management (10.1-10.3), `MANAGE_RBAC` +(10.4), `ERASE_USER_PII` (10.5). These non-stock actions append an immutable `audit_log` row +(actor, action, target); stock actions record attribution in `stock_movement`. The treatment +logic (PIN, audit, throttling, idempotency, atomic stock decrement, computed product +availability) is specified in `mlt.md` (rules RG-T13-T21). This adds entities 20 `audit_log` +and 21 `login_throttle` to the model. + --- ## 2. Representation conventions @@ -102,7 +113,7 @@ For each operation the document provides: | **Synchronisation** | None (single event) | | **Condition** | The kiosk is in service (within business hours 10:00-01:00) | | **Operation** | LOAD_CATALOGUE | -| **Description** | Retrieval of active categories, available products, and available menus (with their slots and eligible options) for display on the kiosk screen. | +| **Description** | Retrieval of active categories, available products, and available menus (with their slots and eligible options) for display on the kiosk screen. Product availability is COMPUTED: a product is orderable only if its `is_available` flag is set AND each non-removable (`is_removable=0`) ingredient in its `product_ingredient` is above the critical band (`stock_quantity > stock_capacity * critical_stock_pct/100`). See rule RG-T21 in `mlt.md`. | | **MCD entities** | R: `category` (is_active=1), `product` (is_available=1), `menu` (is_available=1), `menu_slot`, `menu_slot_option`, `ingredient` (is_active=1), `allergen`, `ingredient_allergen` | | **Result** | Catalogue loaded; kiosk displays the home screen | @@ -337,7 +348,7 @@ For each operation the document provides: | **Synchronisation** | OR (create ingredient, update ingredient, update composition, update allergen mapping) | | **Condition** | Actor holds permission `ingredient.manage`. | | **Operation** | MANAGE_INGREDIENT | -| **Description** | CRUD on `ingredient` (name, unit, pack_size, pack_label, low_stock_threshold, is_active). Manage `product_ingredient` composition (quantity_normal, quantity_maxi, is_removable, is_addable, extra_price_cents) for any product. Manage `ingredient_allergen` mapping (14 EU regulated allergens). Deactivating an ingredient (`is_active=0`) hides it from the configurator without deletion. Physical deletion of `ingredient` is blocked if referenced in `product_ingredient` (FK `ON DELETE RESTRICT`) or `stock_movement` (FK `ON DELETE RESTRICT`). | +| **Description** | CRUD on `ingredient` (name, unit, pack_size, pack_label, stock_capacity, low_stock_pct, critical_stock_pct, is_active). Manage `product_ingredient` composition (quantity_normal, quantity_maxi, is_removable, is_addable, extra_price_cents) for any product. Manage `ingredient_allergen` mapping (14 EU regulated allergens). Deactivating an ingredient (`is_active=0`) hides it from the configurator without deletion. Physical deletion of `ingredient` is blocked if referenced in `product_ingredient` (FK `ON DELETE RESTRICT`) or `stock_movement` (FK `ON DELETE RESTRICT`). | | **MCD entities** | R: `product` (FK validation), `allergen` (FK validation) — W: `ingredient` (INSERT/UPDATE/DELETE conditional), `product_ingredient` (INSERT/UPDATE/DELETE), `ingredient_allergen` (INSERT/DELETE) | | **Result** | Ingredient / composition / allergen mapping updated | @@ -384,7 +395,7 @@ For each operation the document provides: | **Synchronisation** | None | | **Condition** | Actor holds permission `stock.read`. | | **Operation** | READ_STOCK | -| **Description** | Read `ingredient` list with current `stock_quantity`, `low_stock_threshold`, `pack_size`, `pack_label`. Low-stock alert computed at display time: `stock_quantity <= low_stock_threshold`. Optional: read `stock_movement` history for a given ingredient, filtered by date range. | +| **Description** | Read `ingredient` list with current `stock_quantity`, `stock_capacity`, computed `stock_pct`, `low_stock_pct`, `critical_stock_pct`, `pack_size`, `pack_label`. Stock bands computed at display time: `low_stock` when `stock_quantity <= stock_capacity * low_stock_pct/100`, `critical_stock` when `stock_quantity <= stock_capacity * critical_stock_pct/100`. Optional: read `stock_movement` history for a given ingredient, filtered by date range. | | **MCD entities** | R: `ingredient`, `stock_movement` (optional history) | | **Result** | Stock list displayed with low-stock indicators | @@ -452,6 +463,21 @@ For each operation the document provides: --- +### 10.5 ERASE_USER_PII (security-by-design) + +| Field | Value | +|-------|-------| +| **Triggering event** | A RGPD erasure request is processed for a back-office user | +| **Actor** | ADMIN (PIN-gated) | +| **Synchronisation** | None | +| **Condition** | Actor holds permission `user.update` and has re-authorised via PIN. Target user exists and is not already anonymised. | +| **Operation** | ERASE_USER_PII | +| **Description** | RGPD right-to-erasure honoured by **anonymisation**, not physical deletion: PII (`email`, `first_name`, `last_name`) is cleared/replaced by a non-identifying placeholder, credentials invalidated, `anonymized_at` set. The row persists so referential links (`stock_movement`, `customer_order`, `audit_log`) stay valid and resolve to an anonymised principal. See `mlt.md` 10.5 and dictionary note 13. | +| **MCD entities** | W: `user` (UPDATE — PII cleared, `anonymized_at` set), `audit_log` (INSERT) | +| **Result** | User anonymised; PII removed; accountability links preserved; one `audit_log` row recorded | + +--- + ## 11. Domain 9 — Stats and KPI ### 11.1 READ_STATS @@ -478,11 +504,11 @@ For each operation the document provides: | **Triggering event** | An actor submits the login form | | **Actor** | COUNTER / DRIVE / KITCHEN / MANAGER / ADMIN | | **Synchronisation** | None | -| **Condition** | Email exists in database. Password matches argon2id hash. User `is_active=1`. | +| **Condition** | Account not in a throttling window (`lockout_until`). Email exists in database. Password matches argon2id hash. User `is_active=1`. | | **Operation** | AUTHENTICATE_USER | -| **Description** | Credential verification. If valid: session ID regeneration (protection against session fixation), storage of `user_id` and `role_id` in session, UPDATE `last_login_at`. Idle timeout: 4h. Absolute timeout: 10h. Redirect to `role.default_route`. | -| **MCD entities** | R: `user` (verification), `role` (load permissions, default_route), `role_permission` — W: `user` (UPDATE last_login_at) | -| **Result** | Session opened, redirect to role-specific default view | +| **Description** | Credential verification. If valid: session ID regeneration (protection against session fixation), storage of `user_id` and `role_id` in session, UPDATE `last_login_at`, reset of the login failure counter. On failure: increment `failed_login_attempts` and apply a degressive backoff (`lockout_until`), enumeration-safe generic error. Idle timeout: 4h. Absolute timeout: 10h. Redirect to `role.default_route`. See `mlt.md` 12.1. | +| **MCD entities** | R: `user` (verification), `role` (load permissions, default_route), `role_permission`, `login_throttle` (the per-IP throttle gate) — W: `user` (UPDATE last_login_at, `failed_login_attempts`, `lockout_until`), `login_throttle` (upsert `failed_attempts`/`lockout_until` on failure, clear on success), `audit_log` (INSERT login success/failure) | +| **Result** | Session opened, redirect to role-specific default view; or throttled failure logged | --- @@ -501,6 +527,21 @@ For each operation the document provides: --- +### 12.3 RESET_PASSWORD (security-by-design) + +| Field | Value | +|-------|-------| +| **Triggering event** | A user requests a password reset, then confirms it via the emailed link | +| **Actor** | COUNTER / DRIVE / KITCHEN / MANAGER / ADMIN | +| **Synchronisation** | Sequential two-phase: request, then confirm | +| **Condition** | Request: the submitted email is processed enumeration-safely (same neutral response whether or not it exists). Confirm: a valid, non-expired token is presented. | +| **Operation** | RESET_PASSWORD | +| **Description** | Request phase generates a random token, stores its hash + expiry, and e-mails the raw token once. Confirm phase validates the token hash + expiry, replaces `password_hash` (argon2id), clears the token, and resets the login failure counter. See `mlt.md` 12.3. | +| **MCD entities** | W: `user` (UPDATE `password_reset_token_hash` + `password_reset_expires_at` on request; UPDATE `password_hash`, clear token, reset `failed_login_attempts`/`lockout_until` on confirm), `audit_log` (INSERT) | +| **Result** | Password reset via a one-time, time-bound token; one `audit_log` row recorded | + +--- + ## 13. State machine — customer_order.status Summary of transitions covered by MCT operations. @@ -573,8 +614,17 @@ single delivery action (DELIVER_ORDER) collapses the v0.1 three-step sequence in | 24 | READ_STATS | Stats | MANAGER/ADMIN | — | customer_order, order_item | | 25 | AUTHENTICATE_USER | Auth | ALL BACK | user | user, role, role_permission | | 26 | LOGOUT_USER | Auth | ALL BACK | — | — | +| 27 | ERASE_USER_PII | RBAC | ADMIN | user, audit_log | user | +| 28 | RESET_PASSWORD | Auth | ALL BACK | user, audit_log | user | -**Total: 26 operations** covering the complete Wakdo business lifecycle. +**Total: 28 operations** (26 prod-like + `ERASE_USER_PII` and `RESET_PASSWORD` from the +security-by-design layer). + +**Audit log writes (security-by-design)**: the sensitive operations 7.1 (cancel), 8.2/8.3 +(product update/delete), 8.6 (menu delete), 10.1-10.5 (user/RBAC/erasure) and 12.1 (login) +also write an `audit_log` row (W entity not repeated per row above to keep the table legible). +Stock operations 9.1/9.2 record their attribution via `stock_movement.user_id`. PIN-gated set +per `mlt.md` RG-T13. --- @@ -603,9 +653,20 @@ Verification that each MCD entity participates in at least one MCT operation. | `permission` | 23 | — (static seed) | OK (*) | | `role_permission` | 25 | 23 | OK | | `stock_movement` | 19 | 3, 5, 8, 17, 18 | OK | +| `audit_log` | (admin audit view) | 8, 10, 11, 14, 20, 21, 22, 23, 25, 27, 28 | OK | +| `login_throttle` | 25 | 25 | OK | (*) `allergen` and `permission` are read-only at the MCT level: their values are declared in seed migrations and are not modifiable via the UI. `allergen` is managed indirectly via `ingredient_allergen` in MANAGE_INGREDIENT. -**Conclusion**: 19/19 entities covered. MCT <-> MCD consistency validated. +(**) `audit_log` (entity 20, security-by-design) is write-mostly: it is appended by the +sensitive operations above and read through an admin audit view (a dedicated read operation +can be formalised when the audit UI is specified at P3). + +(***) `login_throttle` (entity 21, security-by-design) is the per-source-IP brute-force +throttle gate: it is read AND written (upserted) by `AUTHENTICATE_USER` (25). Its daily purge +of stale rows is a cron, documented in `mlt.md`, outside MCT operation scope. + +**Conclusion**: 21/21 entities covered (19 prod-like + `audit_log` + `login_throttle`). MCT <-> MCD consistency +validated. diff --git a/docs/merise/mld.md b/docs/merise/mld.md index a48d6e2..5cd6563 100644 --- a/docs/merise/mld.md +++ b/docs/merise/mld.md @@ -1,10 +1,10 @@ # Logical Data Model (MLD) — Wakdo **Merise phase** : P1 - Conception, step 5 (after MCD, MCT, MLT) -**Version** : v0.2 — prod-like, 19 tables -**Date** : 2026-06-04 +**Version** : v0.2 — prod-like, 21 tables (19 prod-like + security-by-design layer) +**Date** : 2026-06-04 (security-by-design additions 2026-06-11) **Branch** : `feat/p1-conception` -**Status** : prod-like — all D1-D8 + stock decisions applied (see `docs/notes/revue-alignement-p1.md` §7) +**Status** : prod-like — all D1-D8 + stock decisions applied (see `docs/notes/revue-alignement-p1.md` §7); security-by-design layer (audit_log + accountability/auth columns) in progress **Author** : BYAN (methodology layer) --- @@ -93,7 +93,7 @@ in addition to the composite FK PK. Applied to `product_ingredient`. --- -## 4. Relational schema (19 tables) +## 4. Relational schema (21 tables) Tables are ordered by dependency (no-FK tables first, then tables that depend on them). @@ -245,14 +245,16 @@ No timestamps. Pure join table. ### 4.6 `ingredient` ``` -ingredient (id, name, unit, stock_quantity, pack_size, [pack_label], - low_stock_threshold, is_active, created_at, updated_at) +ingredient (id, name, unit, stock_quantity, stock_capacity, pack_size, [pack_label], + low_stock_pct, critical_stock_pct, is_active, created_at, updated_at) PK : id UK : name - CHK : stock_quantity >= 0 + CHK : stock_capacity > 0 CHK : pack_size > 0 - CHK : low_stock_threshold >= 0 + CHK : low_stock_pct BETWEEN 0 AND 100 + CHK : critical_stock_pct BETWEEN 0 AND 100 + CHK : critical_stock_pct < low_stock_pct ``` | Column | Type | NULL | Notes | @@ -260,16 +262,38 @@ ingredient (id, name, unit, stock_quantity, pack_size, [pack_label], | `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | | `name` | VARCHAR(120) | NO | Unique name, e.g. "Sesame Bun" | | `unit` | VARCHAR(40) | NO | Packaging unit label (free-form, not ENUM) | -| `stock_quantity` | INT NOT NULL DEFAULT 0 | NO | Current stock. Signed INT to detect negative (alert) | +| `stock_quantity` | INT NOT NULL DEFAULT 0 | NO | Current stock. Signed INT that may go negative when sales outrun counted stock (oversell magnitude, surfaced to managers); the system does not block an order on stock | +| `stock_capacity` | INT NOT NULL | NO | Reference "full" level in units = the 100% used to compute the stock percentage; CHECK > 0 also guards the percentage division against divide-by-zero | | `pack_size` | SMALLINT UNSIGNED NOT NULL DEFAULT 1 | NO | Units per restocking pack | | `pack_label` | VARCHAR(80) | YES | Human label of the pack | -| `low_stock_threshold` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Alert threshold | +| `low_stock_pct` | SMALLINT UNSIGNED NOT NULL DEFAULT 10 | NO | Warning band, percent of capacity (CHECK BETWEEN 0 AND 100) | +| `critical_stock_pct` | SMALLINT UNSIGNED NOT NULL DEFAULT 5 | NO | Auto-out-of-stock floor, percent of capacity (CHECK BETWEEN 0 AND 100; table CHECK `critical_stock_pct < low_stock_pct`) | | `is_active` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Deactivate obsolete ingredients | | `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Audit | | `updated_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | NO | Audit | No FK. Root table for the Ingredients & Stock sub-domain. +**Percentage-based stock model**: the stock state is computed (NOT stored) as +`stock_pct = ROUND(stock_quantity / stock_capacity * 100)`. Two bands derive from it: +`LOW` when `stock_quantity <= stock_capacity * low_stock_pct/100`, and +`CRITICAL` when `stock_quantity <= stock_capacity * critical_stock_pct/100`. +Three-band behaviour: above `low` = normal; between `critical` and `low` = orderable +plus manager alert (the manager either pulls the product via `product.is_available=0`, or +restocks to clear the alert); at or below `critical` = auto out-of-stock (computed, rule +RG-T21). `stock_quantity` is signed and may go negative; the system does not block an order +on stock, so a negative value records the oversell magnitude for managers. + +**Computed availability (rule RG-T21)**: a product is effectively orderable when +`product.is_available = 1` AND each non-removable (`is_removable=0`) ingredient in its +`product_ingredient` has `stock_quantity > stock_capacity * critical_stock_pct/100`. At the +critical band a product auto-goes out-of-stock with no write and no cascade; a manual pull +(`product.is_available=0`) is a hard override; a restock above the critical band makes the +product orderable again on its own; a removable/optional ingredient at the critical band does +not block the product (only its add-on becomes unavailable). The dashboard distinguishes a +manual pull (`is_available=0`) from a stock-driven OOS (`is_available=1` but a required +ingredient is critical). + --- ### 4.7 `product_ingredient` @@ -382,8 +406,10 @@ No FK. Root table for RBAC. ### 4.11 `user` ``` -user (id, email, password_hash, first_name, last_name, #role_id, - is_active, [last_login_at], created_at, updated_at) +user (id, email, password_hash, [pin_hash], first_name, last_name, #role_id, + is_active, [last_login_at], failed_login_attempts, [last_failed_login_at], + [lockout_until], [password_reset_token_hash], [password_reset_expires_at], + [anonymized_at], created_at, updated_at) PK : id UK : email @@ -394,19 +420,32 @@ user (id, email, password_hash, first_name, last_name, #role_id, | Column | Type | NULL | Notes | |---|---|---|---| | `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | -| `email` | VARCHAR(254) | NO | RFC 5321 max length | +| `email` | VARCHAR(254) | NO | RFC 5321 max length. PII (RGPD anonymisation, see below) | | `password_hash` | VARCHAR(255) | NO | argon2id hash | -| `first_name` | VARCHAR(60) | NO | | -| `last_name` | VARCHAR(60) | NO | | +| `pin_hash` | VARCHAR(255) | YES | argon2id hash of the per-staff PIN (sensitive-action authorisation). Security-by-design | +| `first_name` | VARCHAR(60) | NO | PII | +| `last_name` | VARCHAR(60) | NO | PII | | `role_id` | INT UNSIGNED | NO | FK -> role | | `is_active` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Deactivation without deletion | | `last_login_at` | DATETIME | YES | Audit, dormant account detection | +| `failed_login_attempts` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Brute-force counter (degressive throttling) | +| `last_failed_login_at` | DATETIME | YES | Timestamp of last failed login | +| `lockout_until` | DATETIME | YES | End of current throttling window (backoff, not indefinite lock) | +| `password_reset_token_hash` | VARCHAR(255) | YES | Hash of the reset token (not the raw token) | +| `password_reset_expires_at` | DATETIME | YES | Reset token expiry | +| `anonymized_at` | DATETIME | YES | RGPD tombstone marker; PII nulled/replaced when set | | `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Audit | | `updated_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | NO | Audit | **ON DELETE RESTRICT** on `role_id`: a role cannot be deleted while users hold it. Deactivate the role first (`is_active = 0`), then reassign users before deleting. +**RGPD anonymisation** (security-by-design, dict. note 13): the right to erasure is honoured by +anonymising, not hard-deleting. `email` becomes a unique non-identifying placeholder +(`anon-@wakdo.invalid`, RFC 2606 reserved domain — preserves the UNIQUE constraint), +`first_name`/`last_name` are cleared, `password_hash`/`pin_hash` are invalidated, `is_active=0`, +`anonymized_at = NOW()`. The row persists so `audit_log` and `stock_movement` FKs stay valid. + --- ### 4.12 `role_visible_source` @@ -488,13 +527,16 @@ No timestamps. Pure join table. ### 4.15 `customer_order` ``` -customer_order (id, order_number, source, service_mode, status, +customer_order (id, order_number, [idempotency_key], source, [#acting_user_id], + service_mode, status, total_ht_cents, total_vat_cents, total_ttc_cents, [paid_at], [delivered_at], [cancelled_at], created_at, updated_at) PK : id UK : order_number + UK : idempotency_key + FK : acting_user_id -> user(id) ON DELETE SET NULL IDX : (status, created_at) IDX : (source, created_at) IDX : created_at @@ -509,7 +551,9 @@ customer_order (id, order_number, source, service_mode, status, |---|---|---|---| | `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | | `order_number` | VARCHAR(20) | NO | Format `K/C/D-YYYY-MM-DD-NNN` by channel | +| `idempotency_key` | VARCHAR(36) | YES | Client UUID, UNIQUE; deduplicates retried POST (security-by-design) | | `source` | ENUM('kiosk','counter','drive') | NO | Input channel | +| `acting_user_id` | INT UNSIGNED | YES | FK -> user; counter/drive staff under PIN; NULL for kiosk | | `service_mode` | ENUM('dine_in','takeaway','drive') | NO | Consumption mode (stats only, no fiscal role) | | `status` | ENUM('pending_payment','paid','delivered','cancelled') NOT NULL DEFAULT 'pending_payment' | NO | 4-state machine | | `total_ht_cents` | INT UNSIGNED | NO | Ex-VAT total snapshot | @@ -521,8 +565,11 @@ customer_order (id, order_number, source, service_mode, status, | `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Used as `service_day` base | | `updated_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | NO | Audit | -No FK toward `user`: staff attribution is not stored on the order. Operational accountability -is covered by `stock_movement.user_id` for stock actions. +**Staff attribution (security-by-design)**: `acting_user_id` (FK -> `user`, ON DELETE SET NULL) +records the counter/drive staff who took the order under PIN; NULL for anonymous kiosk orders. +Kiosk orders stay anonymous by design. `stock_movement.user_id` covers attribution of stock +actions. `idempotency_key` (UNIQUE, nullable) deduplicates a retried `POST /api/orders` +(multiple NULLs allowed by the UNIQUE index, so non-idempotent legacy paths are tolerated). **4-state machine**: `pending_payment -> paid -> delivered` (+ `cancelled`). States `preparing` and `ready` are dropped (decision D4). KPI: `delivered_at - paid_at` (target SLA ~10 min). @@ -693,6 +740,76 @@ No `updated_at`. Immutable append-only table. --- +### 4.20 `audit_log` + +Append-only log of sensitive back-office actions (security-by-design, dict. 3.20). + +``` +audit_log (id, [#actor_user_id], [#actor_role_id], action_code, + [entity_type], [entity_id], [summary], [details], created_at) + + PK : id + FK : actor_user_id -> user(id) ON DELETE SET NULL + FK : actor_role_id -> role(id) ON DELETE SET NULL + IDX : (actor_user_id, created_at) + IDX : (entity_type, entity_id) + IDX : (action_code, created_at) +``` + +| Column | Type | NULL | Notes | +|---|---|---|---| +| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | +| `actor_user_id` | INT UNSIGNED | YES | FK -> user; acting staff (PIN-captured) or NULL if not attributable | +| `actor_role_id` | INT UNSIGNED | YES | FK -> role; denormalised role context (survives user anonymisation) | +| `action_code` | VARCHAR(60) | NO | MCT operation / permission code, e.g. `product.update`, `order.cancel` | +| `entity_type` | VARCHAR(40) | YES | Affected table name | +| `entity_id` | INT UNSIGNED | YES | PK of the affected row | +| `summary` | VARCHAR(255) | YES | Short non-personal change description | +| `details` | JSON | YES | Optional before/after diff (field names for user-targeted actions, not PII values) | +| `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Immutable timestamp | + +**ON DELETE SET NULL** on both FKs: the trail is preserved when a user is anonymised/removed +or a role deleted; only the link is severed (the denormalised `actor_role_id` keeps the role +context even after user anonymisation). + +**Immutability rule**: no UPDATE or DELETE at application layer. **Retention**: a scheduled +cron purge removes rows older than the retention window (~12 months, legitimate-interest / +fiscal traceability), decoupled from the user PII lifecycle (dict. note 13). + +No `updated_at`. Immutable append-only table. + +--- + +### 4.21 `login_throttle` + +Per-source-IP brute-force throttle (security-by-design). Complements the per-account counter +already on `user` (`failed_login_attempts` / `lockout_until`). + +``` +login_throttle (id, ip_address, failed_attempts, window_started_at, + [lockout_until], last_attempt_at) + + PK : id + UK : ip_address + IDX : lockout_until +``` + +| Column | Type | NULL | Notes | +|---|---|---|---| +| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | +| `ip_address` | VARCHAR(45) | NO | Source IP, one row per IP, upserted; 45 chars holds a full IPv6 literal. UNIQUE | +| `failed_attempts` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Consecutive failed logins from this IP in the current window | +| `window_started_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Start of the current counting window | +| `lockout_until` | DATETIME | YES | End of the degressive backoff window; NULL = not throttled | +| `last_attempt_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Timestamp of the last failed attempt | + +No FK: an IP is not a modelled entity. Append/upsert by IP; the window resets when expired. A +daily cron purges rows with no active lockout whose `last_attempt_at` is older than 24h. + +No `updated_at`: rows are upserted by IP, not edited through a UI. + +--- + ## 5. Referential integrity summary | FK column | References | ON DELETE | Rationale | @@ -722,6 +839,9 @@ No `updated_at`. Immutable append-only table. | `stock_movement.ingredient_id` | `ingredient(id)` | RESTRICT | Ingredient with history cannot be deleted | | `stock_movement.order_id` | `customer_order(id)` | SET NULL | Audit preserved, order link lost | | `stock_movement.user_id` | `user(id)` | SET NULL | Audit preserved, user attribution lost | +| `customer_order.acting_user_id` | `user(id)` | SET NULL | Staff attribution preserved as anonymised principal; order kept | +| `audit_log.actor_user_id` | `user(id)` | SET NULL | Audit trail preserved on user anonymisation; only the link is severed | +| `audit_log.actor_role_id` | `role(id)` | SET NULL | Role context kept until role deletion; denormalised so it survives user anonymisation | **Key used**: CASCADE = child has no meaning without parent; RESTRICT = parent deletion blocked while children exist; SET NULL = child is preserved, only the link is severed. @@ -736,9 +856,11 @@ blocked while children exist; SET NULL = child is preserved, only the link is se | `product` | `vat_rate IN (55, 100)` | Only two legal VAT rates for this model | | `menu` | `price_normal_cents > 0` | Same as product | | `menu` | `price_maxi_cents > 0` | Same | -| `ingredient` | `stock_quantity >= 0` | Negative stock is an alert, not a valid state | +| `ingredient` | `stock_capacity > 0` | The 100% reference must be positive; also guards the percentage division against divide-by-zero | | `ingredient` | `pack_size > 0` | Pack size of zero makes restock logic incoherent | -| `ingredient` | `low_stock_threshold >= 0` | Threshold cannot be negative | +| `ingredient` | `low_stock_pct BETWEEN 0 AND 100` | Warning band is a percent of capacity | +| `ingredient` | `critical_stock_pct BETWEEN 0 AND 100` | Auto-out-of-stock floor is a percent of capacity | +| `ingredient` | `critical_stock_pct < low_stock_pct` | Critical floor sits below the warning band | | `product_ingredient` | `quantity_normal > 0` | Recipe quantity of zero is meaningless | | `product_ingredient` | `quantity_maxi >= quantity_normal` | Maxi consumes at least as much as Normal (side/drink more, burger/sauce equal) | | `product_ingredient` | `extra_price_cents >= 0` | No negative surcharge | @@ -776,6 +898,10 @@ MCT / MLT. | `stock_movement` | `(movement_type, created_at)` | Stats: cancellations per week, restocks per month | | `role_permission` | `permission_id` | Reverse query: "which roles have this permission?" | | `user` | `(is_active, role_id)` | Login check + permission resolution | +| `audit_log` | `(actor_user_id, created_at)` | Per-actor audit history | +| `audit_log` | `(entity_type, entity_id)` | "what happened to this product/order/user?" | +| `audit_log` | `(action_code, created_at)` | Audit by action type over a time range | +| `login_throttle` | `lockout_until` | Daily cron purge of rows with no active lockout | **Indexes not added** (intentional): - `customer_order.order_number`: UK index is sufficient; no range query expected on this column. @@ -787,7 +913,8 @@ MCT / MLT. ## 8. Cross-validation MLD <-> MCD -Verification that all 19 MCD entities map to a table, and that all tables trace to the MCD. +Verification that all 21 MCD entities (19 prod-like + 2 security-by-design) map to a table, +and that all tables trace to the MCD. | MCD entity | MLD table | Mapping type | Notes | |---|---|---|---| @@ -810,8 +937,14 @@ Verification that all 19 MCD entities map to a table, and that all tables trace | `order_item_selection` (C17) | `order_item_selection` (4.17) | 1:1 entity | New entity (v0.2) | | `order_item_modifier` (C18) | `order_item_modifier` (4.18) | 1:1 entity | New entity (v0.2) | | `stock_movement` (C19) | `stock_movement` (4.19) | 1:1 entity | New entity (v0.2) | +| `audit_log` (R5/R6) | `audit_log` (4.20) | 1:1 entity | New entity (security-by-design) | +| `login_throttle` (R7) | `login_throttle` (4.21) | 1:1 entity | New entity (security-by-design) | -**Result**: 19/19 entities mapped. No entity without a table; no table outside the MCD. +**Result**: 21/21 entities mapped (19 prod-like + `audit_log` + `login_throttle`). No entity +without a table; no table outside the MCD. New columns on existing tables: `user` +(auth-lifecycle + `pin_hash` + `anonymized_at`), `customer_order` (`idempotency_key`, +`acting_user_id`), `ingredient` (`stock_capacity`, `low_stock_pct`, `critical_stock_pct`; +`low_stock_threshold` repurposed). **Dropped from v0.1**: `commande_event` (replaced by `paid_at`, `delivered_at`, `cancelled_at` phase timestamps on `customer_order` — decision 2.A); `menu_produit` fixed-composition model @@ -842,8 +975,11 @@ phase timestamps on `customer_order` — decision 2.A); `menu_produit` fixed-com | `order_item_selection` | ~300k | 150 bytes | ~45 MB | | `order_item_modifier` | ~150k | 80 bytes | ~12 MB | | `stock_movement` | ~500k | 180 bytes | ~90 MB | +| `audit_log` | ~5k-10k | 200 bytes | ~2 MB | +| `login_throttle` | ~100-1k | 80 bytes | < 1 MB | -**Estimated total**: ~190 MB data + ~60-80 MB for indexes = ~250-270 MB over 6 months. +**Estimated total**: ~190 MB data + ~60-80 MB for indexes = ~250-270 MB over 6 months +(`audit_log` is negligible: sensitive actions are orders of magnitude rarer than orders). Manageable on the MariaDB container (`wakdo_db_data` named volume in `docker-compose.yml`). `stock_movement` is the highest-volume table (~5-15 rows per order across all ingredients). @@ -889,6 +1025,11 @@ history; it will carry meaningful write amplification at scale. - `order_item_selection` (depends on `order_item`, `menu_slot`, `product`) - `order_item_modifier` (depends on `order_item`, `ingredient`) - `stock_movement` (depends on `ingredient`, `customer_order`, `user`) + - `audit_log` (depends on `user`, `role`) + - `login_throttle` (no FK, can be created at any point) + + Note: `customer_order` now carries `acting_user_id -> user`, so `user` must be created + before `customer_order` (already the case: the RBAC block precedes `customer_order`). 2. **Seed** (`db/seeds/0001_demo_data.sql`): - 9 categories + 53 products + 13 menus from JSON sources (`docs/merise/_sources/`) diff --git a/docs/merise/mlt.md b/docs/merise/mlt.md index abcff87..60954c4 100644 --- a/docs/merise/mlt.md +++ b/docs/merise/mlt.md @@ -1,10 +1,10 @@ # Model of Logical Treatments (MLT) — Wakdo **Merise phase** : P1 - Conception, step 4 (derived from MCT) -**Version** : v0.2 — prod-like, 4-state machine -**Date** : 2026-06-04 +**Version** : v0.2 — prod-like, 4-state machine (+ security-by-design layer 2026-06-11) +**Date** : 2026-06-04 (security-by-design additions 2026-06-11) **Branch** : `feat/p1-conception` -**Status** : prod-like — all D1-D8 + stock decisions applied (see `docs/notes/revue-alignement-p1.md` §7) +**Status** : prod-like — all D1-D8 + stock decisions applied (see `docs/notes/revue-alignement-p1.md` §7); security-by-design rules added (RG-T13-T21: PIN, audit, escaping, allowlists, idempotency, atomic decrement, computed product availability (RG-T21); ops RESET_PASSWORD, ERASE_USER_PII, auth throttling; per-IP throttle table `login_throttle`) **Author** : BYAN (methodology layer) --- @@ -49,6 +49,15 @@ These rules apply to multiple operations and are centralised here to avoid repet | **RG-T10** | VAT computation is line-by-line: each `order_item` carries its own `vat_rate_snapshot` (per-mille integer snapshotted from `product.vat_rate`). Order totals (`total_ht_cents`, `total_vat_cents`, `total_ttc_cents`) are the sum of line-level amounts. | 3.3, 4.1 | | **RG-T11** | Stock decrements at the `pending_payment -> paid` transition and re-credits at `paid -> cancelled` are within the same database transaction as the status update (no orphan decrement). | 3.3, 4.1, 7.1 | | **RG-T12** | Dashboard filter by source: each role's visible sources are read from `role_visible_source`; the query uses `WHERE customer_order.source IN (role_visible_sources)`. | 6.1 | +| **RG-T13** | **Sensitive-action PIN** (security-by-design): the set of sensitive operations requires a per-staff PIN re-authorisation before execution: verify the submitted PIN against `user.pin_hash` (`password_verify`, argon2id). On success the acting `user_id` is captured for the audit log; on failure the operation is rejected. Sensitive set: 7.1 (cancel), 8.2/8.3 (product update/delete), 8.6 (menu delete), 9.2 (inventory correction), 10.1/10.2/10.3 (user mgmt), 10.4 (RBAC), 10.5 (PII erasure). Sessions stay shared per workstation for the routine 95%. | 7.1, 8.2, 8.3, 8.6, 9.2, 10.1-10.5 | +| **RG-T14** | **Audit log write**: non-stock sensitive operations append one immutable `audit_log` row in the same transaction as their effect: `actor_user_id` (from RG-T13 PIN), `actor_role_id`, `action_code` (permission/operation code), `entity_type` + `entity_id` of the affected row, `summary` (non-personal change description), `details` JSON (changed field **names** for user-targeted actions, not PII values). No UPDATE/DELETE on `audit_log`. Stock actions (9.1 restock, 9.2 inventory) record their attribution via `stock_movement.user_id` (PIN-captured), which already provides the append-only stock trail — they are not double-logged. | 7.1, 8.2, 8.3, 8.6, 10.1-10.5, 12.1 | +| **RG-T15** | **Output escaping** (anti-XSS): free-text fields (`product.name`/`description`, `ingredient.name`, `user.first_name`/`last_name`, notes) are context-escaped at render. Server-rendered admin views use `htmlspecialchars($v, ENT_QUOTES, 'UTF-8')`; the vanilla-JS kiosk injects text via `textContent` (or an explicit escaper), not `innerHTML`. | All views rendering stored text | +| **RG-T16** | **Mass-assignment allowlist**: INSERT/UPDATE statements bind only an explicit per-operation column allowlist from the request; extra/unknown fields are dropped. Prevents tampering with `price_cents`, `vat_rate`, `role_id`, `is_active`, `status` via injected form fields. | 8.1, 8.2, 8.4, 8.5, 10.1, 10.2 | +| **RG-T17** | **Dynamic identifier allowlist**: column/direction tokens used in dynamic `ORDER BY` / `GROUP BY` are resolved against a fixed allowlist of column names before query build (RG-T06 covers values via bind parameters; SQL identifiers cannot be bound, so they are allowlisted). | 5.1, 9.3, 11.1 | +| **RG-T18** | **Server-side validation and length bounds**: every input is re-validated server-side regardless of client checks — type, range, max length (matching the dictionary VARCHAR sizes), enum membership, FK existence. Client-side validation is a UX aid, not a trust boundary. | All write operations | +| **RG-T19** | **Idempotency**: `POST /api/orders` carries a client-generated `idempotency_key` (UUID). Before creating, look it up on `customer_order.idempotency_key` (UNIQUE); if a row exists, return that order instead of creating a duplicate (replayed network retry). | 3.3, 4.1 | +| **RG-T20** | **Atomic stock decrement**: during the `paid` transition, each affected `ingredient` is decremented with a single self-locking statement `UPDATE ingredient SET stock_quantity = stock_quantity - :units WHERE id = :id` — no preceding read-gate, no `SELECT ... FOR UPDATE`. Concurrent orders on the same ingredient apply their deltas without a lost update and without a deadlock-ordering concern. `stock_quantity` is signed and may go negative when sales outrun counted stock (oversell magnitude surfaced to managers); the decrement does not block on a floor. | 3.3, 4.1 | +| **RG-T21** | **Computed product availability**: a product's effective orderability is computed, not stored. It is orderable when `product.is_available = 1` AND each non-removable (`is_removable = 0`) ingredient in its `product_ingredient` has `stock_quantity > stock_capacity * critical_stock_pct / 100`. At the critical band a required ingredient takes the product out-of-stock with no write and no cascade; restock above the critical band makes it orderable again on its own; a manual pull (`product.is_available = 0`) is a hard override; a removable/optional ingredient at the critical band does not block the product (only its add-on becomes unavailable). | 3.1, 3.3, 4.1, 5.1 | --- @@ -107,10 +116,13 @@ These rules apply to multiple operations and are centralised here to avoid repet | **[RG-2 — service_day]** | `service_day` for a given order is computed at query time as: `CASE WHEN HOUR(created_at) < 10 THEN DATE(created_at) - INTERVAL 1 DAY ELSE DATE(created_at) END`. Cutoff is 10:00. This is NOT stored as a column — computed at query time only. The v0.1 formula with `INTERVAL 4 HOUR 30 MINUTE` was incorrect and is dropped. | | **[RG-3 — order number]** | Order number format: `K-YYYY-MM-DD-NNN` where NNN is the sequential counter for the current service_day for the `kiosk` source (SELECT COUNT + 1 with a table-level lock or serialised insert to avoid duplicate generation under concurrency). Source is `kiosk` (set by the kiosk endpoint, derived from the public entry point). | | **[RG-4 — VAT by line]** | For each `order_item`: `vat_rate_snapshot` is copied from `product.vat_rate`. Line amounts: `unit_ttc = unit_price_cents_snapshot`; `unit_ht = ROUND(unit_ttc * 1000 / (1000 + vat_rate_snapshot))`; `unit_vat = unit_ttc - unit_ht`. Order totals: `total_ttc_cents = SUM(unit_ttc * quantity)` across all lines; `total_ht_cents = SUM(unit_ht * quantity)`; `total_vat_cents = total_ttc_cents - total_ht_cents`. Invariant: `total_ttc_cents = total_ht_cents + total_vat_cents` (verified before INSERT). | -| **[RG-5 — atomic transaction]** | All writes within one database transaction: (1) INSERT `customer_order` (status `pending_payment`, source `kiosk`, service_mode from cart, computed totals); (2) INSERT `order_item` rows (label_snapshot, unit_price_cents_snapshot, vat_rate_snapshot, quantity, format, item_type, product_id or menu_id); (3) INSERT `order_item_selection` rows for each slot filled in a menu item (order_item_id, menu_slot_id, product_id, label_snapshot); (4) INSERT `order_item_modifier` rows for each ingredient modification (order_item_id, ingredient_id, action, extra_price_cents snapshot); (5) for each ingredient consumed: compute units = `(order_item.format = 'maxi' ? product_ingredient.quantity_maxi : product_ingredient.quantity_normal) * order_item.quantity`, adjusted by modifiers (remove => no decrement for that ingredient; add => extra decrement); UPDATE `ingredient.stock_quantity -= units`; INSERT `stock_movement` (type `sale`, delta = -units, order_id, user_id = NULL for kiosk); (6) UPDATE `customer_order` SET status = `paid`, `paid_at = NOW()`. All six steps commit together or roll back entirely. | +| **[RG-5 — atomic transaction]** | All writes within one database transaction: (1) INSERT `customer_order` (status `pending_payment`, source `kiosk`, service_mode from cart, computed totals); (2) INSERT `order_item` rows (label_snapshot, unit_price_cents_snapshot, vat_rate_snapshot, quantity, format, item_type, product_id or menu_id); (3) INSERT `order_item_selection` rows for each slot filled in a menu item (order_item_id, menu_slot_id, product_id, label_snapshot); (4) INSERT `order_item_modifier` rows for each ingredient modification (order_item_id, ingredient_id, action, extra_price_cents snapshot); (5) for each ingredient consumed: compute units = `(order_item.format = 'maxi' ? product_ingredient.quantity_maxi : product_ingredient.quantity_normal) * order_item.quantity`, adjusted by modifiers (remove => no decrement for that ingredient; add => extra decrement); apply the atomic decrement `UPDATE ingredient SET stock_quantity = stock_quantity - :units WHERE id = :id` (single self-locking statement, no preceding read-gate, RG-T20); `stock_quantity` is signed and may go negative (oversell magnitude, surfaced to managers) — the decrement does not gate on a floor; INSERT `stock_movement` (type `sale`, delta = -units, order_id, user_id = NULL for kiosk); (6) UPDATE `customer_order` SET status = `paid`, `paid_at = NOW()`. All six steps commit together or roll back entirely. | | **[RG-6 — cross-constraint]** | Source `kiosk` implies no particular service_mode constraint; the customer selects `dine_in` or `takeaway`. The drive cross-constraint (RG-T09) does not apply to kiosk-originated orders. | | **[RG-7 — immutability]** | After INSERT, `label_snapshot`, `unit_price_cents_snapshot`, and `vat_rate_snapshot` are not modified even if the source product is later renamed or repriced (see RG-T05). | -| **[POST-1]** | One `customer_order` row exists with `status = 'paid'`, `source = 'kiosk'`, all totals computed, `paid_at` set. The `pending_payment` phase is not observable outside the transaction. | +| **[RG-8 — idempotency]** | The body carries a client `idempotency_key` (UUID). Before any write, `SELECT id, order_number, status FROM customer_order WHERE idempotency_key = :key`. If found, skip creation and return that order (deduplicates a replayed retry — RG-T19). The key is stored on the new `customer_order` row. | +| **[RG-9 — server-side modifier re-validation]** | The ingredient modifiers in the body are re-validated server-side against `product_ingredient`: an `action='remove'` requires `is_removable=1`; an `action='add'` requires `is_addable=1` and snapshots the current `extra_price_cents`. Client-side checks (3.2 RG-4) are not trusted; a crafted POST adding a non-addable ingredient is rejected (HTTP 422). | +| **[RG-10 — atomic stock decrement]** | No operation gates on a stock read, so the decrement is a single atomic statement `UPDATE ingredient SET stock_quantity = stock_quantity - :units WHERE id = :id` (RG-T20). The row self-locks for the duration of the update, so concurrent kiosk orders on the same ingredient apply their deltas without a lost update and without a deadlock-ordering concern; `stock_quantity` is signed and may go negative (oversell magnitude surfaced to managers). | +| **[POST-1]** | One `customer_order` row exists with `status = 'paid'`, `source = 'kiosk'`, all totals computed, `paid_at` set, `idempotency_key` stored. The `pending_payment` phase is not observable outside the transaction. | | **[POST-2]** | N `order_item` rows exist, each referencing either a `product_id` (item_type='product') or a `menu_id` (item_type='menu') — exclusivity constraint verified. | | **[POST-3]** | `customer_order.order_number` is unique in the database (UNIQUE constraint). | | **[POST-4]** | `ingredient.stock_quantity` decremented for each consumed ingredient unit; one `stock_movement` row of type `sale` per affected ingredient. | @@ -152,7 +164,8 @@ These rules apply to multiple operations and are centralised here to avoid repet | **[RG-2 — cross-constraint]** | If `source = 'drive'` then `service_mode` must be `'drive'` (RG-T09); verified before INSERT. HTTP 422 if violated. | | **[RG-3 — order number]** | Format: `C-YYYY-MM-DD-NNN` for counter source; `D-YYYY-MM-DD-NNN` for drive source. Sequential NNN counter is per source per service_day. | | **[RG-4 — stock]** | Same stock decrement logic as CREATE_ORDER RG-5; `stock_movement.user_id` is set to the authenticated staff member's id. | -| **[POST-1]** | One `customer_order` row with `status = 'paid'`, `source = 'counter'` or `'drive'`, `paid_at` set. | +| **[RG-5 — staff attribution + decrement]** | `customer_order.acting_user_id` is set to the authenticated staff member's id (targeted accountability on counter/drive orders; kiosk orders stay NULL). Server-side modifier re-validation (3.3 RG-9), idempotency (RG-T19) and the atomic stock decrement (RG-T20) apply identically. No PIN is required to create an order (the `order.create` permission suffices); order creation is not in the sensitive-action set. | +| **[POST-1]** | One `customer_order` row with `status = 'paid'`, `source = 'counter'` or `'drive'`, `paid_at` set, `acting_user_id` set. | | **[POST-2]** | N `order_item` rows with snapshots. Slot selections and modifiers written identically to kiosk flow. | | **[POST-3]** | Stock decremented; movements logged with actor `user_id`. | | **[OUT-1]** | HTTP 201: `{data: {id: int, order_number: string, status: 'paid'}}`. Order number communicated to customer. | @@ -218,7 +231,8 @@ These rules apply to multiple operations and are centralised here to avoid repet | **[RG-3 — stock re-credit — conditional]** | Re-credit applies only if the order was at status `paid` before cancellation. Orders at `pending_payment` had not yet decremented stock (the decrement occurs at the `paid` transition). For each `order_item` line of a `paid` order, recompute ingredient units consumed: `(order_item.format = 'maxi' ? product_ingredient.quantity_maxi : product_ingredient.quantity_normal) * order_item.quantity`, adjusted by `order_item_modifier` rows (remove modifier -> ingredient was not decremented, so no re-credit; add modifier -> ingredient had extra decrement, so extra re-credit). UPDATE `ingredient.stock_quantity += units`. INSERT `stock_movement` (type `cancellation`, delta = +units, order_id, user_id of actor). | | **[RG-4 — transaction]** | Status update and stock re-credit (when applicable) execute in the same database transaction (RG-T11). | | **[RG-5 — history]** | Order is not physically deleted; retained for history and stats. Cancelled orders are excluded from revenue totals but included in volume counts in READ_STATS. `order_item` rows are not deleted (ON DELETE CASCADE is not triggered); they allow reconstruction of what was ordered. | -| **[POST-1]** | `customer_order.status = 'cancelled'`, `cancelled_at` set, terminal state. | +| **[RG-6 — PIN + audit]** | Cancellation is a sensitive money-handling action: it requires the per-staff PIN (RG-T13) and writes one `audit_log` row in the same transaction (RG-T14): `action_code='order.cancel'`, `entity_type='customer_order'`, `entity_id=:id`, `summary` with prior status and re-credited amount. | +| **[POST-1]** | `customer_order.status = 'cancelled'`, `cancelled_at` set, terminal state. One `audit_log` row recorded with the acting staff. | | **[POST-2]** | If prior status was `paid`: `ingredient.stock_quantity` re-credited; one `stock_movement` row of type `cancellation` per affected ingredient. | | **[OUT-1]** | HTTP 200 with cancellation confirmation | | **[ERR-1]** | Attempt to cancel a delivered or already cancelled order: HTTP 422, `{error: {code: "CANNOT_CANCEL_IN_STATE", current_status: "..."}}` | @@ -258,7 +272,8 @@ These rules apply to multiple operations and are centralised here to avoid repet | **[RG-1]** | Same validations as CREATE_PRODUCT on modified fields | | **[RG-2]** | If a new image is uploaded, the old image file is deleted from the filesystem (volume cleanup) | | **[RG-3]** | `label_snapshot`, `unit_price_cents_snapshot`, `vat_rate_snapshot` in historical `order_item` rows are not modified (see RG-T05) | -| **[POST-1]** | `product` updated, `updated_at` refreshed | +| **[RG-4 — PIN + audit + allowlist]** | A price/VAT change is a sensitive action: it requires the per-staff PIN (RG-T13) and writes one `audit_log` row (RG-T14) with `action_code='product.update'`, `entity_type='product'`, `entity_id=:id`, and a `summary` recording changed values (e.g. `price_cents 880 -> 920`). Only the allowlisted columns (`name`, `description`, `price_cents`, `vat_rate`, `image_path`, `is_available`, `display_order`, `category_id`) are bound from the request (RG-T16). | +| **[POST-1]** | `product` updated, `updated_at` refreshed; one `audit_log` row recorded | | **[OUT-1]** | Redirect to product list with success message | --- @@ -275,7 +290,8 @@ These rules apply to multiple operations and are centralised here to avoid repet | **[RG-2]** | Pre-check (PHP): is the product the `burger_product_id` of any `menu`? If yes, block with message to delete or reassign the menu first. | | **[RG-3]** | Pre-check (PHP): is the product referenced in `order_item.product_id` (historical orders)? FK `ON DELETE RESTRICT` blocks at DB level. Recommended response: propose deactivation (`is_available=0`) rather than deletion. | | **[RG-4]** | FK constraints (`menu_slot_option.product_id ON DELETE RESTRICT`, `order_item.product_id ON DELETE RESTRICT`) enforce the constraint even if the PHP check is bypassed. | -| **[POST-1]** | Product deleted if no FK constraint was blocking | +| **[RG-5 — PIN + audit]** | Deletion is a sensitive action: it requires the per-staff PIN (RG-T13) and writes one `audit_log` row (RG-T14) with `action_code='product.delete'`, `entity_type='product'`, `entity_id=:id`, `summary` capturing the product name before deletion (recorded before the row is removed). | +| **[POST-1]** | Product deleted if no FK constraint was blocking; one `audit_log` row recorded | | **[OUT-1]** | Redirect to product list with success message | | **[ERR-1]** | Product in menu slot: HTTP 422 or inline message with blocking menu list | | **[ERR-2]** | Product in historical orders: message proposing deactivation instead | @@ -327,7 +343,8 @@ These rules apply to multiple operations and are centralised here to avoid repet | **[PRE-2]** | Target `menu.id` exists | | **[RG-1]** | Pre-check (PHP): is the menu referenced in `order_item.menu_id`? FK `ON DELETE RESTRICT`. If yes, propose deactivation (`is_available=0`) instead of deletion. | | **[RG-2]** | If no historical reference: DELETE `menu` triggers CASCADE to `menu_slot` (which cascades to `menu_slot_option`) | -| **[POST-1]** | `menu`, its `menu_slot` rows, and its `menu_slot_option` rows deleted | +| **[RG-3 — PIN + audit]** | Deletion is a sensitive action: per-staff PIN (RG-T13) + one `audit_log` row (RG-T14), `action_code='menu.delete'`, `entity_type='menu'`, `entity_id=:id`, `summary` capturing the menu name before deletion. | +| **[POST-1]** | `menu`, its `menu_slot` rows, and its `menu_slot_option` rows deleted; one `audit_log` row recorded | | **[OUT-1]** | Redirect with success message | | **[ERR-1]** | Menu in historical orders: message proposing deactivation instead | @@ -357,8 +374,8 @@ These rules apply to multiple operations and are centralised here to avoid repet | Tag | Content | |-----|---------| | **[PRE-1]** | Actor authenticated, holds permission `ingredient.manage` | -| **[RG-CREATE-ING]** | `name` non-empty and UNIQUE; `unit` non-empty; `pack_size >= 1`; `low_stock_threshold >= 0`; `stock_quantity` defaults to 0 at creation | -| **[RG-UPDATE-ING]** | UPDATE `name`, `unit`, `pack_size`, `pack_label`, `low_stock_threshold`, `is_active` | +| **[RG-CREATE-ING]** | `name` non-empty and UNIQUE; `unit` non-empty; `pack_size >= 1`; `stock_capacity >= 1` (the 100% reference); `low_stock_pct` and `critical_stock_pct` in 0-100 with `critical_stock_pct < low_stock_pct` (defaults 10 / 5); `stock_quantity` defaults to 0 at creation | +| **[RG-UPDATE-ING]** | UPDATE `name`, `unit`, `pack_size`, `pack_label`, `stock_capacity`, `low_stock_pct`, `critical_stock_pct`, `is_active` | | **[RG-DEACTIVATE-ING]** | `is_active=0` hides ingredient from configurator. Physical deletion blocked if referenced in `product_ingredient` (FK `ON DELETE RESTRICT`) or `stock_movement` (FK `ON DELETE RESTRICT`). | | **[RG-COMPOSITION]** | UPDATE `product_ingredient`: for each ingredient in a product's recipe, set `quantity_normal`, `quantity_maxi`, `is_removable`, `is_addable`, `extra_price_cents`. Delete-and-reinsert pattern within transaction. | | **[RG-ALLERGEN]** | Manage `ingredient_allergen`: INSERT or DELETE `(ingredient_id, allergen_id)` pairs. Allergen list is read-only (14 rows fixed by EU regulation 1169/2011). | @@ -398,7 +415,8 @@ These rules apply to multiple operations and are centralised here to avoid repet | **[RG-1]** | `delta = actual_quantity - ingredient.stock_quantity` (may be negative if actual < theoretical) | | **[RG-2]** | Transaction: `UPDATE ingredient SET stock_quantity = :actual_quantity WHERE id = :id`; INSERT `stock_movement` (ingredient_id, movement_type=`inventory_correction`, delta=computed, order_id=NULL, user_id=actor, note=optional) | | **[RG-3]** | `delta = 0` is a valid correction (physical count matches theoretical); a movement row is still inserted for audit completeness | -| **[POST-1]** | `ingredient.stock_quantity = actual_quantity`. One `stock_movement` row of type `inventory_correction` inserted. | +| **[RG-4 — PIN attribution]** | An inventory correction can mask shrinkage, so it requires the per-staff PIN (RG-T13). The PIN-captured `user_id` is written to `stock_movement.user_id`, making the correction attributable to a person even on a shared workstation. No separate `audit_log` row (the `stock_movement` trail already records it). | +| **[POST-1]** | `ingredient.stock_quantity = actual_quantity`. One `stock_movement` row of type `inventory_correction` inserted with the acting `user_id`. | | **[OUT-1]** | Confirmation with reconciled stock level and discrepancy displayed | --- @@ -411,10 +429,11 @@ These rules apply to multiple operations and are centralised here to avoid repet |-----|---------| | **[PRE-1]** | Actor authenticated, holds permission `stock.read` | | **[RG-1]** | `SELECT * FROM ingredient WHERE is_active = 1 ORDER BY name ASC` | -| **[RG-2]** | Low-stock alert computed at render time: `stock_quantity <= low_stock_threshold` -> flag `low_stock: true` in response. Not stored as a column. | +| **[RG-2]** | Stock bands computed at render time from the percentage thresholds: `low_stock: true` when `stock_quantity <= stock_capacity * low_stock_pct / 100`, `critical_stock: true` when `stock_quantity <= stock_capacity * critical_stock_pct / 100`; `stock_pct = ROUND(stock_quantity / stock_capacity * 100)` is also returned. Not stored as columns. | | **[RG-3]** | Optional movement history for a given ingredient: `SELECT * FROM stock_movement WHERE ingredient_id = :id ORDER BY created_at DESC LIMIT :n` | +| **[RG-4 — attribution visibility]** | The `stock_movement.user_id` (who restocked / who corrected) is included for `manager`/`admin` only; line staff (`kitchen`/`counter`/`drive`) see the movement deltas without the actor identity. This limits intra-team exposure while preserving accountability for those who manage. The `details` allowlist is applied at the query/serialisation layer. | | **[POST-1]** | No database write | -| **[OUT-1]** | Ingredient list with `stock_quantity`, `low_stock_threshold`, `pack_size`, `pack_label`, `low_stock` flag | +| **[OUT-1]** | Ingredient list with `stock_quantity`, `stock_capacity`, computed `stock_pct`, `low_stock_pct`, `critical_stock_pct`, `pack_size`, `pack_label`, `low_stock` / `critical_stock` flags; movement history with actor visible to manager/admin only | --- @@ -432,7 +451,8 @@ These rules apply to multiple operations and are centralised here to avoid repet | **[RG-1]** | Validation: `email` conforms to RFC 5321 (PHP `FILTER_VALIDATE_EMAIL`), `first_name` and `last_name` non-empty, `role_id` valid | | **[RG-2]** | Password hash: `password_hash($password, PASSWORD_ARGON2ID)`. Minimum password length: 8 characters. | | **[RG-3]** | `is_active = 1` by default; `last_login_at = NULL` at creation | -| **[POST-1]** | One `user` row with argon2id `password_hash`, valid `role_id` | +| **[RG-4 — PIN + audit + allowlist]** | Creating a back-office account is a sensitive action: per-staff PIN (RG-T13) + one `audit_log` row (RG-T14), `action_code='user.create'`, `entity_type='user'`, `entity_id=:new_id`, `details` recording the assigned `role_id` (field names/role, not the password). Only the allowlisted columns are bound (RG-T16): `email`, `first_name`, `last_name`, `role_id` (+ the hashed password); `is_active` and any other field are server-set, not request-bound. | +| **[POST-1]** | One `user` row with argon2id `password_hash`, valid `role_id`; one `audit_log` row recorded | | **[OUT-1]** | Redirect to user list with success message | | **[ERR-1]** | Duplicate email: message "This email is already in use" | | **[ERR-2]** | Password too short: inline validation message | @@ -450,7 +470,8 @@ These rules apply to multiple operations and are centralised here to avoid repet | **[RG-1]** | If a new password is supplied (non-empty field): rehash via `PASSWORD_ARGON2ID` and replace existing hash | | **[RG-2]** | If password field is empty: existing hash is preserved unchanged | | **[RG-3]** | Email update subject to UNIQUE constraint (pre-check before UPDATE) | -| **[POST-1]** | `user` updated, `updated_at` refreshed | +| **[RG-4 — PIN + audit + allowlist]** | Editing an account (incl. `role_id`, the privilege-escalation vector) is sensitive: per-staff PIN (RG-T13) + one `audit_log` row (RG-T14), `action_code='user.update'`, `entity_type='user'`, `entity_id=:id`, `details` listing changed field names (not values, no PII). Only the allowlisted columns are bound (RG-T16): `first_name`, `last_name`, `email`, `role_id`, `is_active` (+ optional password rehash). | +| **[POST-1]** | `user` updated, `updated_at` refreshed; one `audit_log` row recorded | | **[OUT-1]** | Redirect with success message | --- @@ -465,7 +486,8 @@ These rules apply to multiple operations and are centralised here to avoid repet | **[PRE-2]** | Actor is not targeting their own account (`$targetUserId !== $currentUserId`) | | **[RG-1]** | `UPDATE user SET is_active = 0, updated_at = NOW() WHERE id = :id` | | **[RG-2]** | The user's potentially active session is invalidated on next request: middleware checks `user.is_active = 1` on each authenticated request | -| **[POST-1]** | `user.is_active = 0`; user cannot log in; history remains intact | +| **[RG-3 — PIN + audit]** | Sensitive action: per-staff PIN (RG-T13) + one `audit_log` row (RG-T14), `action_code='user.deactivate'`, `entity_type='user'`, `entity_id=:id`. | +| **[POST-1]** | `user.is_active = 0`; user cannot log in; history remains intact; one `audit_log` row recorded | | **[OUT-1]** | Redirect with success message | | **[ERR-1]** | Self-deactivation attempt: HTTP 403, `{error: {code: "SELF_DEACTIVATION_FORBIDDEN"}}` | @@ -485,11 +507,31 @@ These rules apply to multiple operations and are centralised here to avoid repet | **[RG-3]** | Effect is immediate for new requests; sessions of users bearing this role see the change on the next permission check (sessions store `role_id`; permissions are reloaded from DB on each check). | | **[RG-4 — custom role]** | Creating a custom role: INSERT `role` (code UNIQUE, label, description, default_route nullable, order_source nullable); INSERT `role_visible_source` rows as needed. | | **[RG-5 — order_source]** | `role.order_source` controls the auto-tagging of `customer_order.source` when this role creates an order. NULL for admin and manager (they can create on behalf of any channel). | -| **[POST-1]** | `role_permission` reflects exactly the selected permissions for this role | +| **[RG-6 — PIN + audit change-log]** | RBAC changes are high-impact (privilege escalation): per-staff PIN (RG-T13) + one `audit_log` row (RG-T14) per change, `action_code='role.manage'`, `entity_type='role'`, `entity_id=:role_id`. Because permissions are rewritten delete-and-reinsert (RG-1), the `details` JSON records the **diff** — permission codes added and removed — computed before the rewrite, so the trail shows exactly which capabilities a role gained or lost and who granted them. | +| **[POST-1]** | `role_permission` reflects exactly the selected permissions for this role; one `audit_log` row recorded with the permission diff | | **[OUT-1]** | Redirect with success message | --- +### 10.5 ERASE_USER_PII (RGPD anonymisation) + +**Security-by-design operation (no v0.1 / v0.2 MCT predecessor). Honours the RGPD right to +erasure (Cr 3.d) without breaking referential integrity or the audit trail (dict. note 13).** + +| Tag | Content | +|-----|---------| +| **[PRE-1]** | Actor authenticated, holds permission `user.update` (erasure is an admin operation) | +| **[PRE-2]** | Per-staff PIN verified (RG-T13) — sensitive action | +| **[PRE-3]** | Target `user.id` exists and `anonymized_at IS NULL` (not already anonymised) | +| **[RG-1 — anonymise, not delete]** | In one transaction: `UPDATE user SET email = CONCAT('anon-', id, '@wakdo.invalid'), first_name = '', last_name = '', password_hash = '', pin_hash = NULL, password_reset_token_hash = NULL, is_active = 0, anonymized_at = NOW() WHERE id = :id`. The placeholder domain is RFC 2606 reserved (`.invalid`), keeps `email` UNIQUE and non-identifying. | +| **[RG-2 — preserve links]** | The row persists, so FKs pointing at it (`stock_movement.user_id`, `customer_order.acting_user_id`, `audit_log.actor_user_id`) stay valid and now resolve to an anonymised principal. Accountability for past actions is preserved in form (who-as-id) without retaining PII. | +| **[RG-3 — audit]** | One `audit_log` row (RG-T14): `action_code='user.erase_pii'`, `entity_type='user'`, `entity_id=:id`. The `summary`/`details` record the erasure event and its legal basis, not the erased values. | +| **[POST-1]** | `user` row anonymised: PII fields cleared/placeholdered, credentials invalidated, `anonymized_at` set, `is_active = 0`. Referential links intact. | +| **[OUT-1]** | Confirmation; the user disappears from active lists, remains as an anonymised tombstone in history. | +| **[ERR-1]** | Already anonymised: HTTP 409, `{error: {code: "ALREADY_ANONYMISED"}}` | + +--- + ## 11. Domain 9 — Stats and KPI ### 11.1 READ_STATS @@ -519,17 +561,21 @@ These rules apply to multiple operations and are centralised here to avoid repet |-----|---------| | **[PRE-1]** | Login form submitted with email and password | | **[PRE-2]** | CSRF token of the form is valid (anti-CSRF protection) | +| **[PRE-3 — throttle gate]** | If the account is in a throttling window (`user.lockout_until IS NOT NULL AND lockout_until > NOW()`), reject with the generic error before any password check. Throttling is also keyed per source IP via the `login_throttle` table: if a row exists for the source IP with `lockout_until IS NOT NULL AND lockout_until > NOW()`, reject with the same generic error, so distributed attempts on many accounts are slowed too. | | **[RG-1]** | Lookup: `SELECT * FROM user WHERE email = :email AND is_active = 1 LIMIT 1` | -| **[RG-2]** | Password verification: `password_verify($password, $user->password_hash)`. On failure: same generic error whether the email does not exist or the password is wrong (protection against email enumeration). | +| **[RG-2]** | Password verification: `password_verify($password, $user->password_hash)`. On failure: same generic error whether the email does not exist or the password is wrong (protection against email enumeration). To keep timing comparable when the email is unknown, a dummy `password_verify` against a fixed decoy hash is run. | | **[RG-3]** | On success: `session_regenerate(true)` (session ID regeneration, protection against session fixation) | | **[RG-4]** | Session storage: `$_SESSION['user_id']`, `$_SESSION['role_id']`, `$_SESSION['logged_in_at']` | | **[RG-5]** | UPDATE: `UPDATE user SET last_login_at = NOW() WHERE id = :id` | | **[RG-6]** | Session timeouts: idle timeout 4h (detection via last-activity timestamp in session); absolute timeout 10h (detection via `logged_in_at`) | | **[RG-7]** | Redirect target is `role.default_route` (dynamic; no hardcoded role name in routing logic) | -| **[POST-1]** | PHP session open with `user_id` and `role_id`; `user.last_login_at` updated | +| **[RG-8 — failure handling, degressive backoff]** | On a failed verification, the per-account counter on `user`: `UPDATE user SET failed_login_attempts = failed_login_attempts + 1, last_failed_login_at = NOW()`, and once a threshold is reached (suggestion: 5) set `lockout_until = NOW() + INTERVAL (base * 2^(attempts - threshold)) SECOND`, capped (suggestion: cap a few minutes). In the same step, the per-IP dimension is recorded in the `login_throttle` table: upsert the row keyed on `ip_address` (insert if absent, else increment `failed_attempts`; reset the window when expired via `window_started_at`), update `last_attempt_at = NOW()`, and once the IP threshold is reached set `lockout_until` with the same degressive backoff. This is a degressive backoff, not an indefinite lock — it slows brute force without letting a fat-finger streak deny service to a kitchen mid-shift. Write one `audit_log` row (`action_code='auth.login_failed'`, `actor_user_id` if the email resolved, else NULL). | +| **[RG-9 — success reset]** | On success, reset the per-account counter `failed_login_attempts = 0`, clear `lockout_until = NULL`, and also clear the per-IP `login_throttle` row for the source IP (reset `failed_attempts = 0`, `lockout_until = NULL`, restart `window_started_at`), then write one `audit_log` row (`action_code='auth.login_success'`, `actor_user_id`, `actor_role_id`). | +| **[POST-1]** | PHP session open with `user_id` and `role_id`; `user.last_login_at` updated; `failed_login_attempts` reset | | **[OUT-1]** | Redirect to `role.default_route` | -| **[ERR-1]** | Incorrect credentials or inactive account: generic message "Email or password incorrect" (no distinction to prevent enumeration) | +| **[ERR-1]** | Incorrect credentials or inactive account: generic message "Email or password incorrect" (no distinction to prevent enumeration); failure counter incremented (RG-8) | | **[ERR-2]** | Invalid CSRF token: HTTP 403 | +| **[ERR-3]** | Account in throttling window (PRE-3): same generic message; the attempt does not reveal that the account exists or is locked | --- @@ -548,7 +594,21 @@ These rules apply to multiple operations and are centralised here to avoid repet --- -## 13. Automated treatments — Crons (outside user interactions) +### 12.3 RESET_PASSWORD + +**Security-by-design operation (no v0.1 predecessor). Two phases: request, then confirm.** + +| Tag | Content | +|-----|---------| +| **[PRE-1]** | Request phase: a `user` submits the "forgot password" form with an email; CSRF token valid | +| **[RG-1 — request, enumeration-safe]** | Look up the email. The same neutral response ("if the account exists, an email has been sent") is returned whether or not the email exists, to avoid account enumeration. | +| **[RG-2 — token generation]** | If the email resolves to an active user: generate a cryptographically random token (e.g. 32 bytes from a CSPRNG); store its **hash** in `password_reset_token_hash` and `password_reset_expires_at = NOW() + INTERVAL 1 HOUR`. The **raw** token is sent once in the reset link (not stored in clear). | +| **[PRE-2]** | Confirm phase: the user opens the reset link with the raw token and submits a new password; CSRF token valid | +| **[RG-3 — confirm]** | Hash the submitted token and match it against `password_reset_token_hash` where `password_reset_expires_at > NOW()`. On match: `password_hash = password_hash($new, PASSWORD_ARGON2ID)` (min length 8), then clear `password_reset_token_hash = NULL` and `password_reset_expires_at = NULL`, and reset `failed_login_attempts = 0`, `lockout_until = NULL`. One-time use. | +| **[RG-4 — audit]** | Write one `audit_log` row (RG-T14), `action_code='auth.password_reset'`, `actor_user_id = :id`. | +| **[POST-1]** | Password replaced with a new argon2id hash; reset token consumed and cleared | +| **[OUT-1]** | Confirmation; redirect to login | +| **[ERR-1]** | Invalid or expired token: generic message inviting a new reset request (no detail on which condition failed) | These treatments are executed by the `wakdo-cron` service container in the maintenance window 01:30-09:30 (outside active service). They are outside the MCT scope (technical @@ -581,6 +641,24 @@ treatments, no user trigger) but are documented here for consistency with PROJEC | **[RG-2]** | Retention: keep the last 7 dumps; delete older ones | | **[POST-1]** | SQL dump available for restoration | +### 13.4 Audit log retention purge (cron daily) + +| Tag | Content | +|-----|---------| +| **[TRIGGER]** | Cron: `15 4 * * *` (maintenance window) | +| **[RG-1]** | `DELETE FROM audit_log WHERE created_at < NOW() - INTERVAL :retention_months MONTH` (suggestion: 12 months, legitimate-interest / fiscal traceability — configurable in `.env`). | +| **[RG-2]** | The window is decoupled from user PII lifecycle: anonymisation (10.5) removes PII immediately on request, while the audit trail ages out on its own schedule (dict. note 13). | +| **[POST-1]** | `audit_log` rows older than the retention window removed; recent accountability preserved. | + +### 13.5 login_throttle purge (cron daily) + +| Tag | Content | +|-----|---------| +| **[TRIGGER]** | Cron: `45 4 * * *` (maintenance window) | +| **[RG-1]** | `DELETE FROM login_throttle WHERE (lockout_until IS NULL OR lockout_until < NOW()) AND last_attempt_at < NOW() - INTERVAL 24 HOUR` — purge rows with no active lockout whose last failed attempt is older than 24h. | +| **[RG-2]** | Rows still serving an active lockout are retained; the per-IP counter (S1) is bounded by this purge so the table does not grow unbounded from one-off attempts. | +| **[POST-1]** | Stale `login_throttle` rows removed; active throttles and recent activity preserved. | + --- ## 14. State machine — consistency recap (MLT) diff --git a/docs/uml/security-sequence.md b/docs/uml/security-sequence.md new file mode 100644 index 0000000..1d39c69 --- /dev/null +++ b/docs/uml/security-sequence.md @@ -0,0 +1,232 @@ +# Diagramme de sequence securite - Annulation de commande avec PIN (CANCEL_ORDER) + +**Phase UML** : P1 - Conception, complement UML (passe security-by-design) +**Statut** : v0.2 - flux sensible PIN-gate + audit_log (controle anti-fraude interne) +**Date** : 2026-06-12 +**Branche** : `feat/p1-conception` +**Auteur methodologie** : BYAN + +--- + +## 1. Objet du document + +Ce document decrit le **flux temporel securise** de l'annulation d'une commande +en back-office (`CANCEL_ORDER`). L'annulation est une action de **manipulation +d'argent** : annuler une commande deja `paid` peut servir a masquer un +detournement d'especes (l'equipier encaisse, annule, garde le cash). Le flux +ci-dessous materialise le controle qui adresse ce risque : une **re-authentification +par PIN par equipier** (`RG-T13`) avant l'execution, et l'ecriture d'une ligne +**`audit_log` immuable** dans la meme transaction que l'effet (`RG-T14`), de sorte +que chaque annulation est rattachee a une personne meme sur un poste partage. + +Le diagramme reste au niveau conceptuel / logique. Il nomme les echanges entre +participants sans detailler l'implementation PHP ni le SQL exact. Il complete +l'operation `CANCEL_ORDER` du `docs/merise/mlt.md` (7.1), la transition T5 de +`docs/uml/state-commande.md` (`paid -> cancelled`, re-credit du stock) et le cas +d'utilisation "Annuler une commande" de `docs/uml/use-cases.md` (UC13). + +**Sources** : +- `docs/merise/mlt.md` 7.1 `CANCEL_ORDER` (PRE-1..PRE-3, RG-1..RG-6, POST, ERR) +- `docs/merise/mlt.md` section 2 : `RG-T13` (PIN sensible), `RG-T14` (audit_log), `RG-T11` (re-credit dans la meme transaction), `RG-T07` (garde de concurrence), `RG-T08` (transaction atomique) +- `docs/merise/dictionary.md` 3.14 (`user.pin_hash`, argon2id) et 3.20 (`audit_log`) +- `docs/uml/state-commande.md` T5 (`paid -> cancelled`, `stock_movement` type `cancellation`) + +--- + +## 2. Participants + +| Participant | Role | Couche | +|---|---|---| +| **Equipier** | Counter / Drive / Admin titulaire de `order.cancel`, sur poste partage | Acteur | +| **Admin UI** | Interface back-office (Bloc 3, formulaire d'annulation + saisie PIN) | Presentation | +| **Controleur** | Back-end REST sous `/api/*` (Bloc 2), orchestre la transaction | Application | +| **Verif PIN** | Service de verification du PIN (`password_verify` argon2id) | Application | +| **BDD** | Base de donnees MariaDB | Persistance | + +La session est **partagee par poste de travail** pour le routine 95% ; le PIN +re-introduit une attribution individuelle sur le sous-ensemble sensible +(`mlt.md` `RG-T13`). Le PIN n'est pas une session : il est verifie a chaque action +sensible et sert a capturer le `user_id` qui sera ecrit dans `audit_log`. + +--- + +## 3. Diagramme de sequence + +```mermaid +sequenceDiagram + actor Equipier + participant AdminUI as Admin UI + participant Ctrl as Controleur + participant PIN as Verif PIN + participant BDD + + Note over Equipier,BDD: Phase 1 - Demande et controle de permission + + Equipier->>AdminUI: demander l'annulation d'une commande + AdminUI->>Ctrl: POST /api/orders/{id}/cancel + Ctrl->>Ctrl: verifier session active + is_active = 1 (RG-T02) + Ctrl->>BDD: lire role_permission (permission order.cancel ?) (RG-T03, PRE-1) + BDD-->>Ctrl: permission presente + Ctrl->>BDD: lire customer_order (existe ? statut ?) (PRE-2, PRE-3) + BDD-->>Ctrl: status courant + + alt status hors ['pending_payment','paid'] (delivered ou cancelled) + Ctrl-->>AdminUI: 422 CANNOT_CANCEL_IN_STATE {current_status} + AdminUI-->>Equipier: refus, aucun changement d'etat (ERR-1) + else status annulable + Note over Equipier,BDD: Phase 2 - Re-authentification PIN (RG-T13) + + Ctrl-->>AdminUI: demander la saisie du PIN + Equipier->>AdminUI: saisir le PIN + AdminUI->>Ctrl: soumettre le PIN (re-auth action sensible) + Ctrl->>PIN: verifier le PIN soumis + PIN->>BDD: lire user.pin_hash de l'equipier + BDD-->>PIN: pin_hash (argon2id) + PIN->>PIN: password_verify(pin, pin_hash) + + alt PIN incorrect + PIN-->>Ctrl: echec + Ctrl-->>AdminUI: refus du PIN, action rejetee + AdminUI-->>Equipier: PIN incorrect, aucun changement d'etat + else PIN correct + PIN-->>Ctrl: succes, acting user_id capture (RG-T13) + + Note over Equipier,BDD: Phase 3 - Transaction atomique (RG-T08, RG-T11) + + Ctrl->>BDD: BEGIN transaction + Ctrl->>BDD: UPDATE customer_order SET status = 'cancelled', cancelled_at = NOW() WHERE id = :id AND status IN ('pending_payment','paid') (RG-1, RG-T07) + BDD-->>Ctrl: lignes affectees + + alt 0 ligne affectee (annulation concurrente) + Ctrl->>BDD: ROLLBACK + Ctrl-->>AdminUI: 409 INVALID_TRANSITION (ERR-2) + AdminUI-->>Equipier: deja annulee par un autre poste + else 1 ligne affectee + opt statut anterieur = paid (re-credit du stock) + Ctrl->>BDD: UPDATE ingredient SET stock_quantity = stock_quantity + :units (par ingredient consomme) (RG-3) + Ctrl->>BDD: INSERT stock_movement (type cancellation, delta +units, order_id, user_id de l'equipier) (RG-3) + end + Ctrl->>BDD: INSERT audit_log (action_code order.cancel, actor_user_id, actor_role_id, entity_type customer_order, entity_id, summary [statut anterieur + montant re-credite]) (RG-6, RG-T14) + Ctrl->>BDD: COMMIT + BDD-->>Ctrl: transaction validee + Ctrl-->>AdminUI: 200 annulation confirmee (OUT-1) + AdminUI-->>Equipier: commande annulee, trace enregistree + end + end + end +``` + +--- + +## 4. Notes de modelisation : chaque pas et sa regle + +Le tableau ci-dessous mappe chaque interaction du diagramme a la regle +`mlt.md` 7.1 ou a la regle transverse correspondante, et a l'entite ecrite. + +| # | Interaction | Regle (mlt.md) | Entite ecrite / lue | +|---|---|---|---| +| 1 | Verifier session active + `is_active = 1` | `RG-T02` | `user` (lecture) | +| 2 | Verifier `order.cancel` via `role_permission` | `RG-T03`, 7.1 PRE-1 | `role_permission` (lecture) | +| 3 | Charger la commande et lire son `status` | 7.1 PRE-2, PRE-3 | `customer_order` (lecture) | +| 4 | Bloquer si `status` est `delivered` ou `cancelled` | 7.1 ERR-1 | aucune ecriture (HTTP 422) | +| 5 | Demander + verifier le PIN (`password_verify` argon2id) | `RG-T13`, 7.1 RG-6 | `user.pin_hash` (lecture) | +| 6 | Rejeter si PIN incorrect, sans changement d'etat | `RG-T13` | aucune ecriture | +| 7 | Capturer l'`acting user_id` pour l'audit | `RG-T13` | (en memoire, sert aux pas 11-12) | +| 8 | `BEGIN` transaction | `RG-T08` | transaction | +| 9 | `UPDATE customer_order SET status='cancelled'` avec garde `AND status IN (...)` | 7.1 RG-1, `RG-T07` | `customer_order` (ecriture) | +| 10 | `ROLLBACK` + 409 si 0 ligne affectee (concurrence) | 7.1 ERR-2, `RG-T07` | aucune ecriture nette | +| 11 | Re-credit conditionnel du stock si statut anterieur `paid` | 7.1 RG-3, `RG-T11` | `ingredient`, `stock_movement` (type `cancellation`) | +| 12 | `INSERT audit_log` dans la meme transaction | 7.1 RG-6, `RG-T14` | `audit_log` (ecriture) | +| 13 | `COMMIT` (tout ou rien) | `RG-T08`, `RG-T11` | transaction | +| 14 | Reponse 200 de confirmation | 7.1 OUT-1 | aucune ecriture | + +### 4.1 Re-credit conditionnel du stock (`RG-T11`) + +Le re-credit ne s'applique que si la commande etait au statut `paid` avant +l'annulation (7.1 RG-3). Une commande `pending_payment` n'avait pas encore +decremente le stock (le decrement a lieu a la transition `paid`), donc il n'y a +rien a re-crediter. Pour chaque `order_item` d'une commande `paid`, les unites +consommees sont recalculees (format `normal`/`maxi`, ajustees par les +`order_item_modifier`), `ingredient.stock_quantity` est re-incremente et un +`stock_movement` de type `cancellation` est insere. `RG-T11` garantit que ce +re-credit et l'`UPDATE` du statut sont dans la **meme transaction** : il n'y a pas +de decrement orphelin si une etape echoue. + +### 4.2 Garde de concurrence (`RG-T07`) + +L'`UPDATE` porte la clause `AND status IN ('pending_payment','paid')`. Si deux +postes tentent d'annuler la meme commande au meme instant, seul le premier +obtient une ligne affectee ; le second recoit 0 ligne et le controleur repond +409 `INVALID_TRANSITION` apres `ROLLBACK` (7.1 ERR-2). Cette garde optimiste +reduit le risque d'une double annulation et d'un double re-credit du stock. + +### 4.3 PIN distinct de la session (`RG-T13`) + +La session reste **partagee par poste** pour le flux routine. Le PIN est verifie +a chaque action du sous-ensemble sensible (annulation, prix/VAT, RBAC, gestion +utilisateur, correction d'inventaire), et c'est lui qui fournit l'`actor_user_id` +ecrit dans `audit_log`. Le `pin_hash` est un hash argon2id (`dictionary.md` 3.14), +compare via `password_verify` ; il fait partie des champs RESTRICTED tenus hors +des logs et des reponses API. + +--- + +## 5. Menace adressee : repudiation et detournement d'especes + +L'annulation d'une commande `paid` est le geste qui permet le schema de fraude +"encaisser puis annuler pour garder le cash" (insider cash-skim). Sans controle, +sur un poste a session partagee, une annulation ne serait rattachee a personne : +l'auteur pourrait nier l'avoir faite (repudiation). Le flux ci-dessus reduit le +risque de ce schema en combinant deux mecanismes concrets : + +- **PIN par equipier (`RG-T13`)** : l'annulation exige une re-authentification + individuelle. Sur un poste partage, cela rattache l'acte a une personne et non + au seul poste. Le PIN tend a dissuader l'usage opportuniste d'une session + laissee ouverte par un collegue. +- **`audit_log` immuable (`RG-T14`)** : chaque annulation ecrit une ligne + `audit_log` (`action_code = order.cancel`, `actor_user_id`, `actor_role_id`, + `entity_type`, `entity_id`, `summary` avec le statut anterieur et le montant + re-credite) dans la **meme transaction** que l'`UPDATE` du statut. La table + n'accepte ni `UPDATE` ni `DELETE` au niveau applicatif (`dictionary.md` 3.20). + Une annulation ne peut donc pas exister sans sa trace, et la trace ne peut pas + etre effacee par l'auteur. + +L'effet combine : un pic d'annulations rattachees a un meme `actor_user_id` +devient visible et opposable lors d'une revue. Ceci ne supprime pas le risque, +mais le **reduit** en transformant un acte anonyme et niable en un acte attribue +et trace. La residualite (collusion, partage de PIN) releve de controles +organisationnels hors du modele de donnees. + +> Note : `audit_log` enregistre des **noms de champs** et un `summary` +> non-personnel (`details` stocke les noms de champs modifies, pas de PII), +> conformement a `RG-T14` et a la classification de `PROJECT_CONTEXT.md` section 19. +> L'attribution `stock_movement.user_id` du re-credit complete la trace cote stock +> sans double journalisation. + +--- + +## 6. Coherence avec les autres livrables + +| Verification | Resultat | +|---|---| +| Statuts annulables coherents avec `state-commande.md` | Oui : `pending_payment` et `paid` (T3, T5) ; `delivered` non annulable (7.1 ERR-1) | +| Transition `paid -> cancelled` avec re-credit | Couverte par T5 et 7.1 RG-3 (`stock_movement` type `cancellation`) | +| Entites ecrites presentes au dictionnaire | `customer_order` (3.10), `ingredient`, `stock_movement`, `audit_log` (3.20) | +| Regle PIN appliquee | `RG-T13` (sous-ensemble sensible inclut 7.1) ; `user.pin_hash` (3.14) | +| Regle audit appliquee | `RG-T14` ; colonnes conformes a `audit_log` (3.20) | +| Atomicite re-credit + statut + audit | `RG-T08` + `RG-T11` (une transaction, `COMMIT` / `ROLLBACK`) | +| Codes d'erreur | 422 `CANNOT_CANCEL_IN_STATE` (ERR-1), 409 `INVALID_TRANSITION` (ERR-2) | + +--- + +## 7. Arbitrage tranche + +Le flux retient la re-authentification **par PIN** plutot qu'une re-saisie du mot +de passe complet : le PIN couvre le sous-ensemble sensible sans casser le routine +95% a session partagee (`RG-T13`), tout en fournissant l'attribution +individuelle. L'`audit_log` est ecrit dans la **meme transaction** que l'effet +(`RG-T14` + `RG-T08`) : une annulation sans trace ne peut pas etre committee. Le +re-credit du stock est conditionnel au statut anterieur `paid` (`RG-T11`), ce qui +ecarte un re-credit indu sur une commande `pending_payment` qui n'a pas ete +decrementee. Les codes d'erreur (`CANNOT_CANCEL_IN_STATE`, `INVALID_TRANSITION`) +reprennent ceux de `mlt.md` 7.1, sans en inventer de nouveaux. diff --git a/docs/uml/sequence-passer-commande.md b/docs/uml/sequence-passer-commande.md index 3b00cfe..9ffd0a8 100644 --- a/docs/uml/sequence-passer-commande.md +++ b/docs/uml/sequence-passer-commande.md @@ -1,8 +1,8 @@ # Diagramme de sequence - Passer une commande (borne client) **Phase UML** : P1 - Conception, complement UML (apres MCD) -**Statut** : v0.1 -**Date** : 2026-05-21 +**Statut** : v0.2 - prod-like, creation atomique (create + pay) +**Date** : 2026-06-11 **Branche** : `feat/p1-conception` **Auteur methodologie** : BYAN @@ -12,18 +12,24 @@ Ce document decrit le **flux temporel** du parcours "passer une commande" cote **Client sur la borne kiosk** : navigation dans les categories, selection d'un -produit ou composition d'un menu, gestion du panier, validation avec saisie du -numero de retrait, paiement, puis confirmation. +produit ou composition d'un menu (slots + format Normal/Maxi + modifiers +d'ingredients), gestion du panier, validation avec saisie du numero de retrait, +et confirmation. Dans le modele v0.2, la **creation et le paiement sont +atomiques** : un seul appel `POST /api/orders` cree la commande, la fait passer a +`paid`, decremente le stock et journalise les mouvements, dans une meme +transaction. -Le diagramme reste au niveau **conceptuel / logique**. Il nomme les echanges -entre participants sans detailler l'implementation PHP (controllers, models) -ni le SQL exact. Il complete le cas d'utilisation "Passer une commande" de -`docs/uml/use-cases.md` et la machine a etats de `docs/uml/state-commande.md`. +Le diagramme reste au niveau conceptuel / logique. Il nomme les echanges entre +participants sans detailler l'implementation PHP ni le SQL exact. Il complete le +cas d'utilisation "Passer une commande" de `docs/uml/use-cases.md` (4.1), la +machine a etats de `docs/uml/state-commande.md` (T1/T2) et l'operation +`CREATE_ORDER` du `docs/merise/mct.md` (3.3). **Sources** : - `docs/PROJECT_CONTEXT.md` section 2 (processus metier), section 7 (endpoints API) -- `docs/merise/dictionary.md` (`commande`, `ligne_commande`, `menu`, `produit`) -- `docs/uml/state-commande.md` (transitions `pending_payment -> paid`) +- `docs/merise/dictionary.md` 3.10-3.13 (`customer_order`, `order_item`, `order_item_selection`, `order_item_modifier`) +- `docs/merise/mct.md` 3.3 CREATE_ORDER (transaction, snapshots, decrement stock) +- `docs/uml/state-commande.md` (transitions T1/T2, atomicite `pending_payment -> paid`) --- @@ -71,16 +77,18 @@ sequenceDiagram alt Produit a la carte Client->>Borne: selectionner un produit - Client->>Borne: regler taille / options + opt Personnaliser les ingredients + Client->>Borne: retirer / ajouter un ingredient + end Borne->>Borne: ajouter la ligne au panier local else Composition d'un menu Client->>Borne: selectionner un menu - Borne->>API: GET /api/menus (composition du menu) - API->>BDD: lire menu et composition - BDD-->>API: menu + produits par role + Borne->>API: GET /api/menus (slots + options eligibles) + API->>BDD: lire menu, menu_slot, menu_slot_option + BDD-->>API: menu + slots + options API-->>Borne: composition (JSON) - Borne-->>Client: afficher les choix par slot (burger, accompagnement, boisson, sauce) - Client->>Borne: choisir chaque composant + tailles + Borne-->>Client: afficher les slots (boisson, accompagnement, sauce) + format Normal/Maxi + Client->>Borne: choisir chaque slot + format + modifiers du burger Borne->>Borne: ajouter la ligne menu au panier local end @@ -94,36 +102,35 @@ sequenceDiagram Borne-->>Client: panier mis a jour end - Note over Client,BDD: Phase 4 - Validation du panier et saisie du numero + Note over Client,BDD: Phase 4 - Validation, saisie du numero, creation atomique (create + pay) Client->>Borne: valider la commande Client->>Borne: saisir le numero de retrait - Borne->>Borne: valider le panier (au moins 1 ligne) - Borne->>API: POST /api/orders (lignes + mode_consommation + numero) + Borne->>Borne: valider le panier (au moins 1 ligne, numero non vide) + Borne->>API: POST /api/orders (lignes + selections + modifiers + service_mode + numero) - API->>API: recalculer les totaux cote serveur - API->>BDD: creer la commande (statut pending_payment) - API->>BDD: creer les lignes (snapshot libelle + prix) - BDD-->>API: commande persistee {id, numero, statut: pending_payment} - API-->>Borne: 201 Created {id, numero, statut: pending_payment, total} - Borne-->>Client: afficher le total a regler + API->>API: recalculer les totaux cote serveur (HT / TVA / TTC, taux par produit) + API->>BDD: BEGIN transaction + API->>BDD: INSERT customer_order (status pending_payment, source kiosk) + API->>BDD: INSERT order_item (snapshot libelle + prix + vat_rate) + API->>BDD: INSERT order_item_selection (par slot de menu rempli) + API->>BDD: INSERT order_item_modifier (par modification d'ingredient) + API->>BDD: UPDATE ingredient.stock_quantity (decrement, ajuste par modifiers) + API->>BDD: INSERT stock_movement (type sale, par unite consommee) + API->>BDD: UPDATE customer_order status -> paid, paid_at = NOW() + API->>BDD: COMMIT + BDD-->>API: commande persistee {id, order_number, status: paid} - Note over Client,BDD: Phase 5 - Paiement (pending_payment -> paid) + Note over Client,BDD: Phase 5 - Confirmation - Client->>Borne: payer la commande - Borne->>API: POST /api/orders/{id}/pay - API->>BDD: enregistrer le paiement, passer la commande a paid (paye_a) - BDD-->>API: commande mise a jour {id, numero, statut: paid} - - Note over Client,BDD: Phase 6 - Confirmation - - API-->>Borne: 200 OK {id, numero, statut: paid} - Borne-->>Client: ecran de confirmation avec le numero + API-->>Borne: 201 Created {id, order_number, status: paid, total_ttc} + Borne-->>Client: ecran de confirmation avec le numero de retrait Note over Client,BDD: Cas d'erreur - alt Panier vide ou donnees invalides - API-->>Borne: 4xx {error: code, message} + alt Panier vide, produit indisponible ou donnees invalides + API->>BDD: ROLLBACK (si transaction entamee) + API-->>Borne: 4xx {error: {code, message}} Borne-->>Client: message d'erreur, retour au panier end ``` @@ -132,27 +139,31 @@ sequenceDiagram ## 4. Notes de modelisation -### 4.1 Recalcul des totaux cote serveur +### 4.1 Recalcul des totaux cote serveur (controle de securite) La Borne affiche un total **provisoire** calcule localement pour l'experience utilisateur. L'API recalcule les totaux a la reception du `POST /api/orders` a -partir des prix en base, puis fige les snapshots -(`prix_unitaire_ttc_cents_snapshot`, `libelle_snapshot` dans `ligne_commande`, -voir `dictionary.md` 3.6). Le total affiche par le client n'est pas considere -comme la source de verite : ceci limite la falsification du prix cote client. +partir des prix en base (HT, TVA ligne par ligne via `vat_rate`, TTC), puis fige +les snapshots (`unit_price_cents_snapshot`, `vat_rate_snapshot`, +`label_snapshot` sur `order_item`, voir `dictionary.md` 3.11). Le total affiche +par le client n'est pas considere comme la source de verite : ceci limite la +falsification du prix cote client. -### 4.2 Transitions de statut +### 4.2 Creation atomique (create + pay) Le parcours materialise les transitions T1 et T2 de -`docs/uml/state-commande.md`, en deux phases successives conformes a la regle -metier : +`docs/uml/state-commande.md` dans **un seul appel et une seule transaction** : -- `POST /api/orders` cree la commande composee en `pending_payment` (T1). -- `POST /api/orders/{id}/pay` enregistre le paiement et fait passer la commande - a `paid` (T2), avec l'horodatage `paye_a`. +- `POST /api/orders` cree la commande en `pending_payment` (T1) puis la fait + passer a `paid` (T2) avant le `COMMIT`. `paid_at` est renseigne. +- La saisie du numero de retrait tient lieu de paiement (cadre RNCP) ; il n'y a + pas d'appel `POST /api/orders/{id}/pay` separe (supprime par rapport au v0.1). +- Le decrement du stock (`ingredient.stock_quantity`) et la journalisation + (`stock_movement` type `sale`) sont inclus dans la meme transaction que + l'insert de la commande : soit tout reussit, soit tout est annule (`ROLLBACK`). -La separation des deux appels reflete les deux phases du cycle de vie : -composer la commande, puis la payer. +Le statut `pending_payment` n'est donc pas observable en dehors de la +transaction (coherent avec `mct.md` section 13). ### 4.3 Panier local jusqu'a la validation @@ -166,9 +177,20 @@ navigateur peut etre envisage plus tard. `PROJECT_CONTEXT.md` section 4 prevoit un mode de repli ou la Borne lit des fichiers JSON statiques si l'API est indisponible. Ce mode concerne uniquement -les lectures (phases 1 a 2). La validation (phase 4) et le paiement (phase 5) -requierent l'API ; sans elle, la commande n'est ni persistee ni payee. Ce cas -degrade n'est pas detaille dans le diagramme nominal ci-dessus. +les lectures (phases 1 a 2). La validation et la creation (phase 4) requierent +l'API ; sans elle, la commande n'est ni persistee ni payee. Ce cas degrade +n'est pas detaille dans le diagramme nominal ci-dessus. + +### 4.5 Garde-fous securite a venir (passe security-by-design) + +Le flux ci-dessus est la cible **fonctionnelle** v0.2. La passe security-by-design +ajoutera, en complement (append, sans reecrire ce flux), des garde-fous sur le +`POST /api/orders` anonyme : cle d'idempotence (`idempotency_key` UNIQUE pour +dedupliquer les POST rejoues), limitation de debit / anti-spam, et verrou +pessimiste `SELECT ... FOR UPDATE` sur les ingredients pendant le decrement +(anti-oversell multi-bornes). Ces ajouts dependent de decisions encore a +trancher (oversell/idempotence, throttling) et seront documentes dans un artefact +`docs/uml/security-sequence.md` dedie. --- @@ -176,18 +198,21 @@ degrade n'est pas detaille dans le diagramme nominal ci-dessus. | Verification | Resultat | |---|---| -| Endpoints utilises existent dans `PROJECT_CONTEXT.md` section 7 | `GET /api/categories`, `GET /api/products`, `GET /api/menus`, `POST /api/orders` ; `POST /api/orders/{id}/pay` est a confirmer en section 7 du brief | -| Entites manipulees presentes au MCD | Oui : `categorie`, `produit`, `menu`, `menu_produit`, `commande`, `ligne_commande` | -| Statuts utilises coherents avec `state-commande.md` | Oui : `pending_payment` puis `paid` (T1, T2), valeurs ENUM anglaises | -| Format de reponse JSON | Coherent avec `PROJECT_CONTEXT.md` section 7 (`{data, error}`) et la reponse `{id, number, status}` du POST orders | +| Endpoints utilises existent dans `PROJECT_CONTEXT.md` section 7 | `GET /api/categories`, `GET /api/products`, `GET /api/menus`, `POST /api/orders` ; l'appel `POST /api/orders/{id}/pay` du v0.1 est supprime (creation atomique) | +| Entites manipulees presentes au MCD / dictionnaire | Oui : `category`, `product`, `menu`, `menu_slot`, `menu_slot_option`, `ingredient`, `customer_order`, `order_item`, `order_item_selection`, `order_item_modifier`, `stock_movement` | +| Statuts utilises coherents avec `state-commande.md` | Oui : `pending_payment` puis `paid` (T1, T2), atomiques | +| Operation MCT correspondante | `mct.md` 3.3 CREATE_ORDER (transaction unique, snapshots, decrement stock, transition atomique) | +| Format de reponse JSON | Coherent avec `PROJECT_CONTEXT.md` section 7 (`{data, error}`) et la reponse `{id, order_number, status, total_ttc}` du POST orders | --- ## 6. Arbitrage tranche -La phase de paiement est integree au flux conformement a la regle metier des -deux phases (composer puis payer). La sequence suit la machine canonique de -`state-commande.md` : creation en `pending_payment` (T1) puis paiement vers -`paid` (T2), avec des valeurs ENUM en anglais. Point a confirmer au MCT : -l'endpoint de paiement (`POST /api/orders/{id}/pay`) doit etre reporte dans la -section 7 du brief s'il n'y figure pas encore. +La phase de paiement separee du v0.1 (`POST /api/orders/{id}/pay`) est supprimee : +la creation et le passage a `paid` sont atomiques dans `POST /api/orders`, +conformement au MCT v0.2 (3.3) et a la regle metier (saisie du numero = substitut +de paiement). Le decrement de stock et la journalisation `stock_movement` sont +inclus dans la meme transaction, garantissant la coherence stock/commande. Les +valeurs ENUM sont en anglais (`pending_payment`, `paid`). Les garde-fous de +securite (idempotence, rate-limit, verrou pessimiste) relevent de la passe +security-by-design et seront ajoutes en complement (section 4.5). diff --git a/docs/uml/state-commande.md b/docs/uml/state-commande.md index a99f309..55c38cd 100644 --- a/docs/uml/state-commande.md +++ b/docs/uml/state-commande.md @@ -1,8 +1,8 @@ # Diagramme d'etats-transitions - Commande **Phase UML** : P1 - Conception, complement UML (apres MCD) -**Statut** : v0.1 -**Date** : 2026-05-21 +**Statut** : v0.2 - prod-like, machine a 4 etats +**Date** : 2026-06-11 **Branche** : `feat/p1-conception` **Auteur methodologie** : BYAN @@ -10,38 +10,41 @@ ## 1. Objet du document -Ce document formalise la **machine a etats** de l'attribut `commande.statut`. +Ce document formalise la **machine a etats** de l'attribut `customer_order.status`. Il decrit les etats possibles d'une commande, les transitions autorisees entre ces etats, les **evenements** qui les declenchent et les **gardes** (conditions) qui les conditionnent. -Il complete le MCD (`docs/merise/mcd.md` section 9, qui esquisse le cycle de -vie) et le dictionnaire (`docs/merise/dictionary.md` 3.5, qui declare l'ENUM). +Il complete le MCD (`docs/merise/mcd.md`, cycle de vie de la commande), le +dictionnaire (`docs/merise/dictionary.md` 3.10, qui declare l'ENUM `status`) et +le MCT (`docs/merise/mct.md` section 13, qui resume les transitions par +operation). --- ## 2. Source de verite et regle metier -La regle metier confirmee fixe deux phases successives dans le cycle de vie -d'une commande : le client **compose** sa commande, **puis** il **paie**. Une -fois payee, la commande entre en preparation. Le paiement fait partie integrante -du cycle. Les valeurs d'etat sont en anglais et alignees sur l'ENUM du -dictionnaire. +Le modele v0.2 (prod-like) reduit la machine a **quatre etats**. La regle metier +distingue la **composition payee** de la **remise** : une commande est creee et +payee en une operation atomique (la saisie du numero de retrait tient lieu de +paiement dans le cadre RNCP), puis elle est remise au client en un geste unique. | Source | Valeurs de statut | |---|---| -| `dictionary.md` 3.5 (ENUM SQL) | `pending_payment`, `paid`, `preparing`, `ready`, `delivered`, `cancelled` | -| Regle metier confirmee | composer -> payer -> preparer -> pret -> remettre | +| `dictionary.md` 3.10 (ENUM SQL) | `pending_payment`, `paid`, `delivered`, `cancelled` | +| `mct.md` section 13 (transitions) | creer+payer -> remettre, annulation depuis tout etat non terminal | -**Machine a etats canonique** : la machine ci-dessous est la seule autorisee. -Elle suit l'ENUM du dictionnaire et la regle metier des deux phases : +> Le dictionnaire (`dictionary.md` 3.10) et la machine ci-dessous partagent la +> meme ENUM a 4 valeurs, ce qui maintient la coherence entre le modele de +> donnees et le modele d'etats (cross-validation, mantra #34). -- `pending_payment` : commande composee, en attente de paiement. -- `paid` : paiement effectue ; la commande peut entrer en file de preparation. - -> Le dictionnaire (`dictionary.md` 3.5) et la machine ci-dessous partagent la -> meme ENUM, ce qui maintient la coherence entre le modele de donnees et le -> modele d'etats (cross-validation, mantra #34). +**Etats supprimes par rapport au v0.1** : `preparing` et `ready`. En contexte +fast-food, l'affichage cuisine (KDS) est un dispositif visuel : l'equipier lit +le ticket et agit. Ces deux etats intermediaires ajoutaient des transitions sans +valeur metier proportionnelle. La cuisine est en **lecture seule** ; la remise +(`DELIVER_ORDER`) est le geste unique qui fait avancer le statut. Le KPI est le +temps total `delivered_at - paid_at` (SLA ~10 min) ; la couleur du KDS est +calculee a l'affichage depuis `now - paid_at`, sans etat stocke supplementaire. --- @@ -49,12 +52,16 @@ Elle suit l'ENUM du dictionnaire et la regle metier des deux phases : | Etat | Valeur ENUM | Signification | Acteur qui declenche l'entree | |---|---|---|---| -| En attente de paiement | `pending_payment` | Commande composee, panier fige, en attente de paiement. | Client (kiosk) ou Accueil (counter/drive) | -| Payee | `paid` | Paiement effectue ; la commande peut entrer en file de preparation. | Client (paiement) ou Accueil | -| En preparation | `preparing` | Prise en charge par la Preparation, en cuisine. | Preparation | -| Prete | `ready` | Preparation terminee, prete au comptoir. | Preparation | -| Livree | `delivered` | Remise effectuee au client. Etat **final**. | Accueil | -| Annulee | `cancelled` | Commande abandonnee ou annulee. Etat **final**. | Client, Accueil ou Administration | +| En attente de paiement | `pending_payment` | Etat initial transitoire : commande composee, en attente de paiement. Non observable hors transaction (voir section 7). | Client (kiosk) ou Counter/Drive (back-office) | +| Payee | `paid` | Paiement effectue ; la commande entre en file de preparation (lecture seule cuisine). | Client (kiosk) ou Counter/Drive | +| Livree | `delivered` | Remise effectuee au client. Etat **final**. | Counter ou Drive | +| Annulee | `cancelled` | Commande abandonnee ou annulee avant remise. Etat **final**. | Counter, Drive ou Admin | + +`pending_payment` est l'etat par defaut a l'INSERT (`dictionary.md` 3.10), mais +la transition vers `paid` est realisee dans la **meme transaction** que la +creation (operations `CREATE_ORDER` / `CREATE_COUNTER_ORDER` du MCT). Il est donc +conserve dans l'ENUM pour la lisibilite du cycle et pour laisser la porte +ouverte a un paiement reel ulterieur sans migration destructive. --- @@ -62,19 +69,13 @@ Elle suit l'ENUM du dictionnaire et la regle metier des deux phases : ```mermaid stateDiagram-v2 - [*] --> pending_payment : creer commande (kiosk / counter / drive) + [*] --> pending_payment : creer la commande (kiosk / counter / drive) - pending_payment --> paid : payer\n[panier contient au moins 1 ligne] - pending_payment --> cancelled : abandonner\n[avant paiement] + pending_payment --> paid : payer\n[atomique dans CREATE_ORDER / CREATE_COUNTER_ORDER\nsaisie du numero = substitut de paiement] + pending_payment --> cancelled : annuler\n[avant remise] - paid --> preparing : prendre en charge\n[acteur Preparation, file triee par heure croissante] - paid --> cancelled : annuler\n[Accueil ou Administration] - - preparing --> ready : declarer preparee\n[acteur Preparation] - preparing --> cancelled : annuler\n[rupture produit / decision Administration] - - ready --> delivered : remettre au client\n[acteur Accueil] - ready --> cancelled : annuler\n[client absent / non recuperee] + paid --> delivered : remettre au client\n[acteur Counter / Drive, geste unique] + paid --> cancelled : annuler\n[Counter / Drive / Admin, re-credit du stock] delivered --> [*] cancelled --> [*] @@ -86,29 +87,29 @@ stateDiagram-v2 | # | De | Vers | Evenement declencheur | Garde (condition) | Acteur | |---|---|---|---|---|---| -| T1 | (initial) | `pending_payment` | Creation de la commande composee | Au moins un item ajoute au panier en cours | Client / Accueil | -| T2 | `pending_payment` | `paid` | Paiement de la commande | La commande contient au moins une `ligne_commande` ; le paiement aboutit | Client / Accueil | -| T3 | `pending_payment` | `cancelled` | Abandon avant paiement | Commande pas encore payee | Client / Accueil | -| T4 | `paid` | `preparing` | Prise en charge en file | La commande est la plus ancienne non traitee (tri par heure de livraison croissante) | Preparation | -| T5 | `paid` | `cancelled` | Annulation avant preparation | Decision operationnelle | Accueil / Administration | -| T6 | `preparing` | `ready` | Declaration "preparee" | Preparation terminee | Preparation | -| T7 | `preparing` | `cancelled` | Annulation pendant preparation | Rupture produit ou decision Administration | Preparation / Administration | -| T8 | `ready` | `delivered` | Remise physique au client | Le client se presente avec le bon numero | Accueil | -| T9 | `ready` | `cancelled` | Annulation apres preparation | Client non present / commande non recuperee | Accueil / Administration | +| T1 | (initial) | `pending_payment` | Creation de la commande composee | Au moins une ligne (`order_item`) ; numero de retrait non vide | Client / Counter / Drive | +| T2 | `pending_payment` | `paid` | Paiement (atomique a la creation) | La commande contient au moins une `order_item` ; decrement du stock et insert dans la meme transaction | Client / Counter / Drive | +| T3 | `pending_payment` | `cancelled` | Abandon avant paiement | Commande pas encore payee | Counter / Drive / Admin | +| T4 | `paid` | `delivered` | Remise physique au client | La commande est `paid` ; l'acteur detient `order.deliver` ; source compatible avec son role (`role_visible_source`) | Counter / Drive | +| T5 | `paid` | `cancelled` | Annulation avant remise | L'acteur detient `order.cancel` ; le stock consomme est re-credite (`stock_movement` type `cancellation`) | Counter / Drive / Admin | ### Invariants de la machine a etats - `delivered` et `cancelled` sont des etats **finaux** : aucune transition n'en sort. -- Aucune transition ne revient en arriere (pas de `preparing -> paid`). Une - erreur operationnelle se traite par annulation puis nouvelle commande, pour - preserver l'integrite de l'historique et des snapshots de prix. -- La transition vers `cancelled` est possible depuis tous les etats **sauf** - `delivered` (une commande remise ne s'annule pas dans ce modele). Ceci est - coherent avec `mcd.md` section 9 : "Annuler : transition vers `cancelled` - (depuis tout statut sauf `delivered`)". -- `paye_a` (DATETIME, `dictionary.md` 3.5) est renseigne au moment de la - transition T2 (`pending_payment -> paid`) et reste NULL avant. +- Aucune transition ne revient en arriere. Une erreur operationnelle se traite + par annulation puis nouvelle commande, pour preserver l'integrite de + l'historique et des snapshots (`label_snapshot`, `unit_price_cents_snapshot`, + `vat_rate_snapshot` sur `order_item`). +- La transition vers `cancelled` est possible depuis `pending_payment` et + `paid`, mais pas depuis `delivered` (une commande remise ne s'annule pas dans + ce modele). Coherent avec `mct.md` 7.1 (`CANCEL_ORDER`). +- `paid_at` (DATETIME, `dictionary.md` 3.10) est renseigne a la transition T2. + `delivered_at` est renseigne a T4. `cancelled_at` est renseigne a T3/T5. Les + trois colonnes sont NULL tant que la transition correspondante n'a pas eu lieu. +- La cuisine (`kitchen`) ne declenche aucune transition : son acces a la file + des commandes `paid` est en **lecture seule** (`mct.md` 5.1, + `LIST_ORDERS_DISPLAY`). --- @@ -116,29 +117,36 @@ stateDiagram-v2 | Verification | Resultat | |---|---| -| Tous les etats du diagramme existent dans l'ENUM `dictionary.md` 3.5 | Oui (6 valeurs, toutes utilisees) | -| La regle "annulation possible sauf depuis delivered" de `mcd.md` 9 | Respectee (T5, T7, T9 ; pas de transition depuis `delivered`) | -| Cycle de vie esquisse dans `mcd.md` 9 | Couvert : `pending_payment` -> `paid` (payer), `paid` -> `preparing` (preparer), `preparing` -> `ready` (marquer pret), `ready` -> `delivered` (remettre) | -| Acteurs de `use-cases.md` | Preparation declenche T4/T6/T7 ; Accueil declenche T8/T9 ; Administration peut annuler | +| Tous les etats du diagramme existent dans l'ENUM `dictionary.md` 3.10 | Oui (4 valeurs, toutes utilisees) | +| La regle "annulation possible sauf depuis delivered" de `mct.md` 7.1 | Respectee (T3, T5 ; pas de transition depuis `delivered`) | +| Transition `paid -> delivered` en geste unique de `mct.md` 6.1 | Couvert par T4 (`DELIVER_ORDER`) | +| Atomicite `pending_payment -> paid` de `mct.md` 3.3 / 4.1 / section 13 | Couvert par T2 (saisie du numero = substitut de paiement) | +| Acteurs de `use-cases.md` | Counter/Drive declenchent T4/T5 ; Admin peut annuler (T3/T5) ; Kitchen en lecture seule | +| Timestamps de phase `paid_at` / `delivered_at` / `cancelled_at` | Renseignes a T2 / T4 / T3-T5 | --- ## 7. Arbitrage tranche -La divergence historique entre l'ENUM du dictionnaire et un parcours sans -paiement est resolue par la regle metier confirmee : le cycle de vie comporte -deux phases successives, la composition de la commande puis son paiement. Le -paiement fait partie integrante du cycle. - -La machine canonique retenue est donc : +La machine retenue est reduite a quatre etats (Decision 4, +`docs/notes/revue-alignement-p1.md` section 7) : ``` -pending_payment -> paid -> preparing -> ready -> delivered - (cancelled atteignable depuis pending_payment, paid, preparing) +pending_payment -> paid -> delivered + | | + +------------+--------> cancelled (depuis pending_payment ou paid) ``` -Cette machine est la source de verite partagee par `dictionary.md` 3.5, -`use-cases.md` (cas "Payer la commande" cote Client) et -`sequence-passer-commande.md` (etape paiement entre validation du panier et -confirmation). La colonne `paye_a` est renseignee a la transition T2. A -revalider lors du MCT. +**Note sur la transition `pending_payment -> paid`** : dans le cadre RNCP, le +paiement est remplace par la saisie du numero de commande (kiosk) ou par la +validation de l'equipier (counter/drive). La transition est **atomique** dans +`CREATE_ORDER` et `CREATE_COUNTER_ORDER` : le statut `pending_payment` n'est pas +observable en dehors de la transaction de creation. Il reste declare dans l'ENUM +pour exprimer le cycle de vie complet et pour autoriser un paiement reel +ulterieur (cout d'une valeur d'ENUM). + +Les etats `preparing` et `ready` du v0.1 sont supprimes : la cuisine est un +affichage visuel en lecture seule, et la remise fusionne preparation+remise en +un geste unique (`DELIVER_ORDER`). Effet de bord propage : les operations +`MARK_IN_PREPARATION` et `MARK_READY` disparaissent du MCT (voir `mct.md` +sections 1 et 13). diff --git a/docs/uml/use-cases.md b/docs/uml/use-cases.md index c9897be..3a615a7 100644 --- a/docs/uml/use-cases.md +++ b/docs/uml/use-cases.md @@ -1,8 +1,8 @@ # Diagramme de cas d'utilisation - Wakdo **Phase UML** : P1 - Conception, complement UML (apres MCD) -**Statut** : v0.1 -**Date** : 2026-05-21 +**Statut** : v0.2 - prod-like, 5 roles RBAC + catalogue de 23 permissions +**Date** : 2026-06-11 **Branche** : `feat/p1-conception` **Auteur methodologie** : BYAN @@ -12,174 +12,247 @@ Ce document recense les **cas d'utilisation** de Wakdo, c'est-a-dire les fonctionnalites observables du systeme du point de vue de ses acteurs. Il -complete le MCD (`docs/merise/mcd.md`) et le dictionnaire -(`docs/merise/dictionary.md`) en passant de la vue **donnees** a la vue -**usages**. +complete le MCD (`docs/merise/mcd.md`), le dictionnaire +(`docs/merise/dictionary.md`) et le MCT (`docs/merise/mct.md`, 26 operations) en +passant de la vue **donnees / traitements** a la vue **usages**. -Le diagramme reste au niveau conceptuel. Il ne prejuge pas de l'ecran ou de -l'endpoint qui realisera chaque cas, mais identifie qui fait quoi. +Le diagramme reste au niveau conceptuel : il identifie qui fait quoi, sans +prejuger de l'ecran ou de l'endpoint qui realise chaque cas. Chaque cas +back-office est rattache a la **permission** qui le conditionne (catalogue fige +de 23 codes, `dictionary.md` 3.17), conformement a la regle RBAC +permission-driven : le code teste une permission, pas un nom de role. **Sources** : -- `docs/PROJECT_CONTEXT.md` sections 2 (acteurs, processus), 7 (scope RBAC) -- `docs/merise/dictionary.md` (entites `commande`, `role`, `user`) +- `docs/PROJECT_CONTEXT.md` sections 2 (acteurs, processus), 7 (scope back-office) +- `docs/merise/dictionary.md` 3.14-3.18 (`user`, `role`, `role_visible_source`, `permission`, `role_permission`) +- `docs/merise/mct.md` (operations, acteurs, permissions par operation) --- ## 2. Acteurs - perimetre et challenge de pertinence -Le brief (`PROJECT_CONTEXT.md` section 2 et section 7) definit les acteurs -metier. Avant de les retenir, chaque acteur propose dans la consigne initiale -est confronte au perimetre reel du projet. +Le brief initial (`PROJECT_CONTEXT.md` section 2) decrivait quatre acteurs +metier (Client, Accueil, Preparation, Administration) adosses a 3 roles RBAC. Le +modele v0.2 (prod-like, Decision 4 de `revue-alignement-p1.md` section 7) raffine +le back-office en **5 roles** pour coller a l'organisation reelle d'un fast-food +multi-canal. Chaque acteur candidat est confronte au perimetre reel. -| Acteur candidat | Statut | Justification (perimetre reel) | +| Acteur candidat (brief) | Statut v0.2 | Justification (perimetre reel) | |---|---|---| -| **Client (borne kiosk)** | Retenu | Acteur central du Bloc 1. Compose et valide une commande sur la borne tactile autonome (canal `kiosk`). Non authentifie. | -| **Manager / Admin** | Retenu, fusionne en **Administration** | Le brief ne distingue pas "manager" et "admin" comme deux roles. Le role RBAC reel est `admin` (section 7). Il porte le CRUD catalogue, la gestion des utilisateurs/roles et les stats. On nomme l'acteur **Administration** pour coller au vocabulaire du brief. | -| **Cuisine** | Retenu, renomme **Preparation** | Correspond au role RBAC `preparation` (section 7). Voit la file des commandes a preparer triees par heure de livraison croissante et fait avancer leur statut. Le terme "Cuisine" est un synonyme metier ; le role technique est `preparation`. | -| **Caisse** | Ecarte comme acteur distinct | Challenge : il n'existe pas de role RBAC `caisse` (les 3 roles sont `admin`, `preparation`, `accueil`). Le paiement existe dans le cycle (cote Client sur la borne et cote Accueil au comptoir/drive), mais aucun acteur "Caisse" dedie n'est modelise. L'equivalent operationnel le plus proche est l'**Accueil** (role `accueil`) qui saisit les commandes au comptoir/drive et remet les commandes livrees. | -| **Accueil** | Retenu (non liste dans la consigne mais present au brief) | Role RBAC `accueil`. Saisit les commandes au comptoir (canal `counter`) ou au drive (canal `drive`), puis remet les commandes au client (passage a `delivered`). C'est l'acteur qui recouvre le besoin que la consigne attribuait a "Caisse". | +| **Client (borne kiosk)** | Retenu (acteur `CUSTOMER`) | Acteur central du Bloc 1. Compose et valide une commande sur la borne tactile autonome (canal `kiosk`). **Non authentifie**. | +| **Accueil** | **Scinde** en `counter` et `drive` | Le besoin "Accueil" recouvre deux canaux operationnels distincts : le comptoir (`counter`) et le drive (`drive`). Le v0.2 les separe car le tag `source` de la commande et le filtre de dashboard (`role_visible_source`) different. Tous deux saisissent des commandes, les remettent et les annulent. | +| **Preparation** | Retenu, renomme `kitchen` | Role RBAC `kitchen`. Voit la file des commandes `paid` triees par `paid_at` croissant. **Lecture seule** : ne declenche aucune transition de statut (le KDS est un dispositif visuel ; la remise revient a `counter`/`drive`). | +| **Administration** | **Scinde** en `admin` et `manager` | Le v0.1 fusionnait "Manager/Admin". Le v0.2 distingue : `admin` (gestion des utilisateurs, des roles et permissions, suppressions catalogue) et `manager` (catalogue create/update, stock/reappro, stats), sans acces aux utilisateurs ni au RBAC. Resout le point ouvert v0.1 "Manager vs Admin". | +| **Caisse** | Ecarte (recouvert par `counter`/`drive`) | Aucun role `caisse` n'existe. L'encaissement est atomique a la creation de commande (saisie du numero = substitut de paiement) ; il est realise par le Client (kiosk) ou par `counter`/`drive` (back-office). Resout le point ouvert v0.1 "Caisse absente du RBAC". | +| **Systeme** | Retenu (acteur `SYS`) | Logique interne (generation du numero, reponse API de confirmation). Apparait dans le MCT (3.4 `DISPLAY_CONFIRMATION`) ; non represente comme acteur humain au diagramme. | ### Decision sur les acteurs retenus -Quatre acteurs sont conserves au diagramme : +Six acteurs sont conserves : un acteur public et cinq roles back-office. -1. **Client** (borne, non authentifie) -2. **Administration** (role `admin`) -3. **Preparation** (role `preparation`, ex-"Cuisine") -4. **Accueil** (role `accueil`, recouvre le besoin "Caisse") +1. **Customer** (borne kiosk, non authentifie) +2. **Admin** (role `admin`) +3. **Manager** (role `manager`) +4. **Kitchen** (role `kitchen`, ex-"Preparation", lecture seule) +5. **Counter** (role `counter`, ex-"Accueil" comptoir) +6. **Drive** (role `drive`, ex-"Accueil" drive) -> Decision actee : il n'y a **pas** de parcours employe dedie modelise a part. -> Les cas d'usage des employes (Administration, Preparation, Accueil) sont -> couverts directement ici. Cette decision suit le mantra du Rasoir d'Ockham -> (#37) : on evite une couche de modelisation redondante tant qu'aucun besoin -> ne la justifie. +> Regle RBAC permission-driven (`dictionary.md` 3.15) : les rattachements +> acteur -> cas ci-dessous refletent la **matrice de permissions par defaut au +> seed**. Le gardien reel est la permission, pas le nom du role : un role +> personnalise (ex. "chef-patissier") dote des bonnes permissions ouvre les +> memes cas, sans changement de code. Les 5 roles seed sont un point de depart, +> pas une liste fermee. --- ## 3. Diagramme de cas d'utilisation -Mermaid ne fournit pas de type `usecase` natif. La representation ci-dessous -utilise un `flowchart` : les acteurs sont des noeuds a gauche, les cas -d'utilisation sont des noeuds arrondis regroupes par sous-systeme, et les -fleches portent les relations (`<>`, `<>`) la ou elles -ont du sens. +Mermaid ne fournit pas de type `usecase` natif. La representation utilise un +`flowchart` : les acteurs sont a gauche, les cas d'utilisation regroupes par +sous-systeme. La permission qui conditionne chaque cas back-office est precisee +en section 4. ```mermaid flowchart LR %% Acteurs - Client(("Client
borne kiosk")) - Admin(("Administration
role admin")) - Prep(("Preparation
role preparation")) - Accueil(("Accueil
role accueil")) + Customer(("Customer
borne kiosk
non authentifie")) + Admin(("Admin
role admin")) + Manager(("Manager
role manager")) + Kitchen(("Kitchen
role kitchen
lecture seule")) + Counter(("Counter
role counter")) + Drive(("Drive
role drive")) %% Sous-systeme Borne client - subgraph BORNE["Borne client - Bloc 1"] + subgraph BORNE["Borne client - Bloc 1 (public)"] UC1(["Consulter le catalogue"]) - UC2(["Composer un menu"]) - UC3(["Passer une commande"]) - UC4(["Saisir le numero de retrait"]) - UC5(["Recevoir la confirmation"]) - UC6(["Payer la commande"]) + UC2(["Composer le panier"]) + UC3(["Consulter les allergenes"]) + UC4(["Passer une commande"]) + UC5(["Saisir le numero de retrait"]) + UC6(["Recevoir la confirmation"]) end - %% Sous-systeme Back-office - subgraph BACK["Back-office - Bloc 2"] - UC10(["Gerer le catalogue
categories, produits, menus"]) - UC11(["Gerer les utilisateurs et roles"]) - UC12(["Consulter les statistiques"]) - UC20(["Consulter la file de preparation"]) - UC21(["Faire avancer une commande"]) - UC30(["Saisir une commande
comptoir ou drive"]) - UC31(["Remettre la commande au client"]) - UC40(["S'authentifier"]) + %% Sous-systeme Operations commande + subgraph OPS["Operations commande - back-office"] + UC10(["Saisir une commande
comptoir / drive"]) + UC11(["Consulter la file de preparation"]) + UC12(["Remettre la commande"]) + UC13(["Annuler une commande"]) end - %% Relations Client - Client --> UC1 - Client --> UC2 - Client --> UC3 - Client --> UC6 - Client --> UC5 + %% Sous-systeme Catalogue + subgraph CAT["Catalogue - back-office"] + UC20(["Gerer produits"]) + UC21(["Gerer menus et slots"]) + UC22(["Gerer categories"]) + UC23(["Gerer ingredients,
compositions, allergenes"]) + end - %% include / extend cote borne - UC3 -. include .-> UC4 - UC3 -. include .-> UC6 + %% Sous-systeme Stock + subgraph STK["Stock - back-office"] + UC30(["Consulter le stock"]) + UC31(["Compter l'inventaire"]) + UC32(["Reapprovisionner"]) + end + + %% Sous-systeme Administration + subgraph ADM["Administration - back-office"] + UC40(["Gerer les utilisateurs"]) + UC41(["Gerer roles et permissions"]) + UC42(["Consulter les statistiques"]) + end + + %% Transverse + UC50(["S'authentifier"]) + UC51(["Se deconnecter"]) + + %% Relations Customer + Customer --> UC1 + Customer --> UC2 + Customer --> UC4 + Customer --> UC6 UC2 -. include .-> UC1 - UC3 -. extend .-> UC2 + UC2 -. extend .-> UC3 + UC4 -. include .-> UC5 + UC4 -. include .-> UC2 - %% Relations Administration + %% Relations Counter / Drive (operations commande + stock) + Counter --> UC10 + Counter --> UC11 + Counter --> UC12 + Counter --> UC13 + Drive --> UC10 + Drive --> UC11 + Drive --> UC12 + Drive --> UC13 + UC10 -. include .-> UC1 + + %% Kitchen (lecture seule) + Kitchen --> UC11 + + %% Stock (kitchen / counter / drive / manager / admin) + Kitchen --> UC30 + Kitchen --> UC31 + Counter --> UC30 + Counter --> UC31 + Drive --> UC30 + Drive --> UC31 + Manager --> UC30 + Manager --> UC31 + Manager --> UC32 + + %% Catalogue (manager + admin) + Manager --> UC20 + Manager --> UC21 + Manager --> UC22 + Manager --> UC23 + Admin --> UC20 + Admin --> UC21 + Admin --> UC22 + Admin --> UC23 + + %% Administration (admin) + stats (manager + admin) Admin --> UC40 - Admin --> UC10 + Admin --> UC41 + Admin --> UC42 + Admin --> UC30 + Admin --> UC31 + Admin --> UC32 Admin --> UC11 - Admin --> UC12 + Admin --> UC13 + Manager --> UC42 - %% Relations Preparation - Prep --> UC40 - Prep --> UC20 - Prep --> UC21 - - %% Relations Accueil - Accueil --> UC40 - Accueil --> UC30 - Accueil --> UC31 - UC30 -. include .-> UC1 - - %% Authentification mutualisee - UC10 -. include .-> UC40 - UC11 -. include .-> UC40 - UC20 -. include .-> UC40 - UC30 -. include .-> UC40 + %% Authentification mutualisee (tout cas back-office) + UC10 -. include .-> UC50 + UC11 -. include .-> UC50 + UC20 -. include .-> UC50 + UC30 -. include .-> UC50 + UC40 -. include .-> UC50 + UC42 -. include .-> UC50 ``` --- ## 4. Description des cas d'utilisation -### 4.1 Acteur Client (borne kiosk) +### 4.1 Acteur Customer (borne kiosk, non authentifie) -| Cas | Description | Entites manipulees | +| Cas | Operation MCT | Description | Entites manipulees | +|---|---|---|---| +| Consulter le catalogue | 3.1 LOAD_CATALOGUE | Parcourir categories, produits et menus disponibles, charges via `GET /api/categories`, `/api/products`, `/api/menus` (ou JSON fallback). | `category`, `product`, `menu`, `menu_slot`, `menu_slot_option` | +| Composer le panier | 3.2 COMPOSE_CART | Ajouter produits a la carte ou menus ; remplir les slots d'un menu (`order_item_selection`), choisir le format Normal/Maxi, ajouter/retirer des ingredients (`order_item_modifier`). Panier volatil cote front, aucun ecrit BDD a ce stade. | `product`, `menu`, `menu_slot`, `menu_slot_option`, `ingredient`, `product_ingredient` | +| Consulter les allergenes | (derive de 3.1) | Afficher en modal les allergenes d'un produit, **calcules** par jointure `product_ingredient -> ingredient_allergen -> allergen` (INCO 1169/2011). Etend la composition. | `allergen`, `ingredient_allergen`, `product_ingredient` | +| Passer une commande | 3.3 CREATE_ORDER | Valider le panier et saisir le numero de retrait. Creation atomique : INSERT `customer_order` (`pending_payment` puis `paid` dans la meme transaction), `order_item` + selections + modifiers snapshotes, decrement du stock + `stock_movement`. | `customer_order`, `order_item`, `order_item_selection`, `order_item_modifier`, `ingredient`, `stock_movement` | +| Saisir le numero de retrait | (inclus dans 3.3) | Renseigner le numero qui identifie le client. Tient lieu de paiement (cadre RNCP). Cas inclus par "Passer une commande". | `customer_order.order_number` | +| Recevoir la confirmation | 3.4 DISPLAY_CONFIRMATION | Afficher l'ecran de confirmation avec le numero, apres reponse `201` (statut `paid`). La borne se reinitialise. | `customer_order` | + +### 4.2 Acteurs Counter et Drive (roles `counter`, `drive`) + +| Cas | Operation MCT | Permission | Description | Entites | +|---|---|---|---|---| +| Saisir une commande comptoir/drive | 4.1 CREATE_COUNTER_ORDER | `order.create` | Composer une commande pour un client au comptoir (`counter`) ou au drive (`drive`). Logique identique a CREATE_ORDER ; `source` auto-tague depuis `role.order_source`. Numero `C-`/`D-YYYY-MM-DD-NNN`. | `customer_order`, `order_item`, `order_item_selection`, `order_item_modifier`, `ingredient`, `stock_movement` | +| Consulter la file de preparation | 5.1 LIST_ORDERS_DISPLAY | `order.read` | Voir les commandes `paid` triees par `paid_at` croissant, filtrees par `role_visible_source` (counter voit kiosk+counter ; drive voit drive). Couleur KDS = `now - paid_at`. | `customer_order`, `order_item`, `order_item_selection`, `order_item_modifier`, `role_visible_source` | +| Remettre la commande | 6.1 DELIVER_ORDER | `order.deliver` | Geste unique `paid -> delivered`, `delivered_at = NOW()`. | `customer_order` | +| Annuler une commande | 7.1 CANCEL_ORDER | `order.cancel` | Transition vers `cancelled` depuis `pending_payment`/`paid`, `cancelled_at = NOW()`. Re-credit du stock si `paid`. | `customer_order`, `ingredient`, `stock_movement` | + +### 4.3 Acteur Kitchen (role `kitchen`, lecture seule) + +| Cas | Operation MCT | Permission | Description | Entites | +|---|---|---|---|---| +| Consulter la file de preparation | 5.1 LIST_ORDERS_DISPLAY | `order.read` | Voir toutes les sources (kiosk, counter, drive) en lecture seule. **Aucune transition de statut** : le KDS est un affichage visuel. | `customer_order`, `order_item`, `order_item_selection`, `order_item_modifier`, `role_visible_source` | + +### 4.4 Stock (Kitchen, Counter, Drive, Manager, Admin) + +| Cas | Operation MCT | Permission | Description | Entites | +|---|---|---|---|---| +| Consulter le stock | 9.3 READ_STOCK | `stock.read` | Lister les ingredients avec stock courant ; alerte rupture calculee a l'affichage (`stock_quantity <= low_stock_threshold`). | `ingredient`, `stock_movement` | +| Compter l'inventaire | 9.2 INVENTORY_COUNT | `stock.count` | Saisir un comptage physique ; le systeme enregistre l'ecart (`inventory_correction`). Inclut les equipiers (kitchen/counter/drive). | `ingredient`, `stock_movement` | +| Reapprovisionner | 9.1 RESTOCK | `stock.manage` | Enregistrer une livraison en conditionnements (`+= N * pack_size`). Reserve manager/admin. | `ingredient`, `stock_movement` | + +### 4.5 Catalogue (Manager, Admin) + +| Cas | Operation MCT | Permissions | Description | Entites | +|---|---|---|---|---| +| Gerer produits | 8.1-8.3 CREATE/UPDATE/DELETE_PRODUCT | `product.create`/`update` (manager+admin), `product.delete` (admin seul) | CRUD produits (nom, prix, `vat_rate`, image, dispo). La suppression physique est reservee a `admin` et bloquee si reference (FK RESTRICT). | `product`, `category` | +| Gerer menus et slots | 8.4-8.6 CREATE/UPDATE/DELETE_MENU | `menu.create`/`update` (manager+admin), `menu.delete` (admin seul) | CRUD menus avec leur configuration de slots (`menu_slot`, `menu_slot_option`) et le burger fixe. | `menu`, `menu_slot`, `menu_slot_option`, `product` | +| Gerer categories | 8.7 MANAGE_CATEGORY | `category.manage` (manager+admin) | CRUD categories ; desactivation `is_active=0`. | `category` | +| Gerer ingredients, compositions, allergenes | 8.8 MANAGE_INGREDIENT | `ingredient.manage` (manager+admin) | CRUD `ingredient` ; composition `product_ingredient` (quantites Normal/Maxi, retirable/ajoutable, supplement) ; mapping `ingredient_allergen` (14 allergenes UE). | `ingredient`, `product_ingredient`, `ingredient_allergen`, `allergen` | + +### 4.6 Administration (role `admin`) + Stats (Manager, Admin) + +| Cas | Operation MCT | Permissions | Description | Entites | +|---|---|---|---|---| +| Gerer les utilisateurs | 10.1-10.3 CREATE/UPDATE/DEACTIVATE_USER | `user.create`/`update`/`deactivate` (admin) ; `user.read` (admin+manager) | CRUD comptes back-office avec hash argon2id ; desactivation sans suppression (historique preserve). | `user`, `role` | +| Gerer roles et permissions | 10.4 MANAGE_RBAC | `role.manage` (admin) | Editer la matrice `role_permission`, creer/modifier des roles personnalises (`default_route`, `order_source`), regler `role_visible_source`. Permissions statiques (declarees en migration). | `role`, `permission`, `role_permission`, `role_visible_source` | +| Consulter les statistiques | 11.1 READ_STATS | `stats.read` (admin+manager) | Agregats par `service_day` (coupure 10h), top produits, taux d'annulation, temps moyen de remise `delivered_at - paid_at`, repartition par `source`/`service_mode`. | `customer_order`, `order_item` | + +### 4.7 Cas transverses - Authentification + +| Cas | Operation MCT | Description | |---|---|---| -| Consulter le catalogue | Parcourir les categories, produits et menus disponibles. Charges via `GET /api/categories`, `/api/products`, `/api/menus` (ou JSON fallback). | `categorie`, `produit`, `menu` | -| Composer un menu | Choisir burger + accompagnement + boisson + sauce, regler les options de taille (normale / grande) et de personnalisation. Etend "Passer une commande" car un menu compose est une variante d'item au panier. | `menu`, `menu_produit`, `produit` | -| Passer une commande | Valider le panier, declencher la creation de la commande composee. Inclut la saisie du numero de retrait et le paiement. | `commande`, `ligne_commande` | -| Saisir le numero de retrait | Renseigner le numero qui identifie le client au comptoir. Cas inclus par "Passer une commande". | `commande.numero` | -| Payer la commande | Regler la commande une fois le panier compose et valide. Materialise la transition `pending_payment -> paid` de `state-commande.md`. Cas inclus par "Passer une commande". | `commande.statut`, `commande.paye_a` | -| Recevoir la confirmation | Afficher l'ecran de confirmation avec le numero, apres paiement. | `commande` | - -> Note de coherence : le cycle de vie comporte deux phases successives, la -> composition de la commande puis son paiement (regle metier confirmee). Le cas -> "Payer la commande" est retenu cote Client et materialise la transition -> `pending_payment -> paid` de l'ENUM `statut` -> (`dictionary.md` 3.5, `state-commande.md`). - -### 4.2 Acteur Administration (role admin) - -| Cas | Description | Entites manipulees | -|---|---|---| -| Gerer le catalogue | CRUD sur categories, produits et menus (libelles, prix, images, disponibilite, composition de menu). | `categorie`, `produit`, `menu`, `menu_produit` | -| Gerer les utilisateurs et roles | CRUD sur les comptes back-office et leurs roles ; consultation de la matrice de permissions. | `user`, `role`, `permission`, `role_permission` | -| Consulter les statistiques | Voir les commandes du jour de service, le chiffre d'affaires, les produits les plus commandes. | `commande`, `ligne_commande` | - -### 4.3 Acteur Preparation (role preparation, ex-Cuisine) - -| Cas | Description | Entites manipulees | -|---|---|---| -| Consulter la file de preparation | Afficher les commandes a preparer triees par heure de livraison croissante, tous canaux confondus. | `commande`, `ligne_commande` | -| Faire avancer une commande | Declarer une commande "preparee", ce qui declenche une transition de statut (voir `state-commande.md`). | `commande.statut` | - -### 4.4 Acteur Accueil (role accueil, recouvre Caisse) - -| Cas | Description | Entites manipulees | -|---|---|---| -| Saisir une commande | Creer une commande pour un client au comptoir (`counter`) ou au drive (`drive`), en consultant le catalogue. | `commande`, `ligne_commande`, `produit`, `menu` | -| Remettre la commande au client | Declarer une commande "livree" au moment de la remise physique. | `commande.statut` | - -### 4.5 Cas transverse - S'authentifier - -Tous les acteurs du back-office (Administration, Preparation, Accueil) passent -par "S'authentifier" avant d'acceder a leurs cas. Modelise comme cas inclus -(`<>`) par chaque cas back-office pour eviter de surcharger le -diagramme. Le Client de la borne n'est pas authentifie (canal `kiosk` public). +| S'authentifier | 12.1 AUTHENTICATE_USER | Tous les roles back-office passent par ce cas avant d'acceder a leurs cas (relation `<>`). Verification argon2id, regeneration de session (anti-fixation), `is_active=1` requis, redirection vers `role.default_route`. Le Customer du kiosk n'est pas authentifie. | +| Se deconnecter | 12.2 LOGOUT_USER | Destruction de session (`session_destroy()`) sur clic ou expiration (idle 4h / absolu 10h). | --- @@ -188,35 +261,32 @@ diagramme. Le Client de la borne n'est pas authentifie (canal `kiosk` public). | Relation | Type | Justification | |---|---|---| | Passer une commande -> Saisir le numero de retrait | include | La saisie du numero fait partie integrante de toute validation de commande. | -| Passer une commande -> Payer la commande | include | Le paiement suit la composition du panier et fait partie integrante du parcours (phase 2 du cycle de vie). | -| Composer un menu -> Consulter le catalogue | include | Composer un menu suppose de parcourir les produits eligibles a chaque slot. | -| Passer une commande -> Composer un menu | extend | Le menu est un cas optionnel : une commande peut ne contenir que des produits a la carte. La composition etend le parcours seulement si le client choisit un menu. | -| Saisir une commande (Accueil) -> Consulter le catalogue | include | L'equipier consulte le catalogue pour saisir au comptoir / drive. | -| Cas back-office -> S'authentifier | include | Acces conditionne a une session authentifiee. | +| Passer une commande -> Composer le panier | include | Une commande resulte d'un panier compose. | +| Composer le panier -> Consulter le catalogue | include | Composer suppose de parcourir les produits eligibles (a la carte ou par slot). | +| Composer le panier -> Consulter les allergenes | extend | La consultation des allergenes est un cas optionnel declenche a la demande du client sur un produit. | +| Saisir une commande (Counter/Drive) -> Consulter le catalogue | include | L'equipier consulte le catalogue pour saisir au comptoir/drive. | +| Cas back-office -> S'authentifier | include | Acces conditionne a une session authentifiee detenant la permission requise. | --- -## 6. Incoherences remontees vers les autres livrables +## 6. Points resolus par rapport au v0.1 -Ces ecarts entre les sources sont signales pour arbitrage de l'auteur (la -modelisation finale releve de sa decision, mantra de validation humaine). +Les incoherences que le v0.1 remontait pour arbitrage sont desormais tranchees +par le modele v0.2 (`dictionary.md`, `mct.md`). -1. **ENUM `statut` et phase de paiement (tranche)** - Le dictionnaire (`dictionary.md` 3.5) definit - `statut ENUM('pending_payment','paid','preparing','ready','delivered','cancelled')` - avec un paiement explicite. La regle metier confirmee fixe deux phases - successives, la composition de la commande puis son paiement. Le cas - "Payer la commande" est donc retenu cote Client et materialise la transition - `pending_payment -> paid`. Cet ecart est tranche : la machine canonique de - `state-commande.md` fait foi. - -2. **Acteur "Caisse" absent du RBAC** - Aucun role `caisse` n'existe (`PROJECT_CONTEXT.md` section 7 : `admin`, - `preparation`, `accueil`). La fonction d'encaissement de la consigne a ete - rattachee a l'acteur **Accueil**. A confirmer. - -3. **"Manager" vs "Admin"** - La consigne parle de "Manager/Admin" ; le brief ne connait que `admin`. Les - deux ont ete fusionnes en un acteur **Administration**. A confirmer si un - role manager intermediaire est souhaite (le dictionnaire 3.8 mentionne un - role `manager` extensible, non present dans le scope section 7). +1. **Acteur "Caisse"** : ecarte. L'encaissement est atomique a la creation + (saisie du numero = substitut de paiement, `mct.md` section 13) et realise + par le Customer (kiosk) ou par `counter`/`drive` (back-office). Aucun role + `caisse` n'est necessaire. +2. **"Manager" vs "Admin"** : scindes en deux roles distincts. `manager` gere le + catalogue (create/update), le stock/reappro et les stats ; `admin` ajoute les + suppressions catalogue, la gestion des utilisateurs et le RBAC. +3. **"Accueil" unique** : scinde en `counter` et `drive`, car le tag `source` et + le filtre `role_visible_source` different selon le canal. +4. **Machine a etats** : alignee sur les 4 etats du `dictionary.md` 3.10 + (`pending_payment -> paid -> delivered` + `cancelled`). Plus de `preparing` / + `ready` : la cuisine (`kitchen`) est en lecture seule, la remise est un geste + unique (`counter`/`drive`). +5. **Modele permission-driven** : chaque cas back-office est rattache a sa + permission (catalogue de 23 codes fige, `dictionary.md` 3.17). Le diagramme + reflete la matrice seed ; le gardien effectif reste la permission. diff --git a/scripts/forgejo-branch-protection.sh b/scripts/forgejo-branch-protection.sh new file mode 100755 index 0000000..6f38715 --- /dev/null +++ b/scripts/forgejo-branch-protection.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# +# Wakdo - applique (idempotent) les regles de protection de branche sur Forgejo. +# +# Pourquoi un script versionne : la regle de gouvernance devient reproductible +# et auditable (Cr 7.b), pas un clic dans une UI. Roter le token = editer .env. +# +# Regle posee : +# - main et dev : push direct interdit (PR obligatoire), force-push bloque +# - required_approvals = 0 (travail solo : on ne peut pas approuver sa propre PR) +# - status check : OPTIONNEL via REQUIRE_CI=1, contextes dans CI_CONTEXTS +# (a activer en lot D, une fois les jobs .forgejo/workflows/ nommes ; +# activer avant que le workflow n'existe bloquerait tout merge) +# +# Usage : +# scripts/forgejo-branch-protection.sh # baseline (PR requise) +# REQUIRE_CI=1 CI_CONTEXTS='ci' scripts/forgejo-branch-protection.sh # + CI verte requise +# +set -euo pipefail + +REPO_API="https://git.acadenice.com/api/v1/repos/AcadeNice/corentin_wakdo" +ENV_FILE="$(cd "$(dirname "$0")/.." && pwd)/.env" + +TOKEN="$(grep -E '^FORGEJO_TOKEN=' "$ENV_FILE" | cut -d= -f2-)" +if [ -z "${TOKEN:-}" ]; then + echo "ERREUR : FORGEJO_TOKEN absent de $ENV_FILE" >&2 + exit 1 +fi + +REQUIRE_CI="${REQUIRE_CI:-0}" +CI_CONTEXTS="${CI_CONTEXTS:-ci}" + +# Construit le tableau JSON des contextes de status check si REQUIRE_CI=1. +status_check_json="false" +contexts_json="[]" +if [ "$REQUIRE_CI" = "1" ]; then + status_check_json="true" + contexts_json="$(printf '%s' "$CI_CONTEXTS" | awk -F, '{printf "["; for(i=1;i<=NF;i++){printf "%s\"%s\"", (i>1?",":""), $i}; printf "]"}')" +fi + +for branch in main dev; do + payload=$(cat </dev/null +done + +echo "OK - protections appliquees sur main et dev." diff --git a/scripts/forgejo-pr-automerge.sh b/scripts/forgejo-pr-automerge.sh new file mode 100755 index 0000000..46f5952 --- /dev/null +++ b/scripts/forgejo-pr-automerge.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# +# Wakdo - ouvre une PR Forgejo et planifie son auto-merge quand la CI passe. +# +# Strategie solo dev : la PR reste obligatoire (trace de gouvernance, Cr 4.f) +# mais le merge se declenche tout seul des que les checks requis sont verts. +# Prerequis : status checks requis sur la branche de base +# (voir scripts/forgejo-branch-protection.sh avec REQUIRE_CI=1). +# +# Usage : +# scripts/forgejo-pr-automerge.sh [HEAD] [BASE] ["Titre"] +# Defauts : HEAD = branche courante, BASE = dev, titre = dernier sujet de commit. +# +set -euo pipefail + +REPO_API="https://git.acadenice.com/api/v1/repos/AcadeNice/corentin_wakdo" +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +ENV_FILE="$ROOT/.env" + +TOKEN="$(grep -E '^FORGEJO_TOKEN=' "$ENV_FILE" | cut -d= -f2-)" +[ -n "${TOKEN:-}" ] || { echo "ERREUR : FORGEJO_TOKEN absent de $ENV_FILE" >&2; exit 1; } + +HEAD="${1:-$(git -C "$ROOT" rev-parse --abbrev-ref HEAD)}" +BASE="${2:-dev}" +TITLE="${3:-$(git -C "$ROOT" log -1 --pretty=%s "$HEAD")}" + +if [ "$BASE" = "main" ] && [ "$HEAD" != "dev" ]; then + echo "Garde-fou : seules les PR depuis 'dev' visent 'main'. Abandon." >&2 + exit 1 +fi + +echo "PR : $HEAD -> $BASE" +echo "Titre : $TITLE" + +# 1. Creer la PR (ou recuperer l'index si elle existe deja). +create_resp=$(curl -s -X POST -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ + -d "$(printf '{"head":"%s","base":"%s","title":"%s"}' "$HEAD" "$BASE" "$TITLE")" \ + "$REPO_API/pulls") +index=$(printf '%s' "$create_resp" | python3 -c "import sys,json;d=json.load(sys.stdin);print(d.get('number',''))" 2>/dev/null || true) + +if [ -z "$index" ]; then + # PR deja existante : la retrouver par branche head. + index=$(curl -s -H "Authorization: token $TOKEN" "$REPO_API/pulls?state=open&limit=50" \ + | python3 -c "import sys,json;hs='$HEAD';d=json.load(sys.stdin);print(next((p['number'] for p in d if p['head']['ref']==hs),''))" 2>/dev/null || true) +fi +[ -n "$index" ] || { echo "Impossible de creer/trouver la PR. Reponse : $create_resp" >&2; exit 1; } +echo "PR #$index" + +# 2. Planifier l'auto-merge (squash) quand les checks requis sont verts. +merge_resp=$(curl -s -o /dev/null -w "%{http_code}" -X POST \ + -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ + -d '{"Do":"squash","merge_when_checks_succeed":true,"delete_branch_after_merge":false}' \ + "$REPO_API/pulls/$index/merge") + +case "$merge_resp" in + 200|201|202) echo "Auto-merge planifie sur PR #$index (squash a la CI verte)." ;; + 405) echo "PR #$index : merge differe - checks pas encore verts, auto-merge en attente." ;; + *) echo "Reponse merge HTTP $merge_resp sur PR #$index (verifier l'etat des checks / protections)." ;; +esac -- 2.45.3 From 79d8ad9985dcd9e0dde5377d94fef604b7233d95 Mon Sep 17 00:00:00 2001 From: Corentin JOGUET Date: Mon, 15 Jun 2026 12:39:59 +0200 Subject: [PATCH 03/93] docs(merise): MLD relational schema diagrams (4 sub-domains) (#4) --- docs/merise/_diagrams/mld-catalogue.mmd | 46 +++ docs/merise/_diagrams/mld-catalogue.svg | 1 + .../_diagrams/mld-ingredients-stock.mmd | 59 ++++ .../_diagrams/mld-ingredients-stock.svg | 1 + docs/merise/_diagrams/mld-order.mmd | 72 +++++ docs/merise/_diagrams/mld-order.svg | 1 + docs/merise/_diagrams/mld-rbac.mmd | 61 ++++ docs/merise/_diagrams/mld-rbac.svg | 1 + docs/merise/mld.md | 273 ++++++++++++++++++ 9 files changed, 515 insertions(+) create mode 100644 docs/merise/_diagrams/mld-catalogue.mmd create mode 100644 docs/merise/_diagrams/mld-catalogue.svg create mode 100644 docs/merise/_diagrams/mld-ingredients-stock.mmd create mode 100644 docs/merise/_diagrams/mld-ingredients-stock.svg create mode 100644 docs/merise/_diagrams/mld-order.mmd create mode 100644 docs/merise/_diagrams/mld-order.svg create mode 100644 docs/merise/_diagrams/mld-rbac.mmd create mode 100644 docs/merise/_diagrams/mld-rbac.svg diff --git a/docs/merise/_diagrams/mld-catalogue.mmd b/docs/merise/_diagrams/mld-catalogue.mmd new file mode 100644 index 0000000..7519fdb --- /dev/null +++ b/docs/merise/_diagrams/mld-catalogue.mmd @@ -0,0 +1,46 @@ +erDiagram + category { + int id PK + varchar name UK + varchar slug UK + smallint display_order + tinyint is_active + } + product { + int id PK + int category_id FK + varchar name + int price_cents + smallint vat_rate + tinyint is_available + smallint display_order + } + menu { + int id PK + int category_id FK + int burger_product_id FK + varchar name + int price_normal_cents + int price_maxi_cents + tinyint is_available + smallint display_order + } + menu_slot { + int id PK + int menu_id FK + varchar name + enum slot_type + tinyint is_required + smallint display_order + } + menu_slot_option { + int menu_slot_id PK,FK + int product_id PK,FK + } + + category ||--o{ product : "category_id (RESTRICT)" + category ||--o{ menu : "category_id (RESTRICT)" + product ||--o{ menu : "burger_product_id (RESTRICT)" + menu ||--o{ menu_slot : "menu_id (CASCADE)" + menu_slot ||--o{ menu_slot_option : "menu_slot_id (CASCADE)" + product ||--o{ menu_slot_option : "product_id (RESTRICT)" diff --git a/docs/merise/_diagrams/mld-catalogue.svg b/docs/merise/_diagrams/mld-catalogue.svg new file mode 100644 index 0000000..7d501b3 --- /dev/null +++ b/docs/merise/_diagrams/mld-catalogue.svg @@ -0,0 +1 @@ +

category_id (RESTRICT)

category_id (RESTRICT)

burger_product_id (RESTRICT)

menu_id (CASCADE)

menu_slot_id (CASCADE)

product_id (RESTRICT)

category

int

id

PK

varchar

name

UK

varchar

slug

UK

smallint

display_order

tinyint

is_active

product

int

id

PK

int

category_id

FK

varchar

name

int

price_cents

smallint

vat_rate

tinyint

is_available

smallint

display_order

menu

int

id

PK

int

category_id

FK

int

burger_product_id

FK

varchar

name

int

price_normal_cents

int

price_maxi_cents

tinyint

is_available

smallint

display_order

menu_slot

int

id

PK

int

menu_id

FK

varchar

name

enum

slot_type

tinyint

is_required

smallint

display_order

menu_slot_option

int

menu_slot_id

PK,FK

int

product_id

PK,FK

\ No newline at end of file diff --git a/docs/merise/_diagrams/mld-ingredients-stock.mmd b/docs/merise/_diagrams/mld-ingredients-stock.mmd new file mode 100644 index 0000000..556f22f --- /dev/null +++ b/docs/merise/_diagrams/mld-ingredients-stock.mmd @@ -0,0 +1,59 @@ +erDiagram + ingredient { + int id PK + varchar name UK + varchar unit + int stock_quantity + int stock_capacity + smallint pack_size + smallint low_stock_pct + smallint critical_stock_pct + tinyint is_active + } + product_ingredient { + int product_id PK,FK + int ingredient_id PK,FK + smallint quantity_normal + smallint quantity_maxi + tinyint is_removable + tinyint is_addable + int extra_price_cents + } + allergen { + int id PK + varchar code UK + varchar name + } + ingredient_allergen { + int ingredient_id PK,FK + int allergen_id PK,FK + } + stock_movement { + int id PK + int ingredient_id FK + enum movement_type + int delta + int order_id FK + int user_id FK + varchar note + } + product { + int id PK + varchar name + } + customer_order { + int id PK + varchar order_number + } + user { + int id PK + varchar email + } + + product ||--o{ product_ingredient : "product_id (CASCADE)" + ingredient ||--o{ product_ingredient : "ingredient_id (RESTRICT)" + ingredient ||--o{ ingredient_allergen : "ingredient_id (CASCADE)" + allergen ||--o{ ingredient_allergen : "allergen_id (RESTRICT)" + ingredient ||--o{ stock_movement : "ingredient_id (RESTRICT)" + customer_order ||--o{ stock_movement : "order_id (SET NULL, nullable)" + user ||--o{ stock_movement : "user_id (SET NULL, nullable)" diff --git a/docs/merise/_diagrams/mld-ingredients-stock.svg b/docs/merise/_diagrams/mld-ingredients-stock.svg new file mode 100644 index 0000000..22a377f --- /dev/null +++ b/docs/merise/_diagrams/mld-ingredients-stock.svg @@ -0,0 +1 @@ +

product_id (CASCADE)

ingredient_id (RESTRICT)

ingredient_id (CASCADE)

allergen_id (RESTRICT)

ingredient_id (RESTRICT)

order_id (SET NULL, nullable)

user_id (SET NULL, nullable)

ingredient

int

id

PK

varchar

name

UK

varchar

unit

int

stock_quantity

int

stock_capacity

smallint

pack_size

smallint

low_stock_pct

smallint

critical_stock_pct

tinyint

is_active

product_ingredient

int

product_id

PK,FK

int

ingredient_id

PK,FK

smallint

quantity_normal

smallint

quantity_maxi

tinyint

is_removable

tinyint

is_addable

int

extra_price_cents

allergen

int

id

PK

varchar

code

UK

varchar

name

ingredient_allergen

int

ingredient_id

PK,FK

int

allergen_id

PK,FK

stock_movement

int

id

PK

int

ingredient_id

FK

enum

movement_type

int

delta

int

order_id

FK

int

user_id

FK

varchar

note

product

int

id

PK

varchar

name

customer_order

int

id

PK

varchar

order_number

user

int

id

PK

varchar

email

\ No newline at end of file diff --git a/docs/merise/_diagrams/mld-order.mmd b/docs/merise/_diagrams/mld-order.mmd new file mode 100644 index 0000000..f6ec1e3 --- /dev/null +++ b/docs/merise/_diagrams/mld-order.mmd @@ -0,0 +1,72 @@ +erDiagram + customer_order { + int id PK + varchar order_number UK + varchar idempotency_key UK + enum source + int acting_user_id FK + enum service_mode + enum status + int total_ht_cents + int total_vat_cents + int total_ttc_cents + datetime paid_at + datetime delivered_at + datetime cancelled_at + } + order_item { + int id PK + int order_id FK + enum item_type + int product_id FK + int menu_id FK + enum format + varchar label_snapshot + int unit_price_cents_snapshot + smallint vat_rate_snapshot + smallint quantity + } + order_item_selection { + int id PK + int order_item_id FK + int menu_slot_id FK + int product_id FK + varchar label_snapshot + } + order_item_modifier { + int id PK + int order_item_id FK + int ingredient_id FK + enum action + int extra_price_cents + } + user { + int id PK + varchar email + } + product { + int id PK + varchar name + } + menu { + int id PK + varchar name + } + menu_slot { + int id PK + varchar name + } + ingredient { + int id PK + varchar name + } + + user ||--o{ customer_order : "acting_user_id (SET NULL, nullable)" + customer_order ||--o{ order_item : "order_id (CASCADE)" + product ||--o{ order_item : "product_id (RESTRICT, polymorphic)" + menu ||--o{ order_item : "menu_id (RESTRICT, polymorphic)" + order_item ||--o{ order_item_selection : "order_item_id (CASCADE)" + menu_slot ||--o{ order_item_selection : "menu_slot_id (RESTRICT)" + product ||--o{ order_item_selection : "product_id (RESTRICT)" + order_item ||--o{ order_item_modifier : "order_item_id (CASCADE)" + ingredient ||--o{ order_item_modifier : "ingredient_id (RESTRICT)" diff --git a/docs/merise/_diagrams/mld-order.svg b/docs/merise/_diagrams/mld-order.svg new file mode 100644 index 0000000..3cbc2f8 --- /dev/null +++ b/docs/merise/_diagrams/mld-order.svg @@ -0,0 +1 @@ +

acting_user_id (SET NULL, nullable)

order_id (CASCADE)

product_id (RESTRICT, polymorphic)

menu_id (RESTRICT, polymorphic)

order_item_id (CASCADE)

menu_slot_id (RESTRICT)

product_id (RESTRICT)

order_item_id (CASCADE)

ingredient_id (RESTRICT)

customer_order

int

id

PK

varchar

order_number

UK

varchar

idempotency_key

UK

enum

source

int

acting_user_id

FK

enum

service_mode

enum

status

int

total_ht_cents

int

total_vat_cents

int

total_ttc_cents

datetime

paid_at

datetime

delivered_at

datetime

cancelled_at

order_item

int

id

PK

int

order_id

FK

enum

item_type

int

product_id

FK

int

menu_id

FK

enum

format

varchar

label_snapshot

int

unit_price_cents_snapshot

smallint

vat_rate_snapshot

smallint

quantity

order_item_selection

int

id

PK

int

order_item_id

FK

int

menu_slot_id

FK

int

product_id

FK

varchar

label_snapshot

order_item_modifier

int

id

PK

int

order_item_id

FK

int

ingredient_id

FK

enum

action

int

extra_price_cents

user

int

id

PK

varchar

email

product

int

id

PK

varchar

name

menu

int

id

PK

varchar

name

menu_slot

int

id

PK

varchar

name

ingredient

int

id

PK

varchar

name

\ No newline at end of file diff --git a/docs/merise/_diagrams/mld-rbac.mmd b/docs/merise/_diagrams/mld-rbac.mmd new file mode 100644 index 0000000..2bc76b8 --- /dev/null +++ b/docs/merise/_diagrams/mld-rbac.mmd @@ -0,0 +1,61 @@ +erDiagram + role { + int id PK + varchar code UK + varchar label + varchar default_route + enum order_source + tinyint is_active + } + user { + int id PK + varchar email UK + varchar password_hash + varchar pin_hash + varchar first_name + varchar last_name + int role_id FK + tinyint is_active + smallint failed_login_attempts + datetime lockout_until + datetime anonymized_at + } + role_visible_source { + int role_id PK,FK + enum source PK + } + permission { + int id PK + varchar code UK + varchar label + } + role_permission { + int role_id PK,FK + int permission_id PK,FK + } + audit_log { + int id PK + int actor_user_id FK + int actor_role_id FK + varchar action_code + varchar entity_type + int entity_id + varchar summary + json details + datetime created_at + } + login_throttle { + int id PK + varchar ip_address UK + smallint failed_attempts + datetime window_started_at + datetime lockout_until + datetime last_attempt_at + } + + role ||--o{ user : "role_id (RESTRICT)" + role ||--o{ role_visible_source : "role_id (CASCADE)" + role ||--o{ role_permission : "role_id (CASCADE)" + permission ||--o{ role_permission : "permission_id (CASCADE)" + user ||--o{ audit_log : "actor_user_id (SET NULL, nullable)" + role ||--o{ audit_log : "actor_role_id (SET NULL, nullable)" diff --git a/docs/merise/_diagrams/mld-rbac.svg b/docs/merise/_diagrams/mld-rbac.svg new file mode 100644 index 0000000..ad68a9f --- /dev/null +++ b/docs/merise/_diagrams/mld-rbac.svg @@ -0,0 +1 @@ +

role_id (RESTRICT)

role_id (CASCADE)

role_id (CASCADE)

permission_id (CASCADE)

actor_user_id (SET NULL, nullable)

actor_role_id (SET NULL, nullable)

role

int

id

PK

varchar

code

UK

varchar

label

varchar

default_route

enum

order_source

tinyint

is_active

user

int

id

PK

varchar

email

UK

varchar

password_hash

varchar

pin_hash

varchar

first_name

varchar

last_name

int

role_id

FK

tinyint

is_active

smallint

failed_login_attempts

datetime

lockout_until

datetime

anonymized_at

role_visible_source

int

role_id

PK,FK

enum

source

PK

permission

int

id

PK

varchar

code

UK

varchar

label

role_permission

int

role_id

PK,FK

int

permission_id

PK,FK

audit_log

int

id

PK

int

actor_user_id

FK

int

actor_role_id

FK

varchar

action_code

varchar

entity_type

int

entity_id

varchar

summary

json

details

datetime

created_at

login_throttle

int

id

PK

varchar

ip_address

UK

smallint

failed_attempts

datetime

window_started_at

datetime

lockout_until

datetime

last_attempt_at

\ No newline at end of file diff --git a/docs/merise/mld.md b/docs/merise/mld.md index 5cd6563..c701f63 100644 --- a/docs/merise/mld.md +++ b/docs/merise/mld.md @@ -97,6 +97,279 @@ in addition to the composite FK PK. Applied to `product_ingredient`. Tables are ordered by dependency (no-FK tables first, then tables that depend on them). +### Relational diagrams (by sub-domain) + +The relational schema is shown as four Mermaid `erDiagram` views, one per sub-domain (same +decomposition as the MCD; a single 21-table diagram would not lay out cleanly). These differ +from the MCD: associative entities are resolved into join tables with composite PKs, the +`order_item` polymorphism appears as two nullable FKs (`product_id` / `menu_id`), and every +foreign key is explicit. Audit timestamps (`created_at` / `updated_at`) are present on most +tables (see the per-table sections below) but omitted from the diagrams to keep them readable. +Relationship labels carry the FK column and its `ON DELETE` behaviour. Cross-sub-domain FK +targets are shown as stub tables (id + name). Portable SVG renders live in `_diagrams/` +(`mld-catalogue.svg`, `mld-ingredients-stock.svg`, `mld-order.svg`, `mld-rbac.svg`). + +#### Catalogue + +```mermaid +erDiagram + category { + int id PK + varchar name UK + varchar slug UK + smallint display_order + tinyint is_active + } + product { + int id PK + int category_id FK + varchar name + int price_cents + smallint vat_rate + tinyint is_available + smallint display_order + } + menu { + int id PK + int category_id FK + int burger_product_id FK + varchar name + int price_normal_cents + int price_maxi_cents + tinyint is_available + smallint display_order + } + menu_slot { + int id PK + int menu_id FK + varchar name + enum slot_type + tinyint is_required + smallint display_order + } + menu_slot_option { + int menu_slot_id PK,FK + int product_id PK,FK + } + + category ||--o{ product : "category_id (RESTRICT)" + category ||--o{ menu : "category_id (RESTRICT)" + product ||--o{ menu : "burger_product_id (RESTRICT)" + menu ||--o{ menu_slot : "menu_id (CASCADE)" + menu_slot ||--o{ menu_slot_option : "menu_slot_id (CASCADE)" + product ||--o{ menu_slot_option : "product_id (RESTRICT)" +``` + +#### Ingredients & Stock + +```mermaid +erDiagram + ingredient { + int id PK + varchar name UK + varchar unit + int stock_quantity + int stock_capacity + smallint pack_size + smallint low_stock_pct + smallint critical_stock_pct + tinyint is_active + } + product_ingredient { + int product_id PK,FK + int ingredient_id PK,FK + smallint quantity_normal + smallint quantity_maxi + tinyint is_removable + tinyint is_addable + int extra_price_cents + } + allergen { + int id PK + varchar code UK + varchar name + } + ingredient_allergen { + int ingredient_id PK,FK + int allergen_id PK,FK + } + stock_movement { + int id PK + int ingredient_id FK + enum movement_type + int delta + int order_id FK + int user_id FK + varchar note + } + product { + int id PK + varchar name + } + customer_order { + int id PK + varchar order_number + } + user { + int id PK + varchar email + } + + product ||--o{ product_ingredient : "product_id (CASCADE)" + ingredient ||--o{ product_ingredient : "ingredient_id (RESTRICT)" + ingredient ||--o{ ingredient_allergen : "ingredient_id (CASCADE)" + allergen ||--o{ ingredient_allergen : "allergen_id (RESTRICT)" + ingredient ||--o{ stock_movement : "ingredient_id (RESTRICT)" + customer_order ||--o{ stock_movement : "order_id (SET NULL, nullable)" + user ||--o{ stock_movement : "user_id (SET NULL, nullable)" +``` + +#### Order + +```mermaid +erDiagram + customer_order { + int id PK + varchar order_number UK + varchar idempotency_key UK + enum source + int acting_user_id FK + enum service_mode + enum status + int total_ht_cents + int total_vat_cents + int total_ttc_cents + datetime paid_at + datetime delivered_at + datetime cancelled_at + } + order_item { + int id PK + int order_id FK + enum item_type + int product_id FK + int menu_id FK + enum format + varchar label_snapshot + int unit_price_cents_snapshot + smallint vat_rate_snapshot + smallint quantity + } + order_item_selection { + int id PK + int order_item_id FK + int menu_slot_id FK + int product_id FK + varchar label_snapshot + } + order_item_modifier { + int id PK + int order_item_id FK + int ingredient_id FK + enum action + int extra_price_cents + } + user { + int id PK + varchar email + } + product { + int id PK + varchar name + } + menu { + int id PK + varchar name + } + menu_slot { + int id PK + varchar name + } + ingredient { + int id PK + varchar name + } + + user ||--o{ customer_order : "acting_user_id (SET NULL, nullable)" + customer_order ||--o{ order_item : "order_id (CASCADE)" + product ||--o{ order_item : "product_id (RESTRICT, polymorphic)" + menu ||--o{ order_item : "menu_id (RESTRICT, polymorphic)" + order_item ||--o{ order_item_selection : "order_item_id (CASCADE)" + menu_slot ||--o{ order_item_selection : "menu_slot_id (RESTRICT)" + product ||--o{ order_item_selection : "product_id (RESTRICT)" + order_item ||--o{ order_item_modifier : "order_item_id (CASCADE)" + ingredient ||--o{ order_item_modifier : "ingredient_id (RESTRICT)" +``` + +#### RBAC & security + +```mermaid +erDiagram + role { + int id PK + varchar code UK + varchar label + varchar default_route + enum order_source + tinyint is_active + } + user { + int id PK + varchar email UK + varchar password_hash + varchar pin_hash + varchar first_name + varchar last_name + int role_id FK + tinyint is_active + smallint failed_login_attempts + datetime lockout_until + datetime anonymized_at + } + role_visible_source { + int role_id PK,FK + enum source PK + } + permission { + int id PK + varchar code UK + varchar label + } + role_permission { + int role_id PK,FK + int permission_id PK,FK + } + audit_log { + int id PK + int actor_user_id FK + int actor_role_id FK + varchar action_code + varchar entity_type + int entity_id + varchar summary + json details + datetime created_at + } + login_throttle { + int id PK + varchar ip_address UK + smallint failed_attempts + datetime window_started_at + datetime lockout_until + datetime last_attempt_at + } + + role ||--o{ user : "role_id (RESTRICT)" + role ||--o{ role_visible_source : "role_id (CASCADE)" + role ||--o{ role_permission : "role_id (CASCADE)" + permission ||--o{ role_permission : "permission_id (CASCADE)" + user ||--o{ audit_log : "actor_user_id (SET NULL, nullable)" + role ||--o{ audit_log : "actor_role_id (SET NULL, nullable)" +``` + +> `login_throttle` has no FK (an IP is not a modelled entity); it stands alone, keyed by +> `ip_address`. + --- ### 4.1 `category` -- 2.45.3 From 2a67b6fc340403317c06f6f60882190534439b9f Mon Sep 17 00:00:00 2001 From: Corentin JOGUET Date: Mon, 15 Jun 2026 15:23:06 +0200 Subject: [PATCH 04/93] docs(merise): traduction de la prose en francais (identifiants inchanges) (#5) --- docs/merise/dictionary.md | 1072 ++++++++++++++++++------------------- docs/merise/mcd.md | 484 ++++++++--------- docs/merise/mct.md | 652 +++++++++++----------- docs/merise/mld.md | 1012 +++++++++++++++++----------------- docs/merise/mlt.md | 906 +++++++++++++++---------------- 5 files changed, 2063 insertions(+), 2063 deletions(-) diff --git a/docs/merise/dictionary.md b/docs/merise/dictionary.md index cb72788..b818d77 100644 --- a/docs/merise/dictionary.md +++ b/docs/merise/dictionary.md @@ -1,441 +1,441 @@ -# Data Dictionary — Wakdo +# Dictionnaire de Donnees — Wakdo -**Merise phase** : P1 - Conception, step 1 (data dictionary first, mantra #33) -**Version** : v0.2 — prod-like, 21 entities (19 prod-like + security-by-design layer, incl. the new `login_throttle` entity) -**Date** : 2026-06-04 (security-by-design additions 2026-06-11) -**Branch** : `feat/p1-conception` -**Status** : prod-like — all D1-D8 + stock decisions applied (see `docs/notes/revue-alignement-p1.md` §7); security-by-design layer in progress (see note 13) -**Author** : BYAN (methodology layer) +**Phase Merise** : P1 - Conception, etape 1 (dictionnaire de donnees d'abord, mantra #33) +**Version** : v0.2 — prod-like, 21 entites (19 prod-like + couche security-by-design, incl. la nouvelle entite `login_throttle`) +**Date** : 2026-06-04 (ajouts security-by-design 2026-06-11) +**Branche** : `feat/p1-conception` +**Statut** : prod-like — toutes les decisions D1-D8 + stock appliquees (voir `docs/notes/revue-alignement-p1.md` §7) ; couche security-by-design en cours (voir note 13) +**Auteur** : BYAN (couche methodologie) --- -## 1. Purpose +## 1. Objectif -This dictionary lists **all data entities** identified for Wakdo, with their attributes, -types, constraints, and sources. It serves as the basis for the MCD (entities + relations), -then the MLD (relational mapping), then the DDL (SQL CREATE TABLE). +Ce dictionnaire liste **toutes les entites de donnees** identifiees pour Wakdo, avec leurs attributs, +types, contraintes et sources. Il sert de base au MCD (entites + relations), +puis au MLD (mapping relationnel), puis au DDL (SQL CREATE TABLE). -**Methodology**: bottom-up derivation from available sources: -- **School source**: `docs/merise/_sources/categories.json` + `produits.json` - (66 products, 9 categories) -- **Business brief**: `docs/PROJECT_CONTEXT.md` (menu composition, order flow, RBAC, - service modes) -- **Mockup**: `docs/design/maquette-borne.pdf` (kiosk UX, visible screens) +**Methodologie** : derivation bottom-up depuis les sources disponibles : +- **Source ecole** : `docs/merise/_sources/categories.json` + `produits.json` + (66 produits, 9 categories) +- **Brief metier** : `docs/PROJECT_CONTEXT.md` (composition du menu, flux de commande, RBAC, + modes de service) +- **Maquette** : `docs/design/maquette-borne.pdf` (UX borne, ecrans visibles) -All deviations between school source and final model are documented in the -"Modeling notes" section at the bottom of this document. +Tous les ecarts entre la source ecole et le modele final sont documentes dans la +section "Notes de modelisation" en bas de ce document. -For the entity-relationship diagram and cardinality justifications, see [`mcd.md`](mcd.md). -This dictionary does not duplicate that view to avoid diverging sources of truth. +Pour le diagramme entite-relation et les justifications de cardinalite, voir [`mcd.md`](mcd.md). +Ce dictionnaire ne duplique pas cette vue afin d'eviter des sources de verite divergentes. --- -## 2. General conventions +## 2. Conventions generales -### Naming +### Nommage -- **Tables**: `snake_case`, singular (e.g., `category`, `product`, `customer_order`). - Singular reflects the perspective "1 row = 1 instance of the entity" (standard relational - convention). Application code (PHP, JS) uses these names as-is via ORM mapping. -- **Columns**: `snake_case`. Typical suffixes: `_id` (FK), `_at` (timestamp), - `_cents` (monetary amount in integer cents), `_path` (file path), `_rate` (rate or - fraction stored as per-mille integer). -- **Primary keys**: column `id` (INT UNSIGNED AUTO_INCREMENT). No composite PK except - on pure join tables. -- **Foreign keys**: `_id` (e.g., `category_id` in `product`). -- **ENUM values**: English, snake_case (e.g., `pending_payment`, `dine_in`, `kiosk`). -- **Code-facing strings** (ENUM, permission codes, role codes): English only, consistent - across DB, PHP, and JSON API. +- **Tables** : `snake_case`, singulier (ex. `category`, `product`, `customer_order`). + Le singulier reflete la perspective "1 ligne = 1 instance de l'entite" (convention relationnelle + standard). Le code applicatif (PHP, JS) utilise ces noms tels quels via le mapping ORM. +- **Colonnes** : `snake_case`. Suffixes typiques : `_id` (FK), `_at` (timestamp), + `_cents` (montant monetaire en centimes entiers), `_path` (chemin de fichier), `_rate` (taux ou + fraction stocke en entier pour-mille). +- **Cles primaires** : colonne `id` (INT UNSIGNED AUTO_INCREMENT). Pas de PK composite sauf + sur les tables de jointure pures. +- **Cles etrangeres** : `_id` (ex. `category_id` dans `product`). +- **Valeurs ENUM** : anglais, snake_case (ex. `pending_payment`, `dine_in`, `kiosk`). +- **Chaines cote code** (ENUM, codes de permission, codes de role) : anglais uniquement, coherentes + entre la BDD, PHP et l'API JSON. -### Default types +### Types par defaut -| Category | MariaDB type | Justification | +| Categorie | Type MariaDB | Justification | |---|---|---| -| Identifiers | `INT UNSIGNED AUTO_INCREMENT` | 4 billion ids — sufficient for this project | -| Short labels | `VARCHAR(120)` | Covers most product names (max observed: 41 chars in school source) | -| Descriptions | `TEXT` | Variable length, no strict limit | -| Monetary amounts | `INT UNSIGNED` (cents) | Avoids FLOAT rounding bugs (see note 1) | -| Booleans | `TINYINT(1)` | MariaDB convention for `BOOLEAN` (alias) | -| Timestamps | `DATETIME` | Human-readable, timezone handled at app layer | -| Enumerations | `ENUM('a','b','c')` | DBMS-level constraint, readable (see note 2) | -| File paths | `VARCHAR(255)` | Standard POSIX path length limit | +| Identifiants | `INT UNSIGNED AUTO_INCREMENT` | 4 milliards d'ids — suffisant pour ce projet | +| Libelles courts | `VARCHAR(120)` | Couvre la plupart des noms de produits (max observe : 41 caracteres dans la source ecole) | +| Descriptions | `TEXT` | Longueur variable, sans limite stricte | +| Montants monetaires | `INT UNSIGNED` (cents) | Evite les bugs d'arrondi FLOAT (voir note 1) | +| Booleens | `TINYINT(1)` | Convention MariaDB pour `BOOLEAN` (alias) | +| Horodatages | `DATETIME` | Lisible par l'humain, fuseau horaire gere au niveau applicatif | +| Enumerations | `ENUM('a','b','c')` | Contrainte au niveau SGBD, lisible (voir note 2) | +| Chemins de fichiers | `VARCHAR(255)` | Limite standard de longueur de chemin POSIX | -### Charset and collation +### Charset et collation -- **Charset**: `utf8mb4` (RFC 3629 — real 4-byte UTF-8, supports emoji and Asian characters). - MariaDB handles `utf8mb4` natively. -- **Collation**: `utf8mb4_unicode_ci` (case-insensitive, Unicode-compliant comparison). +- **Charset** : `utf8mb4` (RFC 3629 — vrai UTF-8 sur 4 octets, supporte emoji et caracteres asiatiques). + MariaDB gere `utf8mb4` nativement. +- **Collation** : `utf8mb4_unicode_ci` (insensible a la casse, comparaison conforme Unicode). -### Audit fields (present on all business tables except pure join tables) +### Champs d'audit (presents sur toutes les tables metier sauf les tables de jointure pures) -| Column | Type | Default | Role | +| Colonne | Type | Default | Role | |---|---|---|---| -| `created_at` | `DATETIME` | `CURRENT_TIMESTAMP` | Creation timestamp, written once at insert | -| `updated_at` | `DATETIME` | `CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP` | Last modification timestamp, auto-updated | +| `created_at` | `DATETIME` | `CURRENT_TIMESTAMP` | Timestamp de creation, ecrit une fois a l'insertion | +| `updated_at` | `DATETIME` | `CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP` | Timestamp de derniere modification, mis a jour automatiquement | ### Soft delete -No generalized soft delete. Entities that can be temporarily deactivated carry an -`is_active` or `is_available` boolean column. Hard `DELETE` remains possible but is -reserved for admin operations with prior backup. +Pas de soft delete generalise. Les entites qui peuvent etre temporairement desactivees portent une +colonne booleenne `is_active` ou `is_available`. Le `DELETE` dur reste possible mais est +reserve aux operations admin avec sauvegarde prealable. --- -## 3. Entities +## 3. Entites ### 3.1 `category` -Business grouping of products and menus for display on the kiosk. +Regroupement metier de produits et de menus pour l'affichage sur la borne. -| Attribute | Type | NULL | Default | Constraint | School source | Notes | +| Attribut | Type | NULL | Default | Contrainte | Source ecole | Notes | |---|---|---|---|---|---|---| -| `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | `id` (1-9) | same as source | -| `name` | VARCHAR(60) | NO | — | UNIQUE | `title` | renamed from `title` | -| `slug` | VARCHAR(60) | NO | — | UNIQUE | derived from `title` (kebab-case lowercase) | used for URL `/api/categories/burgers` | -| `image_path` | VARCHAR(255) | YES | NULL | — | `image` | relative path, see note 8 | -| `display_order` | SMALLINT UNSIGNED | NO | 0 | — | (added) | display order on kiosk, adjustable from admin | -| `is_active` | TINYINT(1) | NO | 1 | — | (added) | deactivate without deleting | +| `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | `id` (1-9) | identique a la source | +| `name` | VARCHAR(60) | NO | — | UNIQUE | `title` | renomme depuis `title` | +| `slug` | VARCHAR(60) | NO | — | UNIQUE | derive de `title` (kebab-case minuscule) | utilise pour l'URL `/api/categories/burgers` | +| `image_path` | VARCHAR(255) | YES | NULL | — | `image` | chemin relatif, voir note 8 | +| `display_order` | SMALLINT UNSIGNED | NO | 0 | — | (ajoute) | ordre d'affichage sur la borne, ajustable depuis l'admin | +| `is_active` | TINYINT(1) | NO | 1 | — | (ajoute) | desactiver sans supprimer | | `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | — | — | audit | | `updated_at` | DATETIME | NO | CURRENT_TIMESTAMP ON UPDATE | — | — | audit | -**Examples**: `menus`, `drinks`, `burgers`, `fries`, `snacks`, `wraps`, `salads`, -`desserts`, `sauces`. Volume: 9 rows at init (seed from `categories.json`). +**Exemples** : `menus`, `drinks`, `burgers`, `fries`, `snacks`, `wraps`, `salads`, +`desserts`, `sauces`. Volume : 9 lignes a l'init (seed depuis `categories.json`). --- ### 3.2 `product` -A single sellable item, available a la carte or as a component in a menu slot. +Un article vendable unique, disponible a la carte ou comme composant dans un slot de menu. -| Attribute | Type | NULL | Default | Constraint | School source | Notes | +| Attribut | Type | NULL | Default | Contrainte | Source ecole | Notes | |---|---|---|---|---|---|---| -| `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | `id` | same as source | -| `category_id` | INT UNSIGNED | NO | — | FK -> `category(id)`, ON DELETE RESTRICT | (derived from JSON object key) | | -| `name` | VARCHAR(120) | NO | — | INDEX | `nom` | renamed from `nom` | -| `description` | TEXT | YES | NULL | — | (added) | populated later via admin | -| `price_cents` | INT UNSIGNED | NO | — | CHECK > 0 | `prix` (FLOAT) | FLOAT -> INT cents conversion at seed (see note 1) | -| `vat_rate` | SMALLINT UNSIGNED | NO | 100 | CHECK IN (55, 100) | (added) | VAT rate in per-mille: 100 = 10%, 55 = 5.5%. Default 10%. See note 9 | -| `image_path` | VARCHAR(255) | YES | NULL | — | `image` | relative path, see note 8 | -| `is_available` | TINYINT(1) | NO | 1 | — | (added) | manual availability toggle from admin | -| `display_order` | SMALLINT UNSIGNED | NO | 0 | — | (added) | display order within category | +| `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | `id` | identique a la source | +| `category_id` | INT UNSIGNED | NO | — | FK -> `category(id)`, ON DELETE RESTRICT | (derive de la cle d'objet JSON) | | +| `name` | VARCHAR(120) | NO | — | INDEX | `nom` | renomme depuis `nom` | +| `description` | TEXT | YES | NULL | — | (ajoute) | renseigne plus tard via l'admin | +| `price_cents` | INT UNSIGNED | NO | — | CHECK > 0 | `prix` (FLOAT) | conversion FLOAT -> INT centimes au seed (voir note 1) | +| `vat_rate` | SMALLINT UNSIGNED | NO | 100 | CHECK IN (55, 100) | (ajoute) | taux de TVA en pour-mille : 100 = 10%, 55 = 5,5%. Defaut 10%. Voir note 9 | +| `image_path` | VARCHAR(255) | YES | NULL | — | `image` | chemin relatif, voir note 8 | +| `is_available` | TINYINT(1) | NO | 1 | — | (ajoute) | bascule de disponibilite manuelle depuis l'admin | +| `display_order` | SMALLINT UNSIGNED | NO | 0 | — | (ajoute) | ordre d'affichage au sein de la categorie | | `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | — | — | audit | | `updated_at` | DATETIME | NO | CURRENT_TIMESTAMP ON UPDATE | — | — | audit | -**Volume**: ~53 rows at init (66 rows in `produits.json` minus 13 menus moved to `menu`). +**Volume** : ~53 lignes a l'init (66 lignes dans `produits.json` moins 13 menus deplaces vers `menu`). --- ### 3.3 `menu` -Fixed-price combo built around a specific burger, with customer-selectable slots -(drink, side, sauce). Two price tiers: Normal and Maxi. +Combo a prix fixe construit autour d'un burger specifique, avec des slots selectionnables par le client +(boisson, accompagnement, sauce). Deux paliers de prix : Normal et Maxi. -| Attribute | Type | NULL | Default | Constraint | School source | Notes | +| Attribut | Type | NULL | Default | Contrainte | Source ecole | Notes | |---|---|---|---|---|---|---| -| `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | `id` (1-13 in `menus` category) | | -| `category_id` | INT UNSIGNED | NO | — | FK -> `category(id)`, ON DELETE RESTRICT | implicit (category `menus`) | | -| `burger_product_id` | INT UNSIGNED | NO | — | FK -> `product(id)`, ON DELETE RESTRICT | (added) | the fixed burger that anchors this menu; drives ingredient customization | -| `name` | VARCHAR(120) | NO | — | INDEX | `nom` | e.g., "Menu Le 280" | -| `description` | TEXT | YES | NULL | — | (added) | | -| `price_normal_cents` | INT UNSIGNED | NO | — | CHECK > 0 | `prix` | Normal format price. Replaces single `prix_ttc_cents`. | -| `price_maxi_cents` | INT UNSIGNED | NO | — | CHECK > 0 | (added) | Maxi format price (~+150 cents vs normal; see note 7) | -| `image_path` | VARCHAR(255) | YES | NULL | — | `image` | typically reuses the burger image | -| `is_available` | TINYINT(1) | NO | 1 | — | (added) | | -| `display_order` | SMALLINT UNSIGNED | NO | 0 | — | (added) | | +| `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | `id` (1-13 dans la categorie `menus`) | | +| `category_id` | INT UNSIGNED | NO | — | FK -> `category(id)`, ON DELETE RESTRICT | implicite (categorie `menus`) | | +| `burger_product_id` | INT UNSIGNED | NO | — | FK -> `product(id)`, ON DELETE RESTRICT | (ajoute) | le burger fixe qui ancre ce menu ; pilote la personnalisation des ingredients | +| `name` | VARCHAR(120) | NO | — | INDEX | `nom` | ex. "Menu Le 280" | +| `description` | TEXT | YES | NULL | — | (ajoute) | | +| `price_normal_cents` | INT UNSIGNED | NO | — | CHECK > 0 | `prix` | prix format Normal. Remplace le `prix_ttc_cents` unique. | +| `price_maxi_cents` | INT UNSIGNED | NO | — | CHECK > 0 | (ajoute) | prix format Maxi (~+150 centimes vs normal ; voir note 7) | +| `image_path` | VARCHAR(255) | YES | NULL | — | `image` | reutilise generalement l'image du burger | +| `is_available` | TINYINT(1) | NO | 1 | — | (ajoute) | | +| `display_order` | SMALLINT UNSIGNED | NO | 0 | — | (ajoute) | | | `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | — | — | audit | | `updated_at` | DATETIME | NO | CURRENT_TIMESTAMP ON UPDATE | — | — | audit | -**Volume**: 13 rows at init. Replaces the old fixed-composition `menu_produit` model. +**Volume** : 13 lignes a l'init. Remplace l'ancien modele `menu_produit` a composition fixe. --- ### 3.4 `menu_slot` -A selectable slot within a menu (e.g., "drink slot", "side slot", "sauce slot"). -Each slot constrains which products the customer can choose from, expressed via -the join table `menu_slot_option`. +Un slot selectionnable au sein d'un menu (ex. "slot boisson", "slot accompagnement", "slot sauce"). +Chaque slot contraint les produits parmi lesquels le client peut choisir, exprimes via +la table de jointure `menu_slot_option`. -| Attribute | Type | NULL | Default | Constraint | Notes | +| Attribut | Type | NULL | Default | Contrainte | Notes | |---|---|---|---|---|---| | `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | | -| `menu_id` | INT UNSIGNED | NO | — | FK -> `menu(id)`, ON DELETE CASCADE | a slot belongs to exactly one menu | -| `name` | VARCHAR(80) | NO | — | — | e.g., "Drink", "Side", "Sauce" | -| `slot_type` | ENUM('drink','side','sauce','dessert','extra') | NO | — | — | semantic role of this slot | -| `is_required` | TINYINT(1) | NO | 1 | — | whether the customer must fill this slot | -| `display_order` | SMALLINT UNSIGNED | NO | 0 | — | order of display in the menu builder | +| `menu_id` | INT UNSIGNED | NO | — | FK -> `menu(id)`, ON DELETE CASCADE | un slot appartient a exactement un menu | +| `name` | VARCHAR(80) | NO | — | — | ex. "Drink", "Side", "Sauce" | +| `slot_type` | ENUM('drink','side','sauce','dessert','extra') | NO | — | — | role semantique de ce slot | +| `is_required` | TINYINT(1) | NO | 1 | — | indique si le client doit remplir ce slot | +| `display_order` | SMALLINT UNSIGNED | NO | 0 | — | ordre d'affichage dans le constructeur de menu | -**No audit fields**: a slot is part of menu definition; created and updated with the menu. -**Composite index**: `(menu_id, display_order)`. +**Pas de champs d'audit** : un slot fait partie de la definition du menu ; cree et mis a jour avec le menu. +**Index composite** : `(menu_id, display_order)`. --- ### 3.5 `menu_slot_option` -Eligible products for a given menu slot. Pure join table. +Produits eligibles pour un slot de menu donne. Table de jointure pure. -| Attribute | Type | NULL | Default | Constraint | Notes | +| Attribut | Type | NULL | Default | Contrainte | Notes | |---|---|---|---|---|---| | `menu_slot_id` | INT UNSIGNED | NO | — | FK -> `menu_slot(id)`, ON DELETE CASCADE | | -| `product_id` | INT UNSIGNED | NO | — | FK -> `product(id)`, ON DELETE RESTRICT | RESTRICT: removing a product must not silently break menus | +| `product_id` | INT UNSIGNED | NO | — | FK -> `product(id)`, ON DELETE RESTRICT | RESTRICT : retirer un produit ne doit pas casser silencieusement les menus | -**Primary key**: composite `(menu_slot_id, product_id)`. +**Cle primaire** : composite `(menu_slot_id, product_id)`. -**Volume**: ~3-5 options per slot, ~3 slots per menu, 13 menus = ~120-200 rows at init. +**Volume** : ~3-5 options par slot, ~3 slots par menu, 13 menus = ~120-200 lignes a l'init. --- ### 3.6 `ingredient` -Elementary ingredient used in product composition. Carries stock data. +Ingredient elementaire utilise dans la composition des produits. Porte les donnees de stock. -| Attribute | Type | NULL | Default | Constraint | Notes | +| Attribut | Type | NULL | Default | Contrainte | Notes | |---|---|---|---|---|---| | `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | | -| `name` | VARCHAR(120) | NO | — | UNIQUE | e.g., "Sesame Bun", "Cheddar Slice", "Ketchup Portion" | -| `unit` | VARCHAR(40) | NO | — | — | packaging unit label: piece / portion / sachet 1kg / pot / bottle (free-form label, not an ENUM — units vary per ingredient) | -| `stock_quantity` | INT (signed) | NO | 0 | — | current stock in units. Signed INT with no `CHECK >= 0`: it MAY go negative when sales outrun counted stock (oversell magnitude, surfaced to managers). The system does not block an order on stock. | -| `stock_capacity` | INT | NO | — | CHECK > 0 | reference "full" level in units = the 100% used to compute the stock percentage. The `CHECK > 0` also guards the percentage division against divide-by-zero | -| `pack_size` | SMALLINT UNSIGNED | NO | 1 | CHECK > 0 | units per restocking pack (e.g., 100 for a bag of 100 portions) | -| `pack_label` | VARCHAR(80) | YES | NULL | — | human label of the pack (e.g., "Sac 100 portions") | -| `low_stock_pct` | SMALLINT UNSIGNED | NO | 10 | CHECK BETWEEN 0 AND 100 | warning band, percent of capacity: `stock_quantity <= stock_capacity * low_stock_pct/100` triggers the low-stock indicator | -| `critical_stock_pct` | SMALLINT UNSIGNED | NO | 5 | CHECK BETWEEN 0 AND 100 | auto-out-of-stock floor, percent of capacity: `stock_quantity <= stock_capacity * critical_stock_pct/100` makes the product computed out-of-stock | -| `is_active` | TINYINT(1) | NO | 1 | — | deactivate obsolete ingredients without deleting | +| `name` | VARCHAR(120) | NO | — | UNIQUE | ex. "Sesame Bun", "Cheddar Slice", "Ketchup Portion" | +| `unit` | VARCHAR(40) | NO | — | — | libelle de l'unite de conditionnement : piece / portion / sachet 1kg / pot / bouteille (libelle libre, pas un ENUM — les unites varient par ingredient) | +| `stock_quantity` | INT (signed) | NO | 0 | — | stock courant en unites. INT signe sans `CHECK >= 0` : il PEUT devenir negatif quand les ventes depassent le stock compte (ampleur de la survente, remontee aux managers). Le systeme ne bloque pas une commande sur le stock. | +| `stock_capacity` | INT | NO | — | CHECK > 0 | niveau "plein" de reference en unites = les 100% servant a calculer le pourcentage de stock. Le `CHECK > 0` protege aussi la division du pourcentage contre la division par zero | +| `pack_size` | SMALLINT UNSIGNED | NO | 1 | CHECK > 0 | unites par pack de reapprovisionnement (ex. 100 pour un sac de 100 portions) | +| `pack_label` | VARCHAR(80) | YES | NULL | — | libelle humain du pack (ex. "Sac 100 portions") | +| `low_stock_pct` | SMALLINT UNSIGNED | NO | 10 | CHECK BETWEEN 0 AND 100 | bande d’alerte, pourcentage de capacite : `stock_quantity <= stock_capacity * low_stock_pct/100` declenche l'indicateur de stock bas | +| `critical_stock_pct` | SMALLINT UNSIGNED | NO | 5 | CHECK BETWEEN 0 AND 100 | seuil de rupture automatique, pourcentage de capacite : `stock_quantity <= stock_capacity * critical_stock_pct/100` rend le produit calcule en rupture | +| `is_active` | TINYINT(1) | NO | 1 | — | desactiver les ingredients obsoletes sans supprimer | | `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | — | audit | | `updated_at` | DATETIME | NO | CURRENT_TIMESTAMP ON UPDATE | — | audit | -**Table-level CHECK**: `critical_stock_pct < low_stock_pct` (the critical floor sits below the warning band). +**CHECK au niveau table** : `critical_stock_pct < low_stock_pct` (le seuil critique se situe sous la bande d’alerte). -**Stock decrement rule**: at the `paid` transition, each ingredient is decremented by -`product_ingredient.quantity_normal` or `quantity_maxi` (selected by `order_item.format`) -multiplied by `order_item.quantity`, then adjusted by `order_item_modifier` rows. See note 7. -**Restocking rule**: `stock_quantity += N * pack_size` (restocked in full packs). -**Cancellation rule**: stock is re-credited when a `paid` order is cancelled. -**Stock model (percentage-based, three bands)**: the absolute alert threshold is replaced by a -percentage model anchored on `stock_capacity` (the 100% reference). The stock percentage is -computed, not stored: `stock_pct = ROUND(stock_quantity / stock_capacity * 100)`. The -`CHECK > 0` on `stock_capacity` guards this division against divide-by-zero. Three bands: -- **Normal** — above the low band: nothing flagged. -- **Low** — `stock_quantity <= stock_capacity * low_stock_pct/100`: orderable + manager alert. - The manager either pulls the product via `product.is_available=0`, or restocks to clear the alert. -- **Critical** — `stock_quantity <= stock_capacity * critical_stock_pct/100`: the product - auto-goes out-of-stock (computed availability, see rule RG-T21 in `mlt.md`); no extra stored column. +**Regle de decrement de stock** : a la transition `paid`, chaque ingredient est decremente de +`product_ingredient.quantity_normal` ou `quantity_maxi` (selectionne par `order_item.format`) +multiplie par `order_item.quantity`, puis ajuste par les lignes `order_item_modifier`. Voir note 7. +**Regle de reapprovisionnement** : `stock_quantity += N * pack_size` (reapprovisionne en packs complets). +**Regle d'annulation** : le stock est recredite quand une commande `paid` est annulee. +**Modele de stock (base sur le pourcentage, trois bandes)** : le seuil d'alerte absolu est remplace par un +modele en pourcentage ancre sur `stock_capacity` (la reference 100%). Le pourcentage de stock est +calcule, non stocke : `stock_pct = ROUND(stock_quantity / stock_capacity * 100)`. Le +`CHECK > 0` sur `stock_capacity` protege cette division contre la division par zero. Trois bandes : +- **Normal** — au-dessus de la bande d’alerte : rien n'est signale. +- **Low** — `stock_quantity <= stock_capacity * low_stock_pct/100` : commandable + alerte manager. + Le manager retire le produit via `product.is_available=0`, ou reapprovisionne pour lever l'alerte. +- **Critical** — `stock_quantity <= stock_capacity * critical_stock_pct/100` : le produit + passe automatiquement en rupture (disponibilite calculee, voir regle RG-T21 dans `mlt.md`) ; aucune colonne stockee supplementaire. --- ### 3.7 `product_ingredient` -Default composition of a product (burger, wrap, etc.) in terms of ingredients. -Carries customization metadata for the ingredient configurator. +Composition par defaut d'un produit (burger, wrap, etc.) en termes d'ingredients. +Porte les metadonnees de personnalisation pour le configurateur d'ingredients. -| Attribute | Type | NULL | Default | Constraint | Notes | +| Attribut | Type | NULL | Default | Contrainte | Notes | |---|---|---|---|---|---| | `product_id` | INT UNSIGNED | NO | — | FK -> `product(id)`, ON DELETE CASCADE | | -| `ingredient_id` | INT UNSIGNED | NO | — | FK -> `ingredient(id)`, ON DELETE RESTRICT | RESTRICT: cannot remove an ingredient still referenced in a product recipe | -| `quantity_normal` | SMALLINT UNSIGNED | NO | 1 | CHECK > 0 | units consumed in Normal format (e.g., 2 for double cheese) | -| `quantity_maxi` | SMALLINT UNSIGNED | NO | 1 | CHECK > 0 | units consumed in Maxi format. Equals `quantity_normal` for format-invariant ingredients (burger, sauce); higher for side and drink ingredients (Maxi enlarges side + drink only). See note 7. | -| `is_removable` | TINYINT(1) | NO | 1 | — | customer can remove this ingredient at no cost | -| `is_addable` | TINYINT(1) | NO | 0 | — | customer can add an extra unit of this ingredient | -| `extra_price_cents` | INT UNSIGNED | NO | 0 | CHECK >= 0 | surcharge in cents when `is_addable=1` and customer adds it (0 = free extra) | +| `ingredient_id` | INT UNSIGNED | NO | — | FK -> `ingredient(id)`, ON DELETE RESTRICT | RESTRICT : impossible de retirer un ingredient encore reference dans une recette de produit | +| `quantity_normal` | SMALLINT UNSIGNED | NO | 1 | CHECK > 0 | unites consommees en format Normal (ex. 2 pour double cheese) | +| `quantity_maxi` | SMALLINT UNSIGNED | NO | 1 | CHECK > 0 | unites consommees en format Maxi. Egale `quantity_normal` pour les ingredients invariants au format (burger, sauce) ; superieure pour les ingredients d'accompagnement et de boisson (le Maxi agrandit uniquement l'accompagnement + la boisson). Voir note 7. | +| `is_removable` | TINYINT(1) | NO | 1 | — | le client peut retirer cet ingredient sans frais | +| `is_addable` | TINYINT(1) | NO | 0 | — | le client peut ajouter une unite supplementaire de cet ingredient | +| `extra_price_cents` | INT UNSIGNED | NO | 0 | CHECK >= 0 | supplement en centimes quand `is_addable=1` et que le client l'ajoute (0 = extra gratuit) | -**Primary key**: composite `(product_id, ingredient_id)`. +**Cle primaire** : composite `(product_id, ingredient_id)`. -**Volume**: ~5-10 ingredients per product, ~53 products = ~300-500 rows at seed. +**Volume** : ~5-10 ingredients par produit, ~53 produits = ~300-500 lignes au seed. --- ### 3.8 `allergen` -Catalogue of the 14 regulated allergens (INCO Regulation (EU) 1169/2011). +Catalogue des 14 allergenes reglementes (Reglement INCO (UE) 1169/2011). -| Attribute | Type | NULL | Default | Constraint | Notes | +| Attribut | Type | NULL | Default | Contrainte | Notes | |---|---|---|---|---|---| | `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | | -| `code` | VARCHAR(30) | NO | — | UNIQUE | machine-readable code, e.g., `gluten`, `milk`, `nuts` | -| `name` | VARCHAR(80) | NO | — | — | display name, e.g., "Gluten", "Lait", "Fruits a coque" | -| `description` | TEXT | YES | NULL | — | optional guidance for staff | +| `code` | VARCHAR(30) | NO | — | UNIQUE | code lisible par machine, ex. `gluten`, `milk`, `nuts` | +| `name` | VARCHAR(80) | NO | — | — | nom d'affichage, ex. "Gluten", "Lait", "Fruits a coque" | +| `description` | TEXT | YES | NULL | — | guidance optionnelle pour le personnel | -**Volume**: 14 rows at seed (fixed by EU regulation 1169/2011, list confirmed at seed time). -Allergens for a product are **computed** by joining `product_ingredient` -> -`ingredient_allergen` -> `allergen`; no manual re-entry per product. +**Volume** : 14 lignes au seed (fixe par le reglement UE 1169/2011, liste confirmee au moment du seed). +Les allergenes d'un produit sont **calcules** en joignant `product_ingredient` -> +`ingredient_allergen` -> `allergen` ; pas de ressaisie manuelle par produit. --- ### 3.9 `ingredient_allergen` -Maps which allergens each ingredient contains. Pure join table. +Indique quels allergenes contient chaque ingredient. Table de jointure pure. -| Attribute | Type | NULL | Default | Constraint | Notes | +| Attribut | Type | NULL | Default | Contrainte | Notes | |---|---|---|---|---|---| | `ingredient_id` | INT UNSIGNED | NO | — | FK -> `ingredient(id)`, ON DELETE CASCADE | | | `allergen_id` | INT UNSIGNED | NO | — | FK -> `allergen(id)`, ON DELETE RESTRICT | | -**Primary key**: composite `(ingredient_id, allergen_id)`. +**Cle primaire** : composite `(ingredient_id, allergen_id)`. --- ### 3.10 `customer_order` -Customer transaction: 1 order = 1 validated cart at a point in time. -(Table name rationale: see modeling note 3.) +Transaction client : 1 commande = 1 panier valide a un instant donne. +(Rationale du nom de table : voir note de modelisation 3.) -| Attribute | Type | NULL | Default | Constraint | Notes | +| Attribut | Type | NULL | Default | Contrainte | Notes | |---|---|---|---|---|---| | `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | | -| `order_number` | VARCHAR(20) | NO | — | UNIQUE | human-readable format: `K`/`C`/`D`-YYYY-MM-DD-NNN. Prefix by channel: K=kiosk, C=counter, D=drive. See note 4. | -| `idempotency_key` | VARCHAR(36) | YES | NULL | UNIQUE | client-generated UUID to deduplicate a retried `POST /api/orders` (anti-double-charge). UNIQUE rejects duplicates; multiple NULLs allowed. Security-by-design, see note 13 | -| `source` | ENUM('kiosk','counter','drive') | NO | — | INDEX | input channel (who entered the order). Values in English, see note 5. | -| `acting_user_id` | INT UNSIGNED | YES | NULL | FK -> `user(id)`, ON DELETE SET NULL | back-office staff (counter/drive) who created the order, captured under PIN. NULL for `kiosk` (anonymous). Targeted accountability without forcing per-person login on the kiosk. See note 13 | -| `service_mode` | ENUM('dine_in','takeaway','drive') | NO | — | — | consumption mode, retained for stats/KPI only. No fiscal role (see note 9). `drive` source implies `drive` service_mode (cross-constraint enforced at app layer). | -| `status` | ENUM('pending_payment','paid','delivered','cancelled') | NO | 'pending_payment' | INDEX | 4-state machine: `pending_payment -> paid -> delivered` (+ `cancelled`). See note 6. | -| `total_ht_cents` | INT UNSIGNED | NO | — | CHECK >= 0 | ex-VAT total, snapshot at order validation | -| `total_vat_cents` | INT UNSIGNED | NO | — | CHECK >= 0 | VAT amount, snapshot | -| `total_ttc_cents` | INT UNSIGNED | NO | — | CHECK > 0 | incl.-VAT total; must equal total_ht_cents + total_vat_cents (verified at MLT layer) | -| `paid_at` | DATETIME | YES | NULL | — | timestamp of transition to `paid` (NULL before payment) | -| `delivered_at` | DATETIME | YES | NULL | — | timestamp of transition to `delivered` (NULL before delivery) | -| `cancelled_at` | DATETIME | YES | NULL | — | timestamp of cancellation (NULL if not cancelled) | -| `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | INDEX | used for live stats aggregations; also serves as `service_day` base | +| `order_number` | VARCHAR(20) | NO | — | UNIQUE | format lisible par l'humain : `K`/`C`/`D`-YYYY-MM-DD-NNN. Prefixe par canal : K=kiosk, C=counter, D=drive. Voir note 4. | +| `idempotency_key` | VARCHAR(36) | YES | NULL | UNIQUE | UUID genere par le client pour dedupliquer un `POST /api/orders` reessaye (anti-double-charge). UNIQUE rejette les doublons ; plusieurs NULL autorises. Security-by-design, voir note 13 | +| `source` | ENUM('kiosk','counter','drive') | NO | — | INDEX | canal de saisie (qui a saisi la commande). Valeurs en anglais, voir note 5. | +| `acting_user_id` | INT UNSIGNED | YES | NULL | FK -> `user(id)`, ON DELETE SET NULL | personnel back-office (counter/drive) ayant cree la commande, capture sous PIN. NULL pour `kiosk` (anonyme). Imputabilite ciblee sans imposer un login par personne sur la borne. Voir note 13 | +| `service_mode` | ENUM('dine_in','takeaway','drive') | NO | — | — | mode de consommation, conserve pour les stats/KPI uniquement. Aucun role fiscal (voir note 9). La source `drive` implique le service_mode `drive` (contrainte croisee appliquee au niveau applicatif). | +| `status` | ENUM('pending_payment','paid','delivered','cancelled') | NO | 'pending_payment' | INDEX | machine a 4 etats : `pending_payment -> paid -> delivered` (+ `cancelled`). Voir note 6. | +| `total_ht_cents` | INT UNSIGNED | NO | — | CHECK >= 0 | total hors TVA, snapshot a la validation de la commande | +| `total_vat_cents` | INT UNSIGNED | NO | — | CHECK >= 0 | montant de TVA, snapshot | +| `total_ttc_cents` | INT UNSIGNED | NO | — | CHECK > 0 | total TTC ; doit egaler total_ht_cents + total_vat_cents (verifie a la couche MLT) | +| `paid_at` | DATETIME | YES | NULL | — | timestamp de la transition vers `paid` (NULL avant paiement) | +| `delivered_at` | DATETIME | YES | NULL | — | timestamp de la transition vers `delivered` (NULL avant la remise) | +| `cancelled_at` | DATETIME | YES | NULL | — | timestamp d'annulation (NULL si non annulee) | +| `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | INDEX | utilise pour les agregations de stats en direct ; sert aussi de base a `service_day` | | `updated_at` | DATETIME | NO | CURRENT_TIMESTAMP ON UPDATE | — | audit | -**Dropped from v0.1**: `tva_taux_pourmille` (moved to line level — `order_item.vat_rate_snapshot`), -`paye_a` (renamed `paid_at`). Machine states `preparing` and `ready` dropped (see note 6). +**Retire de v0.1** : `tva_taux_pourmille` (deplace au niveau ligne — `order_item.vat_rate_snapshot`), +`paye_a` (renomme `paid_at`). Etats machine `preparing` et `ready` retires (voir note 6). -**`service_day` computation** (KPI grouping): +**Calcul de `service_day`** (regroupement KPI) : ``` CASE WHEN HOUR(created_at) < 10 THEN DATE(created_at) - INTERVAL 1 DAY ELSE DATE(created_at) END ``` -Computed at query time, not stored as a column (the generated-column formula with `INTERVAL 4 HOUR -30 MINUTE` in v0.1 MLD was incorrect and is dropped). Cutoff: 10:00. +Calcule au moment de la requete, non stocke comme colonne (la formule de colonne generee avec `INTERVAL 4 HOUR +30 MINUTE` dans le MLD v0.1 etait incorrecte et est retiree). Coupure : 10:00. -**Volume**: ~100-300 orders/day at peak, ~10k rows over a 6-month demo. +**Volume** : ~100-300 commandes/jour au pic, ~10k lignes sur une demo de 6 mois. --- ### 3.11 `order_item` -Line of an order: a single product or a menu, with price, label, and VAT rate -snapshotted at transaction time. +Ligne d'une commande : un seul produit ou un menu, avec prix, libelle et taux de TVA +snapshotes au moment de la transaction. -| Attribute | Type | NULL | Default | Constraint | Notes | +| Attribut | Type | NULL | Default | Contrainte | Notes | |---|---|---|---|---|---| | `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | | | `order_id` | INT UNSIGNED | NO | — | FK -> `customer_order(id)`, ON DELETE CASCADE | | -| `item_type` | ENUM('product','menu') | NO | — | — | discriminator | -| `product_id` | INT UNSIGNED | YES | NULL | FK -> `product(id)`, ON DELETE RESTRICT | non-null if `item_type = 'product'` | -| `menu_id` | INT UNSIGNED | YES | NULL | FK -> `menu(id)`, ON DELETE RESTRICT | non-null if `item_type = 'menu'` | -| `format` | ENUM('normal','maxi') | NO | 'normal' | — | applies to menu items (Normal / Maxi). For standalone products, value is `normal` (no individual upsizing in this model). See note 7. | -| `label_snapshot` | VARCHAR(120) | NO | — | — | label at time of order (preserved if product is renamed) | -| `unit_price_cents_snapshot` | INT UNSIGNED | NO | — | CHECK > 0 | unit price incl. VAT at time of order | -| `vat_rate_snapshot` | SMALLINT UNSIGNED | NO | — | CHECK IN (55, 100) | VAT rate in per-mille at time of order (snapshotted from `product.vat_rate`) | -| `quantity` | SMALLINT UNSIGNED | NO | 1 | CHECK > 0 | quantity ordered (e.g., 3 Cocas = 1 line with quantity=3) | +| `item_type` | ENUM('product','menu') | NO | — | — | discriminateur | +| `product_id` | INT UNSIGNED | YES | NULL | FK -> `product(id)`, ON DELETE RESTRICT | non nul si `item_type = 'product'` | +| `menu_id` | INT UNSIGNED | YES | NULL | FK -> `menu(id)`, ON DELETE RESTRICT | non nul si `item_type = 'menu'` | +| `format` | ENUM('normal','maxi') | NO | 'normal' | — | s'applique aux items menu (Normal / Maxi). Pour les produits autonomes, la valeur est `normal` (pas d'agrandissement individuel dans ce modele). Voir note 7. | +| `label_snapshot` | VARCHAR(120) | NO | — | — | libelle au moment de la commande (preserve si le produit est renomme) | +| `unit_price_cents_snapshot` | INT UNSIGNED | NO | — | CHECK > 0 | prix unitaire TTC au moment de la commande | +| `vat_rate_snapshot` | SMALLINT UNSIGNED | NO | — | CHECK IN (55, 100) | taux de TVA en pour-mille au moment de la commande (snapshote depuis `product.vat_rate`) | +| `quantity` | SMALLINT UNSIGNED | NO | 1 | CHECK > 0 | quantite commandee (ex. 3 Cocas = 1 ligne avec quantity=3) | | `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | — | audit | -**CHECK constraint** (applicative or MariaDB CHECK >= 10.2): +**Contrainte CHECK** (applicative ou MariaDB CHECK >= 10.2) : `(item_type='product' AND product_id IS NOT NULL AND menu_id IS NULL) OR (item_type='menu' AND menu_id IS NOT NULL AND product_id IS NULL)` -**Volume**: ~3-5 lines per order -> 30k-50k rows over 6 months. +**Volume** : ~3-5 lignes par commande -> 30k-50k lignes sur 6 mois. --- ### 3.12 `order_item_selection` -The actual choices made by the customer for each slot of a menu line. -1 row = 1 slot filled for 1 order_item of type `menu`. +Les choix reels effectues par le client pour chaque slot d'une ligne de menu. +1 ligne = 1 slot rempli pour 1 order_item de type `menu`. -| Attribute | Type | NULL | Default | Constraint | Notes | +| Attribut | Type | NULL | Default | Contrainte | Notes | |---|---|---|---|---|---| | `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | | -| `order_item_id` | INT UNSIGNED | NO | — | FK -> `order_item(id)`, ON DELETE CASCADE | must reference an order_item with item_type='menu' | -| `menu_slot_id` | INT UNSIGNED | NO | — | FK -> `menu_slot(id)`, ON DELETE RESTRICT | which slot was filled | -| `product_id` | INT UNSIGNED | NO | — | FK -> `product(id)`, ON DELETE RESTRICT | product chosen by the customer for this slot | -| `label_snapshot` | VARCHAR(120) | NO | — | — | product label at time of order | +| `order_item_id` | INT UNSIGNED | NO | — | FK -> `order_item(id)`, ON DELETE CASCADE | doit referencer un order_item avec item_type='menu' | +| `menu_slot_id` | INT UNSIGNED | NO | — | FK -> `menu_slot(id)`, ON DELETE RESTRICT | quel slot a ete rempli | +| `product_id` | INT UNSIGNED | NO | — | FK -> `product(id)`, ON DELETE RESTRICT | produit choisi par le client pour ce slot | +| `label_snapshot` | VARCHAR(120) | NO | — | — | libelle du produit au moment de la commande | -**Volume**: ~2-3 selections per menu line. -**KPI use**: enables analysis of which drink/side combinations are most chosen. +**Volume** : ~2-3 selections par ligne de menu. +**Usage KPI** : permet d'analyser quelles combinaisons boisson/accompagnement sont les plus choisies. --- ### 3.13 `order_item_modifier` -Ingredient-level modifications applied by the customer to a product or to the fixed -burger of a menu: removal (free) or addition (with optional surcharge). +Modifications au niveau ingredient appliquees par le client a un produit ou au burger fixe +d'un menu : retrait (gratuit) ou ajout (avec supplement optionnel). -| Attribute | Type | NULL | Default | Constraint | Notes | +| Attribut | Type | NULL | Default | Contrainte | Notes | |---|---|---|---|---|---| | `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | | -| `order_item_id` | INT UNSIGNED | NO | — | FK -> `order_item(id)`, ON DELETE CASCADE | the order line being modified (product or menu) | -| `ingredient_id` | INT UNSIGNED | NO | — | FK -> `ingredient(id)`, ON DELETE RESTRICT | the ingredient being modified | -| `action` | ENUM('remove','add') | NO | — | — | `remove` = free removal; `add` = extra unit (may have surcharge) | -| `extra_price_cents` | INT UNSIGNED | NO | 0 | CHECK >= 0 | snapshot of `product_ingredient.extra_price_cents` at time of order (0 for removals) | +| `order_item_id` | INT UNSIGNED | NO | — | FK -> `order_item(id)`, ON DELETE CASCADE | la ligne de commande modifiee (produit ou menu) | +| `ingredient_id` | INT UNSIGNED | NO | — | FK -> `ingredient(id)`, ON DELETE RESTRICT | l'ingredient modifie | +| `action` | ENUM('remove','add') | NO | — | — | `remove` = retrait gratuit ; `add` = unite supplementaire (peut avoir un supplement) | +| `extra_price_cents` | INT UNSIGNED | NO | 0 | CHECK >= 0 | snapshot de `product_ingredient.extra_price_cents` au moment de la commande (0 pour les retraits) | -**Modifier attachment rule** (see modeling note 10): -- For a standalone product (`item_type='product'`): the modifier targets the product - directly via `order_item_id`. -- For a menu (`item_type='menu'`): the modifier targets the menu line's fixed burger - via the same `order_item_id`. The burger is identified by `menu.burger_product_id`, - allowing the kitchen display to resolve which ingredients are modified without ambiguity. - No additional FK is needed: given `order_item_id`, the burger is +**Regle de rattachement du modificateur** (voir note de modelisation 10) : +- Pour un produit autonome (`item_type='product'`) : le modificateur cible le produit + directement via `order_item_id`. +- Pour un menu (`item_type='menu'`) : le modificateur cible le burger fixe de la ligne de menu + via le meme `order_item_id`. Le burger est identifie par `menu.burger_product_id`, + permettant a l'affichage cuisine de resoudre sans ambiguite quels ingredients sont modifies. + Aucune FK supplementaire n'est necessaire : etant donne `order_item_id`, le burger est `order_item.menu_id -> menu.burger_product_id`. -**Stock impact**: each modifier affects ingredient stock at `paid` transition -(`remove` -> no decrement for that ingredient; `add` -> extra decrement). +**Impact stock** : chaque modificateur affecte le stock d'ingredient a la transition `paid` +(`remove` -> pas de decrement pour cet ingredient ; `add` -> decrement supplementaire). --- ### 3.14 `user` -Back-office user (admin, manager, kitchen staff, counter, drive). Kiosk customers -are not authenticated and have no row here. +Utilisateur back-office (admin, manager, personnel cuisine, counter, drive). Les clients de la borne +ne sont pas authentifies et n'ont pas de ligne ici. -| Attribute | Type | NULL | Default | Constraint | Notes | +| Attribut | Type | NULL | Default | Contrainte | Notes | |---|---|---|---|---|---| | `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | | -| `email` | VARCHAR(254) | NO | — | UNIQUE | max length per RFC 5321 | -| `password_hash` | VARCHAR(255) | NO | — | — | argon2id hash (see `PASSWORD_ALGO` in `.env`); typical length 96 chars, margin to 255 | +| `email` | VARCHAR(254) | NO | — | UNIQUE | longueur max selon RFC 5321 | +| `password_hash` | VARCHAR(255) | NO | — | — | hash argon2id (voir `PASSWORD_ALGO` dans `.env`) ; longueur typique 96 caracteres, marge jusqu'a 255 | | `first_name` | VARCHAR(60) | NO | — | — | | | `last_name` | VARCHAR(60) | NO | — | — | | -| `role_id` | INT UNSIGNED | NO | — | FK -> `role(id)`, ON DELETE RESTRICT | a user cannot exist without a role | -| `is_active` | TINYINT(1) | NO | 1 | — | deactivation without deletion | -| `last_login_at` | DATETIME | YES | NULL | — | useful for audit and dormant account detection | -| `pin_hash` | VARCHAR(255) | YES | NULL | — | argon2id hash of the per-staff PIN that authorises sensitive actions (price/RBAC/user/cancel/inventory). NULL = no PIN set. Security-by-design, see note 13 | -| `failed_login_attempts` | SMALLINT UNSIGNED | NO | 0 | — | consecutive failed logins; drives degressive throttling (note 13) | -| `last_failed_login_at` | DATETIME | YES | NULL | — | timestamp of the last failed login | -| `lockout_until` | DATETIME | YES | NULL | — | end of the current throttling window (degressive backoff, not a hard indefinite lock) | -| `password_reset_token_hash` | VARCHAR(255) | YES | NULL | — | hash of the reset token (not the raw token); NULL when no reset pending | -| `password_reset_expires_at` | DATETIME | YES | NULL | — | expiry of the reset token | -| `anonymized_at` | DATETIME | YES | NULL | — | RGPD tombstone marker: when set, PII columns are nulled/replaced (note 13). The row is kept for referential integrity | +| `role_id` | INT UNSIGNED | NO | — | FK -> `role(id)`, ON DELETE RESTRICT | un utilisateur ne peut exister sans role | +| `is_active` | TINYINT(1) | NO | 1 | — | desactivation sans suppression | +| `last_login_at` | DATETIME | YES | NULL | — | utile pour l'audit et la detection de comptes dormants | +| `pin_hash` | VARCHAR(255) | YES | NULL | — | hash argon2id du PIN par membre du personnel qui autorise les actions sensibles (prix/RBAC/utilisateur/annulation/inventaire). NULL = aucun PIN defini. Security-by-design, voir note 13 | +| `failed_login_attempts` | SMALLINT UNSIGNED | NO | 0 | — | logins echoues consecutifs ; pilote le throttling degressif (note 13) | +| `last_failed_login_at` | DATETIME | YES | NULL | — | timestamp du dernier login echoue | +| `lockout_until` | DATETIME | YES | NULL | — | fin de la fenetre de throttling courante (backoff degressif, pas un verrouillage dur indefini) | +| `password_reset_token_hash` | VARCHAR(255) | YES | NULL | — | hash du token de reset (pas le token brut) ; NULL quand aucun reset en attente | +| `password_reset_expires_at` | DATETIME | YES | NULL | — | expiration du token de reset | +| `anonymized_at` | DATETIME | YES | NULL | — | marqueur tombstone RGPD : quand renseigne, les colonnes PII sont mises a NULL/remplacees (note 13). La ligne est conservee pour l'integrite referentielle | | `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | — | audit | | `updated_at` | DATETIME | NO | CURRENT_TIMESTAMP ON UPDATE | — | audit | -**Volume**: 5-20 rows (restaurant team + 1-2 admins). +**Volume** : 5-20 lignes (equipe du restaurant + 1-2 admins). -RFC 5321 email length: local-part <= 64, domain <= 255, total <= 254 (including `@`). -VARCHAR(254) is the spec-compliant value. +Longueur d'email RFC 5321 : local-part <= 64, domaine <= 255, total <= 254 (incluant `@`). +VARCHAR(254) est la valeur conforme a la spec. -**PII columns**: `email`, `first_name`, `last_name`. Subject to RGPD anonymisation -(see note 13). `password_hash` and `pin_hash` are credentials, kept out of logs and -API responses. +**Colonnes PII** : `email`, `first_name`, `last_name`. Soumises a l'anonymisation RGPD +(voir note 13). `password_hash` et `pin_hash` sont des credentials, tenus hors des logs et +des reponses d'API. --- ### 3.15 `role` -Back-office roles (RBAC). Creatable / modifiable / deactivatable from admin UI. -Seed provides 5 roles; custom roles (e.g., "chef-patissier") can be added without deployment. +Roles back-office (RBAC). Creables / modifiables / desactivables depuis l'UI admin. +Le seed fournit 5 roles ; des roles personnalises (ex. "chef-patissier") peuvent etre ajoutes sans deploiement. -| Attribute | Type | NULL | Default | Constraint | Notes | +| Attribut | Type | NULL | Default | Contrainte | Notes | |---|---|---|---|---|---| | `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | | -| `code` | VARCHAR(40) | NO | — | UNIQUE | machine code, e.g., `admin`, `manager`, `kitchen`, `counter`, `drive` | -| `label` | VARCHAR(80) | NO | — | — | display name, e.g., `Administrator`, `Kitchen Staff` | +| `code` | VARCHAR(40) | NO | — | UNIQUE | code machine, ex. `admin`, `manager`, `kitchen`, `counter`, `drive` | +| `label` | VARCHAR(80) | NO | — | — | nom d'affichage, ex. `Administrator`, `Kitchen Staff` | | `description` | TEXT | YES | NULL | — | | -| `default_route` | VARCHAR(120) | YES | NULL | — | landing screen for this role (e.g., `/admin/orders`, `/kitchen/display`). Makes routing dynamic — no hardcoded role names in front-end routing. | -| `order_source` | ENUM('kiosk','counter','drive') | YES | NULL | — | auto-tagged `source` when this role creates an order (NULL for admin/manager who can create on behalf of any channel) | -| `is_active` | TINYINT(1) | NO | 1 | — | deactivation preserves history of users who held this role | +| `default_route` | VARCHAR(120) | YES | NULL | — | ecran d'atterrissage pour ce role (ex. `/admin/orders`, `/kitchen/display`). Rend le routage dynamique — pas de noms de role en dur dans le routage front-end. | +| `order_source` | ENUM('kiosk','counter','drive') | YES | NULL | — | `source` auto-taggee quand ce role cree une commande (NULL pour admin/manager qui peuvent creer au nom de n'importe quel canal) | +| `is_active` | TINYINT(1) | NO | 1 | — | la desactivation preserve l'historique des utilisateurs ayant detenu ce role | | `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | — | audit | | `updated_at` | DATETIME | NO | CURRENT_TIMESTAMP ON UPDATE | — | audit | -**Seed roles**: +**Roles du seed** : | Code | `default_route` | `order_source` | |---|---|---| | `admin` | `/admin/dashboard` | NULL | @@ -444,28 +444,28 @@ Seed provides 5 roles; custom roles (e.g., "chef-patissier") can be added withou | `counter` | `/counter/orders` | `counter` | | `drive` | `/drive/orders` | `drive` | -**RBAC architecture rule (P2)**: application code tests permissions, not role names. -Adding a new role with the right permissions requires no code change (permission-driven, -not role-name-driven — per Sandhu/NIST RBAC model). +**Regle d'architecture RBAC (P2)** : le code applicatif teste les permissions, pas les noms de role. +Ajouter un nouveau role avec les bonnes permissions ne requiert aucun changement de code (pilote par permission, +non par nom de role — selon le modele RBAC Sandhu/NIST). --- ### 3.16 `role_visible_source` -Defines which order sources are visible on the preparation dashboard for a given role. -Pure join table. +Definit quelles sources de commande sont visibles sur le tableau de bord de preparation pour un role donne. +Table de jointure pure. -| Attribute | Type | NULL | Default | Constraint | Notes | +| Attribut | Type | NULL | Default | Contrainte | Notes | |---|---|---|---|---|---| | `role_id` | INT UNSIGNED | NO | — | FK -> `role(id)`, ON DELETE CASCADE | | -| `source` | ENUM('kiosk','counter','drive') | NO | — | — | source visible to this role on the kitchen/counter/drive display | +| `source` | ENUM('kiosk','counter','drive') | NO | — | — | source visible pour ce role sur l'affichage kitchen/counter/drive | -**Primary key**: composite `(role_id, source)`. +**Cle primaire** : composite `(role_id, source)`. -**Seed data**: -| Role | Visible sources | +**Donnees du seed** : +| Role | Sources visibles | |---|---| -| `kitchen` | kiosk, counter, drive (all) | +| `kitchen` | kiosk, counter, drive (toutes) | | `counter` | kiosk, counter | | `drive` | drive | @@ -473,19 +473,19 @@ Pure join table. ### 3.17 `permission` -Granular permissions assignable to roles. Catalogue is fixed at seed (no UI creation). +Permissions granulaires assignables aux roles. Le catalogue est fixe au seed (pas de creation via UI). -| Attribute | Type | NULL | Default | Constraint | Notes | +| Attribut | Type | NULL | Default | Contrainte | Notes | |---|---|---|---|---|---| | `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | | | `code` | VARCHAR(60) | NO | — | UNIQUE | format `.` | -| `label` | VARCHAR(120) | NO | — | — | display name | +| `label` | VARCHAR(120) | NO | — | — | nom d'affichage | | `description` | TEXT | YES | NULL | — | | | `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | — | audit | -**Fixed permission catalogue** (23 codes — frozen before DDL): +**Catalogue de permissions fixe** (23 codes — gele avant le DDL) : -| Code | Granted to (seed default) | +| Code | Accorde a (defaut seed) | |---|---| | `product.create` | admin, manager | | `product.read` | admin, manager, kitchen, counter, drive | @@ -511,248 +511,248 @@ Granular permissions assignable to roles. Catalogue is fixed at seed (no UI crea | `role.manage` | admin | | `stats.read` | admin, manager | -**Volume**: 23 rows at seed. +**Volume** : 23 lignes au seed. --- ### 3.18 `role_permission` -N-N mapping between roles and permissions. Pure join table. +Mapping N-N entre roles et permissions. Table de jointure pure. -| Attribute | Type | NULL | Default | Constraint | Notes | +| Attribut | Type | NULL | Default | Contrainte | Notes | |---|---|---|---|---|---| | `role_id` | INT UNSIGNED | NO | — | FK -> `role(id)`, ON DELETE CASCADE | | | `permission_id` | INT UNSIGNED | NO | — | FK -> `permission(id)`, ON DELETE CASCADE | | -**Primary key**: composite `(role_id, permission_id)`. +**Cle primaire** : composite `(role_id, permission_id)`. -**Volume**: ~50-100 rows at seed (admin covers all; others cover a subset). +**Volume** : ~50-100 lignes au seed (admin couvre tout ; les autres couvrent un sous-ensemble). --- ### 3.19 `stock_movement` -Append-only audit log of all stock changes per ingredient. -1 row per movement (sale, cancellation, restock, inventory correction). +Journal d'audit append-only de tous les changements de stock par ingredient. +1 ligne par mouvement (vente, annulation, reapprovisionnement, correction d'inventaire). -| Attribute | Type | NULL | Default | Constraint | Notes | +| Attribut | Type | NULL | Default | Contrainte | Notes | |---|---|---|---|---|---| | `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | | -| `ingredient_id` | INT UNSIGNED | NO | — | FK -> `ingredient(id)`, ON DELETE RESTRICT | ingredient affected | -| `movement_type` | ENUM('sale','cancellation','restock','inventory_correction') | NO | — | INDEX | nature of the movement | -| `delta` | INT | NO | — | — | signed change: negative for consumption (sale), positive for restock/cancellation/correction | -| `order_id` | INT UNSIGNED | YES | NULL | FK -> `customer_order(id)`, ON DELETE SET NULL | linked order for `sale` and `cancellation` movements; NULL for restock/correction | -| `user_id` | INT UNSIGNED | YES | NULL | FK -> `user(id)`, ON DELETE SET NULL | user who triggered the movement (NULL for automated sale decrements) | -| `note` | VARCHAR(255) | YES | NULL | — | optional human note (e.g., reason for correction, pack reference) | -| `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | INDEX | immutable timestamp | +| `ingredient_id` | INT UNSIGNED | NO | — | FK -> `ingredient(id)`, ON DELETE RESTRICT | ingredient affecte | +| `movement_type` | ENUM('sale','cancellation','restock','inventory_correction') | NO | — | INDEX | nature du mouvement | +| `delta` | INT | NO | — | — | changement signe : negatif pour la consommation (vente), positif pour reapprovisionnement/annulation/correction | +| `order_id` | INT UNSIGNED | YES | NULL | FK -> `customer_order(id)`, ON DELETE SET NULL | commande liee pour les mouvements `sale` et `cancellation` ; NULL pour restock/correction | +| `user_id` | INT UNSIGNED | YES | NULL | FK -> `user(id)`, ON DELETE SET NULL | utilisateur ayant declenche le mouvement (NULL pour les decrements de vente automatises) | +| `note` | VARCHAR(255) | YES | NULL | — | note humaine optionnelle (ex. raison de la correction, reference de pack) | +| `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | INDEX | timestamp immuable | -**Immutability**: no UPDATE or DELETE on this table. Corrections are new rows with -`movement_type='inventory_correction'` and a signed delta. +**Immuabilite** : aucun UPDATE ni DELETE sur cette table. Les corrections sont de nouvelles lignes avec +`movement_type='inventory_correction'` et un delta signe. -**Automatic movements** (triggered at status transitions): -- `paid` transition: 1 `sale` row per ingredient unit consumed (accounting for modifiers). -- `cancelled` (from `paid`): 1 `cancellation` row per ingredient unit re-credited. +**Mouvements automatiques** (declenches aux transitions de statut) : +- transition `paid` : 1 ligne `sale` par unite d'ingredient consommee (en tenant compte des modificateurs). +- `cancelled` (depuis `paid`) : 1 ligne `cancellation` par unite d'ingredient recreditee. -**Manual movements**: -- `restock`: manager or admin records a delivery (`+= N * pack_size`). -- `inventory_correction`: morning/evening physical count; system records the discrepancy - (delta = actual - theoretical). +**Mouvements manuels** : +- `restock` : le manager ou l'admin enregistre une livraison (`+= N * pack_size`). +- `inventory_correction` : comptage physique matin/soir ; le systeme enregistre l'ecart + (delta = reel - theorique). -**Volume**: ~5-15 movements per order across all ingredients; index on -`(ingredient_id, created_at)` is recommended for per-ingredient history queries. +**Volume** : ~5-15 mouvements par commande sur tous les ingredients ; un index sur +`(ingredient_id, created_at)` est recommande pour les requetes d'historique par ingredient. --- ### 3.20 `audit_log` -Append-only log of **sensitive back-office actions**, for accountability where it matters -(insider threat, money handling, RBAC changes). Complements `stock_movement` (which is -stock-specific); covers catalogue/price, user, role/permission, and order cancellation events. -Security-by-design addition (see note 13). +Journal append-only des **actions back-office sensibles**, pour l'imputabilite la ou elle importe +(menace interne, manipulation d'argent, changements RBAC). Complete `stock_movement` (specifique au +stock) ; couvre les evenements catalogue/prix, utilisateur, role/permission et annulation de commande. +Ajout security-by-design (voir note 13). -| Attribute | Type | NULL | Default | Constraint | Notes | +| Attribut | Type | NULL | Default | Contrainte | Notes | |---|---|---|---|---|---| | `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | | -| `actor_user_id` | INT UNSIGNED | YES | NULL | FK -> `user(id)`, ON DELETE SET NULL | staff who performed the action, captured via PIN for sensitive operations. NULL if not attributable to an individual | -| `actor_role_id` | INT UNSIGNED | YES | NULL | FK -> `role(id)`, ON DELETE SET NULL | role context at action time (denormalised so the trail survives user anonymisation) | -| `action_code` | VARCHAR(60) | NO | — | INDEX | MCT operation / permission code, e.g. `product.update`, `order.cancel`, `role.manage`, `user.deactivate` | -| `entity_type` | VARCHAR(40) | YES | NULL | — | affected table name, e.g. `product`, `customer_order`, `role`, `user` | -| `entity_id` | INT UNSIGNED | YES | NULL | — | PK of the affected row | -| `summary` | VARCHAR(255) | YES | NULL | — | short non-personal description, e.g. "price_cents 880 -> 920", "added permission stock.manage" | -| `details` | JSON | YES | NULL | — | optional before/after diff. For user-targeted actions, stores changed **field names**, not PII values | -| `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | INDEX | immutable timestamp | +| `actor_user_id` | INT UNSIGNED | YES | NULL | FK -> `user(id)`, ON DELETE SET NULL | personnel ayant effectue l'action, capture via PIN pour les operations sensibles. NULL si non attribuable a un individu | +| `actor_role_id` | INT UNSIGNED | YES | NULL | FK -> `role(id)`, ON DELETE SET NULL | contexte de role au moment de l'action (denormalise pour que la trace survive a l'anonymisation de l'utilisateur) | +| `action_code` | VARCHAR(60) | NO | — | INDEX | code d'operation MCT / de permission, ex. `product.update`, `order.cancel`, `role.manage`, `user.deactivate` | +| `entity_type` | VARCHAR(40) | YES | NULL | — | nom de la table affectee, ex. `product`, `customer_order`, `role`, `user` | +| `entity_id` | INT UNSIGNED | YES | NULL | — | PK de la ligne affectee | +| `summary` | VARCHAR(255) | YES | NULL | — | courte description non personnelle, ex. "price_cents 880 -> 920", "added permission stock.manage" | +| `details` | JSON | YES | NULL | — | diff before/after optionnel. Pour les actions ciblant un utilisateur, stocke les **noms de champs** modifies, pas les valeurs PII | +| `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | INDEX | timestamp immuable | -**Immutability**: no UPDATE or DELETE at application layer (same discipline as `stock_movement`). -**Indexes**: `(actor_user_id, created_at)`, `(entity_type, entity_id)`, `(action_code, created_at)`. -**Retention**: own window (~12 months, legitimate-interest / fiscal traceability), decoupled -from user PII lifecycle (note 13). A scheduled purge (cron) removes rows past the window. +**Immuabilite** : aucun UPDATE ni DELETE au niveau applicatif (meme discipline que `stock_movement`). +**Index** : `(actor_user_id, created_at)`, `(entity_type, entity_id)`, `(action_code, created_at)`. +**Retention** : fenetre propre (~12 mois, interet legitime / tracabilite fiscale), decouplee +du cycle de vie des PII utilisateur (note 13). Une purge planifiee (cron) retire les lignes au-dela de la fenetre. -**Logged operations** (sensitive set): `UPDATE_PRODUCT` (8.2, incl. price), `DELETE_PRODUCT` +**Operations journalisees** (ensemble sensible) : `UPDATE_PRODUCT` (8.2, incl. prix), `DELETE_PRODUCT` (8.3), `DELETE_MENU` (8.6), `CANCEL_ORDER` (7.1), `RESTOCK` (9.1), `INVENTORY_COUNT` (9.2), `CREATE_USER` / `UPDATE_USER` / `DEACTIVATE_USER` (10.1-10.3), `MANAGE_RBAC` (10.4). -**Volume**: low (~10-50 sensitive actions/day) — orders of magnitude below `stock_movement`. +**Volume** : faible (~10-50 actions sensibles/jour) — des ordres de grandeur sous `stock_movement`. --- ### 3.21 `login_throttle` -Per-source-IP brute-force throttle. Complements the per-account counter already on `user` -(`failed_login_attempts` / `lockout_until`), one row per source IP. Security-by-design addition -(see note 13). +Throttle anti-brute-force par IP source. Complete le compteur par compte deja present sur `user` +(`failed_login_attempts` / `lockout_until`), une ligne par IP source. Ajout security-by-design +(voir note 13). -| Attribute | Type | NULL | Default | Constraint | Notes | +| Attribut | Type | NULL | Default | Contrainte | Notes | |---|---|---|---|---|---| | `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | | -| `ip_address` | VARCHAR(45) | NO | — | UNIQUE | source IP, one row per IP, upserted; 45 chars holds a full IPv6 literal | -| `failed_attempts` | SMALLINT UNSIGNED | NO | 0 | — | consecutive failed logins from this IP in the current window | -| `window_started_at` | DATETIME | NO | CURRENT_TIMESTAMP | — | start of the current counting window | -| `lockout_until` | DATETIME | YES | NULL | — | end of the degressive backoff window; NULL = not throttled | -| `last_attempt_at` | DATETIME | NO | CURRENT_TIMESTAMP | — | timestamp of the last failed attempt | +| `ip_address` | VARCHAR(45) | NO | — | UNIQUE | IP source, une ligne par IP, upsertee ; 45 caracteres contiennent un litteral IPv6 complet | +| `failed_attempts` | SMALLINT UNSIGNED | NO | 0 | — | logins echoues consecutifs depuis cette IP dans la fenetre courante | +| `window_started_at` | DATETIME | NO | CURRENT_TIMESTAMP | — | debut de la fenetre de comptage courante | +| `lockout_until` | DATETIME | YES | NULL | — | fin de la fenetre de backoff degressif ; NULL = non throttle | +| `last_attempt_at` | DATETIME | NO | CURRENT_TIMESTAMP | — | timestamp de la derniere tentative echouee | -**No FK**: an IP is not a modelled entity. Rows are appended/upserted by IP; the window resets -when expired. A daily cron purges rows with no active lockout whose `last_attempt_at` is older -than 24h. +**Pas de FK** : une IP n'est pas une entite modelisee. Les lignes sont appended/upsertees par IP ; la fenetre se reinitialise +a son expiration. Un cron quotidien purge les lignes sans lockout actif dont le `last_attempt_at` est plus ancien +que 24h. --- -## 4. Modeling notes +## 4. Notes de modelisation -### Note 1 — Why `INT UNSIGNED` in cents for prices +### Note 1 — Pourquoi `INT UNSIGNED` en centimes pour les prix -Storing a price as `FLOAT` or `DECIMAL(10,2)` is technically valid but introduces two risks: +Stocker un prix en `FLOAT` ou `DECIMAL(10,2)` est techniquement valide mais introduit deux risques : -1. **FLOAT rounding**: `0.1 + 0.2 = 0.30000000000000004` in IEEE 754 floating-point. - Summing 100 order lines can produce cent-level discrepancies vs business reality. -2. **FLOAT-to-string conversion**: different PHP/MariaDB driver versions may serialize floats - with variable precision. +1. **Arrondi FLOAT** : `0.1 + 0.2 = 0.30000000000000004` en virgule flottante IEEE 754. + Sommer 100 lignes de commande peut produire des ecarts au niveau du centime vs la realite metier. +2. **Conversion FLOAT-vers-chaine** : differentes versions de driver PHP/MariaDB peuvent serialiser les floats + avec une precision variable. -Storing as `INT UNSIGNED` (cents: 880 for EUR 8.80) eliminates these risks. Conversion to EUR -for display is done in PHP at output: `number_format($cents / 100, 2)`. +Stocker en `INT UNSIGNED` (centimes : 880 pour 8,80 EUR) elimine ces risques. La conversion en EUR +pour l'affichage se fait en PHP a la sortie : `number_format($cents / 100, 2)`. -Reference: David Goldberg, *What Every Computer Scientist Should Know About Floating-Point +Reference : David Goldberg, *What Every Computer Scientist Should Know About Floating-Point Arithmetic*, ACM Computing Surveys, 1991. -### Note 2 — Why `ENUM` rather than a reference table +### Note 2 — Pourquoi `ENUM` plutot qu'une table de reference -ENUMs (`service_mode`, `status`, `item_type`, `action`, `slot_type`) could have been reference -tables. Choice retained: ENUM. +Les ENUMs (`service_mode`, `status`, `item_type`, `action`, `slot_type`) auraient pu etre des tables de +reference. Choix retenu : ENUM. -Advantages in this context: -- Values are stable and limited (3-7 values max), unlikely to evolve frequently. -- DBMS-level constraint instead of runtime FK; simpler queries. -- Directly readable in SQL: `WHERE status = 'paid'`. +Avantages dans ce contexte : +- Les valeurs sont stables et limitees (3-7 valeurs max), peu susceptibles d'evoluer frequemment. +- Contrainte au niveau SGBD au lieu d'une FK a l'execution ; requetes plus simples. +- Directement lisible en SQL : `WHERE status = 'paid'`. -Cost of a future change: `ALTER TABLE ... MODIFY COLUMN ... ENUM(...)` to add a value. -Acceptable given changes are expected to be rare. +Cout d'un changement futur : `ALTER TABLE ... MODIFY COLUMN ... ENUM(...)` pour ajouter une valeur. +Acceptable etant donne que les changements sont attendus comme rares. -If these ENUMs later require multilingual labels or descriptions, they will be migrated to -reference tables. Not in scope for this iteration. +Si ces ENUMs requierent plus tard des libelles ou descriptions multilingues, ils seront migres vers des +tables de reference. Hors perimetre pour cette iteration. -### Note 3 — Why `customer_order` and not `order` +### Note 3 — Pourquoi `customer_order` et non `order` -`ORDER` is an SQL reserved word (used in `ORDER BY`). Three approaches exist: -- Quote the name everywhere: `` `order` `` — requires quoting in every SQL statement, - error-prone and non-portable across DBMS dialects. -- Use an alias at ORM level: possible but adds a mapping layer. -- Rename: `customer_order` (chosen) — unambiguous, self-documenting, no quoting required. +`ORDER` est un mot reserve SQL (utilise dans `ORDER BY`). Trois approches existent : +- Quoter le nom partout : `` `order` `` — requiert un quoting dans chaque instruction SQL, + source d'erreurs et non portable entre dialectes SGBD. +- Utiliser un alias au niveau ORM : possible mais ajoute une couche de mapping. +- Renommer : `customer_order` (choisi) — sans ambiguite, auto-documente, sans quoting requis. -Alternative considered and rejected: `purchase` (less domain-specific), -`transaction` (also reserved or ambiguous). `customer_order` matches the domain language -and avoids all conflicts. +Alternative consideree et rejetee : `purchase` (moins specifique au domaine), +`transaction` (egalement reserve ou ambigu). `customer_order` correspond au langage du domaine +et evite tous les conflits. -`order_item` is retained as the line table name: `item` is not reserved, and the -`order_` prefix makes the parent relationship clear. +`order_item` est conserve comme nom de table de ligne : `item` n'est pas reserve, et le +prefixe `order_` rend claire la relation parent. -### Note 4 — Order number prefix by channel +### Note 4 — Prefixe de numero de commande par canal -Format: `K`/`C`/`D`-YYYY-MM-DD-NNN (kiosk / counter / drive). +Format : `K`/`C`/`D`-YYYY-MM-DD-NNN (kiosk / counter / drive). -Rationale: the prefix encodes the channel, which is useful for rapid visual identification -by kitchen and counter staff without querying the `source` column. The sequential counter NNN -restarts daily per channel. Collision-free within a day given expected volume. +Rationale : le prefixe encode le canal, ce qui est utile pour une identification visuelle rapide +par le personnel cuisine et comptoir sans interroger la colonne `source`. Le compteur sequentiel NNN +repart chaque jour par canal. Sans collision sur une journee, vu le volume attendu. -Alternative rejected: neutral prefix `W-` for all channels (simpler, but loses channel -readability for staff). +Alternative rejetee : prefixe neutre `W-` pour tous les canaux (plus simple, mais perd la lisibilite +du canal pour le personnel). -### Note 5 — `source` vs `service_mode` (channel vs consumption mode) +### Note 5 — `source` vs `service_mode` (canal vs mode de consommation) -Two distinct dimensions, kept separate: +Deux dimensions distinctes, gardees separees : | | `source` | `service_mode` | |---|---|---| -| Nature | input channel (who entered the order) | consumption mode (where the customer eats) | -| Values | kiosk, counter, drive | dine_in, takeaway, drive | -| Used for | authentication, analytics, permission filtering | KPI, capacity (no fiscal role) | +| Nature | canal de saisie (qui a saisi la commande) | mode de consommation (ou le client mange) | +| Valeurs | kiosk, counter, drive | dine_in, takeaway, drive | +| Sert a | authentification, analytics, filtrage de permission | KPI, capacite (aucun role fiscal) | -The two dimensions are independent for `kiosk` and `counter` (a kiosk customer can choose -`dine_in` or `takeaway`). `drive` is the only case where both dimensions align: -`source=drive` implies `service_mode=drive`. This cross-constraint is verified at app layer. +Les deux dimensions sont independantes pour `kiosk` et `counter` (un client borne peut choisir +`dine_in` ou `takeaway`). `drive` est le seul cas ou les deux dimensions s'alignent : +`source=drive` implique `service_mode=drive`. Cette contrainte croisee est verifiee au niveau applicatif. -### Note 6 — Reduced 4-state machine +### Note 6 — Machine a 4 etats reduite -v0.1 had 6 states (`pending_payment`, `paid`, `preparing`, `ready`, `delivered`, `cancelled`). -v0.2 reduces to 4 states: `pending_payment -> paid -> delivered` (+ `cancelled`). +v0.1 avait 6 etats (`pending_payment`, `paid`, `preparing`, `ready`, `delivered`, `cancelled`). +v0.2 reduit a 4 etats : `pending_payment -> paid -> delivered` (+ `cancelled`). -Rationale (Decision 4 from `revue-alignement-p1.md` §7): in a fast-food context, the kitchen -display (KDS) is a visual system — staff see the ticket and act. `preparing` and `ready` were -intermediate states that added complexity without proportional business value. The single -kitchen action is `deliver` (counter/drive staff hands over the order), collapsing -`preparing + ready + delivered` into one gesture. KPI is total time: `delivered_at - paid_at` -(SLA ~10 min). KDS color coding is computed from `now - paid_at`, no extra stored state. +Rationale (Decision 4 de `revue-alignement-p1.md` §7) : dans un contexte fast-food, l'affichage +cuisine (KDS) est un systeme visuel — le personnel voit le ticket et agit. `preparing` et `ready` etaient +des etats intermediaires qui ajoutaient de la complexite sans valeur metier proportionnelle. L'unique +action cuisine est `deliver` (le personnel counter/drive remet la commande), fusionnant +`preparing + ready + delivered` en un seul geste. Le KPI est le temps total : `delivered_at - paid_at` +(SLA ~10 min). Le codage couleur du KDS est calcule depuis `now - paid_at`, sans etat stocke supplementaire. -**Dropped states and timestamps**: `preparing_at`, `ready_at` are not stored. +**Etats et timestamps retires** : `preparing_at`, `ready_at` ne sont pas stockes. -### Note 7 — Normal / Maxi format cascade +### Note 7 — Cascade de format Normal / Maxi -The Maxi format enlarges the side and the drink only. The burger is unchanged and the sauce -portion is unchanged (a sauce pot is the same in both formats). This scope is explicit so the -stock model stays faithful. +Le format Maxi agrandit uniquement l'accompagnement et la boisson. Le burger est inchange et la portion +de sauce est inchangee (un pot de sauce est identique dans les deux formats). Ce perimetre est explicite afin que le +modele de stock reste fidele. -**Price side** — not modeled at individual component price level: -- `menu` carries two prices: `price_normal_cents` and `price_maxi_cents`. -- `order_item.format` records which format the customer chose (`normal` or `maxi`). -- `order_item.unit_price_cents_snapshot` captures the actual price paid (Normal or Maxi). -- No individual price per slot component is stored; the price differential is a menu-level - attribute, consistent with how fast-food menus tend to be priced in practice. +**Cote prix** — non modelise au niveau du prix de composant individuel : +- `menu` porte deux prix : `price_normal_cents` et `price_maxi_cents`. +- `order_item.format` enregistre le format choisi par le client (`normal` ou `maxi`). +- `order_item.unit_price_cents_snapshot` capture le prix reellement paye (Normal ou Maxi). +- Aucun prix individuel par composant de slot n'est stocke ; le differentiel de prix est un attribut + au niveau menu, coherent avec la maniere dont les menus fast-food tendent a etre tarifes en pratique. -**Stock side** — modeled via a format multiplier on the recipe: -- `product_ingredient` carries `quantity_normal` and `quantity_maxi`. -- At the `paid` transition, the decrement uses `quantity_maxi` when `order_item.format='maxi'`, - otherwise `quantity_normal`. -- For burger and sauce ingredients, `quantity_maxi = quantity_normal` (format-invariant). -- For side and drink ingredients, `quantity_maxi > quantity_normal` (Maxi consumes more). -- The format cascades from the menu line (`order_item.format`) to its slot selections; a - standalone product line defaults to `normal`. -- Single product per choice (e.g., one `Fries` product), not separate medium/large products. +**Cote stock** — modelise via un multiplicateur de format sur la recette : +- `product_ingredient` porte `quantity_normal` et `quantity_maxi`. +- A la transition `paid`, le decrement utilise `quantity_maxi` quand `order_item.format='maxi'`, + sinon `quantity_normal`. +- Pour les ingredients burger et sauce, `quantity_maxi = quantity_normal` (invariants au format). +- Pour les ingredients accompagnement et boisson, `quantity_maxi > quantity_normal` (le Maxi consomme plus). +- Le format se propage de la ligne de menu (`order_item.format`) a ses selections de slot ; une + ligne de produit autonome est par defaut a `normal`. +- Un seul produit par choix (ex. un produit `Fries`), pas de produits medium/large separes. -Calibration: the Maxi surcharge is in the ~1.50 EUR range for this model (derived from -internal data; cross-checked against market magnitude for plausibility. Wakdo is a fictional -pastiche so exact prices are not copied from a real chain). +Calibration : le supplement Maxi est dans la fourchette ~1,50 EUR pour ce modele (derive de +donnees internes ; recoupe avec l'ordre de grandeur du marche pour plausibilite. Wakdo est un pastiche +fictif, donc les prix exacts ne sont pas copies d'une chaine reelle). -Calibration: the Maxi surcharge is in the ~1.50 EUR range for this model (derived from -internal data; cross-checked against market magnitude for plausibility. Wakdo is a fictional -pastiche so exact prices are not copied from a real chain). +Calibration : le supplement Maxi est dans la fourchette ~1,50 EUR pour ce modele (derive de +donnees internes ; recoupe avec l'ordre de grandeur du marche pour plausibilite. Wakdo est un pastiche +fictif, donc les prix exacts ne sont pas copies d'une chaine reelle). -### Note 8 — Image storage: path in VARCHAR vs BLOB in DB +### Note 8 — Stockage des images : chemin en VARCHAR vs BLOB en BDD -`image_path` columns (`category`, `product`, `menu`) store a **relative path** from the -public root (e.g., `/uploads/products/classic-burger.jpg`), not an absolute server path. -PHP resolves via a prefix from `.env` (`UPLOAD_DIR=public/uploads`). +Les colonnes `image_path` (`category`, `product`, `menu`) stockent un **chemin relatif** depuis la +racine publique (ex. `/uploads/products/classic-burger.jpg`), pas un chemin serveur absolu. +PHP resout via un prefixe depuis `.env` (`UPLOAD_DIR=public/uploads`). -BLOB storage was considered and rejected: +Le stockage BLOB a ete considere et rejete : -| Criterion | `image_path` VARCHAR (chosen) | BLOB in DB | +| Critere | `image_path` VARCHAR (choisi) | BLOB en BDD | |---|---|---| -| Kiosk performance | Apache serves files in ms (OS cache) | PHP reads DB + streams, multiplied latency | -| HTTP caching | ETag, Last-Modified, browser cache, CDN native | must be reimplemented in PHP | -| DB backup size | Megabytes (paths only) | Gigabytes (66 products x ~200 KB + responsive variants) | -| Image pipeline | `convert`, `webp`, optimization = standard filesystem tools | must be reinvented in PHP | +| Performance borne | Apache sert les fichiers en ms (cache OS) | PHP lit la BDD + streame, latence multipliee | +| Cache HTTP | ETag, Last-Modified, cache navigateur, CDN natifs | doit etre reimplemente en PHP | +| Taille de backup BDD | Megaoctets (chemins seulement) | Gigaoctets (66 produits x ~200 Ko + variantes responsive) | +| Pipeline d'images | `convert`, `webp`, optimisation = outils standard du systeme de fichiers | doit etre reinvente en PHP | -Sources: OWASP File Upload Cheat Sheet; MariaDB Knowledge Base — LONGBLOB performance; -Apache HTTP Server documentation — serving static content. +Sources : OWASP File Upload Cheat Sheet ; MariaDB Knowledge Base — LONGBLOB performance ; +documentation Apache HTTP Server — serving static content. -### Note 9 — VAT rule in French fast-food (fact-checked) +### Note 9 — Regle de TVA dans le fast-food francais (fact-checked) ``` FACT-CHECK @@ -765,163 +765,163 @@ Actual rule : 10% for immediate consumption (dine-in OR hot takeaway); Confidence : 95% (L1, official text) ``` -**Model consequence**: VAT rate is an attribute of the `product` (`vat_rate` in per-mille: -100 = 10%, 55 = 5.5%), not of the order or the service mode. Default: 100 (10%). -The 5.5% rate applies to products in resealable containers (bottled water, juice bottles). -VAT is computed line by line; the rate is snapshotted on `order_item.vat_rate_snapshot` -at transaction time to preserve historical integrity if legislation changes. +**Consequence sur le modele** : le taux de TVA est un attribut du `product` (`vat_rate` en pour-mille : +100 = 10%, 55 = 5,5%), pas de la commande ni du mode de service. Defaut : 100 (10%). +Le taux de 5,5% s'applique aux produits en contenants refermables (eau en bouteille, bouteilles de jus). +La TVA est calculee ligne par ligne ; le taux est snapshote sur `order_item.vat_rate_snapshot` +au moment de la transaction pour preserver l'integrite historique si la legislation change. -`service_mode` is retained on `customer_order` for stats and KPI only (capacity planning, -per-mode revenue breakdown). It has no fiscal computation role. +`service_mode` est conserve sur `customer_order` pour les stats et le KPI uniquement (planification de capacite, +repartition du chiffre d'affaires par mode). Il n'a aucun role de calcul fiscal. -### Note 10 — Ingredient configurator and modifier attachment +### Note 10 — Configurateur d'ingredients et rattachement du modificateur -`order_item_modifier` attaches to an `order_item` row via `order_item_id`, regardless of -whether the line is a standalone product or a menu. +`order_item_modifier` se rattache a une ligne `order_item` via `order_item_id`, que +la ligne soit un produit autonome ou un menu. -For a **standalone product** (`item_type='product'`): `order_item_id` directly identifies -the product being modified. +Pour un **produit autonome** (`item_type='product'`) : `order_item_id` identifie directement +le produit modifie. -For a **menu** (`item_type='menu'`): the modifiable product is the fixed burger, identified -via `order_item.menu_id -> menu.burger_product_id`. The kitchen display resolves: +Pour un **menu** (`item_type='menu'`) : le produit modifiable est le burger fixe, identifie +via `order_item.menu_id -> menu.burger_product_id`. L'affichage cuisine resout : `modifier.order_item_id -> order_item -> menu -> menu.burger_product_id -> product.name`. -No additional FK column is needed on `order_item_modifier`. This keeps the modifier table -simple and avoids a nullable `target_product_id` column that would only be populated for -menu lines. +Aucune colonne FK supplementaire n'est necessaire sur `order_item_modifier`. Cela garde la table modificateur +simple et evite une colonne nullable `target_product_id` qui ne serait peuplee que pour les +lignes de menu. -Constraint enforced at app layer: `order_item_modifier` rows for a menu line reference -only ingredients belonging to `menu.burger_product_id` via `product_ingredient`. +Contrainte appliquee au niveau applicatif : les lignes `order_item_modifier` pour une ligne de menu referencent +uniquement des ingredients appartenant a `menu.burger_product_id` via `product_ingredient`. -### Note 11 — `menu_slot` eligibility: category filter vs explicit product list +### Note 11 — Eligibilite `menu_slot` : filtre par categorie vs liste de produits explicite -Two options were considered: -- **Category filter**: `menu_slot.category_id` points to a category; all products in that - category are eligible. Simple, but a category may contain products not offered in this slot - (e.g., a premium drink added to the "drinks" category should not automatically appear in - all menu slots). -- **Explicit product list** `menu_slot_option(menu_slot_id, product_id)` (chosen): each - eligible product is listed explicitly per slot. More verbose at seed time but precise — - no accidental eligibility when the catalogue grows. Enables per-slot pricing overrides - in the future without structural change. +Deux options ont ete considerees : +- **Filtre par categorie** : `menu_slot.category_id` pointe vers une categorie ; tous les produits de cette + categorie sont eligibles. Simple, mais une categorie peut contenir des produits non proposes dans ce slot + (ex. une boisson premium ajoutee a la categorie "drinks" ne devrait pas apparaitre automatiquement dans + tous les slots de menu). +- **Liste de produits explicite** `menu_slot_option(menu_slot_id, product_id)` (choisie) : chaque + produit eligible est liste explicitement par slot. Plus verbeux au moment du seed mais precis — + pas d'eligibilite accidentelle quand le catalogue grandit. Permet des overrides de tarification par slot + a l'avenir sans changement structurel. -The explicit list adds one entity (`menu_slot_option`, entity 3.5) but eliminates a class -of correctness bugs. Consistent with the prod-like ambition of this model. +La liste explicite ajoute une entite (`menu_slot_option`, entite 3.5) mais elimine une classe +de bugs de justesse. Coherent avec l'ambition prod-like de ce modele. -### Note 12 — `commande_event` dropped +### Note 12 — `commande_event` retire -v0.1 carried a `commande_event` append-only audit table (event sourcing pattern). -Dropped in v0.2 (Decision 1, `revue-alignement-p1.md` §7). +v0.1 portait une table d'audit append-only `commande_event` (pattern event sourcing). +Retiree en v0.2 (Decision 1, `revue-alignement-p1.md` §7). -Rationale: in a restaurant context, the back-office account is shared per workstation, not -individual. Per-person attribution of a state transition has no business value. The actual -need (phase durations, time-of-day stats) is covered by phase timestamps on `customer_order` -(`paid_at`, `delivered_at`, `cancelled_at`) without the complexity of an event store. +Rationale : dans un contexte restaurant, le compte back-office est partage par poste de travail, non +individuel. L'attribution par personne d'une transition d'etat n'a aucune valeur metier. Le besoin reel +(durees de phase, stats par heure de la journee) est couvert par les timestamps de phase sur `customer_order` +(`paid_at`, `delivered_at`, `cancelled_at`) sans la complexite d'un event store. -The 4-state machine combined with 3 phase timestamps provides all KPI data needed: -- Time-to-deliver: `delivered_at - paid_at` -- Cancellation rate and timing: `cancelled_at - created_at` -- Volume by hour: `HOUR(created_at)` / `service_day` computation +La machine a 4 etats combinee a 3 timestamps de phase fournit toutes les donnees KPI necessaires : +- Temps de remise : `delivered_at - paid_at` +- Taux et timing d'annulation : `cancelled_at - created_at` +- Volume par heure : calcul `HOUR(created_at)` / `service_day` -For stock audit, `stock_movement` (entity 3.19) provides the append-only audit trail -where it is genuinely needed (inventory reconciliation). +Pour l'audit de stock, `stock_movement` (entite 3.19) fournit la trace d'audit append-only +la ou elle est genuinement necessaire (reconciliation d'inventaire). -### Note 13 — Security-by-design data additions (2026-06-11) +### Note 13 — Ajouts de donnees security-by-design (2026-06-11) -These additions extend the prod-like model with a security-by-design layer. They do not -replace any v0.2 decision; they add accountability, auth lifecycle, and abuse resistance. +Ces ajouts etendent le modele prod-like avec une couche security-by-design. Ils ne +remplacent aucune decision v0.2 ; ils ajoutent imputabilite, cycle de vie d'auth et resistance a l'abus. -**Accountability — hybrid shared-account + PIN.** Back-office sessions stay shared per -workstation for the routine flow (a fast-food terminal is shared, `equipiers` rotate). A -per-staff PIN (`user.pin_hash`, argon2id) authorises a defined set of **sensitive actions** -(price/menu edits 8.2/8.3/8.6, order cancellation 7.1, inventory correction 9.2, user -management 10.1-10.3, RBAC 10.4). Those actions write the acting `user_id` into `audit_log` -(3.20). This resolves the circular justification that dropped `commande_event` in v0.1 -(events were considered useless because accounts were shared): accountability is recorded -where it matters, at near-zero friction for the routine 95%. `customer_order.acting_user_id` -captures the staff for counter/drive orders taken under PIN; kiosk orders stay anonymous. +**Imputabilite — compte partage hybride + PIN.** Les sessions back-office restent partagees par +poste de travail pour le flux de routine (un terminal fast-food est partage, les `equipiers` tournent). Un +PIN par membre du personnel (`user.pin_hash`, argon2id) autorise un ensemble defini d'**actions sensibles** +(editions prix/menu 8.2/8.3/8.6, annulation de commande 7.1, correction d'inventaire 9.2, gestion +des utilisateurs 10.1-10.3, RBAC 10.4). Ces actions ecrivent le `user_id` agissant dans `audit_log` +(3.20). Cela resout la justification circulaire qui avait retire `commande_event` en v0.1 +(les events etaient juges inutiles parce que les comptes etaient partages) : l'imputabilite est enregistree +la ou elle importe, a friction quasi nulle pour les 95% de routine. `customer_order.acting_user_id` +capture le personnel pour les commandes counter/drive prises sous PIN ; les commandes borne restent anonymes. -**Auth lifecycle.** `password_reset_token_hash` + `password_reset_expires_at` enable a reset -path (the token is stored hashed, the raw token is e-mailed once). Brute-force resistance uses -degressive throttling rather than a hard indefinite lock: `failed_login_attempts` + -`lockout_until` implement an exponential backoff per (account + source IP), so a fat-finger -streak does not lock out a whole kitchen mid-service (15 h continuous). Failed logins are -written to `audit_log`. +**Cycle de vie d'auth.** `password_reset_token_hash` + `password_reset_expires_at` permettent un parcours +de reset (le token est stocke hashe, le token brut est envoye par e-mail une seule fois). La resistance au brute-force utilise +un throttling degressif plutot qu'un verrouillage dur indefini : `failed_login_attempts` + +`lockout_until` implementent un backoff degressif par (compte + IP source), de sorte qu'une serie +de fautes de frappe ne verrouille pas toute une cuisine en plein service (15 h continues). Les logins echoues sont +ecrits dans `audit_log`. -**RGPD anonymisation vs audit retention.** `user` PII (`email`, `first_name`, `last_name`) -is subject to the right to erasure (Cr 3.d). Erasure **anonymises** rather than hard-deletes: -the row is kept, `email` becomes a non-identifying unique placeholder (`anon-@wakdo.invalid`, -RFC 2606 reserved domain), names are cleared, `password_hash`/`pin_hash` are invalidated, and -`anonymized_at` is set. The `audit_log` retains its own retention window (~12 months, -legitimate-interest / fiscal traceability) and keeps pointing at the anonymised principal, so -erasure and accountability coexist without breaking referential integrity. +**Anonymisation RGPD vs retention d'audit.** Les PII de `user` (`email`, `first_name`, `last_name`) +sont soumises au droit a l'effacement (Cr 3.d). L'effacement **anonymise** plutot qu'il ne supprime durement : +la ligne est conservee, `email` devient un placeholder unique non identifiant (`anon-@wakdo.invalid`, +domaine reserve RFC 2606), les noms sont effaces, `password_hash`/`pin_hash` sont invalides, et +`anonymized_at` est renseigne. `audit_log` conserve sa propre fenetre de retention (~12 mois, +interet legitime / tracabilite fiscale) et continue de pointer vers le principal anonymise, de sorte que +effacement et imputabilite coexistent sans casser l'integrite referentielle. -**Abuse resistance on the anonymous kiosk.** `customer_order.idempotency_key` (client UUID, -UNIQUE) deduplicates a retried `POST /api/orders` so a network retry does not create a -duplicate paid order. Stock is decremented with a single atomic statement -(`UPDATE ingredient SET stock_quantity = stock_quantity - :units WHERE id = :id`): no operation -gates on a stock read, so the row self-locks for the duration of the write — no lost update and -no deadlock-ordering concern. This replaces the earlier pessimistic `SELECT ... FOR UPDATE` -approach (treatment-layer rule, see `mlt.md`); it adds no column here. +**Resistance a l'abus sur la borne anonyme.** `customer_order.idempotency_key` (UUID client, +UNIQUE) deduplique un `POST /api/orders` reessaye de sorte qu'un retry reseau ne cree pas de +commande payee dupliquee. Le stock est decremente avec une seule instruction atomique +(`UPDATE ingredient SET stock_quantity = stock_quantity - :units WHERE id = :id`) : aucune operation +ne depend d'une lecture de stock, donc la ligne s'auto-verrouille pour la duree de l'ecriture — pas de lost update et +pas de souci d'ordre des deadlocks. Cela remplace l'approche pessimiste anterieure `SELECT ... FOR UPDATE` +(regle de la couche traitement, voir `mlt.md`) ; elle n'ajoute aucune colonne ici. -**Percentage stock model + computed availability.** `ingredient` carries `stock_capacity` (the -100% reference), `low_stock_pct` (warning band) and `critical_stock_pct` (auto-out-of-stock -floor) — see 3.6. `stock_quantity` is signed and may go negative (oversell magnitude surfaced to -managers); the system does not block an order on stock. Effective product orderability is -computed (rule RG-T21 in `mlt.md`): `product.is_available = 1` AND each non-removable -(`is_removable=0`) ingredient of its `product_ingredient` has -`stock_quantity > stock_capacity * critical_stock_pct/100`. At the critical band a product -auto-goes out-of-stock with no write and no cascade; a manual pull (`product.is_available=0`) is -a hard override; restock above the critical band makes the product orderable again on its own. +**Modele de stock en pourcentage + disponibilite calculee.** `ingredient` porte `stock_capacity` (la +reference 100%), `low_stock_pct` (bande d’alerte) et `critical_stock_pct` (seuil de rupture +automatique) — voir 3.6. `stock_quantity` est signe et peut devenir negatif (ampleur de survente remontee aux +managers) ; le systeme ne bloque pas une commande sur le stock. La commandabilite effective du produit est +calculee (regle RG-T21 dans `mlt.md`) : `product.is_available = 1` ET chaque ingredient non retirable +(`is_removable=0`) de son `product_ingredient` a +`stock_quantity > stock_capacity * critical_stock_pct/100`. A la bande critique, un produit +passe automatiquement en rupture sans ecriture ni cascade ; un retrait manuel (`product.is_available=0`) est +une surcharge forte ; un reapprovisionnement au-dessus de la bande critique rend le produit a nouveau commandable de lui-meme. -**Per-IP brute-force throttle.** `login_throttle` (3.21) tracks `failed_attempts` and -`lockout_until` per source IP (one upserted row per IP), complementing the per-account counter -on `user`. This adds a second throttling dimension so a single IP hammering many accounts is -slowed independently of any one account's counter. A daily cron purges idle, non-locked rows. +**Throttle anti-brute-force par IP.** `login_throttle` (3.21) suit `failed_attempts` et +`lockout_until` par IP source (une ligne upsertee par IP), completant le compteur par compte +sur `user`. Cela ajoute une seconde dimension de throttling, de sorte qu'une seule IP martelant de nombreux comptes soit +ralentie independamment du compteur de n'importe quel compte. Un cron quotidien purge les lignes inactives et non verrouillees. -References: `docs/notes/revue-alignement-p1.md` §7 (D-decisions), security-by-design impact -map (2026-06-11). Threat model and data-classification matrix: `PROJECT_CONTEXT.md` §19 (to come). +References : `docs/notes/revue-alignement-p1.md` §7 (decisions D), carte d'impact security-by-design +(2026-06-11). Modele de menace et matrice de classification des donnees : `PROJECT_CONTEXT.md` §19 (a venir). --- -## 5. Entity count summary +## 5. Synthese du decompte des entites -| # | Entity | Type | Replaces / new | +| # | Entite | Type | Remplace / nouveau | |---|---|---|---| -| 1 | `category` | business | v0.1 `categorie` (renamed + translated) | +| 1 | `category` | business | v0.1 `categorie` (renommee + traduite) | | 2 | `product` | business | v0.1 `produit` (+ `vat_rate`) | -| 3 | `menu` | business | v0.1 `menu` (+ burger FK, 2 prices) | -| 4 | `menu_slot` | business | new — replaces `menu_produit` fixed composition | -| 5 | `menu_slot_option` | join | new — eligibility list per slot | -| 6 | `ingredient` | business | new — ingredient configurator + stock | -| 7 | `product_ingredient` | join | new — recipe + customization metadata | -| 8 | `allergen` | reference | new — INCO 1169/2011 | -| 9 | `ingredient_allergen` | join | new — maps allergens to ingredients | -| 10 | `customer_order` | business | v0.1 `commande` (renamed, 4-state machine, phase timestamps) | +| 3 | `menu` | business | v0.1 `menu` (+ FK burger, 2 prix) | +| 4 | `menu_slot` | business | nouveau — remplace la composition fixe `menu_produit` | +| 5 | `menu_slot_option` | join | nouveau — liste d'eligibilite par slot | +| 6 | `ingredient` | business | nouveau — configurateur d'ingredients + stock | +| 7 | `product_ingredient` | join | nouveau — recette + metadonnees de personnalisation | +| 8 | `allergen` | reference | nouveau — INCO 1169/2011 | +| 9 | `ingredient_allergen` | join | nouveau — mappe les allergenes aux ingredients | +| 10 | `customer_order` | business | v0.1 `commande` (renommee, machine a 4 etats, timestamps de phase) | | 11 | `order_item` | business | v0.1 `ligne_commande` (+ format, vat_rate_snapshot) | -| 12 | `order_item_selection` | business | new — customer menu slot choices | -| 13 | `order_item_modifier` | business | new — ingredient-level modifications | -| 14 | `user` | business | v0.1 `user` (translated field names) | +| 12 | `order_item_selection` | business | nouveau — choix de slot de menu du client | +| 13 | `order_item_modifier` | business | nouveau — modifications au niveau ingredient | +| 14 | `user` | business | v0.1 `user` (noms de champs traduits) | | 15 | `role` | business | v0.1 `role` (+ default_route, order_source) | -| 16 | `role_visible_source` | join | new — per-role dashboard filter | -| 17 | `permission` | reference | v0.1 `permission` (translated, catalogue frozen) | -| 18 | `role_permission` | join | v0.1 `role_permission` (unchanged) | -| 19 | `stock_movement` | audit | new — append-only stock audit log | -| 20 | `audit_log` | audit | new (security-by-design) — append-only sensitive-action log | -| 21 | `login_throttle` | security | new (security-by-design) - per-IP brute-force throttle | +| 16 | `role_visible_source` | join | nouveau — filtre de tableau de bord par role | +| 17 | `permission` | reference | v0.1 `permission` (traduite, catalogue gele) | +| 18 | `role_permission` | join | v0.1 `role_permission` (inchangee) | +| 19 | `stock_movement` | audit | nouveau — journal d'audit de stock append-only | +| 20 | `audit_log` | audit | nouveau (security-by-design) — journal append-only d'actions sensibles | +| 21 | `login_throttle` | security | nouveau (security-by-design) - throttle anti-brute-force par IP | -**Dropped from v0.1**: `commande_event` (replaced by phase timestamps on `customer_order`), -`menu_produit` (replaced by `menu_slot` + `menu_slot_option` model). +**Retire de v0.1** : `commande_event` (remplace par les timestamps de phase sur `customer_order`), +`menu_produit` (remplace par le modele `menu_slot` + `menu_slot_option`). -**Total: 21 entities** (19 prod-like v0.2 + `audit_log` and `login_throttle` from the -security-by-design layer). +**Total : 21 entites** (19 prod-like v0.2 + `audit_log` et `login_throttle` de la +couche security-by-design). -Security-by-design also adds columns (beyond the two new entities): `user` auth-lifecycle + +Le security-by-design ajoute aussi des colonnes (au-dela des deux nouvelles entites) : cycle de vie d'auth de `user` + `pin_hash` + `anonymized_at` (3.14), `customer_order.acting_user_id` + `idempotency_key` (3.10), -and the percentage stock model on `ingredient` (3.6) — `stock_capacity`, `critical_stock_pct`, -plus the rename of `low_stock_threshold` to `low_stock_pct`. `login_throttle` (3.21) is the 21st -entity. See note 13. +et le modele de stock en pourcentage sur `ingredient` (3.6) — `stock_capacity`, `critical_stock_pct`, +plus le renommage de `low_stock_threshold` en `low_stock_pct`. `login_throttle` (3.21) est la 21e +entite. Voir note 13. --- -*For the ER diagram and cardinality justifications, see [`mcd.md`](mcd.md) — the diagram is -the single source of truth for graphical representation.* +*Pour le diagramme ER et les justifications de cardinalite, voir [`mcd.md`](mcd.md) — le diagramme est +la source de verite unique pour la representation graphique.* diff --git a/docs/merise/mcd.md b/docs/merise/mcd.md index e910e23..16fd019 100644 --- a/docs/merise/mcd.md +++ b/docs/merise/mcd.md @@ -1,97 +1,97 @@ -# Conceptual Data Model (MCD) — Wakdo +# Modele Conceptuel de Donnees (MCD) — Wakdo -**Merise phase** : P1 - Conception, step 2 (data dictionary first, mantra #33) -**Version** : v0.2 — prod-like, 21 entities (19 prod-like + security-by-design layer) -**Date** : 2026-06-04 (security-by-design additions 2026-06-11) -**Branch** : `feat/p1-conception` -**Status** : prod-like — all D1-D8 + stock decisions applied (see `docs/notes/revue-alignement-p1.md` §7); security-by-design layer (audit_log + accountability/auth columns) in progress -**Author** : BYAN (methodology layer) +**Phase Merise** : P1 - Conception, etape 2 (data dictionary first, mantra #33) +**Version** : v0.2 — prod-like, 21 entites (19 prod-like + couche security-by-design) +**Date** : 2026-06-04 (ajouts security-by-design 2026-06-11) +**Branche** : `feat/p1-conception` +**Statut** : prod-like — toutes les decisions D1-D8 + stock appliquees (voir `docs/notes/revue-alignement-p1.md` §7) ; couche security-by-design (audit_log + colonnes imputabilite/auth) en cours +**Auteur** : BYAN (couche methodologie) --- -## 1. Purpose of this document +## 1. Objectif de ce document -The MCD (Modele Conceptuel des Donnees) formalises the **entities** of the Wakdo domain, -their **associations**, and the **cardinalities** governing those associations. -It is the normalised translation of the data dictionary, and serves as the basis for the -MLD (relational mapping). +Le MCD (Modele Conceptuel des Donnees) formalise les **entites** du domaine Wakdo, +leurs **associations**, et les **cardinalites** qui regissent ces associations. +C'est la traduction normalisee du dictionnaire de donnees, et il sert de base au +MLD (mapping relationnel). -Unlike the dictionary (which details attributes and types), the MCD focuses on relational -structure: how many X per Y, whether participation is mandatory, whether associations carry -their own attributes. +Contrairement au dictionnaire (qui detaille les attributs et les types), le MCD se concentre sur la +structure relationnelle : combien de X par Y, si la participation est obligatoire, si les associations portent +leurs propres attributs. -**Sources**: -- `docs/merise/dictionary.md` (v0.2 — 21 entities, source of truth for all names, types, ENUMs) -- `docs/notes/revue-alignement-p1.md` §7 (decision table D1-D8 + stock) -- `docs/PROJECT_CONTEXT.md` (business rules: menu composition, order flow, RBAC, service modes) -- `docs/merise/_sources/` (school data: 9 categories, 53 products, 13 menus) +**Sources** : +- `docs/merise/dictionary.md` (v0.2 — 21 entites, source de verite pour tous les noms, types, ENUMs) +- `docs/notes/revue-alignement-p1.md` §7 (table de decisions D1-D8 + stock) +- `docs/PROJECT_CONTEXT.md` (regles metier : composition de menu, flux de commande, RBAC, modes de service) +- `docs/merise/_sources/` (donnees de l'ecole : 9 categories, 53 produits, 13 menus) --- -## 2. Merise notation used +## 2. Notation Merise utilisee -### Cardinalities at the association foot (French Merise style) +### Cardinalites au pied de l'association (style Merise francais) -At each end of an association, the cardinality `(min,max)` states how many times an -instance of the entity participates in the association. +A chaque extremite d'une association, la cardinalite `(min,max)` indique combien de fois une +instance de l'entite participe a l'association. ``` ENTITY_A (min,max) ----[ ASSOCIATION ]---- (min,max) ENTITY_B ``` -| Notation | Reading | Example | +| Notation | Lecture | Exemple | |---|---|---| -| `(0,1)` | Optional, at most 1 | A stock_movement links to (0,1) customer_order | -| `(1,1)` | Mandatory, exactly 1 | A product belongs to (1,1) category | -| `(0,N)` | Optional, unbounded | A category groups (0,N) products | -| `(1,N)` | At least 1, unbounded | An order contains (1,N) order_items | +| `(0,1)` | Optionnel, au plus 1 | Un stock_movement est lie a (0,1) customer_order | +| `(1,1)` | Obligatoire, exactement 1 | Un product appartient a (1,1) category | +| `(0,N)` | Optionnel, non borne | Une category regroupe (0,N) products | +| `(1,N)` | Au moins 1, non borne | Une commande contient (1,N) order_items | -Reading: "one instance of the source entity participates at least MIN times and at most -MAX times in the association". +Lecture : "une instance de l'entite source participe au moins MIN fois et au plus +MAX fois a l'association". -### Association naming convention +### Convention de nommage des associations -Active verb in business terms, e.g.: `groups`, `anchors`, `defines_slot`, `contains`, +Verbe d'action en termes metier, par exemple : `groups`, `anchors`, `defines_slot`, `contains`, `references_product`, `references_menu`, `fills_slot`, `modifies_ingredient`, `logs`, `holds`, `grants`, `filters_source`, `decrements`. -N-N associations that carry their own attributes become **associative entities** in the MLD -(join table with own columns). +Les associations N-N qui portent leurs propres attributs deviennent des **entites associatives** dans le MLD +(table de jointure avec colonnes propres). --- -## 3. Decomposition by sub-domain +## 3. Decomposition par sous-domaine -The 21-entity model is split into 4 sub-domains for readability. Beyond approximately -5 entities, a single flat diagram becomes difficult to read; decomposition is the standard -Merise practice for models of this size. +Le modele de 21 entites est divise en 4 sous-domaines pour la lisibilite. Au-dela d'environ +5 entites, un diagramme plat unique devient difficile a lire ; la decomposition est la pratique +Merise standard pour les modeles de cette taille. -| Sub-domain | Entities | Count | +| Sous-domaine | Entites | Nombre | |---|---|---| | Catalogue | category, product, menu, menu_slot, menu_slot_option | 5 | | Ingredients & Stock | ingredient, product_ingredient, allergen, ingredient_allergen, stock_movement | 5 | | Order | customer_order, order_item, order_item_selection, order_item_modifier | 4 | | RBAC & Audit | user, role, role_visible_source, permission, role_permission, audit_log, login_throttle | 7 | -> **Security-by-design layer (2026-06-11)**: `audit_log` (entity 20) is a cross-cutting, -> append-only log of sensitive actions; it is placed in the RBAC & Audit sub-domain because -> its references (`actor_user_id`, `actor_role_id`) are RBAC entities. `login_throttle` -> (entity 21) is a per-source-IP brute-force throttle, keyed by IP and carrying no FK; it sits -> in the same sub-domain because it guards the authentication path. New columns on existing -> entities: `user` auth-lifecycle + `pin_hash` + `anonymized_at`, `customer_order.acting_user_id` -> + `idempotency_key`. See dictionary note 13. +> **Couche security-by-design (2026-06-11)** : `audit_log` (entite 20) est un journal transverse, +> append-only des actions sensibles ; il est place dans le sous-domaine RBAC & Audit parce que +> ses references (`actor_user_id`, `actor_role_id`) sont des entites RBAC. `login_throttle` +> (entite 21) est un throttle anti-brute-force par IP source, indexe par IP et ne portant aucune FK ; il se situe +> dans le meme sous-domaine parce qu'il protege le chemin d'authentification. Nouvelles colonnes sur des entites existantes : +> `user` cycle de vie auth + `pin_hash` + `anonymized_at`, `customer_order.acting_user_id` +> + `idempotency_key`. Voir note 13 du dictionnaire. -**Note on the absence of a global diagram**: a single 21-entity ER diagram would be -unreadable and unmaintainable. The sub-domain decomposition below is the intentional -structural choice. Each sub-domain is a Mermaid `erDiagram` (authoritative, rendered -natively) with a portable SVG render in `docs/merise/_diagrams/`; see section 11 for the -sources and regeneration command. +**Note sur l'absence d'un diagramme global** : un unique diagramme ER de 21 entites serait +illisible et impossible a maintenir. La decomposition par sous-domaine ci-dessous est le choix +structurel intentionnel. Chaque sous-domaine est un `erDiagram` Mermaid (faisant autorite, rendu +nativement) avec un rendu SVG portable dans `docs/merise/_diagrams/` ; voir la section 11 pour les +sources et la commande de regeneration. --- -## 4. Sub-domain: Catalogue +## 4. Sous-domaine : Catalogue -### 4.1 Mermaid entity-relationship diagram +### 4.1 Diagramme entite-relation Mermaid ```mermaid erDiagram @@ -147,30 +147,30 @@ erDiagram product ||--o{ menu_slot_option : "is_eligible_for" ``` -### 4.2 Association cardinalities +### 4.2 Cardinalites des associations -| # | Association | Side A | Cardinality A | Side B | Cardinality B | Justification | +| # | Association | Cote A | Cardinalite A | Cote B | Cardinalite B | Justification | |---|---|---|---|---|---|---| -| C1 | groups (product) | category | (0,N) | product | (1,1) | A category can exist with no products yet (created empty). A product must belong to exactly one category to appear on the kiosk. | -| C2 | groups (menu) | category | (0,N) | menu | (1,1) | Same rationale as C1 for menus. All 13 menus belong to the `menus` category. | -| C3 | anchors | menu | (1,1) | product | (0,N) | Each menu is built around exactly one fixed burger product (`burger_product_id`). A product may anchor 0 or more menus (a burger not used in a menu yet; or a popular burger anchoring several formats). | -| C4 | defines_slot | menu | (1,N) | menu_slot | (1,1) | A menu must define at least one slot (drink, side, sauce) to have customisable composition. A slot belongs to exactly one menu. | -| C5 | lists | menu_slot | (1,N) | menu_slot_option | (1,1) | A slot must list at least one eligible product (otherwise the customer cannot fill it). Each option row belongs to exactly one slot. | -| C6 | is_eligible_for | product | (0,N) | menu_slot_option | (1,1) | A product may be eligible for any number of slots across all menus, or none if it is only sold a la carte. Each option row references exactly one product. | +| C1 | groups (product) | category | (0,N) | product | (1,1) | Une categorie peut exister sans aucun produit pour l'instant (creee vide). Un produit doit appartenir a exactement une categorie pour apparaitre sur la borne. | +| C2 | groups (menu) | category | (0,N) | menu | (1,1) | Meme raisonnement que C1 pour les menus. Les 13 menus appartiennent a la categorie `menus`. | +| C3 | anchors | menu | (1,1) | product | (0,N) | Chaque menu est construit autour d'exactement un produit burger fixe (`burger_product_id`). Un produit peut ancrer 0 ou plusieurs menus (un burger pas encore utilise dans un menu ; ou un burger populaire ancrant plusieurs formats). | +| C4 | defines_slot | menu | (1,N) | menu_slot | (1,1) | Un menu doit definir au moins un slot (boisson, accompagnement, sauce) pour avoir une composition personnalisable. Un slot appartient a exactement un menu. | +| C5 | lists | menu_slot | (1,N) | menu_slot_option | (1,1) | Un slot doit lister au moins un produit eligible (sinon le client ne peut pas le remplir). Chaque ligne d'option appartient a exactement un slot. | +| C6 | is_eligible_for | product | (0,N) | menu_slot_option | (1,1) | Un produit peut etre eligible pour un nombre quelconque de slots a travers tous les menus, ou aucun s'il n'est vendu qu'a la carte. Chaque ligne d'option reference exactement un produit. | -### 4.3 Notes on the Catalogue sub-domain +### 4.3 Notes sur le sous-domaine Catalogue -**`menu_slot` vs category filter**: the explicit eligibility list `menu_slot_option(menu_slot_id, product_id)` was chosen over a category-based filter (`menu_slot.category_id`). Rationale: a product added to the `drinks` category should not automatically appear in every drink slot of every menu. The explicit list avoids accidental eligibility when the catalogue grows (see dictionary note 11). +**`menu_slot` vs filtre par categorie** : la liste d'eligibilite explicite `menu_slot_option(menu_slot_id, product_id)` a ete choisie plutot qu'un filtre base sur la categorie (`menu_slot.category_id`). Raisonnement : un produit ajoute a la categorie `drinks` ne devrait pas apparaitre automatiquement dans chaque slot boisson de chaque menu. La liste explicite evite une eligibilite accidentelle quand le catalogue s'agrandit (voir note 11 du dictionnaire). -**`menu.burger_product_id` as anchor**: the menu references a specific burger product, not a generic slot. This allows the ingredient configurator (sub-domain Ingredients & Stock) to resolve which ingredients are modifiable for a menu line, via `menu -> burger_product_id -> product_ingredient`. +**`menu.burger_product_id` comme ancre** : le menu reference un produit burger specifique, pas un slot generique. Cela permet au configurateur d'ingredients (sous-domaine Ingredients & Stock) de resoudre quels ingredients sont modifiables pour une ligne de menu, via `menu -> burger_product_id -> product_ingredient`. -**Normal / Maxi format**: two prices (`price_normal_cents`, `price_maxi_cents`) on `menu`; format recorded at `order_item.format`. No individual slot-level price differential is stored (see dictionary note 7). +**Format Normal / Maxi** : deux prix (`price_normal_cents`, `price_maxi_cents`) sur `menu` ; format enregistre au niveau de `order_item.format`. Aucun differentiel de prix au niveau du slot individuel n'est stocke (voir note 7 du dictionnaire). --- -## 5. Sub-domain: Ingredients & Stock +## 5. Sous-domaine : Ingredients & Stock -### 5.1 Mermaid entity-relationship diagram +### 5.1 Diagramme entite-relation Mermaid ```mermaid erDiagram @@ -236,35 +236,35 @@ erDiagram user |o--o{ stock_movement : "logs" ``` -### 5.2 Association cardinalities +### 5.2 Cardinalites des associations -| # | Association | Side A | Cardinality A | Side B | Cardinality B | Justification | +| # | Association | Cote A | Cardinalite A | Cote B | Cardinalite B | Justification | |---|---|---|---|---|---|---| -| I1 | is_composed_of | product | (0,N) | product_ingredient | (1,1) | A product may have no ingredients entered in the system yet (catalogue row exists before recipe is entered). A recipe row belongs to exactly one product. | -| I2 | appears_in | ingredient | (1,N) | product_ingredient | (1,1) | An ingredient in active use appears in at least one product recipe. Each recipe row references exactly one ingredient. Newly created ingredients with no recipe row yet are modelled as (0,N) from a pure structural standpoint; the business rule of (1,N) applies to ingredients in production use. | -| I3 | contains (allergens) | ingredient | (0,N) | ingredient_allergen | (1,1) | An ingredient may contain no regulated allergens (e.g., pure salt). Each allergen-link row belongs to one ingredient. | -| I4 | is_present_in | allergen | (0,N) | ingredient_allergen | (1,1) | An allergen may initially have no linked ingredients (seed: allergen catalogue is complete before recipe data is entered). Each link row references one allergen. | -| I5 | decrements | ingredient | (0,N) | stock_movement | (1,1) | All movements affect exactly one ingredient. An ingredient may have no stock movement rows yet if it was recently created and no orders have been placed. Each movement row references exactly one ingredient. | -| I6 | triggers | customer_order | (0,1) | stock_movement | (0,N) | A `sale` or `cancellation` movement references the originating order. A `restock` or `inventory_correction` has no order (NULL). A given order triggers movements across all its ingredients; an order still `pending_payment` has triggered no movement yet. | -| I7 | logs | user | (0,1) | stock_movement | (0,N) | Automated sale decrements have no user (NULL). Manual restocks and corrections are attributed to a user. A user may log any number of movements. | +| I1 | is_composed_of | product | (0,N) | product_ingredient | (1,1) | Un produit peut n'avoir aucun ingredient encore saisi dans le systeme (la ligne de catalogue existe avant que la recette ne soit saisie). Une ligne de recette appartient a exactement un produit. | +| I2 | appears_in | ingredient | (1,N) | product_ingredient | (1,1) | Un ingredient en usage actif apparait dans au moins une recette de produit. Chaque ligne de recette reference exactement un ingredient. Les ingredients nouvellement crees sans ligne de recette sont modelises en (0,N) d'un point de vue purement structurel ; la regle metier de (1,N) s'applique aux ingredients en usage de production. | +| I3 | contains (allergens) | ingredient | (0,N) | ingredient_allergen | (1,1) | Un ingredient peut ne contenir aucun allergene reglemente (par exemple, du sel pur). Chaque ligne de lien d'allergene appartient a un ingredient. | +| I4 | is_present_in | allergen | (0,N) | ingredient_allergen | (1,1) | Un allergene peut initialement n'avoir aucun ingredient lie (seed : le catalogue d'allergenes est complet avant que les donnees de recette ne soient saisies). Chaque ligne de lien reference un allergene. | +| I5 | decrements | ingredient | (0,N) | stock_movement | (1,1) | Tous les mouvements affectent exactement un ingredient. Un ingredient peut n'avoir encore aucune ligne de mouvement de stock s'il a ete cree recemment et qu'aucune commande n'a ete passee. Chaque ligne de mouvement reference exactement un ingredient. | +| I6 | triggers | customer_order | (0,1) | stock_movement | (0,N) | Un mouvement `sale` ou `cancellation` reference la commande d'origine. Un `restock` ou `inventory_correction` n'a pas de commande (NULL). Une commande donnee declenche des mouvements sur tous ses ingredients ; une commande encore `pending_payment` n'a declenche aucun mouvement. | +| I7 | logs | user | (0,1) | stock_movement | (0,N) | Les decrements de vente automatises n'ont pas d'utilisateur (NULL). Les reapprovisionnements et corrections manuels sont attribues a un utilisateur. Un utilisateur peut journaliser un nombre quelconque de mouvements. | -### 5.3 Notes on the Ingredients & Stock sub-domain +### 5.3 Notes sur le sous-domaine Ingredients & Stock -**`product_ingredient` as an associative entity**: the N-N association between `product` and `ingredient` carries five attributes (`quantity_normal`, `quantity_maxi`, `is_removable`, `is_addable`, `extra_price_cents`). It becomes a join table in the MLD with composite PK `(product_id, ingredient_id)`. +**`product_ingredient` comme entite associative** : l'association N-N entre `product` et `ingredient` porte cinq attributs (`quantity_normal`, `quantity_maxi`, `is_removable`, `is_addable`, `extra_price_cents`). Elle devient une table de jointure dans le MLD avec une PK composite `(product_id, ingredient_id)`. -**`ingredient_allergen` as a pure join table**: no own attributes. The allergen set for a product is computed at query time by joining `product_ingredient -> ingredient_allergen -> allergen`; no manual per-product entry is needed. +**`ingredient_allergen` comme table de jointure pure** : aucun attribut propre. L'ensemble des allergenes d'un produit est calcule au moment de la requete en joignant `product_ingredient -> ingredient_allergen -> allergen` ; aucune saisie manuelle par produit n'est necessaire. -**`stock_movement` immutability**: this table is append-only. No UPDATE or DELETE is permitted at application layer. Corrections are new rows with `movement_type = 'inventory_correction'` and a signed `delta`. +**Immuabilite de `stock_movement`** : cette table est append-only. Aucun UPDATE ni DELETE n'est autorise au niveau applicatif. Les corrections sont de nouvelles lignes avec `movement_type = 'inventory_correction'` et un `delta` signe. -**Percentage-based stock model**: stock health is anchored on a per-ingredient `stock_capacity` (the 100% reference, `CHECK > 0`). `stock_quantity` is signed and may go negative when sales outrun counted stock; the system does not block an order on a low stock read. `stock_pct = ROUND(stock_quantity / stock_capacity * 100)` is computed, not stored. Two percentage thresholds drive a three-band behaviour: `low_stock_pct` (warning band, default 10%) and `critical_stock_pct` (auto-out-of-stock floor, default 5%), with the table-level invariant `critical_stock_pct < low_stock_pct`. Above the low band is normal; between critical and low the product stays orderable and a manager alert is raised (the manager either pulls the product via `product.is_available = 0` or restocks to clear the alert); at or below the critical band the product auto-goes out-of-stock (computed, see below). +**Modele de stock base sur les pourcentages** : la sante du stock est ancree sur une `stock_capacity` par ingredient (la reference 100%, `CHECK > 0`). `stock_quantity` est signe et peut devenir negatif quand les ventes depassent le stock compte ; le systeme ne bloque pas une commande sur une lecture de stock bas. `stock_pct = ROUND(stock_quantity / stock_capacity * 100)` est calcule, pas stocke. Deux seuils en pourcentage pilotent un comportement a trois bandes : `low_stock_pct` (bande d'alerte, defaut 10%) et `critical_stock_pct` (plancher de mise en rupture automatique, defaut 5%), avec l'invariant au niveau de la table `critical_stock_pct < low_stock_pct`. Au-dessus de la bande d'alerte, c'est normal ; entre critique et bas, le produit reste commandable et une alerte manager est levee (le manager soit retire le produit via `product.is_available = 0`, soit reapprovisionne pour lever l'alerte) ; au niveau ou en dessous de la bande critique, le produit passe automatiquement en rupture (calcule, voir ci-dessous). -**Computed product availability (rule RG-T21, see `mlt.md`)**: effective orderability is derived, not stored. A product is orderable when `product.is_available = 1` AND each non-removable (`is_removable = 0`) ingredient in its `product_ingredient` has `stock_quantity > stock_capacity * critical_stock_pct / 100`. A required ingredient reaching the critical band makes the product auto-out-of-stock with no write and no cascade; a manual pull (`product.is_available = 0`) is a hard override; restock above the critical band makes the product orderable again on its own. A removable/optional ingredient at the critical band does not block the product (only its add-on becomes unavailable). The dashboard distinguishes a manual pull from a stock-driven OOS. +**Disponibilite produit calculee (regle RG-T21, voir `mlt.md`)** : la commandabilite effective est derivee, pas stockee. Un produit est commandable quand `product.is_available = 1` ET que chaque ingredient non retirable (`is_removable = 0`) de son `product_ingredient` a `stock_quantity > stock_capacity * critical_stock_pct / 100`. Un ingredient requis atteignant la bande critique met le produit en rupture automatique sans ecriture et sans cascade ; un retrait manuel (`product.is_available = 0`) est une surcharge forte ; un reapprovisionnement au-dessus de la bande critique rend le produit commandable a nouveau de lui-meme. Un ingredient retirable/optionnel a la bande critique ne bloque pas le produit (seul son supplement devient indisponible). Le tableau de bord distingue un retrait manuel d'une rupture pilotee par le stock. --- -## 6. Sub-domain: Order +## 6. Sous-domaine : Order -### 6.1 Mermaid entity-relationship diagram +### 6.1 Diagramme entite-relation Mermaid ```mermaid erDiagram @@ -336,55 +336,55 @@ erDiagram ingredient ||--o{ order_item_modifier : "modified_by" ``` -### 6.2 Association cardinalities +### 6.2 Cardinalites des associations -| # | Association | Side A | Cardinality A | Side B | Cardinality B | Justification | +| # | Association | Cote A | Cardinalite A | Cote B | Cardinalite B | Justification | |---|---|---|---|---|---|---| -| O1 | contains | customer_order | (1,N) | order_item | (1,1) | An order without at least one line has no business meaning. A line belongs to exactly one order. ON DELETE CASCADE: if the order is purged, its lines go with it. | -| O2 | references_product | order_item | (0,1) | product | (0,N) | When `item_type = 'product'`, `product_id` is non-null (1 product referenced). When `item_type = 'menu'`, `product_id` is NULL (0). A product may appear in any number of order lines across history. | -| O3 | references_menu | order_item | (0,1) | menu | (0,N) | Symmetric to O2 for the menu discriminator branch. Exactly one of O2/O3 is active per line (CHECK constraint in MLD). | -| O4 | fills_slot | order_item | (0,N) | order_item_selection | (1,1) | A `menu`-type order line has one selection per slot (typically 2-3). A `product`-type line has no selections (0). Each selection row belongs to exactly one order line. | -| O5 | slot_filled_by | menu_slot | (0,N) | order_item_selection | (1,1) | A slot definition may have been chosen many times across historical orders (0,N). Each selection row references exactly one slot. ON DELETE RESTRICT: preserves historical records if the slot definition is later changed. | -| O6 | chosen_for_slot | product | (0,N) | order_item_selection | (1,1) | A product may have been selected for many slot choices across history. Each selection references one product. | -| O7 | modifies_ingredient | order_item | (0,N) | order_item_modifier | (1,1) | An order line may have any number of ingredient modifications (remove onion, add cheese). Each modifier row belongs to one order line. | -| O8 | modified_by | ingredient | (0,N) | order_item_modifier | (1,1) | An ingredient may have been modified in many order lines across history. Each modifier references one ingredient. | +| O1 | contains | customer_order | (1,N) | order_item | (1,1) | Une commande sans au moins une ligne n'a aucun sens metier. Une ligne appartient a exactement une commande. ON DELETE CASCADE : si la commande est purgee, ses lignes partent avec elle. | +| O2 | references_product | order_item | (0,1) | product | (0,N) | Quand `item_type = 'product'`, `product_id` est non nul (1 produit reference). Quand `item_type = 'menu'`, `product_id` est NULL (0). Un produit peut apparaitre dans un nombre quelconque de lignes de commande a travers l'historique. | +| O3 | references_menu | order_item | (0,1) | menu | (0,N) | Symetrique a O2 pour la branche du discriminateur menu. Exactement un de O2/O3 est actif par ligne (contrainte CHECK dans le MLD). | +| O4 | fills_slot | order_item | (0,N) | order_item_selection | (1,1) | Une ligne de commande de type `menu` a une selection par slot (typiquement 2-3). Une ligne de type `product` n'a aucune selection (0). Chaque ligne de selection appartient a exactement une ligne de commande. | +| O5 | slot_filled_by | menu_slot | (0,N) | order_item_selection | (1,1) | Une definition de slot peut avoir ete choisie de nombreuses fois a travers les commandes historiques (0,N). Chaque ligne de selection reference exactement un slot. ON DELETE RESTRICT : preserve les enregistrements historiques si la definition de slot est modifiee ulterieurement. | +| O6 | chosen_for_slot | product | (0,N) | order_item_selection | (1,1) | Un produit peut avoir ete selectionne pour de nombreux choix de slot a travers l'historique. Chaque selection reference un produit. | +| O7 | modifies_ingredient | order_item | (0,N) | order_item_modifier | (1,1) | Une ligne de commande peut avoir un nombre quelconque de modifications d'ingredients (retirer l'oignon, ajouter du fromage). Chaque ligne de modificateur appartient a une ligne de commande. | +| O8 | modified_by | ingredient | (0,N) | order_item_modifier | (1,1) | Un ingredient peut avoir ete modifie dans de nombreuses lignes de commande a travers l'historique. Chaque modificateur reference un ingredient. | -### 6.3 Notes on the Order sub-domain +### 6.3 Notes sur le sous-domaine Order -**Polymorphism on `order_item`**: each line references either a `product` or a `menu` (not both, not neither). The discriminator `item_type` ENUM drives which FK is populated. The mutual exclusivity is enforced by a CHECK constraint in the MLD. This pattern (2 nullable FKs + discriminator + CHECK) is a standard relational approach to single-table inheritance without a separate table per type. +**Polymorphisme sur `order_item`** : chaque ligne reference soit un `product`, soit un `menu` (ni les deux, ni aucun). Le discriminateur `item_type` ENUM pilote quelle FK est renseignee. L'exclusivite mutuelle est imposee par une contrainte CHECK dans le MLD. Ce pattern (2 FK nullables + discriminateur + CHECK) est une approche relationnelle standard de l'heritage en table unique sans table separee par type. -**`order_item_selection` (menu slot choices)**: captures which product the customer chose for each slot of a menu line. One row per slot filled. Used for KPI analysis (most popular drink/side combinations). The `label_snapshot` preserves the product name at transaction time. +**`order_item_selection` (choix de slot de menu)** : capture quel produit le client a choisi pour chaque slot d'une ligne de menu. Une ligne par slot rempli. Utilise pour l'analyse de KPI (combinaisons boisson/accompagnement les plus populaires). Le `label_snapshot` preserve le nom du produit au moment de la transaction. -**`order_item_modifier` (ingredient modifications)**: attaches to an `order_item` regardless of whether the line is a standalone product or a menu. For a menu line, the modifiable product is the fixed burger, resolved via `order_item.menu_id -> menu.burger_product_id` (see dictionary note 10). No additional FK column is needed on `order_item_modifier`. +**`order_item_modifier` (modifications d'ingredients)** : se rattache a un `order_item` que la ligne soit un produit autonome ou un menu. Pour une ligne de menu, le produit modifiable est le burger fixe, resolu via `order_item.menu_id -> menu.burger_product_id` (voir note 10 du dictionnaire). Aucune colonne FK supplementaire n'est necessaire sur `order_item_modifier`. -**Price snapshots**: `label_snapshot`, `unit_price_cents_snapshot`, and `vat_rate_snapshot` on `order_item` preserve the state at transaction time. If a product is later renamed or repriced, historical order data remains consistent. ON DELETE RESTRICT on `product_id` and `menu_id` is a secondary safeguard. +**Snapshots de prix** : `label_snapshot`, `unit_price_cents_snapshot`, et `vat_rate_snapshot` sur `order_item` preservent l'etat au moment de la transaction. Si un produit est ulterieurement renomme ou retarife, les donnees de commande historiques restent coherentes. ON DELETE RESTRICT sur `product_id` et `menu_id` est une protection secondaire. -**`service_day` computation** (KPI grouping): not stored as a column. Computed at query time: +**Calcul de `service_day`** (regroupement KPI) : non stocke comme colonne. Calcule au moment de la requete : ```sql CASE WHEN HOUR(created_at) < 10 THEN DATE(created_at) - INTERVAL 1 DAY ELSE DATE(created_at) END ``` -Cutoff: 10:00. The generated-column formula with `INTERVAL 4 HOUR 30 MINUTE` from the v0.1 MLD -was incorrect and is dropped (decision D6, `revue-alignement-p1.md` §7). +Seuil : 10:00. La formule de colonne generee avec `INTERVAL 4 HOUR 30 MINUTE` du MLD v0.1 +etait incorrecte et est abandonnee (decision D6, `revue-alignement-p1.md` §7). -**`source = 'drive' => service_mode = 'drive'`**: cross-constraint. A drive-channel order can -only have `service_mode = 'drive'`. Enforced at application layer (and optionally as a CHECK in -the MLD). +**`source = 'drive' => service_mode = 'drive'`** : contrainte croisee. Une commande du canal drive ne peut +avoir que `service_mode = 'drive'`. Imposee au niveau applicatif (et optionnellement comme CHECK dans +le MLD). -**4-state machine** (`pending_payment -> paid -> delivered` + `cancelled`): -`preparing` and `ready` are dropped (decision D4, `revue-alignement-p1.md` §7). KPI timing is -`delivered_at - paid_at`; KDS colour coding is computed from `NOW() - paid_at`. +**Machine a 4 etats** (`pending_payment -> paid -> delivered` + `cancelled`) : +`preparing` et `ready` sont abandonnes (decision D4, `revue-alignement-p1.md` §7). Le timing KPI est +`delivered_at - paid_at` ; le codage couleur KDS est calcule a partir de `NOW() - paid_at`. -**Security-by-design columns (2026-06-11)**: `idempotency_key` (client UUID, UNIQUE) -deduplicates a retried `POST /api/orders`. `acting_user_id` (FK -> `user`, ON DELETE SET NULL) -records the counter/drive staff who took the order under PIN; NULL for anonymous kiosk orders. -This adds a `customer_order |o--o| user : "taken_by"` association (cardinality: an order is -taken by (0,1) user; a user takes (0,N) orders). See dictionary note 13. +**Colonnes security-by-design (2026-06-11)** : `idempotency_key` (UUID client, UNIQUE) +deduplique un `POST /api/orders` rejoue. `acting_user_id` (FK -> `user`, ON DELETE SET NULL) +enregistre l'employe de comptoir/drive qui a pris la commande sous PIN ; NULL pour les commandes anonymes de la borne. +Cela ajoute une association `customer_order |o--o| user : "taken_by"` (cardinalite : une commande est +prise par (0,1) user ; un user prend (0,N) commandes). Voir note 13 du dictionnaire. --- -## 7. Sub-domain: RBAC +## 7. Sous-domaine : RBAC -### 7.1 Mermaid entity-relationship diagram +### 7.1 Diagramme entite-relation Mermaid ```mermaid erDiagram @@ -453,175 +453,175 @@ erDiagram role |o--o{ audit_log : "context_of" ``` -> `login_throttle` is a standalone entity with no association: it is keyed by source IP -> (`ip_address UNIQUE`), not by a modelled actor, so it carries no FK and connects to no -> other entity in the diagram. +> `login_throttle` est une entite autonome sans association : elle est indexee par IP source +> (`ip_address UNIQUE`), pas par un acteur modelise, donc elle ne porte aucune FK et ne se connecte a aucune +> autre entite du diagramme. -### 7.2 Association cardinalities +### 7.2 Cardinalites des associations -| # | Association | Side A | Cardinality A | Side B | Cardinality B | Justification | +| # | Association | Cote A | Cardinalite A | Cote B | Cardinalite B | Justification | |---|---|---|---|---|---|---| -| R1 | holds | user | (1,1) | role | (0,N) | A user must have exactly one role to access the back-office. A role may have no current users (created but not yet assigned). ON DELETE RESTRICT on `role_id`: a role cannot be deleted while users hold it. | -| R2 | sees_source | role | (0,N) | role_visible_source | (1,1) | A role may see 0 or more order sources on the preparation dashboard (admin/manager use a global view with no source filter). Each visibility row belongs to exactly one role. | -| R3 | grants | role | (0,N) | role_permission | (1,1) | A role may have no permissions (a newly created role before assignment) or many. Each mapping row belongs to one role. | -| R4 | granted_to | permission | (0,N) | role_permission | (1,1) | A permission may be granted to no roles yet (declared at seed, not yet distributed) or to several. Each mapping row references one permission. | -| R5 | performs | user | (0,1) | audit_log | (0,N) | A sensitive action captured under PIN records its acting user; automated/non-attributable entries carry NULL. A user may have logged any number of actions. ON DELETE SET NULL preserves the trail on user anonymisation/removal. | -| R6 | context_of | role | (0,1) | audit_log | (0,N) | Each audit row may denormalise the actor's role at action time (NULL allowed). A role may be the context of many audit rows. ON DELETE SET NULL preserves the trail. | +| R1 | holds | user | (1,1) | role | (0,N) | Un utilisateur doit avoir exactement un role pour acceder au back-office. Un role peut n'avoir aucun utilisateur actuel (cree mais pas encore assigne). ON DELETE RESTRICT sur `role_id` : un role ne peut etre supprime tant que des utilisateurs le detiennent. | +| R2 | sees_source | role | (0,N) | role_visible_source | (1,1) | Un role peut voir 0 ou plusieurs sources de commande sur le tableau de bord de preparation (admin/manager utilisent une vue globale sans filtre de source). Chaque ligne de visibilite appartient a exactement un role. | +| R3 | grants | role | (0,N) | role_permission | (1,1) | Un role peut n'avoir aucune permission (un role nouvellement cree avant assignation) ou plusieurs. Chaque ligne de mapping appartient a un role. | +| R4 | granted_to | permission | (0,N) | role_permission | (1,1) | Une permission peut n'etre encore accordee a aucun role (declaree au seed, pas encore distribuee) ou a plusieurs. Chaque ligne de mapping reference une permission. | +| R5 | performs | user | (0,1) | audit_log | (0,N) | Une action sensible capturee sous PIN enregistre son utilisateur agissant ; les entrees automatisees/non attribuables portent NULL. Un utilisateur peut avoir journalise un nombre quelconque d'actions. ON DELETE SET NULL preserve la trace lors de l'anonymisation/suppression de l'utilisateur. | +| R6 | context_of | role | (0,1) | audit_log | (0,N) | Chaque ligne d'audit peut denormaliser le role de l'acteur au moment de l'action (NULL autorise). Un role peut etre le contexte de nombreuses lignes d'audit. ON DELETE SET NULL preserve la trace. | -### 7.3 Notes on the RBAC sub-domain +### 7.3 Notes sur le sous-domaine RBAC -**RBAC architecture**: roles are dynamic (creatable and modifiable via admin UI). Permissions are static (declared in migration, tied to application code). Application code tests permissions, not role names: adding a new role with the right permissions requires no code change (permission-driven, per Sandhu/NIST RBAC model — decision D4, `revue-alignement-p1.md` §7). +**Architecture RBAC** : les roles sont dynamiques (creables et modifiables via l'UI admin). Les permissions sont statiques (declarees en migration, liees au code applicatif). Le code applicatif teste les permissions, pas les noms de role : ajouter un nouveau role avec les bonnes permissions ne necessite aucun changement de code (permission-driven, selon le modele RBAC Sandhu/NIST — decision D4, `revue-alignement-p1.md` §7). -**`role.order_source`**: when a counter or drive staff member creates an order, the `source` column on `customer_order` is automatically populated from their role's `order_source`. NULL for admin and manager (they can create on behalf of any channel). +**`role.order_source`** : quand un employe de comptoir ou de drive cree une commande, la colonne `source` sur `customer_order` est automatiquement renseignee a partir de l'`order_source` de son role. NULL pour admin et manager (ils peuvent creer pour le compte de n'importe quel canal). -**`role.default_route`**: the landing screen for each role, stored in the database. Front-end routing reads this value at login; no role name is hardcoded in routing logic. +**`role.default_route`** : l'ecran d'arrivee pour chaque role, stocke en base de donnees. Le routage front-end lit cette valeur au login ; aucun nom de role n'est code en dur dans la logique de routage. -**`role_visible_source`**: a pure join table linking a role to the set of order sources visible on the preparation dashboard. A `kitchen` role sees all three sources; a `counter` role sees `kiosk` and `counter`; a `drive` role sees only `drive`. +**`role_visible_source`** : une table de jointure pure liant un role a l'ensemble des sources de commande visibles sur le tableau de bord de preparation. Un role `kitchen` voit les trois sources ; un role `counter` voit `kiosk` et `counter` ; un role `drive` ne voit que `drive`. -**`role_permission`** and **`role_visible_source`** both use composite PKs. ON DELETE CASCADE on both FKs of `role_permission` (deleting a role or a permission removes its mappings). ON DELETE CASCADE on `role_id` of `role_visible_source`. +**`role_permission`** et **`role_visible_source`** utilisent tous deux des PK composites. ON DELETE CASCADE sur les deux FK de `role_permission` (supprimer un role ou une permission retire ses mappings). ON DELETE CASCADE sur le `role_id` de `role_visible_source`. -**Seed roles** (5 roles, frozen at DDL; extendable without code change): +**Roles de seed** (5 roles, figes au DDL ; extensibles sans changement de code) : `admin`, `manager`, `kitchen`, `counter`, `drive`. -**`audit_log` (security-by-design)**: append-only log of sensitive actions, immutable like -`stock_movement`. Both FKs (`actor_user_id`, `actor_role_id`) are nullable with ON DELETE -SET NULL, so the trail survives user anonymisation (RGPD) and role removal. The `actor_role_id` -is denormalised on purpose: even if the user is later anonymised, the role context of the -action is preserved. It carries no PII (the `details` JSON stores changed field names, not -values for user-targeted actions). See dictionary 3.20 and note 13. +**`audit_log` (security-by-design)** : journal append-only des actions sensibles, immuable comme +`stock_movement`. Les deux FK (`actor_user_id`, `actor_role_id`) sont nullables avec ON DELETE +SET NULL, de sorte que la trace survit a l'anonymisation de l'utilisateur (RGPD) et a la suppression de role. Le `actor_role_id` +est denormalise a dessein : meme si l'utilisateur est ulterieurement anonymise, le contexte de role de +l'action est preserve. Il ne porte aucune PII (le JSON `details` stocke les noms des champs modifies, pas les +valeurs pour les actions ciblant un utilisateur). Voir dictionnaire 3.20 et note 13. -**`login_throttle` (security-by-design)**: per-source-IP brute-force throttle, complementing -the per-account counter already on `user` (`failed_login_attempts` / `lockout_until`). One row -per IP (`ip_address VARCHAR(45) UNIQUE`, 45 chars to hold a full IPv6 literal), upserted on each -failed login: `failed_attempts` counts consecutive failures from this IP in the current window, -`window_started_at` marks the start of that window (which resets when expired), `lockout_until` -holds the end of the degressive backoff (NULL = not throttled), `last_attempt_at` the timestamp -of the last failed attempt. It has no FK (an IP is not a modelled entity) and no association. A -daily cron purges rows with no active lockout whose `last_attempt_at` is older than 24h. See -dictionary 3.21 and note 13. +**`login_throttle` (security-by-design)** : throttle anti-brute-force par IP source, complementant +le compteur par compte deja present sur `user` (`failed_login_attempts` / `lockout_until`). Une ligne +par IP (`ip_address VARCHAR(45) UNIQUE`, 45 caracteres pour contenir un litteral IPv6 complet), upsertee a chaque +echec de login : `failed_attempts` compte les echecs consecutifs depuis cette IP dans la fenetre courante, +`window_started_at` marque le debut de cette fenetre (qui se reinitialise a son expiration), `lockout_until` +contient la fin du backoff degressif (NULL = non throttle), `last_attempt_at` l'horodatage +de la derniere tentative echouee. Elle n'a aucune FK (une IP n'est pas une entite modelisee) et aucune association. Un +cron quotidien purge les lignes sans lockout actif dont le `last_attempt_at` est plus ancien que 24h. Voir +dictionnaire 3.21 et note 13. --- -## 8. Cross-validation MCD <-> dictionary +## 8. Validation croisee MCD <-> dictionnaire -Verification that all 21 dictionary entities appear in the MCD and vice versa. +Verification que les 21 entites du dictionnaire apparaissent dans le MCD et reciproquement. -| # | Dictionary entity (section 3) | Sub-domain in MCD | Present | +| # | Entite du dictionnaire (section 3) | Sous-domaine dans le MCD | Presente | |---|---|---|---| -| 1 | `category` (3.1) | Catalogue | Yes | -| 2 | `product` (3.2) | Catalogue + Ingredients + Order | Yes | -| 3 | `menu` (3.3) | Catalogue + Order | Yes | -| 4 | `menu_slot` (3.4) | Catalogue + Order | Yes | -| 5 | `menu_slot_option` (3.5) | Catalogue | Yes | -| 6 | `ingredient` (3.6) | Ingredients + Order | Yes | -| 7 | `product_ingredient` (3.7) | Ingredients | Yes | -| 8 | `allergen` (3.8) | Ingredients | Yes | -| 9 | `ingredient_allergen` (3.9) | Ingredients | Yes | -| 10 | `customer_order` (3.10) | Order | Yes | -| 11 | `order_item` (3.11) | Order | Yes | -| 12 | `order_item_selection` (3.12) | Order | Yes | -| 13 | `order_item_modifier` (3.13) | Order | Yes | -| 14 | `user` (3.14) | RBAC | Yes | -| 15 | `role` (3.15) | RBAC | Yes | -| 16 | `role_visible_source` (3.16) | RBAC | Yes | -| 17 | `permission` (3.17) | RBAC | Yes | -| 18 | `role_permission` (3.18) | RBAC | Yes | -| 19 | `stock_movement` (3.19) | Ingredients & Stock | Yes | -| 20 | `audit_log` (3.20) | RBAC & Audit | Yes | -| 21 | `login_throttle` (3.21) | RBAC & Audit | Yes | +| 1 | `category` (3.1) | Catalogue | Oui | +| 2 | `product` (3.2) | Catalogue + Ingredients + Order | Oui | +| 3 | `menu` (3.3) | Catalogue + Order | Oui | +| 4 | `menu_slot` (3.4) | Catalogue + Order | Oui | +| 5 | `menu_slot_option` (3.5) | Catalogue | Oui | +| 6 | `ingredient` (3.6) | Ingredients + Order | Oui | +| 7 | `product_ingredient` (3.7) | Ingredients | Oui | +| 8 | `allergen` (3.8) | Ingredients | Oui | +| 9 | `ingredient_allergen` (3.9) | Ingredients | Oui | +| 10 | `customer_order` (3.10) | Order | Oui | +| 11 | `order_item` (3.11) | Order | Oui | +| 12 | `order_item_selection` (3.12) | Order | Oui | +| 13 | `order_item_modifier` (3.13) | Order | Oui | +| 14 | `user` (3.14) | RBAC | Oui | +| 15 | `role` (3.15) | RBAC | Oui | +| 16 | `role_visible_source` (3.16) | RBAC | Oui | +| 17 | `permission` (3.17) | RBAC | Oui | +| 18 | `role_permission` (3.18) | RBAC | Oui | +| 19 | `stock_movement` (3.19) | Ingredients & Stock | Oui | +| 20 | `audit_log` (3.20) | RBAC & Audit | Oui | +| 21 | `login_throttle` (3.21) | RBAC & Audit | Oui | -**Result**: 21/21 entities traced (19 prod-like + `audit_log` and `login_throttle` -security-by-design). No entity from the dictionary is absent from the MCD. No entity in the MCD -falls outside the dictionary. +**Resultat** : 21/21 entites tracees (19 prod-like + `audit_log` et `login_throttle` +security-by-design). Aucune entite du dictionnaire n'est absente du MCD. Aucune entite du MCD +ne tombe en dehors du dictionnaire. -**Entities appearing in multiple sub-domains** (cross-domain shared entities): -- `product`: Catalogue (sold item, slot eligibility) + Ingredients (recipe) + Order (line reference, slot choice) -- `menu`: Catalogue (definition, slots) + Order (line reference) -- `menu_slot`: Catalogue (slot definition) + Order (slot choices via `order_item_selection`) -- `ingredient`: Ingredients (recipe, stock) + Order (modifiers) -- `customer_order`: Order (order lifecycle) + Ingredients (stock movement trigger) + RBAC & Audit (taken_by staff via `acting_user_id`) -- `user`: RBAC (authentication) + Ingredients (stock movement author) + Order (`acting_user_id` on counter/drive orders) + Audit (actor of `audit_log`) -- `role`: RBAC (permissions, visible sources) + Audit (denormalised `actor_role_id` context on `audit_log`) +**Entites apparaissant dans plusieurs sous-domaines** (entites partagees inter-domaines) : +- `product` : Catalogue (article vendu, eligibilite de slot) + Ingredients (recette) + Order (reference de ligne, choix de slot) +- `menu` : Catalogue (definition, slots) + Order (reference de ligne) +- `menu_slot` : Catalogue (definition de slot) + Order (choix de slot via `order_item_selection`) +- `ingredient` : Ingredients (recette, stock) + Order (modificateurs) +- `customer_order` : Order (cycle de vie de la commande) + Ingredients (declencheur de mouvement de stock) + RBAC & Audit (employe taken_by via `acting_user_id`) +- `user` : RBAC (authentification) + Ingredients (auteur de mouvement de stock) + Order (`acting_user_id` sur les commandes comptoir/drive) + Audit (acteur de `audit_log`) +- `role` : RBAC (permissions, sources visibles) + Audit (contexte `actor_role_id` denormalise sur `audit_log`) -This is expected in a normalised model. The sub-domain split is for readability; the actual -relational schema is a unified graph. +C'est attendu dans un modele normalise. La division par sous-domaine est pour la lisibilite ; le schema +relationnel reel est un graphe unifie. --- -## 9. Decisions deferred to the MLD +## 9. Decisions reportees au MLD -The MCD remains at the conceptual level. The following decisions are deferred to the MLD: +Le MCD reste au niveau conceptuel. Les decisions suivantes sont reportees au MLD : -1. **Resolution of associative entities into tables**: `product_ingredient`, `menu_slot_option`, - `ingredient_allergen`, `role_visible_source`, `role_permission` become join tables with - composite PKs. -2. **Technical PK vs business identifier**: `id INT UNSIGNED AUTO_INCREMENT` on all main entities. - `customer_order` additionally carries `order_number VARCHAR(20) UNIQUE` (human-readable, - format `K/C/D-YYYY-MM-DD-NNN` per channel). -3. **ON DELETE rules**: CASCADE vs RESTRICT vs SET NULL. Detailed in the MLD. -4. **CHECK constraints**: polymorphism exclusivity on `order_item`, cross-constraint - `source/service_mode` on `customer_order`, arithmetic invariant on totals. -5. **Indexes**: not discussed at MCD level. Defined in the MLD for frequent query patterns. -6. **`service_day` formula**: applicative CASE expression, not a stored generated column. - Documented in the MLD. +1. **Resolution des entites associatives en tables** : `product_ingredient`, `menu_slot_option`, + `ingredient_allergen`, `role_visible_source`, `role_permission` deviennent des tables de jointure avec + des PK composites. +2. **PK technique vs identifiant metier** : `id INT UNSIGNED AUTO_INCREMENT` sur toutes les entites principales. + `customer_order` porte en plus `order_number VARCHAR(20) UNIQUE` (lisible par un humain, + format `K/C/D-YYYY-MM-DD-NNN` par canal). +3. **Regles ON DELETE** : CASCADE vs RESTRICT vs SET NULL. Detaillees dans le MLD. +4. **Contraintes CHECK** : exclusivite de polymorphisme sur `order_item`, contrainte croisee + `source/service_mode` sur `customer_order`, invariant arithmetique sur les totaux. +5. **Index** : non discutes au niveau MCD. Definis dans le MLD pour les patterns de requete frequents. +6. **Formule `service_day`** : expression applicative CASE, pas une colonne generee stockee. + Documentee dans le MLD. --- -## 10. MCD <-> MCT coherence (mantra #34) +## 10. Coherence MCD <-> MCT (mantra #34) -Pre-validation: each entity participates in at least one treatment. +Pre-validation : chaque entite participe a au moins un traitement. -| Entity | Expected treatment(s) | +| Entite | Traitement(s) attendu(s) | |---|---| -| `category` | Admin CRUD | -| `product` | Admin CRUD + kiosk cart add | -| `menu` | Admin CRUD + kiosk cart add | -| `menu_slot` | Admin CRUD (menu composition) | -| `menu_slot_option` | Admin CRUD (slot eligibility management) | -| `ingredient` | Admin CRUD + stock movements | -| `product_ingredient` | Admin recipe management | -| `allergen` | Admin CRUD (seed: read-only catalogue) | -| `ingredient_allergen` | Admin allergen mapping | -| `customer_order` | Full order lifecycle (create -> pay -> deliver / cancel) | -| `order_item` | Cart building, line creation at validation | -| `order_item_selection` | Menu slot selection during cart building | -| `order_item_modifier` | Ingredient modification during cart building | -| `user` | Admin CRUD + login | -| `role` | Admin CRUD + user assignment | -| `role_visible_source` | Admin role configuration | -| `permission` | Admin permission matrix management | -| `role_permission` | Admin permission matrix management | -| `stock_movement` | Automatic at `paid` transition; manual restock and inventory correction | -| `audit_log` | Written by sensitive operations: UPDATE/DELETE product/menu (8.2/8.3/8.6), CANCEL_ORDER (7.1), RESTOCK/INVENTORY_COUNT (9.1/9.2), user ops (10.1-10.3), MANAGE_RBAC (10.4), and failed/successful logins (12.1) | -| `login_throttle` | Read and written by AUTHENTICATE_USER (12.1): per-source-IP throttle upserted on each failed login, read to enforce the backoff window, purged by a daily cron | +| `category` | CRUD admin | +| `product` | CRUD admin + ajout au panier borne | +| `menu` | CRUD admin + ajout au panier borne | +| `menu_slot` | CRUD admin (composition de menu) | +| `menu_slot_option` | CRUD admin (gestion de l'eligibilite des slots) | +| `ingredient` | CRUD admin + mouvements de stock | +| `product_ingredient` | Gestion des recettes admin | +| `allergen` | CRUD admin (seed : catalogue en lecture seule) | +| `ingredient_allergen` | Mapping des allergenes admin | +| `customer_order` | Cycle de vie complet de la commande (create -> pay -> deliver / cancel) | +| `order_item` | Construction du panier, creation de ligne a la validation | +| `order_item_selection` | Selection de slot de menu pendant la construction du panier | +| `order_item_modifier` | Modification d'ingredient pendant la construction du panier | +| `user` | CRUD admin + login | +| `role` | CRUD admin + assignation d'utilisateur | +| `role_visible_source` | Configuration de role admin | +| `permission` | Gestion de la matrice de permissions admin | +| `role_permission` | Gestion de la matrice de permissions admin | +| `stock_movement` | Automatique a la transition `paid` ; reapprovisionnement manuel et correction d'inventaire | +| `audit_log` | Ecrit par les operations sensibles : UPDATE/DELETE product/menu (8.2/8.3/8.6), CANCEL_ORDER (7.1), RESTOCK/INVENTORY_COUNT (9.1/9.2), operations utilisateur (10.1-10.3), MANAGE_RBAC (10.4), et logins echoues/reussis (12.1) | +| `login_throttle` | Lu et ecrit par AUTHENTICATE_USER (12.1) : throttle par IP source upserte a chaque echec de login, lu pour imposer la fenetre de backoff, purge par un cron quotidien | -Cross-validation MCD <-> MCT (mantra #34) to be completed exhaustively in `mct.md` -once the MCT incorporates the security-by-design operations (PIN-gated sensitive actions, -audit writes, reset/lockout, anonymisation). The treatment-layer additions are tracked there. +La validation croisee MCD <-> MCT (mantra #34) sera completee de maniere exhaustive dans `mct.md` +une fois que le MCT integrera les operations security-by-design (actions sensibles protegees par PIN, +ecritures d'audit, reset/lockout, anonymisation). Les ajouts de la couche traitements y sont suivis. --- -## 11. Diagram sources and regeneration +## 11. Sources des diagrammes et regeneration -The authoritative graphical model is the set of Mermaid `erDiagram` blocks in sections 4-7, -one per sub-domain. They render natively on Forgejo and GitHub. The MCD is decomposed by -sub-domain on purpose: a single 21-entity diagram cannot be laid out without crossing -relationship lines (intrinsic planarity limit, and `erDiagram` offers no manual layout -control). Each sub-domain stays at 5-8 entities, which auto-layout handles cleanly. The -integrated view across sub-domains is the cross-validation table in section 8. +Le modele graphique faisant autorite est l'ensemble des blocs `erDiagram` Mermaid des sections 4-7, +un par sous-domaine. Ils s'affichent nativement sur Forgejo et GitHub. Le MCD est decompose par +sous-domaine a dessein : un unique diagramme de 21 entites ne peut etre dispose sans croisement de +lignes de relation (limite de planarite intrinseque, et `erDiagram` n'offre aucun controle de mise en page +manuel). Chaque sous-domaine reste a 5-8 entites, ce que la mise en page automatique gere proprement. La +vue integree a travers les sous-domaines est la table de validation croisee de la section 8. -Portable SVG renders live in `docs/merise/_diagrams/` (for PDF export / offline viewing): +Des rendus SVG portables se trouvent dans `docs/merise/_diagrams/` (pour l'export PDF / consultation hors ligne) : -| Sub-domain | Source | Render | +| Sous-domaine | Source | Rendu | |---|---|---| | Catalogue | `mcd-catalogue.mmd` | `mcd-catalogue.svg` | | Ingredients & Stock | `mcd-ingredients-stock.mmd` | `mcd-ingredients-stock.svg` | | Order | `mcd-order.mmd` | `mcd-order.svg` | | RBAC | `mcd-rbac.mmd` | `mcd-rbac.svg` | -The `.mmd` files are extracted from the `erDiagram` blocks above; the `.svg` are produced by -`make docs-render` (mmdc). If a block here changes, re-extract the matching `.mmd` and re-run -`make docs-render`. The legacy v0.1 `.drawio` sources have been removed: drawio gave manual -layout control but required hand-editing and did not render in the Markdown previews, whereas -the decomposed Mermaid blocks are version-controlled, render everywhere, and stay in sync with -this document. +Les fichiers `.mmd` sont extraits des blocs `erDiagram` ci-dessus ; les `.svg` sont produits par +`make docs-render` (mmdc). Si un bloc ici change, re-extraire le `.mmd` correspondant et relancer +`make docs-render`. Les anciennes sources `.drawio` v0.1 ont ete supprimees : drawio offrait un controle de mise en page +manuel mais necessitait une edition a la main et ne s'affichait pas dans les apercus Markdown, alors que +les blocs Mermaid decomposes sont versionnes, s'affichent partout, et restent synchronises avec +ce document. diff --git a/docs/merise/mct.md b/docs/merise/mct.md index e618deb..1e1dff0 100644 --- a/docs/merise/mct.md +++ b/docs/merise/mct.md @@ -1,44 +1,44 @@ -# Model of Conceptual Treatments (MCT) — Wakdo +# Modele Conceptuel des Traitements (MCT) — Wakdo -**Merise phase** : P1 - Conception, step 3 (after MCD) -**Version** : v0.2 — prod-like, 4-state machine (+ security-by-design layer 2026-06-11) -**Date** : 2026-06-04 (security-by-design additions 2026-06-11) -**Branch** : `feat/p1-conception` -**Status** : prod-like — all D1-D8 + stock decisions applied (see `docs/notes/revue-alignement-p1.md` §7); security-by-design ops added (ERASE_USER_PII, RESET_PASSWORD, PIN-gated sensitive set, audit_log writes, auth throttling) — 28 operations -**Author** : BYAN (methodology layer) +**Phase Merise** : P1 - Conception, etape 3 (apres le MCD) +**Version** : v0.2 — prod-like, machine a 4 etats (+ couche security-by-design 2026-06-11) +**Date** : 2026-06-04 (ajouts security-by-design 2026-06-11) +**Branche** : `feat/p1-conception` +**Statut** : prod-like — toutes les decisions D1-D8 + stock appliquees (voir `docs/notes/revue-alignement-p1.md` §7) ; operations security-by-design ajoutees (ERASE_USER_PII, RESET_PASSWORD, ensemble sensible protege par PIN, ecritures audit_log, throttling d'authentification) — 28 operations +**Auteur** : BYAN (couche methodologie) --- -## 1. Purpose +## 1. Objectif -The MCT (Model of Conceptual Treatments) describes the **business operations** of the Wakdo -domain in the canonical Merise form: **triggering event -> operation -> emitted result**. +Le MCT (Modele Conceptuel des Traitements) decrit les **operations metier** du domaine +Wakdo sous la forme canonique Merise : **evenement declencheur -> operation -> resultat emis**. -It answers the question: what happens in the domain, and when? -It does not answer: who does what, on which workstation, in which organisational order -(the MOT level is intentionally skipped — agile shortcut, consistent with the solo RNCP -framework). +Il repond a la question : que se passe-t-il dans le domaine, et quand ? +Il ne repond pas a : qui fait quoi, sur quel poste de travail, dans quel ordre organisationnel +(le niveau MOT est volontairement saute — raccourci agile, coherent avec le cadre RNCP +solo). -The MCT covers: -- The order lifecycle end-to-end (kiosk, counter, drive) -- Catalogue management (manager / admin) -- User and role management (admin) -- Back-office authentication (all back-office actors) +Le MCT couvre : +- Le cycle de vie de la commande de bout en bout (kiosk, comptoir, drive) +- La gestion du catalogue (manager / admin) +- La gestion des utilisateurs et des roles (admin) +- L'authentification back-office (tous les acteurs back-office) -**Identified actors**: +**Acteurs identifies** : -| Actor | Code | Interface | +| Acteur | Code | Interface | |-------|------|-----------| -| Customer (kiosk) | CUSTOMER | Touch kiosk (public, unauthenticated) | -| Counter staff | COUNTER | Back-office, role `counter` | -| Drive staff | DRIVE | Back-office, role `drive` | -| Kitchen staff | KITCHEN | Back-office, role `kitchen` (read-only on orders) | +| Client (kiosk) | CUSTOMER | Borne tactile (public, non authentifie) | +| Personnel comptoir | COUNTER | Back-office, role `counter` | +| Personnel drive | DRIVE | Back-office, role `drive` | +| Personnel cuisine | KITCHEN | Back-office, role `kitchen` (lecture seule sur les commandes) | | Manager | MANAGER | Back-office, role `manager` | -| Administrator | ADMIN | Back-office, role `admin` | -| System | SYS | Internal API / PHP logic | +| Administrateur | ADMIN | Back-office, role `admin` | +| Systeme | SYS | API interne / logique PHP | -**MCD cross-reference**: each operation references entities from the MCD (section 14). -The MCT is consistent with the `customer_order.status` state machine: +**Reference croisee MCD** : chaque operation reference des entites du MCD (section 14). +Le MCT est coherent avec la machine a etats de `customer_order.status` : ``` pending_payment -> paid -> delivered @@ -46,32 +46,32 @@ pending_payment -> paid -> delivered +--------------+-----------> cancelled (from any non-terminal state) ``` -**Dropped states** (compared to v0.1): `preparing` and `ready` are removed. -Rationale: in a fast-food context the kitchen display (KDS) is a visual system; staff read -the ticket and act. The single staff gesture is "deliver". KPI is total time -`delivered_at - paid_at` (SLA approx. 10 min). KDS colour coding is computed from -`now - paid_at`; no additional stored state is required. +**Etats supprimes** (par rapport a v0.1) : `preparing` et `ready` sont retires. +Justification : dans un contexte fast-food, l'affichage cuisine (KDS) est un systeme visuel ; +le personnel lit le ticket et agit. L'unique geste du personnel est « delivrer ». Le KPI est +le temps total `delivered_at - paid_at` (SLA approx. 10 min). Le code couleur du KDS est calcule a partir de +`now - paid_at` ; aucun etat stocke supplementaire n'est requis. -**Dropped operations** (compared to v0.1): `MARK_IN_PREPARATION` (`MARQUER_EN_PREPARATION`) -and `MARK_READY` (`MARQUER_PRETE`) are removed because their intermediate states no longer -exist. `DELIVER_ORDER` becomes the sole status-advancing action for counter/drive staff. +**Operations supprimees** (par rapport a v0.1) : `MARK_IN_PREPARATION` (`MARQUER_EN_PREPARATION`) +et `MARK_READY` (`MARQUER_PRETE`) sont retirees car leurs etats intermediaires n'existent plus. +`DELIVER_ORDER` devient la seule action faisant avancer le statut pour le personnel comptoir/drive. -**Security-by-design layer (2026-06-11)**: two operations are added — `RESET_PASSWORD` (12.3) -and `ERASE_USER_PII` (10.5, RGPD anonymisation). A subset of operations is **PIN-gated**: -back-office sessions stay shared per workstation, but a per-staff PIN re-authorises the -sensitive set — `CANCEL_ORDER` (7.1), `UPDATE_PRODUCT`/`DELETE_PRODUCT` (8.2/8.3), -`DELETE_MENU` (8.6), `INVENTORY_COUNT` (9.2), user management (10.1-10.3), `MANAGE_RBAC` -(10.4), `ERASE_USER_PII` (10.5). These non-stock actions append an immutable `audit_log` row -(actor, action, target); stock actions record attribution in `stock_movement`. The treatment -logic (PIN, audit, throttling, idempotency, atomic stock decrement, computed product -availability) is specified in `mlt.md` (rules RG-T13-T21). This adds entities 20 `audit_log` -and 21 `login_throttle` to the model. +**Couche security-by-design (2026-06-11)** : deux operations sont ajoutees — `RESET_PASSWORD` (12.3) +et `ERASE_USER_PII` (10.5, anonymisation RGPD). Un sous-ensemble d'operations est **protege par PIN** : +les sessions back-office restent partagees par poste de travail, mais un PIN par membre du personnel +re-autorise l'ensemble sensible — `CANCEL_ORDER` (7.1), `UPDATE_PRODUCT`/`DELETE_PRODUCT` (8.2/8.3), +`DELETE_MENU` (8.6), `INVENTORY_COUNT` (9.2), gestion des utilisateurs (10.1-10.3), `MANAGE_RBAC` +(10.4), `ERASE_USER_PII` (10.5). Ces actions hors stock ajoutent une ligne `audit_log` immuable +(acteur, action, cible) ; les actions de stock enregistrent l'attribution dans `stock_movement`. La logique +de traitement (PIN, audit, throttling, idempotence, decrement atomique du stock, disponibilite produit +calculee) est specifiee dans `mlt.md` (regles RG-T13-T21). Cela ajoute les entites 20 `audit_log` +et 21 `login_throttle` au modele. --- -## 2. Representation conventions +## 2. Conventions de representation -### Operation format +### Format des operations ``` [TRIGGERING EVENT(S)] @@ -84,467 +84,467 @@ and 21 `login_throttle` to the model. [EMITTED RESULT(S)] ``` -**Synchronisations**: -- `AND`: all events must be present simultaneously to trigger the operation. -- `OR`: any one of the events is sufficient. +**Synchronisations** : +- `AND` : tous les evenements doivent etre presents simultanement pour declencher l'operation. +- `OR` : l'un quelconque des evenements suffit. -**Conditions**: expressed in square brackets `[condition]` on the incoming arc. +**Conditions** : exprimees entre crochets `[condition]` sur l'arc entrant. -### Textual notation +### Notation textuelle -For each operation the document provides: -- **Triggering event(s)**: what occurs and causes the operation. -- **Actor(s)**: who initiates (or validates). -- **Synchronisation**: `AND` / `OR` if multiple events, plus condition. -- **Operation**: name and description of what it does. -- **MCD entities touched**: read (R) or write (W). -- **Result(s)**: what is emitted or produced. +Pour chaque operation, le document fournit : +- **Evenement(s) declencheur(s)** : ce qui survient et provoque l'operation. +- **Acteur(s)** : qui initie (ou valide). +- **Synchronisation** : `AND` / `OR` si plusieurs evenements, plus la condition. +- **Operation** : nom et description de ce qu'elle fait. +- **Entites MCD touchees** : lecture (R) ou ecriture (W). +- **Resultat(s)** : ce qui est emis ou produit. --- -## 3. Domain 1 — Order lifecycle (kiosk) +## 3. Domaine 1 — Cycle de vie de la commande (kiosk) ### 3.1 LOAD_CATALOGUE -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | Customer opens the kiosk (connection to the kiosk endpoint) | -| **Actor** | CUSTOMER | -| **Synchronisation** | None (single event) | -| **Condition** | The kiosk is in service (within business hours 10:00-01:00) | +| **Evenement declencheur** | Le client ouvre le kiosk (connexion a l'endpoint du kiosk) | +| **Acteur** | CUSTOMER | +| **Synchronisation** | Aucune (evenement unique) | +| **Condition** | Le kiosk est en service (dans les horaires d'ouverture 10:00-01:00) | | **Operation** | LOAD_CATALOGUE | -| **Description** | Retrieval of active categories, available products, and available menus (with their slots and eligible options) for display on the kiosk screen. Product availability is COMPUTED: a product is orderable only if its `is_available` flag is set AND each non-removable (`is_removable=0`) ingredient in its `product_ingredient` is above the critical band (`stock_quantity > stock_capacity * critical_stock_pct/100`). See rule RG-T21 in `mlt.md`. | -| **MCD entities** | R: `category` (is_active=1), `product` (is_available=1), `menu` (is_available=1), `menu_slot`, `menu_slot_option`, `ingredient` (is_active=1), `allergen`, `ingredient_allergen` | -| **Result** | Catalogue loaded; kiosk displays the home screen | +| **Description** | Recuperation des categories actives, des produits disponibles et des menus disponibles (avec leurs slots et options eligibles) pour affichage sur l'ecran du kiosk. La disponibilite des produits est CALCULEE : un produit est commandable seulement si son flag `is_available` est positionne ET que chaque ingredient non retirable (`is_removable=0`) de son `product_ingredient` est au-dessus de la bande critique (`stock_quantity > stock_capacity * critical_stock_pct/100`). Voir la regle RG-T21 dans `mlt.md`. | +| **Entites MCD** | R: `category` (is_active=1), `product` (is_available=1), `menu` (is_available=1), `menu_slot`, `menu_slot_option`, `ingredient` (is_active=1), `allergen`, `ingredient_allergen` | +| **Resultat** | Catalogue charge ; le kiosk affiche l'ecran d'accueil | --- ### 3.2 COMPOSE_CART -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | Customer selects a product or a menu on the kiosk | -| **Actor** | CUSTOMER | -| **Synchronisation** | Repeatable event (OR: add product, add menu, change quantity, remove item, choose menu slot, choose format Normal/Maxi, add/remove ingredient modifier) | -| **Condition** | The selected product or menu has `is_available=1` | +| **Evenement declencheur** | Le client selectionne un produit ou un menu sur le kiosk | +| **Acteur** | CUSTOMER | +| **Synchronisation** | Evenement repetable (OR : ajouter produit, ajouter menu, modifier quantite, retirer un article, choisir un slot de menu, choisir le format Normal/Maxi, ajouter/retirer un modificateur d'ingredient) | +| **Condition** | Le produit ou le menu selectionne a `is_available=1` | | **Operation** | COMPOSE_CART | -| **Description** | In-memory cart construction: add an item (standalone product or menu), select slot products (`order_item_selection`), optionally modify ingredients (`order_item_modifier`), choose Normal or Maxi format for menus, recalculate TTC total. The cart is a volatile client-side structure; no database write at this stage. | -| **MCD entities** | R: `product`, `menu`, `menu_slot`, `menu_slot_option`, `ingredient`, `product_ingredient` — W: none (volatile front-end state) | -| **Result** | Cart updated, total recalculated, summary displayed | +| **Description** | Construction du panier en memoire : ajouter un article (produit autonome ou menu), selectionner les produits des slots (`order_item_selection`), modifier optionnellement les ingredients (`order_item_modifier`), choisir le format Normal ou Maxi pour les menus, recalculer le total TTC. Le panier est une structure volatile cote client ; aucune ecriture en base a ce stade. | +| **Entites MCD** | R: `product`, `menu`, `menu_slot`, `menu_slot_option`, `ingredient`, `product_ingredient` — W: aucune (etat volatile front-end) | +| **Resultat** | Panier mis a jour, total recalcule, recapitulatif affiche | --- ### 3.3 CREATE_ORDER -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering events** | 1. Customer confirms cart (presses "Validate") AND 2. Customer enters their order number (RNCP payment substitute) | -| **Actor** | CUSTOMER | -| **Synchronisation** | AND (both actions required) | -| **Condition** | Cart contains at least 1 item. The order number entered is non-empty. | +| **Evenements declencheurs** | 1. Le client confirme le panier (appuie sur « Valider ») AND 2. Le client saisit son numero de commande (substitut de paiement RNCP) | +| **Acteur** | CUSTOMER | +| **Synchronisation** | AND (les deux actions requises) | +| **Condition** | Le panier contient au moins 1 article. Le numero de commande saisi est non vide. | | **Operation** | CREATE_ORDER | -| **Description** | Atomic order creation: INSERT `customer_order` with status `pending_payment`, source `kiosk`, snapshot of HT/VAT/TTC totals (computed line by line using `vat_rate` snapshotted per item). INSERT `order_item` lines with `label_snapshot`, `unit_price_cents_snapshot`, `vat_rate_snapshot`. INSERT `order_item_selection` for each slot filled in a menu item. INSERT `order_item_modifier` for each ingredient modification. Decrement `ingredient.stock_quantity` for each ingredient consumed (adjusted by modifiers: remove => no decrement; add => extra decrement); INSERT one `stock_movement` row of type `sale` per affected ingredient unit. Stock decrements and order insert are within the same transaction. After the customer enters their order number, the status transitions `pending_payment -> paid` within the same transaction; `paid_at` is set. The system generates the order number in format `K-YYYY-MM-DD-NNN`. | -| **MCD entities** | R: `product`, `menu`, `ingredient`, `product_ingredient` (snapshot) — W: `customer_order` (INSERT status `pending_payment` then UPDATE status `paid`, `paid_at`), `order_item` (INSERT N lines), `order_item_selection` (INSERT per menu slot chosen), `order_item_modifier` (INSERT per modification), `ingredient` (UPDATE stock_quantity), `stock_movement` (INSERT type `sale` per unit) | -| **Result** | Order created (status `paid` at end of operation), order number displayed to customer, logical event ORDER_CREATED emitted toward the preparation domain | +| **Description** | Creation atomique de la commande : INSERT `customer_order` avec statut `pending_payment`, source `kiosk`, snapshot des totaux HT/TVA/TTC (calcules ligne par ligne en utilisant `vat_rate` snapshote par article). INSERT des lignes `order_item` avec `label_snapshot`, `unit_price_cents_snapshot`, `vat_rate_snapshot`. INSERT `order_item_selection` pour chaque slot rempli dans un article de menu. INSERT `order_item_modifier` pour chaque modification d'ingredient. Decrement de `ingredient.stock_quantity` pour chaque ingredient consomme (ajuste par les modificateurs : retrait => pas de decrement ; ajout => decrement supplementaire) ; INSERT d'une ligne `stock_movement` de type `sale` par unite d'ingredient affectee. Les decrements de stock et l'insertion de la commande sont dans la meme transaction. Apres que le client a saisi son numero de commande, le statut passe `pending_payment -> paid` dans la meme transaction ; `paid_at` est positionne. Le systeme genere le numero de commande au format `K-YYYY-MM-DD-NNN`. | +| **Entites MCD** | R: `product`, `menu`, `ingredient`, `product_ingredient` (snapshot) — W: `customer_order` (INSERT status `pending_payment` then UPDATE status `paid`, `paid_at`), `order_item` (INSERT N lines), `order_item_selection` (INSERT per menu slot chosen), `order_item_modifier` (INSERT per modification), `ingredient` (UPDATE stock_quantity), `stock_movement` (INSERT type `sale` per unit) | +| **Resultat** | Commande creee (statut `paid` en fin d'operation), numero de commande affiche au client, evenement logique ORDER_CREATED emis vers le domaine de preparation | --- ### 3.4 DISPLAY_CONFIRMATION -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | ORDER_CREATED (API response 201 after CREATE_ORDER) | -| **Actor** | SYS | -| **Synchronisation** | None | -| **Condition** | API response contains an id, an order_number and status `paid` | +| **Evenement declencheur** | ORDER_CREATED (reponse API 201 apres CREATE_ORDER) | +| **Acteur** | SYS | +| **Synchronisation** | Aucune | +| **Condition** | La reponse API contient un id, un order_number et le statut `paid` | | **Operation** | DISPLAY_CONFIRMATION | -| **Description** | Display of the confirmation screen on the kiosk with the order number. The kiosk then resets for the next customer. | -| **MCD entities** | R: none (data is in the API response) | -| **Result** | Confirmation screen displayed; kiosk available for next order | +| **Description** | Affichage de l'ecran de confirmation sur le kiosk avec le numero de commande. Le kiosk se reinitialise ensuite pour le client suivant. | +| **Entites MCD** | R: aucune (les donnees sont dans la reponse API) | +| **Resultat** | Ecran de confirmation affiche ; kiosk disponible pour la commande suivante | --- -## 4. Domain 2 — Order lifecycle (counter and drive) +## 4. Domaine 2 — Cycle de vie de la commande (comptoir et drive) ### 4.1 CREATE_COUNTER_ORDER -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | A counter or drive staff member initiates a new order from the back-office | -| **Actor** | COUNTER or DRIVE | -| **Synchronisation** | None | -| **Condition** | The actor is authenticated and holds permission `order.create`. The `source` is `counter` or `drive` (auto-tagged from `role.order_source`). | +| **Evenement declencheur** | Un membre du personnel comptoir ou drive initie une nouvelle commande depuis le back-office | +| **Acteur** | COUNTER ou DRIVE | +| **Synchronisation** | Aucune | +| **Condition** | L'acteur est authentifie et detient la permission `order.create`. La `source` est `counter` ou `drive` (auto-taggee depuis `role.order_source`). | | **Operation** | CREATE_COUNTER_ORDER | -| **Description** | Manual order composition via the back-office: select products and menus, choose service mode (`dine_in`/`takeaway`/`drive`), fill menu slots, add ingredient modifiers. Identical creation logic to CREATE_ORDER (snapshot, stock decrement in same transaction, atomic `pending_payment -> paid` transition). The `source` is auto-tagged from `role.order_source` (counter -> `counter`, drive -> `drive`). Order number format: `C-YYYY-MM-DD-NNN` (counter) or `D-YYYY-MM-DD-NNN` (drive). Cross-constraint: if `source = 'drive'` then `service_mode = 'drive'` (verified at creation). | -| **MCD entities** | R: `product`, `menu`, `menu_slot`, `menu_slot_option`, `ingredient`, `product_ingredient` — W: `customer_order` (INSERT status `pending_payment` then UPDATE status `paid`, `paid_at`), `order_item`, `order_item_selection`, `order_item_modifier`, `ingredient` (stock decrement), `stock_movement` (INSERT type `sale`) | -| **Result** | Order created (status `paid`), order number communicated to customer | +| **Description** | Composition manuelle de la commande via le back-office : selectionner produits et menus, choisir le mode de service (`dine_in`/`takeaway`/`drive`), remplir les slots de menu, ajouter des modificateurs d'ingredient. Logique de creation identique a CREATE_ORDER (snapshot, decrement de stock dans la meme transaction, transition atomique `pending_payment -> paid`). La `source` est auto-taggee depuis `role.order_source` (counter -> `counter`, drive -> `drive`). Format du numero de commande : `C-YYYY-MM-DD-NNN` (comptoir) ou `D-YYYY-MM-DD-NNN` (drive). Contrainte croisee : si `source = 'drive'` alors `service_mode = 'drive'` (verifie a la creation). | +| **Entites MCD** | R: `product`, `menu`, `menu_slot`, `menu_slot_option`, `ingredient`, `product_ingredient` — W: `customer_order` (INSERT status `pending_payment` then UPDATE status `paid`, `paid_at`), `order_item`, `order_item_selection`, `order_item_modifier`, `ingredient` (stock decrement), `stock_movement` (INSERT type `sale`) | +| **Resultat** | Commande creee (statut `paid`), numero de commande communique au client | --- -## 5. Domain 3 — Preparation display (kitchen) +## 5. Domaine 3 — Affichage de preparation (cuisine) ### 5.1 LIST_ORDERS_DISPLAY -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | Kitchen staff accesses or refreshes the preparation display | -| **Actor** | KITCHEN (or COUNTER, DRIVE, ADMIN) | -| **Synchronisation** | None | -| **Condition** | The actor is authenticated and holds permission `order.read`. | +| **Evenement declencheur** | Le personnel cuisine accede a l'affichage de preparation ou le rafraichit | +| **Acteur** | KITCHEN (ou COUNTER, DRIVE, ADMIN) | +| **Synchronisation** | Aucune | +| **Condition** | L'acteur est authentifie et detient la permission `order.read`. | | **Operation** | LIST_ORDERS_DISPLAY | -| **Description** | Read `customer_order` rows with status `paid`, filtered by sources visible to the actor's role (from `role_visible_source`): kitchen sees all sources; counter sees kiosk+counter; drive sees drive. Orders are sorted by `paid_at` ascending (oldest first). For each order, display: order number, source, content (`order_item` with `label_snapshot`, `quantity`, format, slot selections, ingredient modifiers). KDS colour is computed from `now - paid_at` against the SLA threshold (approx. 10 min), not stored. Kitchen staff performs no status transition — this is a read-only operation. | -| **MCD entities** | R: `customer_order` (status=`paid`), `order_item`, `order_item_selection`, `order_item_modifier`, `role_visible_source` | -| **Result** | Preparation display list shown, sorted by payment time ascending | +| **Description** | Lecture des lignes `customer_order` avec statut `paid`, filtrees par les sources visibles selon le role de l'acteur (depuis `role_visible_source`) : la cuisine voit toutes les sources ; le comptoir voit kiosk+counter ; le drive voit drive. Les commandes sont triees par `paid_at` ascendant (les plus anciennes en premier). Pour chaque commande, afficher : numero de commande, source, contenu (`order_item` avec `label_snapshot`, `quantity`, format, selections de slots, modificateurs d'ingredient). La couleur KDS est calculee a partir de `now - paid_at` par rapport au seuil de SLA (approx. 10 min), non stockee. Le personnel cuisine n'effectue aucune transition de statut — c'est une operation en lecture seule. | +| **Entites MCD** | R: `customer_order` (status=`paid`), `order_item`, `order_item_selection`, `order_item_modifier`, `role_visible_source` | +| **Resultat** | Liste d'affichage de preparation montree, triee par heure de paiement ascendante | --- -## 6. Domain 4 — Delivery to customer +## 6. Domaine 4 — Livraison au client ### 6.1 DELIVER_ORDER -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering events** | 1. The order is at status `paid` AND 2. Counter or drive staff clicks "Delivered" | -| **Actor** | COUNTER or DRIVE | +| **Evenements declencheurs** | 1. La commande est au statut `paid` AND 2. Le personnel comptoir ou drive clique sur « Livre » | +| **Acteur** | COUNTER ou DRIVE | | **Synchronisation** | AND | -| **Condition** | The order has status `paid`. The actor holds permission `order.deliver`. The actor's role is consistent with the order source (counter staff handles kiosk+counter orders; drive staff handles drive orders — filtered by role_visible_source). | +| **Condition** | La commande a le statut `paid`. L'acteur detient la permission `order.deliver`. Le role de l'acteur est coherent avec la source de la commande (le personnel comptoir traite les commandes kiosk+counter ; le personnel drive traite les commandes drive — filtre par role_visible_source). | | **Operation** | DELIVER_ORDER | -| **Description** | Single-gesture transition `paid -> delivered`. Sets `delivered_at = NOW()`. The order moves to history. This operation replaces the v0.1 two-step sequence (mark-ready then deliver); the kitchen's visual confirmation (KDS) is sufficient before this action. | -| **MCD entities** | W: `customer_order` (UPDATE status `paid` -> `delivered`, `delivered_at = NOW()`) | -| **Result** | Order at status `delivered`, lifecycle complete | +| **Description** | Transition en geste unique `paid -> delivered`. Positionne `delivered_at = NOW()`. La commande passe en historique. Cette operation remplace la sequence en deux etapes de v0.1 (marquer-prete puis livrer) ; la confirmation visuelle de la cuisine (KDS) suffit avant cette action. | +| **Entites MCD** | W: `customer_order` (UPDATE status `paid` -> `delivered`, `delivered_at = NOW()`) | +| **Resultat** | Commande au statut `delivered`, cycle de vie complet | --- -## 7. Domain 5 — Cancellation +## 7. Domaine 5 — Annulation ### 7.1 CANCEL_ORDER -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | An authorised actor requests cancellation of an order | -| **Actor** | COUNTER, DRIVE, or ADMIN | -| **Synchronisation** | None | -| **Condition** | The order exists. `customer_order.status` is in `['pending_payment', 'paid']`. Terminal statuses `delivered` and `cancelled` cannot transition to `cancelled`. The actor holds permission `order.cancel`. | +| **Evenement declencheur** | Un acteur autorise demande l'annulation d'une commande | +| **Acteur** | COUNTER, DRIVE ou ADMIN | +| **Synchronisation** | Aucune | +| **Condition** | La commande existe. `customer_order.status` est dans `['pending_payment', 'paid']`. Les statuts terminaux `delivered` et `cancelled` ne peuvent pas transiter vers `cancelled`. L'acteur detient la permission `order.cancel`. | | **Operation** | CANCEL_ORDER | -| **Description** | Transition from current status to `cancelled`. Sets `cancelled_at = NOW()`. The order is retained in the database for history and stats (no physical deletion). If the current status is `paid`, stock is re-credited: for each ingredient consumed by the order (accounting for modifiers), `ingredient.stock_quantity` is incremented; one `stock_movement` row of type `cancellation` is inserted per affected ingredient unit. Stock re-credit and status update are within the same transaction. | -| **MCD entities** | R: `order_item`, `order_item_modifier`, `ingredient`, `product_ingredient` — W: `customer_order` (UPDATE status -> `cancelled`, `cancelled_at = NOW()`), `ingredient` (UPDATE stock_quantity, conditional on status `paid`), `stock_movement` (INSERT type `cancellation`, conditional on status `paid`) | -| **Result** | Order at status `cancelled`, visible in admin history | +| **Description** | Transition du statut courant vers `cancelled`. Positionne `cancelled_at = NOW()`. La commande est conservee en base pour l'historique et les stats (pas de suppression physique). Si le statut courant est `paid`, le stock est recredite : pour chaque ingredient consomme par la commande (en tenant compte des modificateurs), `ingredient.stock_quantity` est incremente ; une ligne `stock_movement` de type `cancellation` est inseree par unite d'ingredient affectee. Le recredit du stock et la mise a jour du statut sont dans la meme transaction. | +| **Entites MCD** | R: `order_item`, `order_item_modifier`, `ingredient`, `product_ingredient` — W: `customer_order` (UPDATE status -> `cancelled`, `cancelled_at = NOW()`), `ingredient` (UPDATE stock_quantity, conditional on status `paid`), `stock_movement` (INSERT type `cancellation`, conditional on status `paid`) | +| **Resultat** | Commande au statut `cancelled`, visible dans l'historique admin | --- -## 8. Domain 6 — Catalogue management +## 8. Domaine 6 — Gestion du catalogue ### 8.1 CREATE_PRODUCT -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | Admin or manager submits the product creation form | -| **Actor** | ADMIN or MANAGER | -| **Synchronisation** | None | -| **Condition** | Actor holds permission `product.create`. Target category exists and `is_active=1`. `name` is non-empty. `price_cents > 0`. | +| **Evenement declencheur** | L'admin ou le manager soumet le formulaire de creation de produit | +| **Acteur** | ADMIN ou MANAGER | +| **Synchronisation** | Aucune | +| **Condition** | L'acteur detient la permission `product.create`. La categorie cible existe et `is_active=1`. `name` est non vide. `price_cents > 0`. | | **Operation** | CREATE_PRODUCT | -| **Description** | INSERT a new `product` with its category, name, price in cents, VAT rate in per-mille (`vat_rate`: 100=10%, 55=5.5%, default 100), optional image path. `is_available=1` by default. | -| **MCD entities** | R: `category` (FK validation) — W: `product` (INSERT) | -| **Result** | Product created, redirect to product list | +| **Description** | INSERT d'un nouveau `product` avec sa categorie, son nom, son prix en centimes, son taux de TVA en pour-mille (`vat_rate` : 100=10%, 55=5.5%, defaut 100), chemin d'image optionnel. `is_available=1` par defaut. | +| **Entites MCD** | R: `category` (FK validation) — W: `product` (INSERT) | +| **Resultat** | Produit cree, redirection vers la liste des produits | --- ### 8.2 UPDATE_PRODUCT -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | Admin or manager submits the product update form | -| **Actor** | ADMIN or MANAGER | -| **Synchronisation** | None | -| **Condition** | Actor holds permission `product.update`. Product exists. New values respect constraints (`price_cents > 0`, non-empty name). | +| **Evenement declencheur** | L'admin ou le manager soumet le formulaire de modification de produit | +| **Acteur** | ADMIN ou MANAGER | +| **Synchronisation** | Aucune | +| **Condition** | L'acteur detient la permission `product.update`. Le produit existe. Les nouvelles valeurs respectent les contraintes (`price_cents > 0`, nom non vide). | | **Operation** | UPDATE_PRODUCT | -| **Description** | UPDATE modifiable columns (`name`, `description`, `price_cents`, `vat_rate`, `image_path`, `is_available`, `display_order`, `category_id`). Snapshots already stored in `order_item` are not affected (historical integrity guaranteed by design). | -| **MCD entities** | W: `product` (UPDATE) | -| **Result** | Product updated, product list refreshed | +| **Description** | UPDATE des colonnes modifiables (`name`, `description`, `price_cents`, `vat_rate`, `image_path`, `is_available`, `display_order`, `category_id`). Les snapshots deja stockes dans `order_item` ne sont pas affectes (integrite historique garantie par conception). | +| **Entites MCD** | W: `product` (UPDATE) | +| **Resultat** | Produit mis a jour, liste des produits rafraichie | --- ### 8.3 DELETE_PRODUCT -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | Admin confirms deletion of a product | -| **Actor** | ADMIN | -| **Synchronisation** | None | -| **Condition** | Actor holds permission `product.delete`. Product is not a slot option in any `menu_slot_option` (FK `ON DELETE RESTRICT`). Product is not referenced in any `order_item` historical line (FK `ON DELETE RESTRICT`). Preliminary check required. | +| **Evenement declencheur** | L'admin confirme la suppression d'un produit | +| **Acteur** | ADMIN | +| **Synchronisation** | Aucune | +| **Condition** | L'acteur detient la permission `product.delete`. Le produit n'est option de slot dans aucun `menu_slot_option` (FK `ON DELETE RESTRICT`). Le produit n'est reference dans aucune ligne historique `order_item` (FK `ON DELETE RESTRICT`). Verification prealable requise. | | **Operation** | DELETE_PRODUCT | -| **Description** | Physical deletion of the product if no FK constraint blocks. If the product is referenced in a menu slot or historical order line, deletion is blocked. The recommended alternative is to deactivate (`is_available=0`). Also blocks if the product is the `burger_product_id` of any `menu`. | -| **MCD entities** | W: `product` (DELETE — blocked if referenced in `menu_slot_option`, `order_item`, or `menu.burger_product_id`) | -| **Result** | Product deleted OR error "product in use" | +| **Description** | Suppression physique du produit si aucune contrainte FK ne la bloque. Si le produit est reference dans un slot de menu ou une ligne de commande historique, la suppression est bloquee. L'alternative recommandee est de le desactiver (`is_available=0`). Bloque egalement si le produit est le `burger_product_id` d'un `menu`. | +| **Entites MCD** | W: `product` (DELETE — blocked if referenced in `menu_slot_option`, `order_item`, or `menu.burger_product_id`) | +| **Resultat** | Produit supprime OU erreur « produit utilise » | --- ### 8.4 CREATE_MENU -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | Admin or manager submits the menu creation form with its slot configuration | -| **Actor** | ADMIN or MANAGER | -| **Synchronisation** | None | -| **Condition** | Actor holds permission `menu.create`. `name` is non-empty. `price_normal_cents > 0`, `price_maxi_cents > 0`. `burger_product_id` references an existing product. At least one slot is defined with at least one option. | +| **Evenement declencheur** | L'admin ou le manager soumet le formulaire de creation de menu avec sa configuration de slots | +| **Acteur** | ADMIN ou MANAGER | +| **Synchronisation** | Aucune | +| **Condition** | L'acteur detient la permission `menu.create`. `name` est non vide. `price_normal_cents > 0`, `price_maxi_cents > 0`. `burger_product_id` reference un produit existant. Au moins un slot est defini avec au moins une option. | | **Operation** | CREATE_MENU | -| **Description** | Transaction: INSERT `menu` (with `burger_product_id`, `price_normal_cents`, `price_maxi_cents`), then INSERT `menu_slot` rows (one per slot: drink, side, sauce...), then INSERT `menu_slot_option` rows (eligible products per slot). | -| **MCD entities** | R: `product` (burger FK validation, slot options validation), `category` — W: `menu` (INSERT), `menu_slot` (INSERT), `menu_slot_option` (INSERT) | -| **Result** | Menu created with its slot configuration, visible on the kiosk | +| **Description** | Transaction : INSERT `menu` (avec `burger_product_id`, `price_normal_cents`, `price_maxi_cents`), puis INSERT des lignes `menu_slot` (une par slot : boisson, accompagnement, sauce...), puis INSERT des lignes `menu_slot_option` (produits eligibles par slot). | +| **Entites MCD** | R: `product` (burger FK validation, slot options validation), `category` — W: `menu` (INSERT), `menu_slot` (INSERT), `menu_slot_option` (INSERT) | +| **Resultat** | Menu cree avec sa configuration de slots, visible sur le kiosk | --- ### 8.5 UPDATE_MENU -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | Admin or manager submits the menu update form | -| **Actor** | ADMIN or MANAGER | -| **Synchronisation** | None | -| **Condition** | Actor holds permission `menu.update`. Menu exists. Updated configuration preserves at least one slot with at least one option. | +| **Evenement declencheur** | L'admin ou le manager soumet le formulaire de modification de menu | +| **Acteur** | ADMIN ou MANAGER | +| **Synchronisation** | Aucune | +| **Condition** | L'acteur detient la permission `menu.update`. Le menu existe. La configuration mise a jour preserve au moins un slot avec au moins une option. | | **Operation** | UPDATE_MENU | -| **Description** | UPDATE `menu` columns. If slot configuration is modified: DELETE all `menu_slot_option` rows for this menu's slots, DELETE `menu_slot` rows, then re-INSERT (delete-and-reinsert pattern, atomic in transaction). Snapshots in `order_item` are not affected. | -| **MCD entities** | W: `menu` (UPDATE), `menu_slot` (DELETE + INSERT), `menu_slot_option` (DELETE + INSERT) | -| **Result** | Menu updated | +| **Description** | UPDATE des colonnes `menu`. Si la configuration des slots est modifiee : DELETE de toutes les lignes `menu_slot_option` pour les slots de ce menu, DELETE des lignes `menu_slot`, puis re-INSERT (pattern delete-and-reinsert, atomique en transaction). Les snapshots dans `order_item` ne sont pas affectes. | +| **Entites MCD** | W: `menu` (UPDATE), `menu_slot` (DELETE + INSERT), `menu_slot_option` (DELETE + INSERT) | +| **Resultat** | Menu mis a jour | --- ### 8.6 DELETE_MENU -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | Admin confirms deletion of a menu | -| **Actor** | ADMIN | -| **Synchronisation** | None | -| **Condition** | Actor holds permission `menu.delete`. Menu is not referenced in any `order_item` historical line (FK `ON DELETE RESTRICT`). Preliminary check required. | +| **Evenement declencheur** | L'admin confirme la suppression d'un menu | +| **Acteur** | ADMIN | +| **Synchronisation** | Aucune | +| **Condition** | L'acteur detient la permission `menu.delete`. Le menu n'est reference dans aucune ligne historique `order_item` (FK `ON DELETE RESTRICT`). Verification prealable requise. | | **Operation** | DELETE_MENU | -| **Description** | If no `order_item` references this menu: DELETE `menu_slot_option` (CASCADE from `menu_slot`), DELETE `menu_slot` (CASCADE from `menu`), DELETE `menu`. If historical references exist, propose deactivation (`is_available=0`) instead. | -| **MCD entities** | W: `menu_slot_option` (DELETE CASCADE), `menu_slot` (DELETE CASCADE), `menu` (DELETE — blocked if referenced in `order_item`) | -| **Result** | Menu deleted OR error "menu present in historical orders" | +| **Description** | Si aucun `order_item` ne reference ce menu : DELETE `menu_slot_option` (CASCADE from `menu_slot`), DELETE `menu_slot` (CASCADE from `menu`), DELETE `menu`. Si des references historiques existent, proposer la desactivation (`is_available=0`) a la place. | +| **Entites MCD** | W: `menu_slot_option` (DELETE CASCADE), `menu_slot` (DELETE CASCADE), `menu` (DELETE — blocked if referenced in `order_item`) | +| **Resultat** | Menu supprime OU erreur « menu present dans des commandes historiques » | --- ### 8.7 MANAGE_CATEGORY -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | Admin or manager creates, updates, or deactivates a category | -| **Actor** | ADMIN or MANAGER | -| **Synchronisation** | OR (create, update, deactivation) | -| **Condition** | Actor holds permission `category.manage`. For deactivation: products and menus in the category are not auto-deactivated in DB (no CASCADE on `is_active`); the application layer proposes deactivating child products/menus. | +| **Evenement declencheur** | L'admin ou le manager cree, modifie ou desactive une categorie | +| **Acteur** | ADMIN ou MANAGER | +| **Synchronisation** | OR (creation, modification, desactivation) | +| **Condition** | L'acteur detient la permission `category.manage`. Pour la desactivation : les produits et menus de la categorie ne sont pas auto-desactives en base (pas de CASCADE sur `is_active`) ; la couche applicative propose de desactiver les produits/menus enfants. | | **Operation** | MANAGE_CATEGORY | -| **Description** | CRUD on `category`. Deactivation (`is_active=0`) hides the category and its products from the kiosk without physical deletion. Physical deletion is blocked if products or menus reference this category (FK `ON DELETE RESTRICT`). | -| **MCD entities** | W: `category` (INSERT / UPDATE / conditional DELETE) | -| **Result** | Category created / updated / deactivated | +| **Description** | CRUD sur `category`. La desactivation (`is_active=0`) masque la categorie et ses produits du kiosk sans suppression physique. La suppression physique est bloquee si des produits ou des menus referencent cette categorie (FK `ON DELETE RESTRICT`). | +| **Entites MCD** | W: `category` (INSERT / UPDATE / conditional DELETE) | +| **Resultat** | Categorie creee / modifiee / desactivee | --- ### 8.8 MANAGE_INGREDIENT -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | Admin or manager creates, updates, or deactivates an ingredient; or manages product composition (`product_ingredient`) or allergen mapping (`ingredient_allergen`) | -| **Actor** | ADMIN or MANAGER | -| **Synchronisation** | OR (create ingredient, update ingredient, update composition, update allergen mapping) | -| **Condition** | Actor holds permission `ingredient.manage`. | +| **Evenement declencheur** | L'admin ou le manager cree, modifie ou desactive un ingredient ; ou gere la composition produit (`product_ingredient`) ou le mapping allergene (`ingredient_allergen`) | +| **Acteur** | ADMIN ou MANAGER | +| **Synchronisation** | OR (creer ingredient, modifier ingredient, modifier composition, modifier mapping allergene) | +| **Condition** | L'acteur detient la permission `ingredient.manage`. | | **Operation** | MANAGE_INGREDIENT | -| **Description** | CRUD on `ingredient` (name, unit, pack_size, pack_label, stock_capacity, low_stock_pct, critical_stock_pct, is_active). Manage `product_ingredient` composition (quantity_normal, quantity_maxi, is_removable, is_addable, extra_price_cents) for any product. Manage `ingredient_allergen` mapping (14 EU regulated allergens). Deactivating an ingredient (`is_active=0`) hides it from the configurator without deletion. Physical deletion of `ingredient` is blocked if referenced in `product_ingredient` (FK `ON DELETE RESTRICT`) or `stock_movement` (FK `ON DELETE RESTRICT`). | -| **MCD entities** | R: `product` (FK validation), `allergen` (FK validation) — W: `ingredient` (INSERT/UPDATE/DELETE conditional), `product_ingredient` (INSERT/UPDATE/DELETE), `ingredient_allergen` (INSERT/DELETE) | -| **Result** | Ingredient / composition / allergen mapping updated | +| **Description** | CRUD sur `ingredient` (name, unit, pack_size, pack_label, stock_capacity, low_stock_pct, critical_stock_pct, is_active). Gestion de la composition `product_ingredient` (quantity_normal, quantity_maxi, is_removable, is_addable, extra_price_cents) pour tout produit. Gestion du mapping `ingredient_allergen` (14 allergenes reglementes UE). Desactiver un ingredient (`is_active=0`) le masque du configurateur sans suppression. La suppression physique de `ingredient` est bloquee s'il est reference dans `product_ingredient` (FK `ON DELETE RESTRICT`) ou `stock_movement` (FK `ON DELETE RESTRICT`). | +| **Entites MCD** | R: `product` (FK validation), `allergen` (FK validation) — W: `ingredient` (INSERT/UPDATE/DELETE conditional), `product_ingredient` (INSERT/UPDATE/DELETE), `ingredient_allergen` (INSERT/DELETE) | +| **Resultat** | Ingredient / composition / mapping allergene mis a jour | --- -## 9. Domain 7 — Stock management +## 9. Domaine 7 — Gestion du stock ### 9.1 RESTOCK -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | Manager or admin records a delivery of ingredient packs | -| **Actor** | MANAGER or ADMIN | -| **Synchronisation** | None | -| **Condition** | Actor holds permission `stock.manage`. Ingredient exists and `is_active=1`. Number of packs `N >= 1`. | +| **Evenement declencheur** | Le manager ou l'admin enregistre une livraison de packs d'ingredient | +| **Acteur** | MANAGER ou ADMIN | +| **Synchronisation** | Aucune | +| **Condition** | L'acteur detient la permission `stock.manage`. L'ingredient existe et `is_active=1`. Nombre de packs `N >= 1`. | | **Operation** | RESTOCK | -| **Description** | UPDATE `ingredient.stock_quantity += N * pack_size`. INSERT one `stock_movement` row: type `restock`, delta `+= N * pack_size`, `user_id` of the actor, optional `note` (e.g. delivery reference). Both writes are in the same transaction. | -| **MCD entities** | R: `ingredient` — W: `ingredient` (UPDATE stock_quantity), `stock_movement` (INSERT type `restock`) | -| **Result** | Stock incremented, movement logged | +| **Description** | UPDATE `ingredient.stock_quantity += N * pack_size`. INSERT d'une ligne `stock_movement` : type `restock`, delta `+= N * pack_size`, `user_id` de l'acteur, `note` optionnelle (ex. reference de livraison). Les deux ecritures sont dans la meme transaction. | +| **Entites MCD** | R: `ingredient` — W: `ingredient` (UPDATE stock_quantity), `stock_movement` (INSERT type `restock`) | +| **Resultat** | Stock incremente, mouvement journalise | --- ### 9.2 INVENTORY_COUNT -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | A staff member or manager records the result of a physical inventory count | -| **Actor** | KITCHEN, COUNTER, DRIVE, MANAGER, or ADMIN | -| **Synchronisation** | None | -| **Condition** | Actor holds permission `stock.count`. Ingredient exists. Physical count `actual_quantity >= 0`. | +| **Evenement declencheur** | Un membre du personnel ou un manager enregistre le resultat d'un inventaire physique | +| **Acteur** | KITCHEN, COUNTER, DRIVE, MANAGER ou ADMIN | +| **Synchronisation** | Aucune | +| **Condition** | L'acteur detient la permission `stock.count`. L'ingredient existe. Comptage physique `actual_quantity >= 0`. | | **Operation** | INVENTORY_COUNT | -| **Description** | Compute `delta = actual_quantity - ingredient.stock_quantity` (may be negative or positive). UPDATE `ingredient.stock_quantity = actual_quantity`. INSERT one `stock_movement` row: type `inventory_correction`, delta = computed discrepancy, `user_id` of the actor, optional `note`. Both writes in the same transaction. | -| **MCD entities** | R: `ingredient` (read current stock_quantity) — W: `ingredient` (UPDATE stock_quantity), `stock_movement` (INSERT type `inventory_correction`) | -| **Result** | Stock reconciled to physical count, discrepancy logged | +| **Description** | Calcul de `delta = actual_quantity - ingredient.stock_quantity` (peut etre negatif ou positif). UPDATE `ingredient.stock_quantity = actual_quantity`. INSERT d'une ligne `stock_movement` : type `inventory_correction`, delta = ecart calcule, `user_id` de l'acteur, `note` optionnelle. Les deux ecritures dans la meme transaction. | +| **Entites MCD** | R: `ingredient` (read current stock_quantity) — W: `ingredient` (UPDATE stock_quantity), `stock_movement` (INSERT type `inventory_correction`) | +| **Resultat** | Stock reconcilie au comptage physique, ecart journalise | --- ### 9.3 READ_STOCK -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | An authorised actor accesses the stock view | -| **Actor** | KITCHEN, COUNTER, DRIVE, MANAGER, or ADMIN | -| **Synchronisation** | None | -| **Condition** | Actor holds permission `stock.read`. | +| **Evenement declencheur** | Un acteur autorise accede a la vue du stock | +| **Acteur** | KITCHEN, COUNTER, DRIVE, MANAGER ou ADMIN | +| **Synchronisation** | Aucune | +| **Condition** | L'acteur detient la permission `stock.read`. | | **Operation** | READ_STOCK | -| **Description** | Read `ingredient` list with current `stock_quantity`, `stock_capacity`, computed `stock_pct`, `low_stock_pct`, `critical_stock_pct`, `pack_size`, `pack_label`. Stock bands computed at display time: `low_stock` when `stock_quantity <= stock_capacity * low_stock_pct/100`, `critical_stock` when `stock_quantity <= stock_capacity * critical_stock_pct/100`. Optional: read `stock_movement` history for a given ingredient, filtered by date range. | -| **MCD entities** | R: `ingredient`, `stock_movement` (optional history) | -| **Result** | Stock list displayed with low-stock indicators | +| **Description** | Lecture de la liste `ingredient` avec le `stock_quantity` courant, `stock_capacity`, `stock_pct` calcule, `low_stock_pct`, `critical_stock_pct`, `pack_size`, `pack_label`. Bandes de stock calculees au moment de l'affichage : `low_stock` lorsque `stock_quantity <= stock_capacity * low_stock_pct/100`, `critical_stock` lorsque `stock_quantity <= stock_capacity * critical_stock_pct/100`. Optionnel : lecture de l'historique `stock_movement` pour un ingredient donne, filtre par plage de dates. | +| **Entites MCD** | R: `ingredient`, `stock_movement` (optional history) | +| **Resultat** | Liste du stock affichee avec indicateurs de stock bas | --- -## 10. Domain 8 — User and role management (admin) +## 10. Domaine 8 — Gestion des utilisateurs et des roles (admin) ### 10.1 CREATE_USER -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | Admin submits the user creation form | -| **Actor** | ADMIN | -| **Synchronisation** | None | -| **Condition** | Actor holds permission `user.create`. Email does not already exist in `user.email` (UNIQUE constraint). A valid and active `role_id` is selected. | +| **Evenement declencheur** | L'admin soumet le formulaire de creation d'utilisateur | +| **Acteur** | ADMIN | +| **Synchronisation** | Aucune | +| **Condition** | L'acteur detient la permission `user.create`. L'email n'existe pas deja dans `user.email` (contrainte UNIQUE). Un `role_id` valide et actif est selectionne. | | **Operation** | CREATE_USER | -| **Description** | INSERT user with argon2id password hash. Email is unique. `role_id` is mandatory (FK NOT NULL). `is_active=1` by default. `last_login_at=NULL` at creation. | -| **MCD entities** | R: `role` (FK validation) — W: `user` (INSERT) | -| **Result** | User created, can log into the back-office | +| **Description** | INSERT de l'utilisateur avec un hash de mot de passe argon2id. L'email est unique. `role_id` est obligatoire (FK NOT NULL). `is_active=1` par defaut. `last_login_at=NULL` a la creation. | +| **Entites MCD** | R: `role` (FK validation) — W: `user` (INSERT) | +| **Resultat** | Utilisateur cree, peut se connecter au back-office | --- ### 10.2 UPDATE_USER -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | Admin submits the user update form | -| **Actor** | ADMIN | -| **Synchronisation** | None | -| **Condition** | Actor holds permission `user.update`. User exists. If a new password is provided, it is re-hashed. | +| **Evenement declencheur** | L'admin soumet le formulaire de modification d'utilisateur | +| **Acteur** | ADMIN | +| **Synchronisation** | Aucune | +| **Condition** | L'acteur detient la permission `user.update`. L'utilisateur existe. Si un nouveau mot de passe est fourni, il est re-hashe. | | **Operation** | UPDATE_USER | -| **Description** | UPDATE modifiable fields (`first_name`, `last_name`, `email`, `role_id`, `is_active`). If a new password is supplied, it replaces the existing hash (argon2id rehash). | -| **MCD entities** | W: `user` (UPDATE) | -| **Result** | User updated | +| **Description** | UPDATE des champs modifiables (`first_name`, `last_name`, `email`, `role_id`, `is_active`). Si un nouveau mot de passe est fourni, il remplace le hash existant (rehash argon2id). | +| **Entites MCD** | W: `user` (UPDATE) | +| **Resultat** | Utilisateur mis a jour | --- ### 10.3 DEACTIVATE_USER -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | Admin clicks "Deactivate" for a user | -| **Actor** | ADMIN | -| **Synchronisation** | None | -| **Condition** | Actor holds permission `user.deactivate`. Admin cannot deactivate their own account (application-level protection). | +| **Evenement declencheur** | L'admin clique sur « Desactiver » pour un utilisateur | +| **Acteur** | ADMIN | +| **Synchronisation** | Aucune | +| **Condition** | L'acteur detient la permission `user.deactivate`. L'admin ne peut pas desactiver son propre compte (protection au niveau applicatif). | | **Operation** | DEACTIVATE_USER | -| **Description** | UPDATE `is_active=0`. The user's active session is invalidated on next access (middleware checks `is_active=1` on each authenticated request). User is not deleted; history remains traceable. | -| **MCD entities** | W: `user` (UPDATE is_active=0) | -| **Result** | User deactivated, back-office access blocked | +| **Description** | UPDATE `is_active=0`. La session active de l'utilisateur est invalidee au prochain acces (le middleware verifie `is_active=1` a chaque requete authentifiee). L'utilisateur n'est pas supprime ; l'historique reste tracable. | +| **Entites MCD** | W: `user` (UPDATE is_active=0) | +| **Resultat** | Utilisateur desactive, acces back-office bloque | --- ### 10.4 MANAGE_RBAC -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | Admin modifies permission assignments for a role, or creates / updates a custom role | -| **Actor** | ADMIN | -| **Synchronisation** | OR (update role permissions, create custom role, update role attributes) | -| **Condition** | Actor holds permission `role.manage`. Selected permissions exist in the `permission` catalogue. | +| **Evenement declencheur** | L'admin modifie les affectations de permissions pour un role, ou cree / modifie un role personnalise | +| **Acteur** | ADMIN | +| **Synchronisation** | OR (modifier les permissions du role, creer un role personnalise, modifier les attributs du role) | +| **Condition** | L'acteur detient la permission `role.manage`. Les permissions selectionnees existent dans le catalogue `permission`. | | **Operation** | MANAGE_RBAC | -| **Description** | Update `role_permission` for a given role: DELETE existing assignments, INSERT new ones (delete-and-reinsert, atomic in transaction). Permissions themselves are static (declared in migration, not modifiable via UI). Also covers: CREATE/UPDATE custom `role` (code, label, description, default_route, order_source), UPDATE `role_visible_source` (visible dashboard sources for the role). RBAC architecture rule: application code tests permissions, not role names — adding a new role with correct permissions requires no code change. | -| **MCD entities** | R: `role`, `permission` — W: `role_permission` (DELETE + INSERT), `role` (INSERT/UPDATE for custom roles), `role_visible_source` (INSERT/DELETE) | -| **Result** | RBAC matrix updated, effective immediately for new requests of users bearing this role | +| **Description** | Mise a jour de `role_permission` pour un role donne : DELETE des affectations existantes, INSERT des nouvelles (delete-and-reinsert, atomique en transaction). Les permissions elles-memes sont statiques (declarees en migration, non modifiables via l'UI). Couvre egalement : CREATE/UPDATE d'un `role` personnalise (code, label, description, default_route, order_source), UPDATE de `role_visible_source` (sources de tableau de bord visibles pour le role). Regle d'architecture RBAC : le code applicatif teste les permissions, pas les noms de role — ajouter un nouveau role avec les bonnes permissions ne requiert aucun changement de code. | +| **Entites MCD** | R: `role`, `permission` — W: `role_permission` (DELETE + INSERT), `role` (INSERT/UPDATE for custom roles), `role_visible_source` (INSERT/DELETE) | +| **Resultat** | Matrice RBAC mise a jour, effective immediatement pour les nouvelles requetes des utilisateurs porteurs de ce role | --- ### 10.5 ERASE_USER_PII (security-by-design) -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | A RGPD erasure request is processed for a back-office user | -| **Actor** | ADMIN (PIN-gated) | -| **Synchronisation** | None | -| **Condition** | Actor holds permission `user.update` and has re-authorised via PIN. Target user exists and is not already anonymised. | +| **Evenement declencheur** | Une demande d'effacement RGPD est traitee pour un utilisateur back-office | +| **Acteur** | ADMIN (protege par PIN) | +| **Synchronisation** | Aucune | +| **Condition** | L'acteur detient la permission `user.update` et s'est re-autorise via PIN. L'utilisateur cible existe et n'est pas deja anonymise. | | **Operation** | ERASE_USER_PII | -| **Description** | RGPD right-to-erasure honoured by **anonymisation**, not physical deletion: PII (`email`, `first_name`, `last_name`) is cleared/replaced by a non-identifying placeholder, credentials invalidated, `anonymized_at` set. The row persists so referential links (`stock_movement`, `customer_order`, `audit_log`) stay valid and resolve to an anonymised principal. See `mlt.md` 10.5 and dictionary note 13. | -| **MCD entities** | W: `user` (UPDATE — PII cleared, `anonymized_at` set), `audit_log` (INSERT) | -| **Result** | User anonymised; PII removed; accountability links preserved; one `audit_log` row recorded | +| **Description** | Le droit a l'effacement RGPD est honore par **anonymisation**, non par suppression physique : les PII (`email`, `first_name`, `last_name`) sont effacees/remplacees par un placeholder non identifiant, les identifiants invalides, `anonymized_at` positionne. La ligne persiste afin que les liens referentiels (`stock_movement`, `customer_order`, `audit_log`) restent valides et se resolvent vers un principal anonymise. Voir `mlt.md` 10.5 et la note 13 du dictionnaire. | +| **Entites MCD** | W: `user` (UPDATE — PII cleared, `anonymized_at` set), `audit_log` (INSERT) | +| **Resultat** | Utilisateur anonymise ; PII supprimees ; liens d'imputabilite preserves ; une ligne `audit_log` enregistree | --- -## 11. Domain 9 — Stats and KPI +## 11. Domaine 9 — Stats et KPI ### 11.1 READ_STATS -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | Manager or admin accesses the stats dashboard | -| **Actor** | MANAGER or ADMIN | -| **Synchronisation** | None | -| **Condition** | Actor holds permission `stats.read`. | +| **Evenement declencheur** | Le manager ou l'admin accede au tableau de bord des stats | +| **Acteur** | MANAGER ou ADMIN | +| **Synchronisation** | Aucune | +| **Condition** | L'acteur detient la permission `stats.read`. | | **Operation** | READ_STATS | -| **Description** | Aggregate queries on `customer_order` and `order_item`. Key aggregations: order count and revenue (TTC) by `service_day` (computed with CASE WHEN HOUR(created_at) < 10 THEN DATE(created_at) - INTERVAL 1 DAY ELSE DATE(created_at) END; cutoff at 10:00); top products by `label_snapshot` COUNT in `order_item`; cancellation rate; average delivery time `delivered_at - paid_at`; breakdown by `source` and `service_mode`. Queries exclude cancelled orders from revenue sums but include them in volume counts. No additional stored column for `service_day`; computation at query time. | -| **MCD entities** | R: `customer_order`, `order_item` | -| **Result** | Stats dashboard displayed | +| **Description** | Requetes d'agregation sur `customer_order` et `order_item`. Agregations cles : nombre de commandes et chiffre d'affaires (TTC) par `service_day` (calcule avec CASE WHEN HOUR(created_at) < 10 THEN DATE(created_at) - INTERVAL 1 DAY ELSE DATE(created_at) END ; coupure a 10:00) ; top produits par COUNT de `label_snapshot` dans `order_item` ; taux d'annulation ; temps de livraison moyen `delivered_at - paid_at` ; ventilation par `source` et `service_mode`. Les requetes excluent les commandes annulees des sommes de chiffre d'affaires mais les incluent dans les comptages de volume. Pas de colonne stockee supplementaire pour `service_day` ; calcul au moment de la requete. | +| **Entites MCD** | R: `customer_order`, `order_item` | +| **Resultat** | Tableau de bord des stats affiche | --- -## 12. Domain 10 — Back-office authentication +## 12. Domaine 10 — Authentification back-office ### 12.1 AUTHENTICATE_USER -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | An actor submits the login form | -| **Actor** | COUNTER / DRIVE / KITCHEN / MANAGER / ADMIN | -| **Synchronisation** | None | -| **Condition** | Account not in a throttling window (`lockout_until`). Email exists in database. Password matches argon2id hash. User `is_active=1`. | +| **Evenement declencheur** | Un acteur soumet le formulaire de connexion | +| **Acteur** | COUNTER / DRIVE / KITCHEN / MANAGER / ADMIN | +| **Synchronisation** | Aucune | +| **Condition** | Le compte n'est pas dans une fenetre de throttling (`lockout_until`). L'email existe en base. Le mot de passe correspond au hash argon2id. L'utilisateur `is_active=1`. | | **Operation** | AUTHENTICATE_USER | -| **Description** | Credential verification. If valid: session ID regeneration (protection against session fixation), storage of `user_id` and `role_id` in session, UPDATE `last_login_at`, reset of the login failure counter. On failure: increment `failed_login_attempts` and apply a degressive backoff (`lockout_until`), enumeration-safe generic error. Idle timeout: 4h. Absolute timeout: 10h. Redirect to `role.default_route`. See `mlt.md` 12.1. | -| **MCD entities** | R: `user` (verification), `role` (load permissions, default_route), `role_permission`, `login_throttle` (the per-IP throttle gate) — W: `user` (UPDATE last_login_at, `failed_login_attempts`, `lockout_until`), `login_throttle` (upsert `failed_attempts`/`lockout_until` on failure, clear on success), `audit_log` (INSERT login success/failure) | -| **Result** | Session opened, redirect to role-specific default view; or throttled failure logged | +| **Description** | Verification des identifiants. Si valide : regeneration de l'ID de session (protection contre la fixation de session), stockage de `user_id` et `role_id` en session, UPDATE `last_login_at`, remise a zero du compteur d'echecs de connexion. En cas d'echec : incrementation de `failed_login_attempts` et application d'un backoff degressif (`lockout_until`), erreur generique resistante a l'enumeration. Idle timeout : 4h. Absolute timeout : 10h. Redirection vers `role.default_route`. Voir `mlt.md` 12.1. | +| **Entites MCD** | R: `user` (verification), `role` (load permissions, default_route), `role_permission`, `login_throttle` (the per-IP throttle gate) — W: `user` (UPDATE last_login_at, `failed_login_attempts`, `lockout_until`), `login_throttle` (upsert `failed_attempts`/`lockout_until` on failure, clear on success), `audit_log` (INSERT login success/failure) | +| **Resultat** | Session ouverte, redirection vers la vue par defaut specifique au role ; ou echec throttle journalise | --- ### 12.2 LOGOUT_USER -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | Actor clicks "Logout" OR session expires | -| **Actor** | COUNTER / DRIVE / KITCHEN / MANAGER / ADMIN / SYS (expiry) | +| **Evenement declencheur** | L'acteur clique sur « Deconnexion » OU la session expire | +| **Acteur** | COUNTER / DRIVE / KITCHEN / MANAGER / ADMIN / SYS (expiration) | | **Synchronisation** | OR | -| **Condition** | A valid session is open | +| **Condition** | Une session valide est ouverte | | **Operation** | LOGOUT_USER | -| **Description** | PHP session destruction (`session_destroy()`). Session deleted server-side. Session cookie invalidated. | -| **MCD entities** | No database write (session management is in PHP native, outside DB for this project) | -| **Result** | Session destroyed, redirect to login page | +| **Description** | Destruction de la session PHP (`session_destroy()`). Session supprimee cote serveur. Cookie de session invalide. | +| **Entites MCD** | Aucune ecriture en base (la gestion des sessions est en PHP natif, hors base pour ce projet) | +| **Resultat** | Session detruite, redirection vers la page de connexion | --- ### 12.3 RESET_PASSWORD (security-by-design) -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | A user requests a password reset, then confirms it via the emailed link | -| **Actor** | COUNTER / DRIVE / KITCHEN / MANAGER / ADMIN | -| **Synchronisation** | Sequential two-phase: request, then confirm | -| **Condition** | Request: the submitted email is processed enumeration-safely (same neutral response whether or not it exists). Confirm: a valid, non-expired token is presented. | +| **Evenement declencheur** | Un utilisateur demande une reinitialisation de mot de passe, puis la confirme via le lien envoye par email | +| **Acteur** | COUNTER / DRIVE / KITCHEN / MANAGER / ADMIN | +| **Synchronisation** | Sequentielle en deux phases : demande, puis confirmation | +| **Condition** | Demande : l'email soumis est traite de maniere resistante a l'enumeration (meme reponse neutre qu'il existe ou non). Confirmation : un token valide et non expire est presente. | | **Operation** | RESET_PASSWORD | -| **Description** | Request phase generates a random token, stores its hash + expiry, and e-mails the raw token once. Confirm phase validates the token hash + expiry, replaces `password_hash` (argon2id), clears the token, and resets the login failure counter. See `mlt.md` 12.3. | -| **MCD entities** | W: `user` (UPDATE `password_reset_token_hash` + `password_reset_expires_at` on request; UPDATE `password_hash`, clear token, reset `failed_login_attempts`/`lockout_until` on confirm), `audit_log` (INSERT) | -| **Result** | Password reset via a one-time, time-bound token; one `audit_log` row recorded | +| **Description** | La phase de demande genere un token aleatoire, stocke son hash + expiration, et envoie le token brut une seule fois par email. La phase de confirmation valide le hash du token + expiration, remplace `password_hash` (argon2id), efface le token et remet a zero le compteur d'echecs de connexion. Voir `mlt.md` 12.3. | +| **Entites MCD** | W: `user` (UPDATE `password_reset_token_hash` + `password_reset_expires_at` on request; UPDATE `password_hash`, clear token, reset `failed_login_attempts`/`lockout_until` on confirm), `audit_log` (INSERT) | +| **Resultat** | Mot de passe reinitialise via un token a usage unique et a duree limitee ; une ligne `audit_log` enregistree | --- -## 13. State machine — customer_order.status +## 13. Machine a etats — customer_order.status -Summary of transitions covered by MCT operations. +Recapitulatif des transitions couvertes par les operations MCT. ``` [CUSTOMER / COUNTER / DRIVE] @@ -573,20 +573,20 @@ Summary of transitions covered by MCT operations. [ cancelled ] (terminal) ``` -**Note on the `pending_payment -> paid` transition**: in the RNCP context, payment is -replaced by the customer entering their order number (kiosk) or by staff validation -(counter/drive). The transition is atomic within CREATE_ORDER and CREATE_COUNTER_ORDER. -The `pending_payment` status is not observable outside the transaction. +**Note sur la transition `pending_payment -> paid`** : dans le contexte RNCP, le paiement est +remplace par la saisie du numero de commande par le client (kiosk) ou par la validation du personnel +(comptoir/drive). La transition est atomique au sein de CREATE_ORDER et CREATE_COUNTER_ORDER. +Le statut `pending_payment` n'est pas observable en dehors de la transaction. -**Dropped from v0.1**: `preparing` and `ready` states; `MARK_IN_PREPARATION` and `MARK_READY` -operations. Kitchen staff have a read-only view of `paid` orders (LIST_ORDERS_DISPLAY). The -single delivery action (DELIVER_ORDER) collapses the v0.1 three-step sequence into one gesture. +**Supprime de v0.1** : etats `preparing` et `ready` ; operations `MARK_IN_PREPARATION` et `MARK_READY`. +Le personnel cuisine a une vue en lecture seule des commandes `paid` (LIST_ORDERS_DISPLAY). L'unique +action de livraison (DELIVER_ORDER) condense la sequence en trois etapes de v0.1 en un seul geste. --- -## 14. Operations summary table +## 14. Tableau recapitulatif des operations -| # | Operation | Domain | Actor | W Entities | R Entities | +| # | Operation | Domaine | Acteur | Entites W | Entites R | |---|-----------|--------|-------|------------|------------| | 1 | LOAD_CATALOGUE | Order kiosk | CUSTOMER | — | category, product, menu, menu_slot, menu_slot_option, ingredient, allergen, ingredient_allergen | | 2 | COMPOSE_CART | Order kiosk | CUSTOMER | — (volatile) | product, menu, menu_slot, menu_slot_option, ingredient, product_ingredient | @@ -617,22 +617,22 @@ single delivery action (DELIVER_ORDER) collapses the v0.1 three-step sequence in | 27 | ERASE_USER_PII | RBAC | ADMIN | user, audit_log | user | | 28 | RESET_PASSWORD | Auth | ALL BACK | user, audit_log | user | -**Total: 28 operations** (26 prod-like + `ERASE_USER_PII` and `RESET_PASSWORD` from the -security-by-design layer). +**Total : 28 operations** (26 prod-like + `ERASE_USER_PII` et `RESET_PASSWORD` de la +couche security-by-design). -**Audit log writes (security-by-design)**: the sensitive operations 7.1 (cancel), 8.2/8.3 -(product update/delete), 8.6 (menu delete), 10.1-10.5 (user/RBAC/erasure) and 12.1 (login) -also write an `audit_log` row (W entity not repeated per row above to keep the table legible). -Stock operations 9.1/9.2 record their attribution via `stock_movement.user_id`. PIN-gated set -per `mlt.md` RG-T13. +**Ecritures du journal d'audit (security-by-design)** : les operations sensibles 7.1 (annulation), 8.2/8.3 +(modification/suppression de produit), 8.6 (suppression de menu), 10.1-10.5 (utilisateur/RBAC/effacement) et 12.1 (connexion) +ecrivent egalement une ligne `audit_log` (entite W non repetee par ligne ci-dessus pour garder le tableau lisible). +Les operations de stock 9.1/9.2 enregistrent leur attribution via `stock_movement.user_id`. Ensemble protege par PIN +selon `mlt.md` RG-T13. --- -## 15. MCT -> MCD cross-validation (mantra #34) +## 15. Verification croisee MCT -> MCD (mantra #34) -Verification that each MCD entity participates in at least one MCT operation. +Verification que chaque entite MCD participe a au moins une operation MCT. -| MCD entity | Operations that read | Operations that write | Coverage | +| Entite MCD | Operations en lecture | Operations en ecriture | Couverture | |------------|---------------------|----------------------|----------| | `category` | 1, 9, 12, 15 | 15 | OK | | `product` | 1, 2, 3, 5, 9, 11, 12 | 9, 10, 11 | OK | @@ -653,20 +653,20 @@ Verification that each MCD entity participates in at least one MCT operation. | `permission` | 23 | — (static seed) | OK (*) | | `role_permission` | 25 | 23 | OK | | `stock_movement` | 19 | 3, 5, 8, 17, 18 | OK | -| `audit_log` | (admin audit view) | 8, 10, 11, 14, 20, 21, 22, 23, 25, 27, 28 | OK | +| `audit_log` | (vue d'audit admin) | 8, 10, 11, 14, 20, 21, 22, 23, 25, 27, 28 | OK | | `login_throttle` | 25 | 25 | OK | -(*) `allergen` and `permission` are read-only at the MCT level: their values are declared -in seed migrations and are not modifiable via the UI. `allergen` is managed indirectly -via `ingredient_allergen` in MANAGE_INGREDIENT. +(*) `allergen` et `permission` sont en lecture seule au niveau MCT : leurs valeurs sont declarees +dans les migrations de seed et ne sont pas modifiables via l'UI. `allergen` est gere indirectement +via `ingredient_allergen` dans MANAGE_INGREDIENT. -(**) `audit_log` (entity 20, security-by-design) is write-mostly: it is appended by the -sensitive operations above and read through an admin audit view (a dedicated read operation -can be formalised when the audit UI is specified at P3). +(**) `audit_log` (entite 20, security-by-design) est principalement en ecriture : il est ajoute par les +operations sensibles ci-dessus et lu via une vue d'audit admin (une operation de lecture dediee +peut etre formalisee lorsque l'UI d'audit sera specifiee en P3). -(***) `login_throttle` (entity 21, security-by-design) is the per-source-IP brute-force -throttle gate: it is read AND written (upserted) by `AUTHENTICATE_USER` (25). Its daily purge -of stale rows is a cron, documented in `mlt.md`, outside MCT operation scope. +(***) `login_throttle` (entite 21, security-by-design) est le verrou de throttling anti-force-brute par IP source : +il est lu ET ecrit (upserte) par `AUTHENTICATE_USER` (25). Sa purge quotidienne +des lignes obsoletes est un cron, documente dans `mlt.md`, hors du perimetre des operations MCT. -**Conclusion**: 21/21 entities covered (19 prod-like + `audit_log` + `login_throttle`). MCT <-> MCD consistency -validated. +**Conclusion** : 21/21 entites couvertes (19 prod-like + `audit_log` + `login_throttle`). Coherence MCT <-> MCD +validee. diff --git a/docs/merise/mld.md b/docs/merise/mld.md index c701f63..7f84821 100644 --- a/docs/merise/mld.md +++ b/docs/merise/mld.md @@ -1,39 +1,39 @@ -# Logical Data Model (MLD) — Wakdo +# Modele Logique de Donnees (MLD) — Wakdo -**Merise phase** : P1 - Conception, step 5 (after MCD, MCT, MLT) -**Version** : v0.2 — prod-like, 21 tables (19 prod-like + security-by-design layer) -**Date** : 2026-06-04 (security-by-design additions 2026-06-11) -**Branch** : `feat/p1-conception` -**Status** : prod-like — all D1-D8 + stock decisions applied (see `docs/notes/revue-alignement-p1.md` §7); security-by-design layer (audit_log + accountability/auth columns) in progress -**Author** : BYAN (methodology layer) +**Phase Merise** : P1 - Conception, etape 5 (apres MCD, MCT, MLT) +**Version** : v0.2 — prod-like, 21 tables (19 prod-like + couche security-by-design) +**Date** : 2026-06-04 (ajouts security-by-design 2026-06-11) +**Branche** : `feat/p1-conception` +**Statut** : prod-like — toutes les decisions D1-D8 + stock appliquees (voir `docs/notes/revue-alignement-p1.md` §7) ; couche security-by-design (audit_log + colonnes imputabilite/auth) en cours +**Auteur** : BYAN (couche methodologique) --- -## 1. Purpose of this document +## 1. Objectif de ce document -The MLD transcribes the MCD into a formal relational schema: 1 entity -> 1 table, each -association translated according to its cardinality, referential constraints materialised, -indexes sized for frequent access patterns. +Le MLD transcrit le MCD en un schema relationnel formel : 1 entite -> 1 table, chaque +association traduite selon sa cardinalite, contraintes referentielles materialisees, +index dimensionnes pour les patterns d'acces frequents. -This is the step that transforms conceptual modelling into an implementable specification. -The DDL SQL (`db/migrations/0001_init_schema.sql`) will be derived directly from this -document at P2. +C'est l'etape qui transforme la modelisation conceptuelle en une specification implementable. +Le DDL SQL (`db/migrations/0001_init_schema.sql`) sera derive directement de ce +document en P2. -**Sources**: -- `docs/merise/dictionary.md` (v0.2 — types and constraints per attribute, source of truth) -- `docs/merise/mcd.md` (v0.2 — entities + cardinalities + deferred decisions) -- `docs/notes/revue-alignement-p1.md` §7 (decision table D1-D8 + stock) +**Sources** : +- `docs/merise/dictionary.md` (v0.2 — types et contraintes par attribut, source de verite) +- `docs/merise/mcd.md` (v0.2 — entites + cardinalites + decisions reportees) +- `docs/notes/revue-alignement-p1.md` §7 (table de decisions D1-D8 + stock) -**Target platform**: +**Plateforme cible** : - MariaDB 11.4 LTS (cf. `docker-compose.yml` service `wakdo-db`) -- Engine InnoDB (ACID, FK support, row-level locking, CHECK from 10.2.1) +- Moteur InnoDB (ACID, support des FK, verrouillage au niveau ligne, CHECK depuis 10.2.1) - Charset `utf8mb4`, collation `utf8mb4_unicode_ci` --- -## 2. Notation conventions +## 2. Conventions de notation -### Relational notation +### Notation relationnelle ``` table_name (col1, col2, #col_fk, [col_nullable]) @@ -45,68 +45,68 @@ table_name (col1, col2, #col_fk, [col_nullable]) CHK : ``` -| Symbol | Meaning | +| Symbole | Signification | |---|---| -| `col` | NOT NULL column | -| `[col]` | Nullable column | -| `#col` | FK column | +| `col` | Colonne NOT NULL | +| `[col]` | Colonne nullable | +| `#col` | Colonne FK | -Notation follows Merise French usage (Nanci/Espinasse convention adapted for ASCII). +La notation suit l'usage Merise francais (convention Nanci/Espinasse adaptee a l'ASCII). -### Type summary +### Resume des types -All exact types are defined in `dictionary.md` section 2. Conventions retained: -- `INT UNSIGNED AUTO_INCREMENT` for all technical PKs -- `INT UNSIGNED` for all monetary amounts in cents (anti-FLOAT, see dictionary note 1) -- `SMALLINT UNSIGNED` for `vat_rate` per-mille values (55 or 100) -- `ENUM(...)` for stable business values (see dictionary note 2) -- `DATETIME` for timestamps (not TIMESTAMP, which implicitly converts to UTC in MariaDB) +Tous les types exacts sont definis dans `dictionary.md` section 2. Conventions retenues : +- `INT UNSIGNED AUTO_INCREMENT` pour toutes les PK techniques +- `INT UNSIGNED` pour tous les montants monetaires en centimes (anti-FLOAT, voir note 1 du dictionnaire) +- `SMALLINT UNSIGNED` pour les valeurs pour-mille de `vat_rate` (55 ou 100) +- `ENUM(...)` pour les valeurs metier stables (voir note 2 du dictionnaire) +- `DATETIME` pour les horodatages (pas TIMESTAMP, qui se convertit implicitement en UTC dans MariaDB) --- -## 3. MCD -> MLD translation rules applied +## 3. Regles de traduction MCD -> MLD appliquees -### 3.1 Entity -> Table +### 3.1 Entite -> Table -Each MCD entity becomes one table. The conceptual identifier `id` becomes PK -`INT UNSIGNED AUTO_INCREMENT`. Attributes retain their names and types. +Chaque entite MCD devient une table. L'identifiant conceptuel `id` devient une PK +`INT UNSIGNED AUTO_INCREMENT`. Les attributs conservent leurs noms et types. -### 3.2 `(1,1) - (1,N)` association -> simple FK +### 3.2 Association `(1,1) - (1,N)` -> FK simple -The entity on the `(1,1)` side carries the FK toward the `(0,N)` or `(1,N)` entity. +L'entite du cote `(1,1)` porte la FK vers l'entite `(0,N)` ou `(1,N)`. -### 3.3 `(0,N) - (0,N)` or `(1,N) - (1,N)` association -> join table +### 3.3 Association `(0,N) - (0,N)` ou `(1,N) - (1,N)` -> table de jointure -The association becomes its own table with a composite PK of the two FKs. Applied to: +L'association devient sa propre table avec une PK composite des deux FK. Applique a : `product_ingredient`, `menu_slot_option`, `ingredient_allergen`, `role_visible_source`, `role_permission`. -### 3.4 Associative entity with own attributes -> join table with columns +### 3.4 Entite associative avec attributs propres -> table de jointure avec colonnes -When an N-N association carries its own attributes, it becomes a table with those attributes -in addition to the composite FK PK. Applied to `product_ingredient`. +Quand une association N-N porte ses propres attributs, elle devient une table avec ces attributs +en plus de la PK composite des FK. Applique a `product_ingredient`. -### 3.5 Polymorphism -> 2 nullable FKs + discriminator + CHECK +### 3.5 Polymorphisme -> 2 FK nullables + discriminateur + CHECK -`order_item` references either `product` or `menu`. Translated as 2 nullable FK columns + -1 discriminator ENUM + 1 CHECK constraint enforcing mutual exclusivity. +`order_item` reference soit `product` soit `menu`. Traduit en 2 colonnes FK nullables + +1 discriminateur ENUM + 1 contrainte CHECK imposant l'exclusivite mutuelle. --- -## 4. Relational schema (21 tables) +## 4. Schema relationnel (21 tables) -Tables are ordered by dependency (no-FK tables first, then tables that depend on them). +Les tables sont ordonnees par dependance (tables sans FK d'abord, puis tables qui en dependent). -### Relational diagrams (by sub-domain) +### Diagrammes relationnels (par sous-domaine) -The relational schema is shown as four Mermaid `erDiagram` views, one per sub-domain (same -decomposition as the MCD; a single 21-table diagram would not lay out cleanly). These differ -from the MCD: associative entities are resolved into join tables with composite PKs, the -`order_item` polymorphism appears as two nullable FKs (`product_id` / `menu_id`), and every -foreign key is explicit. Audit timestamps (`created_at` / `updated_at`) are present on most -tables (see the per-table sections below) but omitted from the diagrams to keep them readable. -Relationship labels carry the FK column and its `ON DELETE` behaviour. Cross-sub-domain FK -targets are shown as stub tables (id + name). Portable SVG renders live in `_diagrams/` +Le schema relationnel est presente sous forme de quatre vues Mermaid `erDiagram`, une par sous-domaine (meme +decomposition que le MCD ; un unique diagramme de 21 tables ne se disposerait pas proprement). Elles different +du MCD : les entites associatives sont resolues en tables de jointure avec PK composites, le +polymorphisme de `order_item` apparait sous forme de deux FK nullables (`product_id` / `menu_id`), et chaque +cle etrangere est explicite. Les horodatages d'audit (`created_at` / `updated_at`) sont presents sur la plupart des +tables (voir les sections par table ci-dessous) mais omis des diagrammes pour les garder lisibles. +Les libelles de relation portent la colonne FK et son comportement `ON DELETE`. Les cibles de FK +inter-sous-domaines sont representees comme des tables stub (id + name). Les rendus SVG portables sont dans `_diagrams/` (`mld-catalogue.svg`, `mld-ingredients-stock.svg`, `mld-order.svg`, `mld-rbac.svg`). #### Catalogue @@ -160,7 +160,7 @@ erDiagram product ||--o{ menu_slot_option : "product_id (RESTRICT)" ``` -#### Ingredients & Stock +#### Ingredients et Stock ```mermaid erDiagram @@ -224,7 +224,7 @@ erDiagram user ||--o{ stock_movement : "user_id (SET NULL, nullable)" ``` -#### Order +#### Commande ```mermaid erDiagram @@ -301,7 +301,7 @@ erDiagram ingredient ||--o{ order_item_modifier : "ingredient_id (RESTRICT)" ``` -#### RBAC & security +#### RBAC & securite ```mermaid erDiagram @@ -367,7 +367,7 @@ erDiagram role ||--o{ audit_log : "actor_role_id (SET NULL, nullable)" ``` -> `login_throttle` has no FK (an IP is not a modelled entity); it stands alone, keyed by +> `login_throttle` n'a pas de FK (une IP n'est pas une entite modelisee) ; elle est autonome, cle par > `ip_address`. --- @@ -382,18 +382,18 @@ category (id, name, slug, [image_path], display_order, is_active, created_at, up UK : slug ``` -| Column | Type | NULL | Notes | +| Colonne | Type | NULL | Notes | |---|---|---|---| | `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | -| `name` | VARCHAR(60) | NO | Unique display name (see dict 3.1) | -| `slug` | VARCHAR(60) | NO | URL slug, e.g. `burgers` | -| `image_path` | VARCHAR(255) | YES | Relative path from public root | -| `display_order` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Kiosk display order | -| `is_active` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Soft deactivation | +| `name` | VARCHAR(60) | NO | Nom d'affichage unique (voir dict 3.1) | +| `slug` | VARCHAR(60) | NO | Slug d'URL, p. ex. `burgers` | +| `image_path` | VARCHAR(255) | YES | Chemin relatif depuis la racine publique | +| `display_order` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Ordre d'affichage borne | +| `is_active` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Desactivation logique | | `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Audit | | `updated_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | NO | Audit | -No FK. Root table for the Catalogue sub-domain. +Pas de FK. Table racine du sous-domaine Catalogue. --- @@ -410,22 +410,22 @@ product (id, #category_id, name, [description], price_cents, vat_rate, CHK : vat_rate IN (55, 100) ``` -| Column | Type | NULL | Notes | +| Colonne | Type | NULL | Notes | |---|---|---|---| | `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | | `category_id` | INT UNSIGNED | NO | FK -> category | -| `name` | VARCHAR(120) | NO | Product label | -| `description` | TEXT | YES | Optional long description | -| `price_cents` | INT UNSIGNED | NO | A la carte price, incl. VAT, in cents | -| `vat_rate` | SMALLINT UNSIGNED | NO | Per-mille: 100 = 10%, 55 = 5.5% | -| `image_path` | VARCHAR(255) | YES | Relative path from public root | -| `is_available` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Manual availability toggle | -| `display_order` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Within-category display order | +| `name` | VARCHAR(120) | NO | Libelle du produit | +| `description` | TEXT | YES | Description longue optionnelle | +| `price_cents` | INT UNSIGNED | NO | Prix a la carte, TVA incluse, en centimes | +| `vat_rate` | SMALLINT UNSIGNED | NO | Pour-mille : 100 = 10%, 55 = 5.5% | +| `image_path` | VARCHAR(255) | YES | Chemin relatif depuis la racine publique | +| `is_available` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Bascule de disponibilite manuelle | +| `display_order` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Ordre d'affichage au sein de la categorie | | `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Audit | | `updated_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | NO | Audit | -**ON DELETE RESTRICT** on `category_id`: a category with products cannot be deleted. Prevents -orphaned products. +**ON DELETE RESTRICT** sur `category_id` : une categorie avec des produits ne peut pas etre supprimee. Empeche les +produits orphelins. --- @@ -444,23 +444,23 @@ menu (id, #category_id, #burger_product_id, name, [description], CHK : price_maxi_cents > 0 ``` -| Column | Type | NULL | Notes | +| Colonne | Type | NULL | Notes | |---|---|---|---| | `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | -| `category_id` | INT UNSIGNED | NO | FK -> category (typically the `menus` category) | -| `burger_product_id` | INT UNSIGNED | NO | FK -> product — the fixed burger that anchors this menu | -| `name` | VARCHAR(120) | NO | e.g. "Menu Le 280" | -| `description` | TEXT | YES | Optional | -| `price_normal_cents` | INT UNSIGNED | NO | Normal format price in cents | -| `price_maxi_cents` | INT UNSIGNED | NO | Maxi format price in cents (~+150 cents) | -| `image_path` | VARCHAR(255) | YES | Typically reuses the burger image | -| `is_available` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Availability toggle | -| `display_order` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Display order | +| `category_id` | INT UNSIGNED | NO | FK -> category (typiquement la categorie `menus`) | +| `burger_product_id` | INT UNSIGNED | NO | FK -> product — le burger fixe qui ancre ce menu | +| `name` | VARCHAR(120) | NO | p. ex. "Menu Le 280" | +| `description` | TEXT | YES | Optionnel | +| `price_normal_cents` | INT UNSIGNED | NO | Prix du format Normal en centimes | +| `price_maxi_cents` | INT UNSIGNED | NO | Prix du format Maxi en centimes (~+150 centimes) | +| `image_path` | VARCHAR(255) | YES | Reutilise typiquement l'image du burger | +| `is_available` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Bascule de disponibilite | +| `display_order` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Ordre d'affichage | | `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Audit | | `updated_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | NO | Audit | -**ON DELETE RESTRICT** on both FKs: prevents deletion of a category or burger product that -is still referenced by a menu definition. +**ON DELETE RESTRICT** sur les deux FK : empeche la suppression d'une categorie ou d'un produit burger +encore reference par une definition de menu. --- @@ -474,25 +474,25 @@ menu_slot (id, #menu_id, name, slot_type, is_required, display_order) IDX : (menu_id, display_order) ``` -| Column | Type | NULL | Notes | +| Colonne | Type | NULL | Notes | |---|---|---|---| | `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | | `menu_id` | INT UNSIGNED | NO | FK -> menu | -| `name` | VARCHAR(80) | NO | e.g. "Drink", "Side", "Sauce" | -| `slot_type` | ENUM('drink','side','sauce','dessert','extra') | NO | Semantic role | -| `is_required` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Whether the customer must fill this slot | -| `display_order` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Display order within menu builder | +| `name` | VARCHAR(80) | NO | p. ex. "Drink", "Side", "Sauce" | +| `slot_type` | ENUM('drink','side','sauce','dessert','extra') | NO | Role semantique | +| `is_required` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Indique si le client doit remplir ce slot | +| `display_order` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Ordre d'affichage dans le constructeur de menu | -**No audit fields**: a slot is part of menu definition; created and updated together with -the menu. +**Pas de champs d'audit** : un slot fait partie de la definition du menu ; cree et mis a jour en meme temps que +le menu. -**ON DELETE CASCADE** on `menu_id`: if a menu is deleted, its slots are deleted with it. +**ON DELETE CASCADE** sur `menu_id` : si un menu est supprime, ses slots sont supprimes avec lui. --- ### 4.5 `menu_slot_option` -Pure join table. Composite PK. +Table de jointure pure. PK composite. ``` menu_slot_option (#menu_slot_id, #product_id) @@ -502,16 +502,16 @@ menu_slot_option (#menu_slot_id, #product_id) FK : product_id -> product(id) ON DELETE RESTRICT ``` -| Column | Type | NULL | Notes | +| Colonne | Type | NULL | Notes | |---|---|---|---| | `menu_slot_id` | INT UNSIGNED | NO | FK -> menu_slot | | `product_id` | INT UNSIGNED | NO | FK -> product | -**ON DELETE CASCADE** on `menu_slot_id`: if a slot is deleted, its eligibility list goes with it. -**ON DELETE RESTRICT** on `product_id`: a product listed as eligible in a slot cannot be -deleted without first removing it from the slot options. Prevents silent breakage of menus. +**ON DELETE CASCADE** sur `menu_slot_id` : si un slot est supprime, sa liste d'eligibilite disparait avec lui. +**ON DELETE RESTRICT** sur `product_id` : un produit liste comme eligible dans un slot ne peut pas etre +supprime sans le retirer d'abord des options du slot. Empeche la rupture silencieuse des menus. -No timestamps. Pure join table. +Pas d'horodatages. Table de jointure pure. --- @@ -530,48 +530,48 @@ ingredient (id, name, unit, stock_quantity, stock_capacity, pack_size, [pack_lab CHK : critical_stock_pct < low_stock_pct ``` -| Column | Type | NULL | Notes | +| Colonne | Type | NULL | Notes | |---|---|---|---| | `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | -| `name` | VARCHAR(120) | NO | Unique name, e.g. "Sesame Bun" | -| `unit` | VARCHAR(40) | NO | Packaging unit label (free-form, not ENUM) | -| `stock_quantity` | INT NOT NULL DEFAULT 0 | NO | Current stock. Signed INT that may go negative when sales outrun counted stock (oversell magnitude, surfaced to managers); the system does not block an order on stock | -| `stock_capacity` | INT NOT NULL | NO | Reference "full" level in units = the 100% used to compute the stock percentage; CHECK > 0 also guards the percentage division against divide-by-zero | -| `pack_size` | SMALLINT UNSIGNED NOT NULL DEFAULT 1 | NO | Units per restocking pack | -| `pack_label` | VARCHAR(80) | YES | Human label of the pack | -| `low_stock_pct` | SMALLINT UNSIGNED NOT NULL DEFAULT 10 | NO | Warning band, percent of capacity (CHECK BETWEEN 0 AND 100) | -| `critical_stock_pct` | SMALLINT UNSIGNED NOT NULL DEFAULT 5 | NO | Auto-out-of-stock floor, percent of capacity (CHECK BETWEEN 0 AND 100; table CHECK `critical_stock_pct < low_stock_pct`) | -| `is_active` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Deactivate obsolete ingredients | +| `name` | VARCHAR(120) | NO | Nom unique, p. ex. "Sesame Bun" | +| `unit` | VARCHAR(40) | NO | Libelle d'unite de conditionnement (libre, pas ENUM) | +| `stock_quantity` | INT NOT NULL DEFAULT 0 | NO | Stock courant. INT signe pouvant devenir negatif quand les ventes depassent le stock compte (ampleur de la survente, remontee aux managers) ; le systeme ne bloque pas une commande sur le stock | +| `stock_capacity` | INT NOT NULL | NO | Niveau "plein" de reference en unites = le 100% utilise pour calculer le pourcentage de stock ; CHECK > 0 protege aussi la division du pourcentage contre la division par zero | +| `pack_size` | SMALLINT UNSIGNED NOT NULL DEFAULT 1 | NO | Unites par lot de reapprovisionnement | +| `pack_label` | VARCHAR(80) | YES | Libelle humain du lot | +| `low_stock_pct` | SMALLINT UNSIGNED NOT NULL DEFAULT 10 | NO | Bande d’alerte, pourcentage de la capacite (CHECK BETWEEN 0 AND 100) | +| `critical_stock_pct` | SMALLINT UNSIGNED NOT NULL DEFAULT 5 | NO | Plancher de rupture automatique, pourcentage de la capacite (CHECK BETWEEN 0 AND 100 ; CHECK de table `critical_stock_pct < low_stock_pct`) | +| `is_active` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Desactiver les ingredients obsoletes | | `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Audit | | `updated_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | NO | Audit | -No FK. Root table for the Ingredients & Stock sub-domain. +Pas de FK. Table racine du sous-domaine Ingredients & Stock. -**Percentage-based stock model**: the stock state is computed (NOT stored) as -`stock_pct = ROUND(stock_quantity / stock_capacity * 100)`. Two bands derive from it: -`LOW` when `stock_quantity <= stock_capacity * low_stock_pct/100`, and -`CRITICAL` when `stock_quantity <= stock_capacity * critical_stock_pct/100`. -Three-band behaviour: above `low` = normal; between `critical` and `low` = orderable -plus manager alert (the manager either pulls the product via `product.is_available=0`, or -restocks to clear the alert); at or below `critical` = auto out-of-stock (computed, rule -RG-T21). `stock_quantity` is signed and may go negative; the system does not block an order -on stock, so a negative value records the oversell magnitude for managers. +**Modele de stock base sur les pourcentages** : l'etat de stock est calcule (PAS stocke) comme +`stock_pct = ROUND(stock_quantity / stock_capacity * 100)`. Deux bandes en derivent : +`LOW` quand `stock_quantity <= stock_capacity * low_stock_pct/100`, et +`CRITICAL` quand `stock_quantity <= stock_capacity * critical_stock_pct/100`. +Comportement a trois bandes : au-dessus de `low` = normal ; entre `critical` et `low` = commandable +plus alerte manager (le manager soit retire le produit via `product.is_available=0`, soit +reapprovisionne pour lever l'alerte) ; au niveau ou en dessous de `critical` = rupture automatique (calculee, regle +RG-T21). `stock_quantity` est signe et peut devenir negatif ; le systeme ne bloque pas une commande +sur le stock, donc une valeur negative enregistre l'ampleur de la survente pour les managers. -**Computed availability (rule RG-T21)**: a product is effectively orderable when -`product.is_available = 1` AND each non-removable (`is_removable=0`) ingredient in its -`product_ingredient` has `stock_quantity > stock_capacity * critical_stock_pct/100`. At the -critical band a product auto-goes out-of-stock with no write and no cascade; a manual pull -(`product.is_available=0`) is a hard override; a restock above the critical band makes the -product orderable again on its own; a removable/optional ingredient at the critical band does -not block the product (only its add-on becomes unavailable). The dashboard distinguishes a -manual pull (`is_available=0`) from a stock-driven OOS (`is_available=1` but a required -ingredient is critical). +**Disponibilite calculee (regle RG-T21)** : un produit est effectivement commandable quand +`product.is_available = 1` ET chaque ingredient non-retirable (`is_removable=0`) de son +`product_ingredient` a `stock_quantity > stock_capacity * critical_stock_pct/100`. A la +bande critique, un produit passe automatiquement en rupture sans ecriture ni cascade ; un retrait manuel +(`product.is_available=0`) est une surcharge forte ; un reapprovisionnement au-dessus de la bande critique rend le +produit a nouveau commandable de lui-meme ; un ingredient retirable/optionnel a la bande critique ne +bloque pas le produit (seul son supplement devient indisponible). Le tableau de bord distingue un +retrait manuel (`is_available=0`) d'une rupture pilotee par le stock (`is_available=1` mais un ingredient +requis est critique). --- ### 4.7 `product_ingredient` -Associative table carrying recipe and customisation metadata. Composite PK. +Table associative portant les metadonnees de recette et de personnalisation. PK composite. ``` product_ingredient (#product_id, #ingredient_id, quantity_normal, quantity_maxi, @@ -585,21 +585,21 @@ product_ingredient (#product_id, #ingredient_id, quantity_normal, quantity_maxi, CHK : extra_price_cents >= 0 ``` -| Column | Type | NULL | Notes | +| Colonne | Type | NULL | Notes | |---|---|---|---| | `product_id` | INT UNSIGNED | NO | FK -> product | | `ingredient_id` | INT UNSIGNED | NO | FK -> ingredient | -| `quantity_normal` | SMALLINT UNSIGNED NOT NULL DEFAULT 1 | NO | Units consumed in Normal format | -| `quantity_maxi` | SMALLINT UNSIGNED NOT NULL DEFAULT 1 | NO | Units consumed in Maxi format; equals `quantity_normal` for burger/sauce (format-invariant), higher for side/drink | -| `is_removable` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Customer may remove at no cost | -| `is_addable` | TINYINT(1) NOT NULL DEFAULT 0 | NO | Customer may add an extra unit | -| `extra_price_cents` | INT UNSIGNED NOT NULL DEFAULT 0 | NO | Surcharge if `is_addable=1` and customer adds it | +| `quantity_normal` | SMALLINT UNSIGNED NOT NULL DEFAULT 1 | NO | Unites consommees au format Normal | +| `quantity_maxi` | SMALLINT UNSIGNED NOT NULL DEFAULT 1 | NO | Unites consommees au format Maxi ; egal a `quantity_normal` pour burger/sauce (invariant au format), superieur pour side/drink | +| `is_removable` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Le client peut retirer sans frais | +| `is_addable` | TINYINT(1) NOT NULL DEFAULT 0 | NO | Le client peut ajouter une unite supplementaire | +| `extra_price_cents` | INT UNSIGNED NOT NULL DEFAULT 0 | NO | Supplement si `is_addable=1` et que le client l'ajoute | -**ON DELETE CASCADE** on `product_id`: if a product is deleted, its recipe rows are deleted. -**ON DELETE RESTRICT** on `ingredient_id`: cannot delete an ingredient still referenced in a -recipe. Admin must remove the product-ingredient link first. +**ON DELETE CASCADE** sur `product_id` : si un produit est supprime, ses lignes de recette sont supprimees. +**ON DELETE RESTRICT** sur `ingredient_id` : impossible de supprimer un ingredient encore reference dans une +recette. L'administrateur doit d'abord retirer le lien produit-ingredient. -No timestamps. Join table with attributes. +Pas d'horodatages. Table de jointure avec attributs. --- @@ -612,21 +612,21 @@ allergen (id, code, name, [description]) UK : code ``` -| Column | Type | NULL | Notes | +| Colonne | Type | NULL | Notes | |---|---|---|---| | `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | -| `code` | VARCHAR(30) | NO | Machine code, e.g. `gluten`, `milk` | -| `name` | VARCHAR(80) | NO | Display name | -| `description` | TEXT | YES | Optional guidance | +| `code` | VARCHAR(30) | NO | Code machine, p. ex. `gluten`, `milk` | +| `name` | VARCHAR(80) | NO | Nom d'affichage | +| `description` | TEXT | YES | Indication optionnelle | -No FK. Reference table; 14 rows at seed (INCO Regulation (EU) 1169/2011). -No `updated_at`: allergen catalogue is considered stable (additions require a migration, not a UI action). +Pas de FK. Table de reference ; 14 lignes au seed (Reglement INCO (UE) 1169/2011). +Pas de `updated_at` : le catalogue d'allergenes est considere stable (les ajouts requierent une migration, pas une action UI). --- ### 4.9 `ingredient_allergen` -Pure join table. Composite PK. +Table de jointure pure. PK composite. ``` ingredient_allergen (#ingredient_id, #allergen_id) @@ -636,21 +636,21 @@ ingredient_allergen (#ingredient_id, #allergen_id) FK : allergen_id -> allergen(id) ON DELETE RESTRICT ``` -| Column | Type | NULL | Notes | +| Colonne | Type | NULL | Notes | |---|---|---|---| | `ingredient_id` | INT UNSIGNED | NO | FK -> ingredient | | `allergen_id` | INT UNSIGNED | NO | FK -> allergen | -**ON DELETE CASCADE** on `ingredient_id`: if an ingredient is deleted, its allergen links go with it. -**ON DELETE RESTRICT** on `allergen_id`: an allergen in the regulated catalogue cannot be deleted. +**ON DELETE CASCADE** sur `ingredient_id` : si un ingredient est supprime, ses liens d'allergenes disparaissent avec lui. +**ON DELETE RESTRICT** sur `allergen_id` : un allergene du catalogue reglemente ne peut pas etre supprime. -No timestamps. Pure join table. +Pas d'horodatages. Table de jointure pure. --- ### 4.10 `role` -Placed before `user` because `user` depends on `role`. +Placee avant `user` car `user` depend de `role`. ``` role (id, code, label, [description], [default_route], [order_source], @@ -660,19 +660,19 @@ role (id, code, label, [description], [default_route], [order_source], UK : code ``` -| Column | Type | NULL | Notes | +| Colonne | Type | NULL | Notes | |---|---|---|---| | `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | -| `code` | VARCHAR(40) | NO | Machine code: `admin`, `manager`, `kitchen`, `counter`, `drive` | -| `label` | VARCHAR(80) | NO | Display name | -| `description` | TEXT | YES | Optional | -| `default_route` | VARCHAR(120) | YES | Landing screen, e.g. `/admin/dashboard` | -| `order_source` | ENUM('kiosk','counter','drive') | YES | Auto-tagged source when this role creates an order; NULL for admin/manager | -| `is_active` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Deactivation preserves history | +| `code` | VARCHAR(40) | NO | Code machine : `admin`, `manager`, `kitchen`, `counter`, `drive` | +| `label` | VARCHAR(80) | NO | Nom d'affichage | +| `description` | TEXT | YES | Optionnel | +| `default_route` | VARCHAR(120) | YES | Ecran d'arrivee, p. ex. `/admin/dashboard` | +| `order_source` | ENUM('kiosk','counter','drive') | YES | Source auto-etiquetee quand ce role cree une commande ; NULL pour admin/manager | +| `is_active` | TINYINT(1) NOT NULL DEFAULT 1 | NO | La desactivation preserve l'historique | | `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Audit | | `updated_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | NO | Audit | -No FK. Root table for RBAC. +Pas de FK. Table racine pour le RBAC. --- @@ -690,40 +690,40 @@ user (id, email, password_hash, [pin_hash], first_name, last_name, #role_id, IDX : (is_active, role_id) ``` -| Column | Type | NULL | Notes | +| Colonne | Type | NULL | Notes | |---|---|---|---| | `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | -| `email` | VARCHAR(254) | NO | RFC 5321 max length. PII (RGPD anonymisation, see below) | -| `password_hash` | VARCHAR(255) | NO | argon2id hash | -| `pin_hash` | VARCHAR(255) | YES | argon2id hash of the per-staff PIN (sensitive-action authorisation). Security-by-design | +| `email` | VARCHAR(254) | NO | Longueur max RFC 5321. PII (anonymisation RGPD, voir ci-dessous) | +| `password_hash` | VARCHAR(255) | NO | hash argon2id | +| `pin_hash` | VARCHAR(255) | YES | hash argon2id du PIN par membre du personnel (autorisation d'action sensible). Security-by-design | | `first_name` | VARCHAR(60) | NO | PII | | `last_name` | VARCHAR(60) | NO | PII | | `role_id` | INT UNSIGNED | NO | FK -> role | -| `is_active` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Deactivation without deletion | -| `last_login_at` | DATETIME | YES | Audit, dormant account detection | -| `failed_login_attempts` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Brute-force counter (degressive throttling) | -| `last_failed_login_at` | DATETIME | YES | Timestamp of last failed login | -| `lockout_until` | DATETIME | YES | End of current throttling window (backoff, not indefinite lock) | -| `password_reset_token_hash` | VARCHAR(255) | YES | Hash of the reset token (not the raw token) | -| `password_reset_expires_at` | DATETIME | YES | Reset token expiry | -| `anonymized_at` | DATETIME | YES | RGPD tombstone marker; PII nulled/replaced when set | +| `is_active` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Desactivation sans suppression | +| `last_login_at` | DATETIME | YES | Audit, detection de compte dormant | +| `failed_login_attempts` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Compteur de force brute (throttling degressif) | +| `last_failed_login_at` | DATETIME | YES | Horodatage de la derniere connexion echouee | +| `lockout_until` | DATETIME | YES | Fin de la fenetre de throttling courante (backoff, pas un verrou indefini) | +| `password_reset_token_hash` | VARCHAR(255) | YES | Hash du token de reinitialisation (pas le token brut) | +| `password_reset_expires_at` | DATETIME | YES | Expiration du token de reinitialisation | +| `anonymized_at` | DATETIME | YES | Marqueur tombstone RGPD ; PII annulees/remplacees quand defini | | `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Audit | | `updated_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | NO | Audit | -**ON DELETE RESTRICT** on `role_id`: a role cannot be deleted while users hold it. -Deactivate the role first (`is_active = 0`), then reassign users before deleting. +**ON DELETE RESTRICT** sur `role_id` : un role ne peut pas etre supprime tant que des utilisateurs le detiennent. +Desactivez d'abord le role (`is_active = 0`), puis reaffectez les utilisateurs avant suppression. -**RGPD anonymisation** (security-by-design, dict. note 13): the right to erasure is honoured by -anonymising, not hard-deleting. `email` becomes a unique non-identifying placeholder -(`anon-@wakdo.invalid`, RFC 2606 reserved domain — preserves the UNIQUE constraint), -`first_name`/`last_name` are cleared, `password_hash`/`pin_hash` are invalidated, `is_active=0`, -`anonymized_at = NOW()`. The row persists so `audit_log` and `stock_movement` FKs stay valid. +**Anonymisation RGPD** (security-by-design, note 13 du dict.) : le droit a l'effacement est honore en +anonymisant, pas en supprimant physiquement. `email` devient un placeholder unique non identifiant +(`anon-@wakdo.invalid`, domaine reserve RFC 2606 — preserve la contrainte UNIQUE), +`first_name`/`last_name` sont effaces, `password_hash`/`pin_hash` sont invalides, `is_active=0`, +`anonymized_at = NOW()`. La ligne persiste pour que les FK `audit_log` et `stock_movement` restent valides. --- ### 4.12 `role_visible_source` -Pure join table. Composite PK. +Table de jointure pure. PK composite. ``` role_visible_source (#role_id, source) @@ -732,20 +732,20 @@ role_visible_source (#role_id, source) FK : role_id -> role(id) ON DELETE CASCADE ``` -| Column | Type | NULL | Notes | +| Colonne | Type | NULL | Notes | |---|---|---|---| | `role_id` | INT UNSIGNED | NO | FK -> role | -| `source` | ENUM('kiosk','counter','drive') | NO | Order source visible on dashboard | +| `source` | ENUM('kiosk','counter','drive') | NO | Source de commande visible sur le tableau de bord | -**ON DELETE CASCADE** on `role_id`: if a role is deleted, its dashboard source filters go with it. +**ON DELETE CASCADE** sur `role_id` : si un role est supprime, ses filtres de source du tableau de bord disparaissent avec lui. -No timestamps. Pure join table. +Pas d'horodatages. Table de jointure pure. -Seed data: -- `kitchen`: kiosk, counter, drive -- `counter`: kiosk, counter -- `drive`: drive -- `admin`, `manager`: no rows (global view, no source filter) +Donnees de seed : +- `kitchen` : kiosk, counter, drive +- `counter` : kiosk, counter +- `drive` : drive +- `admin`, `manager` : pas de lignes (vue globale, pas de filtre de source) --- @@ -758,22 +758,22 @@ permission (id, code, label, [description], created_at) UK : code ``` -| Column | Type | NULL | Notes | +| Colonne | Type | NULL | Notes | |---|---|---|---| | `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | | `code` | VARCHAR(60) | NO | Format `.` | -| `label` | VARCHAR(120) | NO | Display name | -| `description` | TEXT | YES | Optional | +| `label` | VARCHAR(120) | NO | Nom d'affichage | +| `description` | TEXT | YES | Optionnel | | `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Audit | -No `updated_at`: permissions are declared in migration and not modified via UI. -Catalogue is frozen at 23 codes (see dictionary section 3.17). +Pas de `updated_at` : les permissions sont declarees en migration et non modifiees via l'UI. +Le catalogue est fige a 23 codes (voir dictionnaire section 3.17). --- ### 4.14 `role_permission` -Pure join table. Composite PK. +Table de jointure pure. PK composite. ``` role_permission (#role_id, #permission_id) @@ -784,16 +784,16 @@ role_permission (#role_id, #permission_id) IDX : permission_id ``` -| Column | Type | NULL | Notes | +| Colonne | Type | NULL | Notes | |---|---|---|---| | `role_id` | INT UNSIGNED | NO | FK -> role | | `permission_id` | INT UNSIGNED | NO | FK -> permission | -**ON DELETE CASCADE** on both FKs: deleting a role or a permission removes its mappings. -The secondary index on `permission_id` supports the reverse query "which roles have this -permission?" without scanning the full table. +**ON DELETE CASCADE** sur les deux FK : supprimer un role ou une permission retire ses associations. +L'index secondaire sur `permission_id` supporte la requete inverse "quels roles ont cette +permission ?" sans scanner la table entiere. -No timestamps. Pure join table. +Pas d'horodatages. Table de jointure pure. --- @@ -820,49 +820,49 @@ customer_order (id, order_number, [idempotency_key], source, [#acting_user_id], CHK : source != 'drive' OR service_mode = 'drive' ``` -| Column | Type | NULL | Notes | +| Colonne | Type | NULL | Notes | |---|---|---|---| | `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | -| `order_number` | VARCHAR(20) | NO | Format `K/C/D-YYYY-MM-DD-NNN` by channel | -| `idempotency_key` | VARCHAR(36) | YES | Client UUID, UNIQUE; deduplicates retried POST (security-by-design) | -| `source` | ENUM('kiosk','counter','drive') | NO | Input channel | -| `acting_user_id` | INT UNSIGNED | YES | FK -> user; counter/drive staff under PIN; NULL for kiosk | -| `service_mode` | ENUM('dine_in','takeaway','drive') | NO | Consumption mode (stats only, no fiscal role) | -| `status` | ENUM('pending_payment','paid','delivered','cancelled') NOT NULL DEFAULT 'pending_payment' | NO | 4-state machine | -| `total_ht_cents` | INT UNSIGNED | NO | Ex-VAT total snapshot | -| `total_vat_cents` | INT UNSIGNED | NO | VAT amount snapshot | -| `total_ttc_cents` | INT UNSIGNED | NO | Incl.-VAT total; must equal HT + VAT | -| `paid_at` | DATETIME | YES | Timestamp of transition to `paid` | -| `delivered_at` | DATETIME | YES | Timestamp of transition to `delivered` | -| `cancelled_at` | DATETIME | YES | Timestamp of cancellation | -| `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Used as `service_day` base | +| `order_number` | VARCHAR(20) | NO | Format `K/C/D-YYYY-MM-DD-NNN` par canal | +| `idempotency_key` | VARCHAR(36) | YES | UUID client, UNIQUE ; deduplique un POST reessaye (security-by-design) | +| `source` | ENUM('kiosk','counter','drive') | NO | Canal de saisie | +| `acting_user_id` | INT UNSIGNED | YES | FK -> user ; personnel counter/drive sous PIN ; NULL pour kiosk | +| `service_mode` | ENUM('dine_in','takeaway','drive') | NO | Mode de consommation (stats uniquement, pas de role fiscal) | +| `status` | ENUM('pending_payment','paid','delivered','cancelled') NOT NULL DEFAULT 'pending_payment' | NO | Machine a 4 etats | +| `total_ht_cents` | INT UNSIGNED | NO | Snapshot du total HT | +| `total_vat_cents` | INT UNSIGNED | NO | Snapshot du montant de TVA | +| `total_ttc_cents` | INT UNSIGNED | NO | Total TTC ; doit egaler HT + TVA | +| `paid_at` | DATETIME | YES | Horodatage de la transition vers `paid` | +| `delivered_at` | DATETIME | YES | Horodatage de la transition vers `delivered` | +| `cancelled_at` | DATETIME | YES | Horodatage de l'annulation | +| `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Utilise comme base de `service_day` | | `updated_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | NO | Audit | -**Staff attribution (security-by-design)**: `acting_user_id` (FK -> `user`, ON DELETE SET NULL) -records the counter/drive staff who took the order under PIN; NULL for anonymous kiosk orders. -Kiosk orders stay anonymous by design. `stock_movement.user_id` covers attribution of stock -actions. `idempotency_key` (UNIQUE, nullable) deduplicates a retried `POST /api/orders` -(multiple NULLs allowed by the UNIQUE index, so non-idempotent legacy paths are tolerated). +**Attribution du personnel (security-by-design)** : `acting_user_id` (FK -> `user`, ON DELETE SET NULL) +enregistre le personnel counter/drive qui a pris la commande sous PIN ; NULL pour les commandes kiosk anonymes. +Les commandes kiosk restent anonymes par conception. `stock_movement.user_id` couvre l'attribution des actions +de stock. `idempotency_key` (UNIQUE, nullable) deduplique un `POST /api/orders` reessaye +(plusieurs NULL autorises par l'index UNIQUE, donc les chemins legacy non idempotents sont toleres). -**4-state machine**: `pending_payment -> paid -> delivered` (+ `cancelled`). States `preparing` -and `ready` are dropped (decision D4). KPI: `delivered_at - paid_at` (target SLA ~10 min). +**Machine a 4 etats** : `pending_payment -> paid -> delivered` (+ `cancelled`). Les etats `preparing` +et `ready` sont abandonnes (decision D4). KPI : `delivered_at - paid_at` (SLA cible ~10 min). -**`service_day` computation** (used in stats queries — NOT a stored column): +**Calcul de `service_day`** (utilise dans les requetes de stats — PAS une colonne stockee) : ```sql CASE WHEN HOUR(created_at) < 10 THEN DATE(created_at) - INTERVAL 1 DAY ELSE DATE(created_at) END ``` -Cutoff: 10:00. The generated-column formula with `INTERVAL 4 HOUR 30 MINUTE` from v0.1 was -incorrect and is dropped (decision D6). +Coupure : 10:00. La formule de colonne generee avec `INTERVAL 4 HOUR 30 MINUTE` de la v0.1 etait +incorrecte et est abandonnee (decision D6). -**VAT calculation**: totals on `customer_order` are the sum of line-level calculations. -Line-level VAT: `unit_price_cents_snapshot * quantity` is the TTC amount per line; -HT = `ROUND(ttc_cents * 100 / (100 + vat_rate_per_cent))` where `vat_rate_per_cent` -is `vat_rate_snapshot / 10`. Computed at application layer at cart validation. +**Calcul de TVA** : les totaux sur `customer_order` sont la somme des calculs au niveau ligne. +TVA au niveau ligne : `unit_price_cents_snapshot * quantity` est le montant TTC par ligne ; +HT = `ROUND(ttc_cents * 100 / (100 + vat_rate_per_cent))` ou `vat_rate_per_cent` +vaut `vat_rate_snapshot / 10`. Calcule au niveau applicatif a la validation du panier. -**`source = 'drive' => service_mode = 'drive'`**: the CHECK enforces this at DB level. +**`source = 'drive' => service_mode = 'drive'`** : le CHECK l'impose au niveau de la BD. --- @@ -885,32 +885,32 @@ order_item (id, #order_id, item_type, [#product_id], [#menu_id], format, OR (item_type = 'menu' AND menu_id IS NOT NULL AND product_id IS NULL) ``` -| Column | Type | NULL | Notes | +| Colonne | Type | NULL | Notes | |---|---|---|---| | `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | | `order_id` | INT UNSIGNED | NO | FK -> customer_order | -| `item_type` | ENUM('product','menu') | NO | Discriminator | -| `product_id` | INT UNSIGNED | YES | Non-null if `item_type = 'product'`, NULL otherwise | -| `menu_id` | INT UNSIGNED | YES | Non-null if `item_type = 'menu'`, NULL otherwise | -| `format` | ENUM('normal','maxi') NOT NULL DEFAULT 'normal' | NO | Menu format. For standalone products, value is `normal` | -| `label_snapshot` | VARCHAR(120) | NO | Label at time of order | -| `unit_price_cents_snapshot` | INT UNSIGNED | NO | Unit price incl. VAT at time of order | -| `vat_rate_snapshot` | SMALLINT UNSIGNED | NO | VAT rate per-mille at time of order | -| `quantity` | SMALLINT UNSIGNED NOT NULL DEFAULT 1 | NO | Quantity (e.g. 3 drinks = 1 line, quantity=3) | +| `item_type` | ENUM('product','menu') | NO | Discriminateur | +| `product_id` | INT UNSIGNED | YES | Non-null si `item_type = 'product'`, NULL sinon | +| `menu_id` | INT UNSIGNED | YES | Non-null si `item_type = 'menu'`, NULL sinon | +| `format` | ENUM('normal','maxi') NOT NULL DEFAULT 'normal' | NO | Format du menu. Pour les produits autonomes, la valeur est `normal` | +| `label_snapshot` | VARCHAR(120) | NO | Libelle au moment de la commande | +| `unit_price_cents_snapshot` | INT UNSIGNED | NO | Prix unitaire TVA incluse au moment de la commande | +| `vat_rate_snapshot` | SMALLINT UNSIGNED | NO | Taux de TVA pour-mille au moment de la commande | +| `quantity` | SMALLINT UNSIGNED NOT NULL DEFAULT 1 | NO | Quantite (p. ex. 3 boissons = 1 ligne, quantity=3) | | `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Audit | -**ON DELETE CASCADE** on `order_id`: lines are deleted with the order. -**ON DELETE RESTRICT** on `product_id` and `menu_id`: a product or menu referenced in an -historical order line cannot be deleted. The snapshot makes the FK reference non-critical -for display, but RESTRICT avoids silent orphaning of the relational structure. +**ON DELETE CASCADE** sur `order_id` : les lignes sont supprimees avec la commande. +**ON DELETE RESTRICT** sur `product_id` et `menu_id` : un produit ou menu reference dans une +ligne de commande historique ne peut pas etre supprime. Le snapshot rend la reference FK non critique +pour l'affichage, mais RESTRICT evite l'orphelinage silencieux de la structure relationnelle. -**Polymorphism exclusivity CHECK**: MariaDB 10.2+ enforces this at INSERT/UPDATE time. +**CHECK d'exclusivite du polymorphisme** : MariaDB 10.2+ l'impose au moment de l'INSERT/UPDATE. --- ### 4.17 `order_item_selection` -Customer's choice for one slot of a menu order line. +Choix du client pour un slot d'une ligne de commande de menu. ``` order_item_selection (id, #order_item_id, #menu_slot_id, #product_id, label_snapshot) @@ -922,27 +922,27 @@ order_item_selection (id, #order_item_id, #menu_slot_id, #product_id, label_snap IDX : order_item_id ``` -| Column | Type | NULL | Notes | +| Colonne | Type | NULL | Notes | |---|---|---|---| | `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | -| `order_item_id` | INT UNSIGNED | NO | FK -> order_item (must be a menu-type line) | -| `menu_slot_id` | INT UNSIGNED | NO | FK -> menu_slot (which slot was filled) | -| `product_id` | INT UNSIGNED | NO | FK -> product (chosen by customer) | -| `label_snapshot` | VARCHAR(120) | NO | Product label at time of order | +| `order_item_id` | INT UNSIGNED | NO | FK -> order_item (doit etre une ligne de type menu) | +| `menu_slot_id` | INT UNSIGNED | NO | FK -> menu_slot (quel slot a ete rempli) | +| `product_id` | INT UNSIGNED | NO | FK -> product (choisi par le client) | +| `label_snapshot` | VARCHAR(120) | NO | Libelle du produit au moment de la commande | -**ON DELETE CASCADE** on `order_item_id`: if the parent order line is deleted, its slot -selections go with it. -**ON DELETE RESTRICT** on `menu_slot_id` and `product_id`: historical slot choice records -must not be silently broken by catalogue changes. +**ON DELETE CASCADE** sur `order_item_id` : si la ligne de commande parente est supprimee, ses +selections de slot disparaissent avec elle. +**ON DELETE RESTRICT** sur `menu_slot_id` et `product_id` : les enregistrements historiques de choix de slot +ne doivent pas etre silencieusement rompus par des changements de catalogue. -Note: the business constraint that `order_item_id` references a line with `item_type='menu'` -is enforced at application layer (not in MariaDB without a trigger or deferred constraint). +Note : la contrainte metier voulant que `order_item_id` reference une ligne avec `item_type='menu'` +est imposee au niveau applicatif (pas dans MariaDB sans trigger ou contrainte differee). --- ### 4.18 `order_item_modifier` -Ingredient-level modification applied by the customer to a product or the fixed burger of a menu. +Modification au niveau ingredient appliquee par le client a un produit ou au burger fixe d'un menu. ``` order_item_modifier (id, #order_item_id, #ingredient_id, action, extra_price_cents) @@ -954,27 +954,27 @@ order_item_modifier (id, #order_item_id, #ingredient_id, action, extra_price_cen CHK : extra_price_cents >= 0 ``` -| Column | Type | NULL | Notes | +| Colonne | Type | NULL | Notes | |---|---|---|---| | `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | | `order_item_id` | INT UNSIGNED | NO | FK -> order_item | | `ingredient_id` | INT UNSIGNED | NO | FK -> ingredient | -| `action` | ENUM('remove','add') | NO | `remove` = free removal; `add` = extra unit | -| `extra_price_cents` | INT UNSIGNED NOT NULL DEFAULT 0 | NO | Snapshot of surcharge at time of order (0 for removals) | +| `action` | ENUM('remove','add') | NO | `remove` = retrait gratuit ; `add` = unite supplementaire | +| `extra_price_cents` | INT UNSIGNED NOT NULL DEFAULT 0 | NO | Snapshot du supplement au moment de la commande (0 pour les retraits) | -**ON DELETE CASCADE** on `order_item_id`: if the order line is deleted, its modifiers go with it. -**ON DELETE RESTRICT** on `ingredient_id`: an ingredient referenced in a historical modifier -cannot be deleted. +**ON DELETE CASCADE** sur `order_item_id` : si la ligne de commande est supprimee, ses modificateurs disparaissent avec elle. +**ON DELETE RESTRICT** sur `ingredient_id` : un ingredient reference dans un modificateur historique +ne peut pas etre supprime. -**Modifier attachment for menu lines**: the modifiable product is the fixed burger, resolved -via `order_item.menu_id -> menu.burger_product_id`. No additional FK column is needed on -this table (see dictionary note 10). +**Rattachement du modificateur pour les lignes de menu** : le produit modifiable est le burger fixe, resolu +via `order_item.menu_id -> menu.burger_product_id`. Aucune colonne FK supplementaire n'est necessaire sur +cette table (voir note 10 du dictionnaire). --- ### 4.19 `stock_movement` -Append-only audit log of all stock changes per ingredient. +Journal d'audit append-only de tous les changements de stock par ingredient. ``` stock_movement (id, #ingredient_id, movement_type, delta, @@ -988,34 +988,34 @@ stock_movement (id, #ingredient_id, movement_type, delta, IDX : (movement_type, created_at) ``` -| Column | Type | NULL | Notes | +| Colonne | Type | NULL | Notes | |---|---|---|---| | `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | | `ingredient_id` | INT UNSIGNED | NO | FK -> ingredient | -| `movement_type` | ENUM('sale','cancellation','restock','inventory_correction') | NO | Nature of movement | -| `delta` | INT | NO | Signed change: negative for consumption, positive for restock/cancellation/correction | -| `order_id` | INT UNSIGNED | YES | FK -> customer_order; non-null for `sale` and `cancellation` | -| `user_id` | INT UNSIGNED | YES | FK -> user; null for automated sale decrements | -| `note` | VARCHAR(255) | YES | Optional human note | -| `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Immutable timestamp | +| `movement_type` | ENUM('sale','cancellation','restock','inventory_correction') | NO | Nature du mouvement | +| `delta` | INT | NO | Changement signe : negatif pour consommation, positif pour reapprovisionnement/annulation/correction | +| `order_id` | INT UNSIGNED | YES | FK -> customer_order ; non-null pour `sale` et `cancellation` | +| `user_id` | INT UNSIGNED | YES | FK -> user ; null pour les decrements de vente automatises | +| `note` | VARCHAR(255) | YES | Note humaine optionnelle | +| `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Horodatage immuable | -**ON DELETE RESTRICT** on `ingredient_id`: an ingredient with a movement history cannot be -deleted. Admin must archive the ingredient (`is_active = 0`) instead. -**ON DELETE SET NULL** on `order_id`: if an order is purged from the system, its movement -records remain with `order_id = NULL`. The audit log is preserved; only the order link is lost. -**ON DELETE SET NULL** on `user_id`: if a user is deleted, movement records remain with -`user_id = NULL`. Audit is preserved; individual attribution is lost. +**ON DELETE RESTRICT** sur `ingredient_id` : un ingredient avec un historique de mouvements ne peut pas etre +supprime. L'administrateur doit plutot archiver l'ingredient (`is_active = 0`). +**ON DELETE SET NULL** sur `order_id` : si une commande est purgee du systeme, ses enregistrements de +mouvement restent avec `order_id = NULL`. Le journal d'audit est preserve ; seul le lien de commande est perdu. +**ON DELETE SET NULL** sur `user_id` : si un utilisateur est supprime, les enregistrements de mouvement restent avec +`user_id = NULL`. L'audit est preserve ; l'attribution individuelle est perdue. -**Immutability rule**: no UPDATE or DELETE at application layer. Corrections are new rows -with `movement_type = 'inventory_correction'` and a signed `delta`. +**Regle d'immuabilite** : aucun UPDATE ni DELETE au niveau applicatif. Les corrections sont de nouvelles lignes +avec `movement_type = 'inventory_correction'` et un `delta` signe. -No `updated_at`. Immutable append-only table. +Pas de `updated_at`. Table immuable append-only. --- ### 4.20 `audit_log` -Append-only log of sensitive back-office actions (security-by-design, dict. 3.20). +Journal append-only des actions back-office sensibles (security-by-design, dict. 3.20). ``` audit_log (id, [#actor_user_id], [#actor_role_id], action_code, @@ -1029,34 +1029,34 @@ audit_log (id, [#actor_user_id], [#actor_role_id], action_code, IDX : (action_code, created_at) ``` -| Column | Type | NULL | Notes | +| Colonne | Type | NULL | Notes | |---|---|---|---| | `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | -| `actor_user_id` | INT UNSIGNED | YES | FK -> user; acting staff (PIN-captured) or NULL if not attributable | -| `actor_role_id` | INT UNSIGNED | YES | FK -> role; denormalised role context (survives user anonymisation) | -| `action_code` | VARCHAR(60) | NO | MCT operation / permission code, e.g. `product.update`, `order.cancel` | -| `entity_type` | VARCHAR(40) | YES | Affected table name | -| `entity_id` | INT UNSIGNED | YES | PK of the affected row | -| `summary` | VARCHAR(255) | YES | Short non-personal change description | -| `details` | JSON | YES | Optional before/after diff (field names for user-targeted actions, not PII values) | -| `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Immutable timestamp | +| `actor_user_id` | INT UNSIGNED | YES | FK -> user ; personnel agissant (capture par PIN) ou NULL si non attribuable | +| `actor_role_id` | INT UNSIGNED | YES | FK -> role ; contexte de role denormalise (survit a l'anonymisation de l'utilisateur) | +| `action_code` | VARCHAR(60) | NO | Code d'operation MCT / permission, p. ex. `product.update`, `order.cancel` | +| `entity_type` | VARCHAR(40) | YES | Nom de la table affectee | +| `entity_id` | INT UNSIGNED | YES | PK de la ligne affectee | +| `summary` | VARCHAR(255) | YES | Description courte non personnelle du changement | +| `details` | JSON | YES | Diff avant/apres optionnel (noms de champs pour les actions ciblant un utilisateur, pas les valeurs PII) | +| `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Horodatage immuable | -**ON DELETE SET NULL** on both FKs: the trail is preserved when a user is anonymised/removed -or a role deleted; only the link is severed (the denormalised `actor_role_id` keeps the role -context even after user anonymisation). +**ON DELETE SET NULL** sur les deux FK : la piste est preservee quand un utilisateur est anonymise/supprime +ou un role supprime ; seul le lien est rompu (le `actor_role_id` denormalise conserve le contexte de +role meme apres l'anonymisation de l'utilisateur). -**Immutability rule**: no UPDATE or DELETE at application layer. **Retention**: a scheduled -cron purge removes rows older than the retention window (~12 months, legitimate-interest / -fiscal traceability), decoupled from the user PII lifecycle (dict. note 13). +**Regle d'immuabilite** : aucun UPDATE ni DELETE au niveau applicatif. **Retention** : une purge cron +planifiee retire les lignes plus anciennes que la fenetre de retention (~12 mois, interet legitime / +tracabilite fiscale), decouplee du cycle de vie des PII de l'utilisateur (note 13 du dict.). -No `updated_at`. Immutable append-only table. +Pas de `updated_at`. Table immuable append-only. --- ### 4.21 `login_throttle` -Per-source-IP brute-force throttle (security-by-design). Complements the per-account counter -already on `user` (`failed_login_attempts` / `lockout_until`). +Throttle de force brute par IP source (security-by-design). Complete le compteur par compte +deja present sur `user` (`failed_login_attempts` / `lockout_until`). ``` login_throttle (id, ip_address, failed_attempts, window_started_at, @@ -1067,254 +1067,254 @@ login_throttle (id, ip_address, failed_attempts, window_started_at, IDX : lockout_until ``` -| Column | Type | NULL | Notes | +| Colonne | Type | NULL | Notes | |---|---|---|---| | `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | -| `ip_address` | VARCHAR(45) | NO | Source IP, one row per IP, upserted; 45 chars holds a full IPv6 literal. UNIQUE | -| `failed_attempts` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Consecutive failed logins from this IP in the current window | -| `window_started_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Start of the current counting window | -| `lockout_until` | DATETIME | YES | End of the degressive backoff window; NULL = not throttled | -| `last_attempt_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Timestamp of the last failed attempt | +| `ip_address` | VARCHAR(45) | NO | IP source, une ligne par IP, upsertee ; 45 caracteres contiennent un litteral IPv6 complet. UNIQUE | +| `failed_attempts` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Connexions echouees consecutives depuis cette IP dans la fenetre courante | +| `window_started_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Debut de la fenetre de comptage courante | +| `lockout_until` | DATETIME | YES | Fin de la fenetre de backoff degressif ; NULL = pas throttle | +| `last_attempt_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Horodatage de la derniere tentative echouee | -No FK: an IP is not a modelled entity. Append/upsert by IP; the window resets when expired. A -daily cron purges rows with no active lockout whose `last_attempt_at` is older than 24h. +Pas de FK : une IP n'est pas une entite modelisee. Append/upsert par IP ; la fenetre se reinitialise a expiration. Un +cron quotidien purge les lignes sans verrouillage actif dont le `last_attempt_at` est plus ancien que 24h. -No `updated_at`: rows are upserted by IP, not edited through a UI. +Pas de `updated_at` : les lignes sont upsertees par IP, pas editees via une UI. --- -## 5. Referential integrity summary +## 5. Resume de l'integrite referentielle -| FK column | References | ON DELETE | Rationale | +| Colonne FK | References | ON DELETE | Justification | |---|---|---|---| -| `product.category_id` | `category(id)` | RESTRICT | No orphaned product | -| `menu.category_id` | `category(id)` | RESTRICT | Same | -| `menu.burger_product_id` | `product(id)` | RESTRICT | Menu definition requires its anchor burger | -| `menu_slot.menu_id` | `menu(id)` | CASCADE | Slots have no meaning without their menu | -| `menu_slot_option.menu_slot_id` | `menu_slot(id)` | CASCADE | Eligibility list disappears with the slot | -| `menu_slot_option.product_id` | `product(id)` | RESTRICT | Removing a product must not silently break menus | -| `product_ingredient.product_id` | `product(id)` | CASCADE | Recipe disappears with the product | -| `product_ingredient.ingredient_id` | `ingredient(id)` | RESTRICT | Cannot remove ingredient still in a recipe | -| `ingredient_allergen.ingredient_id` | `ingredient(id)` | CASCADE | Allergen links disappear with the ingredient | -| `ingredient_allergen.allergen_id` | `allergen(id)` | RESTRICT | Regulated allergen catalogue is immutable | -| `user.role_id` | `role(id)` | RESTRICT | A user cannot exist without a role | -| `role_visible_source.role_id` | `role(id)` | CASCADE | Dashboard filters disappear with the role | -| `role_permission.role_id` | `role(id)` | CASCADE | Permission mappings disappear with the role | -| `role_permission.permission_id` | `permission(id)` | CASCADE | Permission mappings disappear with the permission | -| `order_item.order_id` | `customer_order(id)` | CASCADE | Lines disappear with the order | -| `order_item.product_id` | `product(id)` | RESTRICT | Historical reference must not be silently orphaned | -| `order_item.menu_id` | `menu(id)` | RESTRICT | Same | -| `order_item_selection.order_item_id` | `order_item(id)` | CASCADE | Slot choices disappear with the line | -| `order_item_selection.menu_slot_id` | `menu_slot(id)` | RESTRICT | Historical slot record preserved | -| `order_item_selection.product_id` | `product(id)` | RESTRICT | Historical choice record preserved | -| `order_item_modifier.order_item_id` | `order_item(id)` | CASCADE | Modifiers disappear with the line | -| `order_item_modifier.ingredient_id` | `ingredient(id)` | RESTRICT | Historical modifier record preserved | -| `stock_movement.ingredient_id` | `ingredient(id)` | RESTRICT | Ingredient with history cannot be deleted | -| `stock_movement.order_id` | `customer_order(id)` | SET NULL | Audit preserved, order link lost | -| `stock_movement.user_id` | `user(id)` | SET NULL | Audit preserved, user attribution lost | -| `customer_order.acting_user_id` | `user(id)` | SET NULL | Staff attribution preserved as anonymised principal; order kept | -| `audit_log.actor_user_id` | `user(id)` | SET NULL | Audit trail preserved on user anonymisation; only the link is severed | -| `audit_log.actor_role_id` | `role(id)` | SET NULL | Role context kept until role deletion; denormalised so it survives user anonymisation | +| `product.category_id` | `category(id)` | RESTRICT | Pas de produit orphelin | +| `menu.category_id` | `category(id)` | RESTRICT | Idem | +| `menu.burger_product_id` | `product(id)` | RESTRICT | La definition du menu requiert son burger d'ancrage | +| `menu_slot.menu_id` | `menu(id)` | CASCADE | Les slots n'ont pas de sens sans leur menu | +| `menu_slot_option.menu_slot_id` | `menu_slot(id)` | CASCADE | La liste d'eligibilite disparait avec le slot | +| `menu_slot_option.product_id` | `product(id)` | RESTRICT | Retirer un produit ne doit pas rompre silencieusement les menus | +| `product_ingredient.product_id` | `product(id)` | CASCADE | La recette disparait avec le produit | +| `product_ingredient.ingredient_id` | `ingredient(id)` | RESTRICT | Impossible de retirer un ingredient encore dans une recette | +| `ingredient_allergen.ingredient_id` | `ingredient(id)` | CASCADE | Les liens d'allergenes disparaissent avec l'ingredient | +| `ingredient_allergen.allergen_id` | `allergen(id)` | RESTRICT | Le catalogue d'allergenes reglemente est immuable | +| `user.role_id` | `role(id)` | RESTRICT | Un utilisateur ne peut pas exister sans role | +| `role_visible_source.role_id` | `role(id)` | CASCADE | Les filtres du tableau de bord disparaissent avec le role | +| `role_permission.role_id` | `role(id)` | CASCADE | Les associations de permission disparaissent avec le role | +| `role_permission.permission_id` | `permission(id)` | CASCADE | Les associations de permission disparaissent avec la permission | +| `order_item.order_id` | `customer_order(id)` | CASCADE | Les lignes disparaissent avec la commande | +| `order_item.product_id` | `product(id)` | RESTRICT | La reference historique ne doit pas etre silencieusement orphelinee | +| `order_item.menu_id` | `menu(id)` | RESTRICT | Idem | +| `order_item_selection.order_item_id` | `order_item(id)` | CASCADE | Les choix de slot disparaissent avec la ligne | +| `order_item_selection.menu_slot_id` | `menu_slot(id)` | RESTRICT | Enregistrement historique de slot preserve | +| `order_item_selection.product_id` | `product(id)` | RESTRICT | Enregistrement historique de choix preserve | +| `order_item_modifier.order_item_id` | `order_item(id)` | CASCADE | Les modificateurs disparaissent avec la ligne | +| `order_item_modifier.ingredient_id` | `ingredient(id)` | RESTRICT | Enregistrement historique de modificateur preserve | +| `stock_movement.ingredient_id` | `ingredient(id)` | RESTRICT | Un ingredient avec historique ne peut pas etre supprime | +| `stock_movement.order_id` | `customer_order(id)` | SET NULL | Audit preserve, lien de commande perdu | +| `stock_movement.user_id` | `user(id)` | SET NULL | Audit preserve, attribution utilisateur perdue | +| `customer_order.acting_user_id` | `user(id)` | SET NULL | Attribution du personnel preservee comme principal anonymise ; commande conservee | +| `audit_log.actor_user_id` | `user(id)` | SET NULL | Piste d'audit preservee a l'anonymisation de l'utilisateur ; seul le lien est rompu | +| `audit_log.actor_role_id` | `role(id)` | SET NULL | Contexte de role conserve jusqu'a la suppression du role ; denormalise donc il survit a l'anonymisation de l'utilisateur | -**Key used**: CASCADE = child has no meaning without parent; RESTRICT = parent deletion -blocked while children exist; SET NULL = child is preserved, only the link is severed. +**Cle utilisee** : CASCADE = l'enfant n'a pas de sens sans le parent ; RESTRICT = la suppression du parent +est bloquee tant que des enfants existent ; SET NULL = l'enfant est preserve, seul le lien est rompu. --- -## 6. CHECK constraints summary +## 6. Resume des contraintes CHECK -| Table | CHECK expression | Purpose | +| Table | Expression CHECK | Objectif | |---|---|---| -| `product` | `price_cents > 0` | Zero or negative price is a bug | -| `product` | `vat_rate IN (55, 100)` | Only two legal VAT rates for this model | -| `menu` | `price_normal_cents > 0` | Same as product | -| `menu` | `price_maxi_cents > 0` | Same | -| `ingredient` | `stock_capacity > 0` | The 100% reference must be positive; also guards the percentage division against divide-by-zero | -| `ingredient` | `pack_size > 0` | Pack size of zero makes restock logic incoherent | -| `ingredient` | `low_stock_pct BETWEEN 0 AND 100` | Warning band is a percent of capacity | -| `ingredient` | `critical_stock_pct BETWEEN 0 AND 100` | Auto-out-of-stock floor is a percent of capacity | -| `ingredient` | `critical_stock_pct < low_stock_pct` | Critical floor sits below the warning band | -| `product_ingredient` | `quantity_normal > 0` | Recipe quantity of zero is meaningless | -| `product_ingredient` | `quantity_maxi >= quantity_normal` | Maxi consumes at least as much as Normal (side/drink more, burger/sauce equal) | -| `product_ingredient` | `extra_price_cents >= 0` | No negative surcharge | -| `customer_order` | `total_ht_cents >= 0` | Zero is allowed (edge case during cart building) | -| `customer_order` | `total_vat_cents >= 0` | Same | -| `customer_order` | `total_ttc_cents > 0` | A validated order must have a positive total | -| `customer_order` | `total_ttc_cents = total_ht_cents + total_vat_cents` | Arithmetic invariant; defence-in-depth vs application bugs | -| `customer_order` | `source != 'drive' OR service_mode = 'drive'` | Cross-dimension constraint (dict. note 5) | -| `order_item` | `unit_price_cents_snapshot > 0` | Non-zero price at transaction time | -| `order_item` | `vat_rate_snapshot IN (55, 100)` | Snapshot must match allowed rates | -| `order_item` | `quantity > 0` | Non-zero quantity | -| `order_item` | `(item_type='product' AND product_id IS NOT NULL AND menu_id IS NULL) OR (item_type='menu' AND menu_id IS NOT NULL AND product_id IS NULL)` | Polymorphism: exactly one FK populated per discriminator value | -| `order_item_modifier` | `extra_price_cents >= 0` | Snapshot of surcharge; cannot be negative | +| `product` | `price_cents > 0` | Un prix nul ou negatif est un bug | +| `product` | `vat_rate IN (55, 100)` | Seuls deux taux de TVA legaux pour ce modele | +| `menu` | `price_normal_cents > 0` | Comme pour product | +| `menu` | `price_maxi_cents > 0` | Idem | +| `ingredient` | `stock_capacity > 0` | La reference 100% doit etre positive ; protege aussi la division du pourcentage contre la division par zero | +| `ingredient` | `pack_size > 0` | Une taille de lot nulle rend la logique de reapprovisionnement incoherente | +| `ingredient` | `low_stock_pct BETWEEN 0 AND 100` | La bande d’alerte est un pourcentage de la capacite | +| `ingredient` | `critical_stock_pct BETWEEN 0 AND 100` | Le plancher de rupture automatique est un pourcentage de la capacite | +| `ingredient` | `critical_stock_pct < low_stock_pct` | Le plancher critique se situe sous la bande d’alerte | +| `product_ingredient` | `quantity_normal > 0` | Une quantite de recette nulle n'a pas de sens | +| `product_ingredient` | `quantity_maxi >= quantity_normal` | Maxi consomme au moins autant que Normal (side/drink plus, burger/sauce egal) | +| `product_ingredient` | `extra_price_cents >= 0` | Pas de supplement negatif | +| `customer_order` | `total_ht_cents >= 0` | Zero est autorise (cas limite pendant la construction du panier) | +| `customer_order` | `total_vat_cents >= 0` | Idem | +| `customer_order` | `total_ttc_cents > 0` | Une commande validee doit avoir un total positif | +| `customer_order` | `total_ttc_cents = total_ht_cents + total_vat_cents` | Invariant arithmetique ; defense en profondeur contre les bugs applicatifs | +| `customer_order` | `source != 'drive' OR service_mode = 'drive'` | Contrainte inter-dimensions (note 5 du dict.) | +| `order_item` | `unit_price_cents_snapshot > 0` | Prix non nul au moment de la transaction | +| `order_item` | `vat_rate_snapshot IN (55, 100)` | Le snapshot doit correspondre aux taux autorises | +| `order_item` | `quantity > 0` | Quantite non nulle | +| `order_item` | `(item_type='product' AND product_id IS NOT NULL AND menu_id IS NULL) OR (item_type='menu' AND menu_id IS NOT NULL AND product_id IS NULL)` | Polymorphisme : exactement une FK renseignee par valeur de discriminateur | +| `order_item_modifier` | `extra_price_cents >= 0` | Snapshot du supplement ; ne peut pas etre negatif | --- -## 7. Recommended indexes (beyond PK / UK / FK auto-indexes) +## 7. Index recommandes (au-dela des auto-index PK / UK / FK) -MariaDB InnoDB creates an index automatically for each FK declaration (if no usable index -exists). The following additional indexes target frequent query patterns identified in the +MariaDB InnoDB cree automatiquement un index pour chaque declaration de FK (s'il n'existe pas d'index +utilisable). Les index supplementaires suivants ciblent les patterns de requete frequents identifies dans le MCT / MLT. -| Table | Index columns | Query pattern | +| Table | Colonnes d'index | Pattern de requete | |---|---|---| -| `product` | `(category_id, is_available, display_order)` | Kiosk catalogue load: filter by category + availability, sort by order | -| `menu` | `(category_id, is_available, display_order)` | Same pattern for menus | -| `menu_slot` | `(menu_id, display_order)` | Menu builder: load all slots of a menu in order | -| `customer_order` | `(status, created_at)` | Active orders queue: pending/paid orders sorted by time | -| `customer_order` | `(source, created_at)` | Per-channel analytics and order filtering | -| `customer_order` | `created_at` | Time-range aggregations (hourly stats, `service_day`) | -| `order_item` | `order_id` | Retrieve all lines of an order | -| `order_item_selection` | `order_item_id` | Retrieve slot choices for a menu line | -| `order_item_modifier` | `order_item_id` | Retrieve ingredient modifications for a line | -| `stock_movement` | `(ingredient_id, created_at)` | Per-ingredient stock history (dict. section 3.19) | -| `stock_movement` | `(movement_type, created_at)` | Stats: cancellations per week, restocks per month | -| `role_permission` | `permission_id` | Reverse query: "which roles have this permission?" | -| `user` | `(is_active, role_id)` | Login check + permission resolution | -| `audit_log` | `(actor_user_id, created_at)` | Per-actor audit history | -| `audit_log` | `(entity_type, entity_id)` | "what happened to this product/order/user?" | -| `audit_log` | `(action_code, created_at)` | Audit by action type over a time range | -| `login_throttle` | `lockout_until` | Daily cron purge of rows with no active lockout | +| `product` | `(category_id, is_available, display_order)` | Chargement du catalogue borne : filtre par categorie + disponibilite, tri par ordre | +| `menu` | `(category_id, is_available, display_order)` | Meme pattern pour les menus | +| `menu_slot` | `(menu_id, display_order)` | Constructeur de menu : charger tous les slots d'un menu dans l'ordre | +| `customer_order` | `(status, created_at)` | File des commandes actives : commandes pending/paid triees par temps | +| `customer_order` | `(source, created_at)` | Analytics par canal et filtrage des commandes | +| `customer_order` | `created_at` | Agregations par plage de temps (stats horaires, `service_day`) | +| `order_item` | `order_id` | Recuperer toutes les lignes d'une commande | +| `order_item_selection` | `order_item_id` | Recuperer les choix de slot d'une ligne de menu | +| `order_item_modifier` | `order_item_id` | Recuperer les modifications d'ingredient d'une ligne | +| `stock_movement` | `(ingredient_id, created_at)` | Historique de stock par ingredient (dict. section 3.19) | +| `stock_movement` | `(movement_type, created_at)` | Stats : annulations par semaine, reapprovisionnements par mois | +| `role_permission` | `permission_id` | Requete inverse : "quels roles ont cette permission ?" | +| `user` | `(is_active, role_id)` | Verification de connexion + resolution des permissions | +| `audit_log` | `(actor_user_id, created_at)` | Historique d'audit par acteur | +| `audit_log` | `(entity_type, entity_id)` | "qu'est-il arrive a ce produit/commande/utilisateur ?" | +| `audit_log` | `(action_code, created_at)` | Audit par type d'action sur une plage de temps | +| `login_throttle` | `lockout_until` | Purge cron quotidienne des lignes sans verrouillage actif | -**Indexes not added** (intentional): -- `customer_order.order_number`: UK index is sufficient; no range query expected on this column. -- `customer_order.service_mode`: low cardinality (3 values); full scan on the status index - with a `service_mode` filter is acceptable at expected volume. -- `customer_order.paid_at`: NULL for most in-flight rows; sparse index provides limited benefit. +**Index non ajoutes** (intentionnel) : +- `customer_order.order_number` : l'index UK suffit ; aucune requete de plage attendue sur cette colonne. +- `customer_order.service_mode` : faible cardinalite (3 valeurs) ; un scan complet sur l'index de status + avec un filtre `service_mode` est acceptable au volume attendu. +- `customer_order.paid_at` : NULL pour la plupart des lignes en cours ; un index clairseme apporte un benefice limite. --- -## 8. Cross-validation MLD <-> MCD +## 8. Validation croisee MLD <-> MCD -Verification that all 21 MCD entities (19 prod-like + 2 security-by-design) map to a table, -and that all tables trace to the MCD. +Verification que les 21 entites MCD (19 prod-like + 2 security-by-design) correspondent a une table, +et que toutes les tables se rattachent au MCD. -| MCD entity | MLD table | Mapping type | Notes | +| Entite MCD | Table MLD | Type de mapping | Notes | |---|---|---|---| -| `category` (C1) | `category` (4.1) | 1:1 entity | | -| `product` (C2) | `product` (4.2) | 1:1 entity | | -| `menu` (C3) | `menu` (4.3) | 1:1 entity | New: `burger_product_id`, `price_normal_cents`, `price_maxi_cents` | -| `menu_slot` (C4) | `menu_slot` (4.4) | 1:1 entity | New entity (v0.2) | -| `menu_slot_option` (C5) | `menu_slot_option` (4.5) | Join table (composite PK) | New entity (v0.2) | -| `ingredient` (C6) | `ingredient` (4.6) | 1:1 entity | New entity (v0.2) | -| `product_ingredient` (C7) | `product_ingredient` (4.7) | Join table with attributes | New entity (v0.2) | -| `allergen` (C8) | `allergen` (4.8) | 1:1 entity | New entity (v0.2) | -| `ingredient_allergen` (C9) | `ingredient_allergen` (4.9) | Join table (composite PK) | New entity (v0.2) | -| `role` (C10) | `role` (4.10) | 1:1 entity | New: `default_route`, `order_source` | -| `user` (C11) | `user` (4.11) | 1:1 entity | Columns renamed to English | -| `role_visible_source` (C12) | `role_visible_source` (4.12) | Join table (composite PK) | New entity (v0.2) | -| `permission` (C13) | `permission` (4.13) | 1:1 entity | | -| `role_permission` (C14) | `role_permission` (4.14) | Join table (composite PK) | | -| `customer_order` (C15) | `customer_order` (4.15) | 1:1 entity | Renamed from `commande`; 4-state machine; phase timestamps | -| `order_item` (C16) | `order_item` (4.16) | 1:1 entity | New: `format`, `vat_rate_snapshot`; polymorphism CHECK | -| `order_item_selection` (C17) | `order_item_selection` (4.17) | 1:1 entity | New entity (v0.2) | -| `order_item_modifier` (C18) | `order_item_modifier` (4.18) | 1:1 entity | New entity (v0.2) | -| `stock_movement` (C19) | `stock_movement` (4.19) | 1:1 entity | New entity (v0.2) | -| `audit_log` (R5/R6) | `audit_log` (4.20) | 1:1 entity | New entity (security-by-design) | -| `login_throttle` (R7) | `login_throttle` (4.21) | 1:1 entity | New entity (security-by-design) | +| `category` (C1) | `category` (4.1) | entite 1:1 | | +| `product` (C2) | `product` (4.2) | entite 1:1 | | +| `menu` (C3) | `menu` (4.3) | entite 1:1 | Nouveau : `burger_product_id`, `price_normal_cents`, `price_maxi_cents` | +| `menu_slot` (C4) | `menu_slot` (4.4) | entite 1:1 | Nouvelle entite (v0.2) | +| `menu_slot_option` (C5) | `menu_slot_option` (4.5) | Table de jointure (PK composite) | Nouvelle entite (v0.2) | +| `ingredient` (C6) | `ingredient` (4.6) | entite 1:1 | Nouvelle entite (v0.2) | +| `product_ingredient` (C7) | `product_ingredient` (4.7) | Table de jointure avec attributs | Nouvelle entite (v0.2) | +| `allergen` (C8) | `allergen` (4.8) | entite 1:1 | Nouvelle entite (v0.2) | +| `ingredient_allergen` (C9) | `ingredient_allergen` (4.9) | Table de jointure (PK composite) | Nouvelle entite (v0.2) | +| `role` (C10) | `role` (4.10) | entite 1:1 | Nouveau : `default_route`, `order_source` | +| `user` (C11) | `user` (4.11) | entite 1:1 | Colonnes renommees en anglais | +| `role_visible_source` (C12) | `role_visible_source` (4.12) | Table de jointure (PK composite) | Nouvelle entite (v0.2) | +| `permission` (C13) | `permission` (4.13) | entite 1:1 | | +| `role_permission` (C14) | `role_permission` (4.14) | Table de jointure (PK composite) | | +| `customer_order` (C15) | `customer_order` (4.15) | entite 1:1 | Renommee depuis `commande` ; machine a 4 etats ; horodatages de phase | +| `order_item` (C16) | `order_item` (4.16) | entite 1:1 | Nouveau : `format`, `vat_rate_snapshot` ; CHECK de polymorphisme | +| `order_item_selection` (C17) | `order_item_selection` (4.17) | entite 1:1 | Nouvelle entite (v0.2) | +| `order_item_modifier` (C18) | `order_item_modifier` (4.18) | entite 1:1 | Nouvelle entite (v0.2) | +| `stock_movement` (C19) | `stock_movement` (4.19) | entite 1:1 | Nouvelle entite (v0.2) | +| `audit_log` (R5/R6) | `audit_log` (4.20) | entite 1:1 | Nouvelle entite (security-by-design) | +| `login_throttle` (R7) | `login_throttle` (4.21) | entite 1:1 | Nouvelle entite (security-by-design) | -**Result**: 21/21 entities mapped (19 prod-like + `audit_log` + `login_throttle`). No entity -without a table; no table outside the MCD. New columns on existing tables: `user` -(auth-lifecycle + `pin_hash` + `anonymized_at`), `customer_order` (`idempotency_key`, -`acting_user_id`), `ingredient` (`stock_capacity`, `low_stock_pct`, `critical_stock_pct`; -`low_stock_threshold` repurposed). +**Resultat** : 21/21 entites mappees (19 prod-like + `audit_log` + `login_throttle`). Aucune entite +sans table ; aucune table hors du MCD. Nouvelles colonnes sur les tables existantes : `user` +(cycle de vie auth + `pin_hash` + `anonymized_at`), `customer_order` (`idempotency_key`, +`acting_user_id`), `ingredient` (`stock_capacity`, `low_stock_pct`, `critical_stock_pct` ; +`low_stock_threshold` reaffecte). -**Dropped from v0.1**: `commande_event` (replaced by `paid_at`, `delivered_at`, `cancelled_at` -phase timestamps on `customer_order` — decision 2.A); `menu_produit` fixed-composition model -(replaced by `menu_slot` + `menu_slot_option` — decision D1). +**Abandonne depuis v0.1** : `commande_event` (remplace par les horodatages de phase `paid_at`, `delivered_at`, `cancelled_at` +sur `customer_order` — decision 2.A) ; le modele de composition fixe `menu_produit` +(remplace par `menu_slot` + `menu_slot_option` — decision D1). --- -## 9. Volume estimation (6 months) +## 9. Estimation de volume (6 mois) -| Table | Rows at 6 months | Avg row size | Est. size | +| Table | Lignes a 6 mois | Taille moyenne de ligne | Taille est. | |---|---|---|---| -| `category` | ~10 | 200 bytes | < 1 KB | -| `product` | ~55 | 400 bytes | ~22 KB | -| `menu` | ~13 | 450 bytes | ~6 KB | -| `menu_slot` | ~40 | 150 bytes | ~6 KB | -| `menu_slot_option` | ~150 | 30 bytes | ~5 KB | -| `ingredient` | ~100 | 300 bytes | ~30 KB | -| `product_ingredient` | ~400 | 40 bytes | ~16 KB | -| `allergen` | 14 | 200 bytes | ~3 KB | -| `ingredient_allergen` | ~200 | 20 bytes | ~4 KB | -| `role` | ~5 | 200 bytes | ~1 KB | -| `user` | ~20 | 500 bytes | ~10 KB | -| `role_visible_source` | ~7 | 15 bytes | < 1 KB | -| `permission` | 23 | 250 bytes | ~6 KB | -| `role_permission` | ~80 | 15 bytes | ~2 KB | -| `customer_order` | ~30k | 300 bytes | ~9 MB | -| `order_item` | ~150k | 250 bytes | ~37 MB | -| `order_item_selection` | ~300k | 150 bytes | ~45 MB | -| `order_item_modifier` | ~150k | 80 bytes | ~12 MB | -| `stock_movement` | ~500k | 180 bytes | ~90 MB | -| `audit_log` | ~5k-10k | 200 bytes | ~2 MB | -| `login_throttle` | ~100-1k | 80 bytes | < 1 MB | +| `category` | ~10 | 200 octets | < 1 KB | +| `product` | ~55 | 400 octets | ~22 KB | +| `menu` | ~13 | 450 octets | ~6 KB | +| `menu_slot` | ~40 | 150 octets | ~6 KB | +| `menu_slot_option` | ~150 | 30 octets | ~5 KB | +| `ingredient` | ~100 | 300 octets | ~30 KB | +| `product_ingredient` | ~400 | 40 octets | ~16 KB | +| `allergen` | 14 | 200 octets | ~3 KB | +| `ingredient_allergen` | ~200 | 20 octets | ~4 KB | +| `role` | ~5 | 200 octets | ~1 KB | +| `user` | ~20 | 500 octets | ~10 KB | +| `role_visible_source` | ~7 | 15 octets | < 1 KB | +| `permission` | 23 | 250 octets | ~6 KB | +| `role_permission` | ~80 | 15 octets | ~2 KB | +| `customer_order` | ~30k | 300 octets | ~9 MB | +| `order_item` | ~150k | 250 octets | ~37 MB | +| `order_item_selection` | ~300k | 150 octets | ~45 MB | +| `order_item_modifier` | ~150k | 80 octets | ~12 MB | +| `stock_movement` | ~500k | 180 octets | ~90 MB | +| `audit_log` | ~5k-10k | 200 octets | ~2 MB | +| `login_throttle` | ~100-1k | 80 octets | < 1 MB | -**Estimated total**: ~190 MB data + ~60-80 MB for indexes = ~250-270 MB over 6 months -(`audit_log` is negligible: sensitive actions are orders of magnitude rarer than orders). -Manageable on the MariaDB container (`wakdo_db_data` named volume in `docker-compose.yml`). +**Total estime** : ~190 MB de donnees + ~60-80 MB pour les index = ~250-270 MB sur 6 mois +(`audit_log` est negligeable : les actions sensibles sont d'un ordre de grandeur plus rares que les commandes). +Gerable sur le conteneur MariaDB (volume nomme `wakdo_db_data` dans `docker-compose.yml`). -`stock_movement` is the highest-volume table (~5-15 rows per order across all ingredients). -The `(ingredient_id, created_at)` index is the primary query path for per-ingredient -history; it will carry meaningful write amplification at scale. +`stock_movement` est la table au plus fort volume (~5-15 lignes par commande tous ingredients confondus). +L'index `(ingredient_id, created_at)` est le chemin de requete principal pour l'historique par +ingredient ; il portera une amplification d'ecriture significative a l'echelle. --- -## 10. Decisions deferred to DDL and P2 +## 10. Decisions reportees au DDL et a P2 -1. **MariaDB generated column** for `service_day`: a `VIRTUAL GENERATED` column is technically - possible in MariaDB 5.7+ syntax. If stats queries prove burdensome without a materialised - column, a `STORED GENERATED` column could be added as a migration. For this model, the - applicative CASE expression is retained (simpler, avoids generated-column edge cases). -2. **Partitioning**: `stock_movement` could be partitioned by month if volume exceeds - estimates. Not in scope for the initial DDL. -3. **Triggers**: stock decrement on `paid` transition and re-credit on `cancelled` (from `paid`) - could be implemented as MariaDB triggers or as application-layer logic. To be decided at P2. -4. **Collation**: `utf8mb4_unicode_ci` retained (Unicode-compliant, case-insensitive). - If strict French alphabetical sort is needed, `utf8mb4_fr_0900_ai_ci` is available in - MySQL 8 but not MariaDB; `unicode_ci` is the portable choice. -5. **Migration tooling**: Phinx, Doctrine Migrations, or a plain PHP script. Decision at P2. -6. **`order_item_id` constraint for selections**: the business rule that - `order_item_selection.order_item_id` must reference a line with `item_type='menu'` - is enforced at application layer. A MariaDB trigger could reinforce this at DB level if - needed. +1. **Colonne generee MariaDB** pour `service_day` : une colonne `VIRTUAL GENERATED` est techniquement + possible avec la syntaxe MariaDB 5.7+. Si les requetes de stats s'averent lourdes sans colonne + materialisee, une colonne `STORED GENERATED` pourrait etre ajoutee en migration. Pour ce modele, + l'expression CASE applicative est retenue (plus simple, evite les cas limites des colonnes generees). +2. **Partitionnement** : `stock_movement` pourrait etre partitionnee par mois si le volume depasse les + estimations. Hors perimetre pour le DDL initial. +3. **Triggers** : decrement de stock a la transition `paid` et re-credit a `cancelled` (depuis `paid`) + pourraient etre implementes en triggers MariaDB ou en logique applicative. A decider en P2. +4. **Collation** : `utf8mb4_unicode_ci` retenue (conforme Unicode, insensible a la casse). + Si un tri alphabetique francais strict est necessaire, `utf8mb4_fr_0900_ai_ci` est disponible dans + MySQL 8 mais pas MariaDB ; `unicode_ci` est le choix portable. +5. **Outillage de migration** : Phinx, Doctrine Migrations, ou un simple script PHP. Decision en P2. +6. **Contrainte `order_item_id` pour les selections** : la regle metier voulant que + `order_item_selection.order_item_id` reference une ligne avec `item_type='menu'` + est imposee au niveau applicatif. Un trigger MariaDB pourrait renforcer cela au niveau de la BD si + necessaire. --- -## 11. Next steps (DDL + Seed) +## 11. Prochaines etapes (DDL + Seed) -1. **DDL** (`db/migrations/0001_init_schema.sql`): transcribe this MLD into executable - `CREATE TABLE` statements, in dependency order: +1. **DDL** (`db/migrations/0001_init_schema.sql`) : transcrire ce MLD en instructions + `CREATE TABLE` executables, dans l'ordre de dependance : - `category` -> `product`, `ingredient`, `allergen`, `role` - - `menu` (depends on `category`, `product`) - - `menu_slot` (depends on `menu`), `menu_slot_option` (depends on `menu_slot`, `product`) - - `product_ingredient` (depends on `product`, `ingredient`) - - `ingredient_allergen` (depends on `ingredient`, `allergen`) - - `user` (depends on `role`), `role_visible_source` (depends on `role`) - - `permission`, `role_permission` (depends on `role`, `permission`) + - `menu` (depend de `category`, `product`) + - `menu_slot` (depend de `menu`), `menu_slot_option` (depend de `menu_slot`, `product`) + - `product_ingredient` (depend de `product`, `ingredient`) + - `ingredient_allergen` (depend de `ingredient`, `allergen`) + - `user` (depend de `role`), `role_visible_source` (depend de `role`) + - `permission`, `role_permission` (depend de `role`, `permission`) - `customer_order` - - `order_item` (depends on `customer_order`, `product`, `menu`) - - `order_item_selection` (depends on `order_item`, `menu_slot`, `product`) - - `order_item_modifier` (depends on `order_item`, `ingredient`) - - `stock_movement` (depends on `ingredient`, `customer_order`, `user`) - - `audit_log` (depends on `user`, `role`) - - `login_throttle` (no FK, can be created at any point) + - `order_item` (depend de `customer_order`, `product`, `menu`) + - `order_item_selection` (depend de `order_item`, `menu_slot`, `product`) + - `order_item_modifier` (depend de `order_item`, `ingredient`) + - `stock_movement` (depend de `ingredient`, `customer_order`, `user`) + - `audit_log` (depend de `user`, `role`) + - `login_throttle` (pas de FK, peut etre cree a n'importe quel moment) - Note: `customer_order` now carries `acting_user_id -> user`, so `user` must be created - before `customer_order` (already the case: the RBAC block precedes `customer_order`). + Note : `customer_order` porte desormais `acting_user_id -> user`, donc `user` doit etre cree + avant `customer_order` (deja le cas : le bloc RBAC precede `customer_order`). -2. **Seed** (`db/seeds/0001_demo_data.sql`): - - 9 categories + 53 products + 13 menus from JSON sources (`docs/merise/_sources/`) - - 13 menus with slots and slot options - - 14 allergens (INCO EU 1169/2011) - - Sample ingredient catalogue with recipes - - 5 roles with `role_permission` matrix and `role_visible_source` data - - 1 admin user - - Sample orders for demo +2. **Seed** (`db/seeds/0001_demo_data.sql`) : + - 9 categories + 53 produits + 13 menus depuis les sources JSON (`docs/merise/_sources/`) + - 13 menus avec slots et options de slot + - 14 allergenes (INCO UE 1169/2011) + - Catalogue d'ingredients exemple avec recettes + - 5 roles avec matrice `role_permission` et donnees `role_visible_source` + - 1 utilisateur admin + - Commandes exemple pour la demo -3. **Fallback JSON export** (`scripts/export-fallback.{sh|php}`): extract seed data to - `src/public/borne/data/*.json` for isolated kiosk mode (Bloc 1 without DB). +3. **Export JSON de fallback** (`scripts/export-fallback.{sh|php}`) : extraire les donnees de seed vers + `src/public/borne/data/*.json` pour le mode borne isole (Bloc 1 sans BD). -4. **DDL validation tests**: confirm CHECK constraints trigger as expected; confirm - ON DELETE CASCADE / RESTRICT / SET NULL behaviours match specification. +4. **Tests de validation DDL** : confirmer que les contraintes CHECK se declenchent comme attendu ; confirmer + que les comportements ON DELETE CASCADE / RESTRICT / SET NULL correspondent a la specification. diff --git a/docs/merise/mlt.md b/docs/merise/mlt.md index 60954c4..db30820 100644 --- a/docs/merise/mlt.md +++ b/docs/merise/mlt.md @@ -1,715 +1,715 @@ -# Model of Logical Treatments (MLT) — Wakdo +# Modele Logique des Traitements (MLT) — Wakdo -**Merise phase** : P1 - Conception, step 4 (derived from MCT) -**Version** : v0.2 — prod-like, 4-state machine (+ security-by-design layer 2026-06-11) -**Date** : 2026-06-04 (security-by-design additions 2026-06-11) -**Branch** : `feat/p1-conception` -**Status** : prod-like — all D1-D8 + stock decisions applied (see `docs/notes/revue-alignement-p1.md` §7); security-by-design rules added (RG-T13-T21: PIN, audit, escaping, allowlists, idempotency, atomic decrement, computed product availability (RG-T21); ops RESET_PASSWORD, ERASE_USER_PII, auth throttling; per-IP throttle table `login_throttle`) -**Author** : BYAN (methodology layer) +**Phase Merise** : P1 - Conception, etape 4 (derivee du MCT) +**Version** : v0.2 — prod-like, machine a 4 etats (+ couche security-by-design 2026-06-11) +**Date** : 2026-06-04 (ajouts security-by-design 2026-06-11) +**Branche** : `feat/p1-conception` +**Statut** : prod-like — toutes les decisions D1-D8 + stock appliquees (voir `docs/notes/revue-alignement-p1.md` §7) ; regles security-by-design ajoutees (RG-T13-T21 : PIN, audit, escaping, allowlists, idempotence, decrement atomique, disponibilite produit calculee (RG-T21) ; ops RESET_PASSWORD, ERASE_USER_PII, throttling d'authentification ; table de throttle par IP `login_throttle`) +**Auteur** : BYAN (couche methodologie) --- -## 1. Purpose +## 1. Objectif -The MLT (Model of Logical Treatments) refines each MCT operation by specifying: -- **preconditions** — what must be true before execution -- **business rules** — validation, computation, business logic -- **postconditions** — the state guaranteed after success -- **outputs** — produced data or emitted events -- **error cases** — alternative outputs when a condition fails +Le MLT (Modele Logique des Traitements) affine chaque operation du MCT en specifiant : +- **preconditions** — ce qui doit etre vrai avant l'execution +- **regles de gestion** — validation, calcul, logique metier +- **postconditions** — l'etat garanti apres succes +- **sorties** — donnees produites ou evenements emis +- **cas d'erreur** — sorties alternatives lorsqu'une condition echoue -It bridges the MCT (conceptual level) and the PHP/SQL implementation (physical level). -All entity/attribute references use the names from `docs/merise/dictionary.md` (English, -snake_case). All monetary amounts are in integer cents. +Il fait le lien entre le MCT (niveau conceptuel) et l'implementation PHP/SQL (niveau physique). +Toutes les references aux entites/attributs utilisent les noms de `docs/merise/dictionary.md` (anglais, +snake_case). Tous les montants monetaires sont en centimes entiers. -**Tag conventions**: -- `[PRE]` — precondition; must be satisfied for the operation to execute -- `[RG]` — business rule (regle de gestion); logic applied during execution -- `[POST]` — postcondition; database state guaranteed after success -- `[OUT]` — output; data or event produced -- `[ERR]` — error case; alternative output when a condition fails +**Conventions de tags** : +- `[PRE]` — precondition ; doit etre satisfaite pour que l'operation s'execute +- `[RG]` — regle de gestion ; logique appliquee pendant l'execution +- `[POST]` — postcondition ; etat de la base garanti apres succes +- `[OUT]` — sortie ; donnee ou evenement produit +- `[ERR]` — cas d'erreur ; sortie alternative lorsqu'une condition echoue --- -## 2. Transverse business rules +## 2. Regles de gestion transverses -These rules apply to multiple operations and are centralised here to avoid repetition. +Ces regles s'appliquent a plusieurs operations et sont centralisees ici pour eviter la repetition. -| Rule code | Label | Operations concerned | +| Code de regle | Libelle | Operations concernees | |-----------|-------|----------------------| -| **RG-T01** | CSRF token verified on every back-office POST/PUT/DELETE form | AUTH, all admin ops | -| **RG-T02** | Session active + `user.is_active = 1` verified on each authenticated request | All domains 3-10 | -| **RG-T03** | Permission verified via `role_permission` before executing operation | All domains 3-10 | -| **RG-T04** | All monetary amounts are manipulated in integer cents; EUR conversion at output only | 3.3, 4.1, 8.1, 8.4 | -| **RG-T05** | Snapshots (`label_snapshot`, `unit_price_cents_snapshot`, `vat_rate_snapshot`) on `order_item` are not modified after INSERT (historical integrity of placed orders — design guarantee) | 3.3, 4.1, 8.2, 8.5 | -| **RG-T06** | All SQL queries use PDO with prepared statements; no user data concatenated into SQL | All operations | -| **RG-T07** | Status transition UPDATE statements include `AND status = ` in the WHERE clause (optimistic concurrency protection against double transition) | 6.1, 7.1 | -| **RG-T08** | Operations touching multiple tables execute in an atomic database transaction; partial failure triggers full rollback | 3.3, 4.1, 7.1, 8.4, 9.1, 9.2 | -| **RG-T09** | Cross-constraint on `customer_order`: `source = 'drive'` implies `service_mode = 'drive'`; verified at order creation. Materialisable as a MariaDB CHECK: `CHECK (source != 'drive' OR service_mode = 'drive')`. | 3.3, 4.1 | -| **RG-T10** | VAT computation is line-by-line: each `order_item` carries its own `vat_rate_snapshot` (per-mille integer snapshotted from `product.vat_rate`). Order totals (`total_ht_cents`, `total_vat_cents`, `total_ttc_cents`) are the sum of line-level amounts. | 3.3, 4.1 | -| **RG-T11** | Stock decrements at the `pending_payment -> paid` transition and re-credits at `paid -> cancelled` are within the same database transaction as the status update (no orphan decrement). | 3.3, 4.1, 7.1 | -| **RG-T12** | Dashboard filter by source: each role's visible sources are read from `role_visible_source`; the query uses `WHERE customer_order.source IN (role_visible_sources)`. | 6.1 | -| **RG-T13** | **Sensitive-action PIN** (security-by-design): the set of sensitive operations requires a per-staff PIN re-authorisation before execution: verify the submitted PIN against `user.pin_hash` (`password_verify`, argon2id). On success the acting `user_id` is captured for the audit log; on failure the operation is rejected. Sensitive set: 7.1 (cancel), 8.2/8.3 (product update/delete), 8.6 (menu delete), 9.2 (inventory correction), 10.1/10.2/10.3 (user mgmt), 10.4 (RBAC), 10.5 (PII erasure). Sessions stay shared per workstation for the routine 95%. | 7.1, 8.2, 8.3, 8.6, 9.2, 10.1-10.5 | -| **RG-T14** | **Audit log write**: non-stock sensitive operations append one immutable `audit_log` row in the same transaction as their effect: `actor_user_id` (from RG-T13 PIN), `actor_role_id`, `action_code` (permission/operation code), `entity_type` + `entity_id` of the affected row, `summary` (non-personal change description), `details` JSON (changed field **names** for user-targeted actions, not PII values). No UPDATE/DELETE on `audit_log`. Stock actions (9.1 restock, 9.2 inventory) record their attribution via `stock_movement.user_id` (PIN-captured), which already provides the append-only stock trail — they are not double-logged. | 7.1, 8.2, 8.3, 8.6, 10.1-10.5, 12.1 | -| **RG-T15** | **Output escaping** (anti-XSS): free-text fields (`product.name`/`description`, `ingredient.name`, `user.first_name`/`last_name`, notes) are context-escaped at render. Server-rendered admin views use `htmlspecialchars($v, ENT_QUOTES, 'UTF-8')`; the vanilla-JS kiosk injects text via `textContent` (or an explicit escaper), not `innerHTML`. | All views rendering stored text | -| **RG-T16** | **Mass-assignment allowlist**: INSERT/UPDATE statements bind only an explicit per-operation column allowlist from the request; extra/unknown fields are dropped. Prevents tampering with `price_cents`, `vat_rate`, `role_id`, `is_active`, `status` via injected form fields. | 8.1, 8.2, 8.4, 8.5, 10.1, 10.2 | -| **RG-T17** | **Dynamic identifier allowlist**: column/direction tokens used in dynamic `ORDER BY` / `GROUP BY` are resolved against a fixed allowlist of column names before query build (RG-T06 covers values via bind parameters; SQL identifiers cannot be bound, so they are allowlisted). | 5.1, 9.3, 11.1 | -| **RG-T18** | **Server-side validation and length bounds**: every input is re-validated server-side regardless of client checks — type, range, max length (matching the dictionary VARCHAR sizes), enum membership, FK existence. Client-side validation is a UX aid, not a trust boundary. | All write operations | -| **RG-T19** | **Idempotency**: `POST /api/orders` carries a client-generated `idempotency_key` (UUID). Before creating, look it up on `customer_order.idempotency_key` (UNIQUE); if a row exists, return that order instead of creating a duplicate (replayed network retry). | 3.3, 4.1 | -| **RG-T20** | **Atomic stock decrement**: during the `paid` transition, each affected `ingredient` is decremented with a single self-locking statement `UPDATE ingredient SET stock_quantity = stock_quantity - :units WHERE id = :id` — no preceding read-gate, no `SELECT ... FOR UPDATE`. Concurrent orders on the same ingredient apply their deltas without a lost update and without a deadlock-ordering concern. `stock_quantity` is signed and may go negative when sales outrun counted stock (oversell magnitude surfaced to managers); the decrement does not block on a floor. | 3.3, 4.1 | -| **RG-T21** | **Computed product availability**: a product's effective orderability is computed, not stored. It is orderable when `product.is_available = 1` AND each non-removable (`is_removable = 0`) ingredient in its `product_ingredient` has `stock_quantity > stock_capacity * critical_stock_pct / 100`. At the critical band a required ingredient takes the product out-of-stock with no write and no cascade; restock above the critical band makes it orderable again on its own; a manual pull (`product.is_available = 0`) is a hard override; a removable/optional ingredient at the critical band does not block the product (only its add-on becomes unavailable). | 3.1, 3.3, 4.1, 5.1 | +| **RG-T01** | Token CSRF verifie sur chaque formulaire POST/PUT/DELETE du back-office | AUTH, toutes ops admin | +| **RG-T02** | Session active + `user.is_active = 1` verifies a chaque requete authentifiee | Tous domaines 3-10 | +| **RG-T03** | Permission verifiee via `role_permission` avant l'execution de l'operation | Tous domaines 3-10 | +| **RG-T04** | Tous les montants monetaires sont manipules en centimes entiers ; conversion EUR uniquement en sortie | 3.3, 4.1, 8.1, 8.4 | +| **RG-T05** | Les snapshots (`label_snapshot`, `unit_price_cents_snapshot`, `vat_rate_snapshot`) sur `order_item` ne sont pas modifies apres l'INSERT (integrite historique des commandes passees — garantie de conception) | 3.3, 4.1, 8.2, 8.5 | +| **RG-T06** | Toutes les requetes SQL utilisent PDO avec des requetes preparees ; aucune donnee utilisateur concatenee dans le SQL | Toutes operations | +| **RG-T07** | Les instructions UPDATE de transition d'etat incluent `AND status = ` dans la clause WHERE (protection de concurrence optimiste contre la double transition) | 6.1, 7.1 | +| **RG-T08** | Les operations touchant plusieurs tables s'executent dans une transaction de base de donnees atomique ; un echec partiel declenche un rollback complet | 3.3, 4.1, 7.1, 8.4, 9.1, 9.2 | +| **RG-T09** | Contrainte croisee sur `customer_order` : `source = 'drive'` implique `service_mode = 'drive'` ; verifiee a la creation de la commande. Materialisable en CHECK MariaDB : `CHECK (source != 'drive' OR service_mode = 'drive')`. | 3.3, 4.1 | +| **RG-T10** | Le calcul de TVA se fait ligne par ligne : chaque `order_item` porte son propre `vat_rate_snapshot` (entier pour-mille snapshote depuis `product.vat_rate`). Les totaux de commande (`total_ht_cents`, `total_vat_cents`, `total_ttc_cents`) sont la somme des montants au niveau des lignes. | 3.3, 4.1 | +| **RG-T11** | Le decrement de stock a la transition `pending_payment -> paid` et le re-credit a `paid -> cancelled` sont dans la meme transaction de base de donnees que la mise a jour du statut (pas de decrement orphelin). | 3.3, 4.1, 7.1 | +| **RG-T12** | Filtre du tableau de bord par source : les sources visibles de chaque role sont lues depuis `role_visible_source` ; la requete utilise `WHERE customer_order.source IN (role_visible_sources)`. | 6.1 | +| **RG-T13** | **PIN d'action sensible** (security-by-design) : l'ensemble des operations sensibles requiert une re-autorisation par PIN propre a chaque membre du personnel avant l'execution : verifier le PIN soumis contre `user.pin_hash` (`password_verify`, argon2id). En cas de succes, le `user_id` agissant est capture pour le journal d'audit ; en cas d'echec, l'operation est rejetee. Ensemble sensible : 7.1 (annulation), 8.2/8.3 (mise a jour/suppression produit), 8.6 (suppression menu), 9.2 (correction d'inventaire), 10.1/10.2/10.3 (gestion utilisateur), 10.4 (RBAC), 10.5 (effacement PII). Les sessions restent partagees par poste de travail pour les 95% de routine. | 7.1, 8.2, 8.3, 8.6, 9.2, 10.1-10.5 | +| **RG-T14** | **Ecriture du journal d'audit** : les operations sensibles hors stock ajoutent une ligne `audit_log` immuable dans la meme transaction que leur effet : `actor_user_id` (issu du PIN RG-T13), `actor_role_id`, `action_code` (code de permission/operation), `entity_type` + `entity_id` de la ligne affectee, `summary` (description de changement non personnelle), `details` JSON (**noms** des champs modifies pour les actions ciblant un utilisateur, pas les valeurs PII). Aucun UPDATE/DELETE sur `audit_log`. Les actions de stock (9.1 restock, 9.2 inventaire) enregistrent leur attribution via `stock_movement.user_id` (capture par PIN), qui fournit deja la trace de stock append-only — elles ne sont pas doublement journalisees. | 7.1, 8.2, 8.3, 8.6, 10.1-10.5, 12.1 | +| **RG-T15** | **Echappement en sortie** (anti-XSS) : les champs de texte libre (`product.name`/`description`, `ingredient.name`, `user.first_name`/`last_name`, notes) sont echappes selon le contexte au rendu. Les vues admin rendues cote serveur utilisent `htmlspecialchars($v, ENT_QUOTES, 'UTF-8')` ; le kiosk en vanilla-JS injecte le texte via `textContent` (ou un echappeur explicite), pas `innerHTML`. | Toutes les vues rendant du texte stocke | +| **RG-T16** | **Allowlist d'affectation de masse** : les instructions INSERT/UPDATE ne lient qu'une allowlist de colonnes explicite par operation issue de la requete ; les champs supplementaires/inconnus sont ecartes. Empeche l'alteration de `price_cents`, `vat_rate`, `role_id`, `is_active`, `status` via des champs de formulaire injectes. | 8.1, 8.2, 8.4, 8.5, 10.1, 10.2 | +| **RG-T17** | **Allowlist d'identifiants dynamiques** : les tokens de colonne/direction utilises dans un `ORDER BY` / `GROUP BY` dynamique sont resolus contre une allowlist fixe de noms de colonnes avant la construction de la requete (RG-T06 couvre les valeurs via les parametres lies ; les identifiants SQL ne peuvent pas etre lies, ils sont donc en allowlist). | 5.1, 9.3, 11.1 | +| **RG-T18** | **Validation cote serveur et bornes de longueur** : chaque entree est re-validee cote serveur independamment des verifications cote client — type, plage, longueur max (correspondant aux tailles VARCHAR du dictionnaire), appartenance a l'enum, existence de FK. La validation cote client est une aide UX, pas une frontiere de confiance. | Toutes operations d'ecriture | +| **RG-T19** | **Idempotence** : `POST /api/orders` porte un `idempotency_key` (UUID) genere par le client. Avant de creer, le rechercher sur `customer_order.idempotency_key` (UNIQUE) ; si une ligne existe, retourner cette commande au lieu de creer un doublon (retry reseau rejoue). | 3.3, 4.1 | +| **RG-T20** | **Decrement de stock atomique** : pendant la transition `paid`, chaque `ingredient` affecte est decremente par une unique instruction auto-verrouillante `UPDATE ingredient SET stock_quantity = stock_quantity - :units WHERE id = :id` — pas de lecture-gate prealable, pas de `SELECT ... FOR UPDATE`. Les commandes concurrentes sur le meme ingredient appliquent leurs deltas sans perte de mise a jour et sans souci d'ordonnancement de deadlock. `stock_quantity` est signe et peut devenir negatif quand les ventes depassent le stock compte (l'ampleur de la survente est remontee aux managers) ; le decrement ne bloque pas sur un plancher. | 3.3, 4.1 | +| **RG-T21** | **Disponibilite produit calculee** : la commandabilite effective d'un produit est calculee, pas stockee. Il est commandable lorsque `product.is_available = 1` ET que chaque ingredient non retirable (`is_removable = 0`) de son `product_ingredient` a `stock_quantity > stock_capacity * critical_stock_pct / 100`. A la bande critique, un ingredient requis met le produit en rupture sans ecriture et sans cascade ; un reapprovisionnement au-dessus de la bande critique le rend commandable a nouveau de lui-meme ; un retrait manuel (`product.is_available = 0`) est une surcharge forte ; un ingredient retirable/optionnel a la bande critique ne bloque pas le produit (seul son supplement devient indisponible). | 3.1, 3.3, 4.1, 5.1 | --- -## 3. Domain 1 — Order lifecycle (kiosk) +## 3. Domaine 1 — Cycle de vie de la commande (kiosk) ### 3.1 LOAD_CATALOGUE -**Corresponds to MCT section 3.1** +**Correspond a la section 3.1 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[PRE-1]** | Request originates from the kiosk endpoint (public, no authentication required) | -| **[PRE-2]** | Current time is within the service window (10:00-01:00); outside the window the kiosk displays a closed message | -| **[RG-1]** | Read all `category` rows with `is_active = 1`, ordered by `category.display_order ASC` | -| **[RG-2]** | For each category, read `product` rows with `is_available = 1` and matching `category_id`, ordered by `product.display_order ASC` | -| **[RG-3]** | Read all `menu` rows with `is_available = 1`; for each menu, load `menu_slot` rows ordered by `menu_slot.display_order ASC`; for each slot, load eligible products via `menu_slot_option JOIN product` (where `product.is_available = 1`) | -| **[RG-4]** | For each product, compute allergens by joining `product_ingredient -> ingredient_allergen -> allergen` (no manual re-entry per product) | -| **[RG-5]** | For each product with `product_ingredient` rows, load `ingredient` composition (for the configurator) | -| **[RG-6]** | Prices are returned in integer cents; EUR conversion is performed client-side | -| **[POST-1]** | No database write; database state unchanged | -| **[OUT-1]** | JSON response: `{data: {categories: [...], products: {...}, menus: [{..., slots: [{..., options: [...]}]}]}}` | -| **[ERR-1]** | DB unreachable: response `{data: null, error: {code: "DB_ERROR"}}` and front-end falls back to static JSON | +| **[PRE-1]** | La requete provient de l'endpoint kiosk (public, aucune authentification requise) | +| **[PRE-2]** | L'heure courante est dans la fenetre de service (10:00-01:00) ; en dehors de la fenetre le kiosk affiche un message de fermeture | +| **[RG-1]** | Lire toutes les lignes `category` avec `is_active = 1`, triees par `category.display_order ASC` | +| **[RG-2]** | Pour chaque categorie, lire les lignes `product` avec `is_available = 1` et `category_id` correspondant, triees par `product.display_order ASC` | +| **[RG-3]** | Lire toutes les lignes `menu` avec `is_available = 1` ; pour chaque menu, charger les lignes `menu_slot` triees par `menu_slot.display_order ASC` ; pour chaque slot, charger les produits eligibles via `menu_slot_option JOIN product` (ou `product.is_available = 1`) | +| **[RG-4]** | Pour chaque produit, calculer les allergenes en joignant `product_ingredient -> ingredient_allergen -> allergen` (pas de ressaisie manuelle par produit) | +| **[RG-5]** | Pour chaque produit avec des lignes `product_ingredient`, charger la composition `ingredient` (pour le configurateur) | +| **[RG-6]** | Les prix sont retournes en centimes entiers ; la conversion EUR est effectuee cote client | +| **[POST-1]** | Aucune ecriture en base ; etat de la base inchange | +| **[OUT-1]** | Reponse JSON : `{data: {categories: [...], products: {...}, menus: [{..., slots: [{..., options: [...]}]}]}}` | +| **[ERR-1]** | Base inaccessible : reponse `{data: null, error: {code: "DB_ERROR"}}` et le front-end bascule sur un JSON statique | --- ### 3.2 COMPOSE_CART -**Corresponds to MCT section 3.2** +**Correspond a la section 3.2 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[PRE-1]** | Catalogue loaded into front-end memory (LOAD_CATALOGUE completed) | -| **[PRE-2]** | Selected item (product or menu) is present in the loaded catalogue with `is_available = 1` | -| **[RG-1]** | Cart is a JavaScript in-memory structure (array of items); no database persistence at this stage | -| **[RG-2]** | Each item contains: `type` (`product` or `menu`), `item_id`, `label`, `unit_price_cents` (snapshot from catalogue), `quantity`, `format` (`normal` or `maxi`, for menus), `slot_selections` (array of `{menu_slot_id, product_id, label}` for menu items), `modifiers` (array of `{ingredient_id, action, extra_price_cents}`) | -| **[RG-3]** | Format Normal/Maxi (menu items only): `normal` uses `menu.price_normal_cents`; `maxi` uses `menu.price_maxi_cents`. No individual component price change is stored; the price differential is at menu level. | -| **[RG-4]** | Ingredient modifier rules: `action = 'remove'` requires `is_removable = 1` on `product_ingredient` (free); `action = 'add'` requires `is_addable = 1` (may carry `extra_price_cents`). These constraints are verified at cart composition time against the loaded catalogue. | -| **[RG-5]** | If an item with the same `(type, item_id, format, slot_selections, modifiers)` already exists in the cart, its quantity is incremented rather than adding a new item | -| **[RG-6]** | Cart total recomputed after each change: `SUM(unit_price_cents * quantity + modifier_extras)` across all items | -| **[POST-1]** | No database write; cart in-memory state updated | -| **[OUT-1]** | Cart summary displayed with TTC total | -| **[ERR-1]** | If a product becomes `is_available = 0` between catalogue load and order submission, the server-side validation in CREATE_ORDER catches it | +| **[PRE-1]** | Catalogue charge en memoire front-end (LOAD_CATALOGUE termine) | +| **[PRE-2]** | L'article selectionne (produit ou menu) est present dans le catalogue charge avec `is_available = 1` | +| **[RG-1]** | Le panier est une structure JavaScript en memoire (tableau d'articles) ; aucune persistance en base a ce stade | +| **[RG-2]** | Chaque article contient : `type` (`product` ou `menu`), `item_id`, `label`, `unit_price_cents` (snapshot depuis le catalogue), `quantity`, `format` (`normal` ou `maxi`, pour les menus), `slot_selections` (tableau de `{menu_slot_id, product_id, label}` pour les articles menu), `modifiers` (tableau de `{ingredient_id, action, extra_price_cents}`) | +| **[RG-3]** | Format Normal/Maxi (articles menu uniquement) : `normal` utilise `menu.price_normal_cents` ; `maxi` utilise `menu.price_maxi_cents`. Aucun changement de prix de composant individuel n'est stocke ; le differentiel de prix est au niveau du menu. | +| **[RG-4]** | Regles de modificateur d'ingredient : `action = 'remove'` requiert `is_removable = 1` sur `product_ingredient` (gratuit) ; `action = 'add'` requiert `is_addable = 1` (peut porter un `extra_price_cents`). Ces contraintes sont verifiees au moment de la composition du panier contre le catalogue charge. | +| **[RG-5]** | Si un article avec les memes `(type, item_id, format, slot_selections, modifiers)` existe deja dans le panier, sa quantite est incrementee plutot que d'ajouter un nouvel article | +| **[RG-6]** | Total du panier recalcule apres chaque changement : `SUM(unit_price_cents * quantity + modifier_extras)` sur tous les articles | +| **[POST-1]** | Aucune ecriture en base ; etat en memoire du panier mis a jour | +| **[OUT-1]** | Recapitulatif du panier affiche avec total TTC | +| **[ERR-1]** | Si un produit passe a `is_available = 0` entre le chargement du catalogue et la soumission de la commande, la validation cote serveur dans CREATE_ORDER le detecte | --- ### 3.3 CREATE_ORDER -**Corresponds to MCT section 3.3** +**Correspond a la section 3.3 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[PRE-1]** | Cart contains at least 1 item (`items.length >= 1`) | -| **[PRE-2]** | Order number entered by customer is non-empty (front-end validation) | -| **[PRE-3]** | POST JSON body is valid (schema validation at API layer) | -| **[RG-1]** | Server-side availability check: for each item, verify `product.is_available = 1` or `menu.is_available = 1`. If any item is unavailable, reject with list of unavailable articles. | -| **[RG-2 — service_day]** | `service_day` for a given order is computed at query time as: `CASE WHEN HOUR(created_at) < 10 THEN DATE(created_at) - INTERVAL 1 DAY ELSE DATE(created_at) END`. Cutoff is 10:00. This is NOT stored as a column — computed at query time only. The v0.1 formula with `INTERVAL 4 HOUR 30 MINUTE` was incorrect and is dropped. | -| **[RG-3 — order number]** | Order number format: `K-YYYY-MM-DD-NNN` where NNN is the sequential counter for the current service_day for the `kiosk` source (SELECT COUNT + 1 with a table-level lock or serialised insert to avoid duplicate generation under concurrency). Source is `kiosk` (set by the kiosk endpoint, derived from the public entry point). | -| **[RG-4 — VAT by line]** | For each `order_item`: `vat_rate_snapshot` is copied from `product.vat_rate`. Line amounts: `unit_ttc = unit_price_cents_snapshot`; `unit_ht = ROUND(unit_ttc * 1000 / (1000 + vat_rate_snapshot))`; `unit_vat = unit_ttc - unit_ht`. Order totals: `total_ttc_cents = SUM(unit_ttc * quantity)` across all lines; `total_ht_cents = SUM(unit_ht * quantity)`; `total_vat_cents = total_ttc_cents - total_ht_cents`. Invariant: `total_ttc_cents = total_ht_cents + total_vat_cents` (verified before INSERT). | -| **[RG-5 — atomic transaction]** | All writes within one database transaction: (1) INSERT `customer_order` (status `pending_payment`, source `kiosk`, service_mode from cart, computed totals); (2) INSERT `order_item` rows (label_snapshot, unit_price_cents_snapshot, vat_rate_snapshot, quantity, format, item_type, product_id or menu_id); (3) INSERT `order_item_selection` rows for each slot filled in a menu item (order_item_id, menu_slot_id, product_id, label_snapshot); (4) INSERT `order_item_modifier` rows for each ingredient modification (order_item_id, ingredient_id, action, extra_price_cents snapshot); (5) for each ingredient consumed: compute units = `(order_item.format = 'maxi' ? product_ingredient.quantity_maxi : product_ingredient.quantity_normal) * order_item.quantity`, adjusted by modifiers (remove => no decrement for that ingredient; add => extra decrement); apply the atomic decrement `UPDATE ingredient SET stock_quantity = stock_quantity - :units WHERE id = :id` (single self-locking statement, no preceding read-gate, RG-T20); `stock_quantity` is signed and may go negative (oversell magnitude, surfaced to managers) — the decrement does not gate on a floor; INSERT `stock_movement` (type `sale`, delta = -units, order_id, user_id = NULL for kiosk); (6) UPDATE `customer_order` SET status = `paid`, `paid_at = NOW()`. All six steps commit together or roll back entirely. | -| **[RG-6 — cross-constraint]** | Source `kiosk` implies no particular service_mode constraint; the customer selects `dine_in` or `takeaway`. The drive cross-constraint (RG-T09) does not apply to kiosk-originated orders. | -| **[RG-7 — immutability]** | After INSERT, `label_snapshot`, `unit_price_cents_snapshot`, and `vat_rate_snapshot` are not modified even if the source product is later renamed or repriced (see RG-T05). | -| **[RG-8 — idempotency]** | The body carries a client `idempotency_key` (UUID). Before any write, `SELECT id, order_number, status FROM customer_order WHERE idempotency_key = :key`. If found, skip creation and return that order (deduplicates a replayed retry — RG-T19). The key is stored on the new `customer_order` row. | -| **[RG-9 — server-side modifier re-validation]** | The ingredient modifiers in the body are re-validated server-side against `product_ingredient`: an `action='remove'` requires `is_removable=1`; an `action='add'` requires `is_addable=1` and snapshots the current `extra_price_cents`. Client-side checks (3.2 RG-4) are not trusted; a crafted POST adding a non-addable ingredient is rejected (HTTP 422). | -| **[RG-10 — atomic stock decrement]** | No operation gates on a stock read, so the decrement is a single atomic statement `UPDATE ingredient SET stock_quantity = stock_quantity - :units WHERE id = :id` (RG-T20). The row self-locks for the duration of the update, so concurrent kiosk orders on the same ingredient apply their deltas without a lost update and without a deadlock-ordering concern; `stock_quantity` is signed and may go negative (oversell magnitude surfaced to managers). | -| **[POST-1]** | One `customer_order` row exists with `status = 'paid'`, `source = 'kiosk'`, all totals computed, `paid_at` set, `idempotency_key` stored. The `pending_payment` phase is not observable outside the transaction. | -| **[POST-2]** | N `order_item` rows exist, each referencing either a `product_id` (item_type='product') or a `menu_id` (item_type='menu') — exclusivity constraint verified. | -| **[POST-3]** | `customer_order.order_number` is unique in the database (UNIQUE constraint). | -| **[POST-4]** | `ingredient.stock_quantity` decremented for each consumed ingredient unit; one `stock_movement` row of type `sale` per affected ingredient. | -| **[OUT-1]** | HTTP 201: `{data: {id: int, order_number: string, status: 'paid'}}` | -| **[OUT-2]** | Logical event ORDER_CREATED available for preparation domain (preparation display refreshes via polling or server push depending on implementation) | -| **[ERR-1]** | Empty cart: HTTP 422, `{error: {code: "EMPTY_CART"}}` | -| **[ERR-2]** | Unavailable item: HTTP 422, `{error: {code: "ITEM_UNAVAILABLE", items: [...]}}` | -| **[ERR-3]** | DB error / timeout: HTTP 500 with rollback, `{error: {code: "DB_ERROR"}}` | +| **[PRE-1]** | Le panier contient au moins 1 article (`items.length >= 1`) | +| **[PRE-2]** | Le numero de commande saisi par le client est non vide (validation front-end) | +| **[PRE-3]** | Le corps JSON du POST est valide (validation de schema a la couche API) | +| **[RG-1]** | Verification de disponibilite cote serveur : pour chaque article, verifier `product.is_available = 1` ou `menu.is_available = 1`. Si un article est indisponible, rejeter avec la liste des articles indisponibles. | +| **[RG-2 — service_day]** | Le `service_day` d'une commande donnee est calcule a l'execution de la requete comme : `CASE WHEN HOUR(created_at) < 10 THEN DATE(created_at) - INTERVAL 1 DAY ELSE DATE(created_at) END`. La coupure est a 10:00. Ce n'est PAS stocke comme colonne — calcule uniquement a l'execution de la requete. La formule v0.1 avec `INTERVAL 4 HOUR 30 MINUTE` etait incorrecte et est abandonnee. | +| **[RG-3 — order number]** | Format du numero de commande : `K-YYYY-MM-DD-NNN` ou NNN est le compteur sequentiel pour le service_day courant pour la source `kiosk` (SELECT COUNT + 1 avec un verrou au niveau table ou un insert serialise pour eviter une generation en double sous concurrence). La source est `kiosk` (definie par l'endpoint kiosk, derivee du point d'entree public). | +| **[RG-4 — VAT by line]** | Pour chaque `order_item` : `vat_rate_snapshot` est copie depuis `product.vat_rate`. Montants de ligne : `unit_ttc = unit_price_cents_snapshot` ; `unit_ht = ROUND(unit_ttc * 1000 / (1000 + vat_rate_snapshot))` ; `unit_vat = unit_ttc - unit_ht`. Totaux de commande : `total_ttc_cents = SUM(unit_ttc * quantity)` sur toutes les lignes ; `total_ht_cents = SUM(unit_ht * quantity)` ; `total_vat_cents = total_ttc_cents - total_ht_cents`. Invariant : `total_ttc_cents = total_ht_cents + total_vat_cents` (verifie avant l'INSERT). | +| **[RG-5 — atomic transaction]** | Toutes les ecritures dans une seule transaction de base de donnees : (1) INSERT `customer_order` (status `pending_payment`, source `kiosk`, service_mode depuis le panier, totaux calcules) ; (2) INSERT des lignes `order_item` (label_snapshot, unit_price_cents_snapshot, vat_rate_snapshot, quantity, format, item_type, product_id ou menu_id) ; (3) INSERT des lignes `order_item_selection` pour chaque slot rempli dans un article menu (order_item_id, menu_slot_id, product_id, label_snapshot) ; (4) INSERT des lignes `order_item_modifier` pour chaque modification d'ingredient (order_item_id, ingredient_id, action, extra_price_cents snapshot) ; (5) pour chaque ingredient consomme : calculer units = `(order_item.format = 'maxi' ? product_ingredient.quantity_maxi : product_ingredient.quantity_normal) * order_item.quantity`, ajuste par les modificateurs (remove => pas de decrement pour cet ingredient ; add => decrement supplementaire) ; appliquer le decrement atomique `UPDATE ingredient SET stock_quantity = stock_quantity - :units WHERE id = :id` (instruction unique auto-verrouillante, sans lecture-gate prealable, RG-T20) ; `stock_quantity` est signe et peut devenir negatif (ampleur de survente, remontee aux managers) — le decrement ne se conditionne pas a un plancher ; INSERT `stock_movement` (type `sale`, delta = -units, order_id, user_id = NULL pour le kiosk) ; (6) UPDATE `customer_order` SET status = `paid`, `paid_at = NOW()`. Les six etapes committent ensemble ou sont entierement annulees. | +| **[RG-6 — cross-constraint]** | La source `kiosk` n'implique aucune contrainte particuliere de service_mode ; le client selectionne `dine_in` ou `takeaway`. La contrainte croisee drive (RG-T09) ne s'applique pas aux commandes provenant du kiosk. | +| **[RG-7 — immutability]** | Apres l'INSERT, `label_snapshot`, `unit_price_cents_snapshot` et `vat_rate_snapshot` ne sont pas modifies meme si le produit source est renomme ou voit son prix change plus tard (voir RG-T05). | +| **[RG-8 — idempotency]** | Le corps porte un `idempotency_key` client (UUID). Avant toute ecriture, `SELECT id, order_number, status FROM customer_order WHERE idempotency_key = :key`. Si trouve, sauter la creation et retourner cette commande (deduplique un retry rejoue — RG-T19). La cle est stockee sur la nouvelle ligne `customer_order`. | +| **[RG-9 — server-side modificateur re-validation]** | Les modificateurs d'ingredient dans le corps sont re-valides cote serveur contre `product_ingredient` : un `action='remove'` requiert `is_removable=1` ; un `action='add'` requiert `is_addable=1` et snapshote le `extra_price_cents` courant. Les verifications cote client (3.2 RG-4) ne sont pas dignes de confiance ; un POST forge ajoutant un ingredient non addable est rejete (HTTP 422). | +| **[RG-10 — atomic stock decrement]** | Aucune operation ne se conditionne a une lecture de stock, donc le decrement est une instruction atomique unique `UPDATE ingredient SET stock_quantity = stock_quantity - :units WHERE id = :id` (RG-T20). La ligne s'auto-verrouille pour la duree de la mise a jour, donc les commandes kiosk concurrentes sur le meme ingredient appliquent leurs deltas sans perte de mise a jour et sans souci d'ordonnancement de deadlock ; `stock_quantity` est signe et peut devenir negatif (ampleur de survente remontee aux managers). | +| **[POST-1]** | Une ligne `customer_order` existe avec `status = 'paid'`, `source = 'kiosk'`, tous les totaux calcules, `paid_at` defini, `idempotency_key` stocke. La phase `pending_payment` n'est pas observable hors de la transaction. | +| **[POST-2]** | N lignes `order_item` existent, chacune referencant soit un `product_id` (item_type='product') soit un `menu_id` (item_type='menu') — contrainte d'exclusivite verifiee. | +| **[POST-3]** | `customer_order.order_number` est unique dans la base (contrainte UNIQUE). | +| **[POST-4]** | `ingredient.stock_quantity` decremente pour chaque unite d'ingredient consommee ; une ligne `stock_movement` de type `sale` par ingredient affecte. | +| **[OUT-1]** | HTTP 201 : `{data: {id: int, order_number: string, status: 'paid'}}` | +| **[OUT-2]** | Evenement logique ORDER_CREATED disponible pour le domaine de preparation (l'affichage de preparation se rafraichit via polling ou push serveur selon l'implementation) | +| **[ERR-1]** | Panier vide : HTTP 422, `{error: {code: "EMPTY_CART"}}` | +| **[ERR-2]** | Article indisponible : HTTP 422, `{error: {code: "ITEM_UNAVAILABLE", items: [...]}}` | +| **[ERR-3]** | Erreur DB / timeout : HTTP 500 avec rollback, `{error: {code: "DB_ERROR"}}` | --- ### 3.4 DISPLAY_CONFIRMATION -**Corresponds to MCT section 3.4** +**Correspond a la section 3.4 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[PRE-1]** | CREATE_ORDER returned HTTP 201 with `{id, order_number, status: 'paid'}` | -| **[RG-1]** | Order number displayed prominently on the confirmation screen | -| **[RG-2]** | After a configurable delay (suggestion: 15 seconds), the kiosk auto-resets for the next customer | -| **[POST-1]** | No database write | -| **[OUT-1]** | Confirmation screen displayed with order number | -| **[ERR-1]** | If API response is an error: generic error message displayed with option to retry | +| **[PRE-1]** | CREATE_ORDER a retourne HTTP 201 avec `{id, order_number, status: 'paid'}` | +| **[RG-1]** | Numero de commande affiche de maniere proeminente sur l'ecran de confirmation | +| **[RG-2]** | Apres un delai configurable (suggestion : 15 secondes), le kiosk se reinitialise automatiquement pour le client suivant | +| **[POST-1]** | Aucune ecriture en base | +| **[OUT-1]** | Ecran de confirmation affiche avec le numero de commande | +| **[ERR-1]** | Si la reponse de l'API est une erreur : message d'erreur generique affiche avec une option de reessai | --- -## 4. Domain 2 — Order lifecycle (counter and drive) +## 4. Domaine 2 — Cycle de vie de la commande (comptoir et drive) ### 4.1 CREATE_COUNTER_ORDER -**Corresponds to MCT section 4.1** +**Correspond a la section 4.1 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[PRE-1]** | Actor is authenticated (valid session, `user.is_active = 1`) | -| **[PRE-2]** | Actor holds permission `order.create` (verified via `role_permission`) | -| **[PRE-3]** | Cart contains at least 1 item | -| **[RG-1]** | Creation logic identical to CREATE_ORDER (RG-1 through RG-7 apply), with the following differences: `source` is auto-tagged from `role.order_source` (counter role -> `counter`, drive role -> `drive`); `service_mode` is selected by the staff member (`dine_in` / `takeaway` / `drive`); `user_id` is set to the authenticated user's id in `stock_movement` rows (instead of NULL for kiosk). | -| **[RG-2 — cross-constraint]** | If `source = 'drive'` then `service_mode` must be `'drive'` (RG-T09); verified before INSERT. HTTP 422 if violated. | -| **[RG-3 — order number]** | Format: `C-YYYY-MM-DD-NNN` for counter source; `D-YYYY-MM-DD-NNN` for drive source. Sequential NNN counter is per source per service_day. | -| **[RG-4 — stock]** | Same stock decrement logic as CREATE_ORDER RG-5; `stock_movement.user_id` is set to the authenticated staff member's id. | -| **[RG-5 — staff attribution + decrement]** | `customer_order.acting_user_id` is set to the authenticated staff member's id (targeted accountability on counter/drive orders; kiosk orders stay NULL). Server-side modifier re-validation (3.3 RG-9), idempotency (RG-T19) and the atomic stock decrement (RG-T20) apply identically. No PIN is required to create an order (the `order.create` permission suffices); order creation is not in the sensitive-action set. | -| **[POST-1]** | One `customer_order` row with `status = 'paid'`, `source = 'counter'` or `'drive'`, `paid_at` set, `acting_user_id` set. | -| **[POST-2]** | N `order_item` rows with snapshots. Slot selections and modifiers written identically to kiosk flow. | -| **[POST-3]** | Stock decremented; movements logged with actor `user_id`. | -| **[OUT-1]** | HTTP 201: `{data: {id: int, order_number: string, status: 'paid'}}`. Order number communicated to customer. | -| **[ERR-1]** | Same error cases as CREATE_ORDER (ERR-1, ERR-2, ERR-3) | -| **[ERR-2]** | Cross-constraint violation (`source = drive` but `service_mode != drive`): HTTP 422, `{error: {code: "INVALID_SERVICE_MODE"}}` | +| **[PRE-1]** | L'acteur est authentifie (session valide, `user.is_active = 1`) | +| **[PRE-2]** | L'acteur detient la permission `order.create` (verifiee via `role_permission`) | +| **[PRE-3]** | Le panier contient au moins 1 article | +| **[RG-1]** | Logique de creation identique a CREATE_ORDER (RG-1 a RG-7 s'appliquent), avec les differences suivantes : `source` est auto-tagguee depuis `role.order_source` (role comptoir -> `counter`, role drive -> `drive`) ; `service_mode` est selectionne par le membre du personnel (`dine_in` / `takeaway` / `drive`) ; `user_id` est defini a l'id de l'utilisateur authentifie dans les lignes `stock_movement` (au lieu de NULL pour le kiosk). | +| **[RG-2 — cross-constraint]** | Si `source = 'drive'` alors `service_mode` doit etre `'drive'` (RG-T09) ; verifie avant l'INSERT. HTTP 422 si viole. | +| **[RG-3 — order number]** | Format : `C-YYYY-MM-DD-NNN` pour la source comptoir ; `D-YYYY-MM-DD-NNN` pour la source drive. Le compteur sequentiel NNN est par source par service_day. | +| **[RG-4 — stock]** | Meme logique de decrement de stock que CREATE_ORDER RG-5 ; `stock_movement.user_id` est defini a l'id du membre du personnel authentifie. | +| **[RG-5 — staff attribution + decrement]** | `customer_order.acting_user_id` est defini a l'id du membre du personnel authentifie (imputabilite ciblee sur les commandes comptoir/drive ; les commandes kiosk restent NULL). La re-validation des modificateurs cote serveur (3.3 RG-9), l'idempotence (RG-T19) et le decrement de stock atomique (RG-T20) s'appliquent a l'identique. Aucun PIN n'est requis pour creer une commande (la permission `order.create` suffit) ; la creation de commande n'est pas dans l'ensemble des actions sensibles. | +| **[POST-1]** | Une ligne `customer_order` avec `status = 'paid'`, `source = 'counter'` ou `'drive'`, `paid_at` defini, `acting_user_id` defini. | +| **[POST-2]** | N lignes `order_item` avec snapshots. Selections de slot et modificateurs ecrits a l'identique du flux kiosk. | +| **[POST-3]** | Stock decremente ; mouvements journalises avec l'acteur `user_id`. | +| **[OUT-1]** | HTTP 201 : `{data: {id: int, order_number: string, status: 'paid'}}`. Numero de commande communique au client. | +| **[ERR-1]** | Memes cas d'erreur que CREATE_ORDER (ERR-1, ERR-2, ERR-3) | +| **[ERR-2]** | Violation de contrainte croisee (`source = drive` mais `service_mode != drive`) : HTTP 422, `{error: {code: "INVALID_SERVICE_MODE"}}` | --- -## 5. Domain 3 — Preparation display (kitchen) +## 5. Domaine 3 — Affichage de preparation (cuisine) ### 5.1 LIST_ORDERS_DISPLAY -**Corresponds to MCT section 5.1** +**Correspond a la section 5.1 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[PRE-1]** | Actor is authenticated, `is_active = 1` | -| **[PRE-2]** | Actor holds permission `order.read` | -| **[RG-1 — source filter]** | Retrieve visible sources for the actor's role: `SELECT source FROM role_visible_source WHERE role_id = :role_id`. Kitchen sees all three; counter sees `kiosk` and `counter`; drive sees `drive`. | +| **[PRE-1]** | L'acteur est authentifie, `is_active = 1` | +| **[PRE-2]** | L'acteur detient la permission `order.read` | +| **[RG-1 — source filter]** | Recuperer les sources visibles pour le role de l'acteur : `SELECT source FROM role_visible_source WHERE role_id = :role_id`. La cuisine voit les trois ; le comptoir voit `kiosk` et `counter` ; le drive voit `drive`. | | **[RG-2 — query]** | `SELECT customer_order.*, order_item.* FROM customer_order JOIN order_item ON order_item.order_id = customer_order.id WHERE customer_order.status = 'paid' AND customer_order.source IN (:visible_sources) ORDER BY customer_order.paid_at ASC` | -| **[RG-3 — item detail]** | For each order line of type `menu`, also load `order_item_selection` rows (slot choices). For all lines, load `order_item_modifier` rows (ingredient modifications). Display uses snapshots (`label_snapshot`, `quantity`, `format`); no re-join on `product` or `menu` tables needed. | -| **[RG-4 — KDS colour]** | Colour indicator computed at render time: `elapsed = NOW() - customer_order.paid_at`; green if elapsed < SLA threshold (configurable, approx. 10 min); amber if approaching; red if exceeded. Not stored; computed client-side or in PHP before response. | -| **[RG-5 — read only]** | Kitchen staff perform no status transition from this view. No UPDATE is issued by this operation. | -| **[POST-1]** | No database write | -| **[OUT-1]** | List of orders with status `paid`, filtered by role, sorted by `paid_at` ascending, with full item detail (selections, modifiers, KDS colour) | +| **[RG-3 — item detail]** | Pour chaque ligne de commande de type `menu`, charger aussi les lignes `order_item_selection` (choix de slot). Pour toutes les lignes, charger les lignes `order_item_modifier` (modifications d'ingredient). L'affichage utilise les snapshots (`label_snapshot`, `quantity`, `format`) ; aucune re-jointure sur les tables `product` ou `menu` necessaire. | +| **[RG-4 — KDS colour]** | Indicateur de couleur calcule au rendu : `elapsed = NOW() - customer_order.paid_at` ; vert si elapsed < seuil SLA (configurable, approx. 10 min) ; ambre si en approche ; rouge si depasse. Non stocke ; calcule cote client ou en PHP avant la reponse. | +| **[RG-5 — read only]** | Le personnel de cuisine n'effectue aucune transition de statut depuis cette vue. Aucun UPDATE n'est emis par cette operation. | +| **[POST-1]** | Aucune ecriture en base | +| **[OUT-1]** | Liste des commandes au statut `paid`, filtree par role, triee par `paid_at` croissant, avec le detail complet des articles (selections, modificateurs, couleur KDS) | --- -## 6. Domain 4 — Delivery to customer +## 6. Domaine 4 — Remise au client ### 6.1 DELIVER_ORDER -**Corresponds to MCT section 6.1** +**Correspond a la section 6.1 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[PRE-1]** | Actor is authenticated, holds permission `order.deliver` | -| **[PRE-2]** | Targeted order exists and `status = 'paid'` | -| **[PRE-3]** | Order source is in the actor's visible sources (verified via `role_visible_source`) | +| **[PRE-1]** | L'acteur est authentifie, detient la permission `order.deliver` | +| **[PRE-2]** | La commande ciblee existe et `status = 'paid'` | +| **[PRE-3]** | La source de la commande est dans les sources visibles de l'acteur (verifiee via `role_visible_source`) | | **[RG-1]** | `UPDATE customer_order SET status = 'delivered', delivered_at = NOW(), updated_at = NOW() WHERE id = :id AND status = 'paid'` | -| **[RG-2 — concurrency]** | The `AND status = 'paid'` clause in the UPDATE protects against concurrent double-delivery: if two staff members click simultaneously, only the first succeeds (second receives 0 rows affected). | -| **[RG-3]** | `delivered` is a terminal status: no further transition is defined from this status (application constraint, not enforced as a DB trigger). | -| **[POST-1]** | `customer_order.status = 'delivered'`, `delivered_at` set, lifecycle complete. Order passes to history. | -| **[OUT-1]** | HTTP 200 with confirmation. Order disappears from the `paid` queue. | -| **[ERR-1]** | Invalid transition (status was not `paid` when UPDATE executed — concurrency): HTTP 409, `{error: {code: "INVALID_TRANSITION"}}` | -| **[ERR-2]** | Order source not in actor's visible sources: HTTP 403, `{error: {code: "FORBIDDEN"}}` | +| **[RG-2 — concurrency]** | La clause `AND status = 'paid'` dans l'UPDATE protege contre une double remise concurrente : si deux membres du personnel cliquent simultanement, seul le premier reussit (le second recoit 0 ligne affectee). | +| **[RG-3]** | `delivered` est un statut terminal : aucune transition ulterieure n'est definie depuis ce statut (contrainte applicative, pas appliquee comme trigger DB). | +| **[POST-1]** | `customer_order.status = 'delivered'`, `delivered_at` defini, cycle de vie complet. La commande passe a l'historique. | +| **[OUT-1]** | HTTP 200 avec confirmation. La commande disparait de la file `paid`. | +| **[ERR-1]** | Transition invalide (le statut n'etait pas `paid` au moment de l'execution de l'UPDATE — concurrence) : HTTP 409, `{error: {code: "INVALID_TRANSITION"}}` | +| **[ERR-2]** | Source de commande hors des sources visibles de l'acteur : HTTP 403, `{error: {code: "FORBIDDEN"}}` | --- -## 7. Domain 5 — Cancellation +## 7. Domaine 5 — Annulation ### 7.1 CANCEL_ORDER -**Corresponds to MCT section 7.1** +**Correspond a la section 7.1 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[PRE-1]** | Actor is authenticated, holds permission `order.cancel` | -| **[PRE-2]** | Targeted order exists | -| **[PRE-3]** | `customer_order.status` is in `['pending_payment', 'paid']`. Terminal statuses `delivered` and `cancelled` cannot transition to `cancelled`. | +| **[PRE-1]** | L'acteur est authentifie, detient la permission `order.cancel` | +| **[PRE-2]** | La commande ciblee existe | +| **[PRE-3]** | `customer_order.status` est dans `['pending_payment', 'paid']`. Les statuts terminaux `delivered` et `cancelled` ne peuvent pas transiter vers `cancelled`. | | **[RG-1 — status update]** | `UPDATE customer_order SET status = 'cancelled', cancelled_at = NOW(), updated_at = NOW() WHERE id = :id AND status IN ('pending_payment', 'paid')` | -| **[RG-2 — concurrency]** | The `AND status IN (...)` clause protects against concurrent cancellation (see RG-T07). | -| **[RG-3 — stock re-credit — conditional]** | Re-credit applies only if the order was at status `paid` before cancellation. Orders at `pending_payment` had not yet decremented stock (the decrement occurs at the `paid` transition). For each `order_item` line of a `paid` order, recompute ingredient units consumed: `(order_item.format = 'maxi' ? product_ingredient.quantity_maxi : product_ingredient.quantity_normal) * order_item.quantity`, adjusted by `order_item_modifier` rows (remove modifier -> ingredient was not decremented, so no re-credit; add modifier -> ingredient had extra decrement, so extra re-credit). UPDATE `ingredient.stock_quantity += units`. INSERT `stock_movement` (type `cancellation`, delta = +units, order_id, user_id of actor). | -| **[RG-4 — transaction]** | Status update and stock re-credit (when applicable) execute in the same database transaction (RG-T11). | -| **[RG-5 — history]** | Order is not physically deleted; retained for history and stats. Cancelled orders are excluded from revenue totals but included in volume counts in READ_STATS. `order_item` rows are not deleted (ON DELETE CASCADE is not triggered); they allow reconstruction of what was ordered. | -| **[RG-6 — PIN + audit]** | Cancellation is a sensitive money-handling action: it requires the per-staff PIN (RG-T13) and writes one `audit_log` row in the same transaction (RG-T14): `action_code='order.cancel'`, `entity_type='customer_order'`, `entity_id=:id`, `summary` with prior status and re-credited amount. | -| **[POST-1]** | `customer_order.status = 'cancelled'`, `cancelled_at` set, terminal state. One `audit_log` row recorded with the acting staff. | -| **[POST-2]** | If prior status was `paid`: `ingredient.stock_quantity` re-credited; one `stock_movement` row of type `cancellation` per affected ingredient. | -| **[OUT-1]** | HTTP 200 with cancellation confirmation | -| **[ERR-1]** | Attempt to cancel a delivered or already cancelled order: HTTP 422, `{error: {code: "CANNOT_CANCEL_IN_STATE", current_status: "..."}}` | -| **[ERR-2]** | Concurrent cancellation (0 rows affected by UPDATE): HTTP 409, `{error: {code: "INVALID_TRANSITION"}}` | +| **[RG-2 — concurrency]** | La clause `AND status IN (...)` protege contre une annulation concurrente (voir RG-T07). | +| **[RG-3 — stock re-credit — conditional]** | Le re-credit ne s'applique que si la commande etait au statut `paid` avant l'annulation. Les commandes a `pending_payment` n'avaient pas encore decremente le stock (le decrement a lieu a la transition `paid`). Pour chaque ligne `order_item` d'une commande `paid`, recalculer les unites d'ingredient consommees : `(order_item.format = 'maxi' ? product_ingredient.quantity_maxi : product_ingredient.quantity_normal) * order_item.quantity`, ajuste par les lignes `order_item_modifier` (modificateur remove -> l'ingredient n'a pas ete decremente, donc pas de re-credit ; modificateur add -> l'ingredient avait un decrement supplementaire, donc re-credit supplementaire). UPDATE `ingredient.stock_quantity += units`. INSERT `stock_movement` (type `cancellation`, delta = +units, order_id, user_id de l'acteur). | +| **[RG-4 — transaction]** | La mise a jour du statut et le re-credit de stock (quand applicable) s'executent dans la meme transaction de base de donnees (RG-T11). | +| **[RG-5 — history]** | La commande n'est pas physiquement supprimee ; conservee pour l'historique et les stats. Les commandes annulees sont exclues des totaux de chiffre d'affaires mais incluses dans les comptes de volume dans READ_STATS. Les lignes `order_item` ne sont pas supprimees (ON DELETE CASCADE n'est pas declenche) ; elles permettent de reconstruire ce qui a ete commande. | +| **[RG-6 — PIN + audit]** | L'annulation est une action sensible de manipulation d'argent : elle requiert le PIN propre a chaque membre du personnel (RG-T13) et ecrit une ligne `audit_log` dans la meme transaction (RG-T14) : `action_code='order.cancel'`, `entity_type='customer_order'`, `entity_id=:id`, `summary` avec le statut anterieur et le montant re-credite. | +| **[POST-1]** | `customer_order.status = 'cancelled'`, `cancelled_at` defini, etat terminal. Une ligne `audit_log` enregistree avec le personnel agissant. | +| **[POST-2]** | Si le statut anterieur etait `paid` : `ingredient.stock_quantity` re-credite ; une ligne `stock_movement` de type `cancellation` par ingredient affecte. | +| **[OUT-1]** | HTTP 200 avec confirmation d'annulation | +| **[ERR-1]** | Tentative d'annulation d'une commande livree ou deja annulee : HTTP 422, `{error: {code: "CANNOT_CANCEL_IN_STATE", current_status: "..."}}` | +| **[ERR-2]** | Annulation concurrente (0 ligne affectee par l'UPDATE) : HTTP 409, `{error: {code: "INVALID_TRANSITION"}}` | --- -## 8. Domain 6 — Catalogue management +## 8. Domaine 6 — Gestion du catalogue ### 8.1 CREATE_PRODUCT -**Corresponds to MCT section 8.1** +**Correspond a la section 8.1 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[PRE-1]** | Actor authenticated, holds permission `product.create` | -| **[PRE-2]** | `category_id` references an existing category with `is_active = 1` | -| **[RG-1]** | Form validation: `name` non-empty, `price_cents > 0`, `category_id` valid, `vat_rate` in `(55, 100)` | -| **[RG-2]** | Image upload (optional): validate MIME type (JPEG, PNG, WEBP), max size configurable (suggestion: 2 MB), store under `UPLOAD_DIR/products/`, record relative path in `image_path` | -| **[RG-3]** | `is_available = 1` by default at INSERT | -| **[RG-4]** | `display_order` set to `MAX(display_order) + 1` for the target category, or 0 if first product | -| **[POST-1]** | One `product` row in the database with all valid fields | -| **[OUT-1]** | Redirect to category product list with success message | -| **[ERR-1]** | Validation failure: inline field errors displayed | -| **[ERR-2]** | Invalid image (type or size): specific error message | +| **[PRE-1]** | Acteur authentifie, detient la permission `product.create` | +| **[PRE-2]** | `category_id` reference une categorie existante avec `is_active = 1` | +| **[RG-1]** | Validation du formulaire : `name` non vide, `price_cents > 0`, `category_id` valide, `vat_rate` dans `(55, 100)` | +| **[RG-2]** | Upload d'image (optionnel) : valider le type MIME (JPEG, PNG, WEBP), taille max configurable (suggestion : 2 MB), stocker sous `UPLOAD_DIR/products/`, enregistrer le chemin relatif dans `image_path` | +| **[RG-3]** | `is_available = 1` par defaut a l'INSERT | +| **[RG-4]** | `display_order` defini a `MAX(display_order) + 1` pour la categorie cible, ou 0 si premier produit | +| **[POST-1]** | Une ligne `product` dans la base avec tous les champs valides | +| **[OUT-1]** | Redirection vers la liste des produits de la categorie avec message de succes | +| **[ERR-1]** | Echec de validation : erreurs de champ affichees en ligne | +| **[ERR-2]** | Image invalide (type ou taille) : message d'erreur specifique | --- ### 8.2 UPDATE_PRODUCT -**Corresponds to MCT section 8.2** +**Correspond a la section 8.2 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[PRE-1]** | Actor authenticated, holds permission `product.update` | -| **[PRE-2]** | Target `product.id` exists | -| **[RG-1]** | Same validations as CREATE_PRODUCT on modified fields | -| **[RG-2]** | If a new image is uploaded, the old image file is deleted from the filesystem (volume cleanup) | -| **[RG-3]** | `label_snapshot`, `unit_price_cents_snapshot`, `vat_rate_snapshot` in historical `order_item` rows are not modified (see RG-T05) | -| **[RG-4 — PIN + audit + allowlist]** | A price/VAT change is a sensitive action: it requires the per-staff PIN (RG-T13) and writes one `audit_log` row (RG-T14) with `action_code='product.update'`, `entity_type='product'`, `entity_id=:id`, and a `summary` recording changed values (e.g. `price_cents 880 -> 920`). Only the allowlisted columns (`name`, `description`, `price_cents`, `vat_rate`, `image_path`, `is_available`, `display_order`, `category_id`) are bound from the request (RG-T16). | -| **[POST-1]** | `product` updated, `updated_at` refreshed; one `audit_log` row recorded | -| **[OUT-1]** | Redirect to product list with success message | +| **[PRE-1]** | Acteur authentifie, detient la permission `product.update` | +| **[PRE-2]** | Le `product.id` cible existe | +| **[RG-1]** | Memes validations que CREATE_PRODUCT sur les champs modifies | +| **[RG-2]** | Si une nouvelle image est uploadee, l'ancien fichier image est supprime du systeme de fichiers (nettoyage du volume) | +| **[RG-3]** | `label_snapshot`, `unit_price_cents_snapshot`, `vat_rate_snapshot` dans les lignes `order_item` historiques ne sont pas modifies (voir RG-T05) | +| **[RG-4 — PIN + audit + allowlist]** | Un changement de prix/TVA est une action sensible : il requiert le PIN propre a chaque membre du personnel (RG-T13) et ecrit une ligne `audit_log` (RG-T14) avec `action_code='product.update'`, `entity_type='product'`, `entity_id=:id`, et un `summary` enregistrant les valeurs modifiees (ex. `price_cents 880 -> 920`). Seules les colonnes en allowlist (`name`, `description`, `price_cents`, `vat_rate`, `image_path`, `is_available`, `display_order`, `category_id`) sont liees depuis la requete (RG-T16). | +| **[POST-1]** | `product` mis a jour, `updated_at` rafraichi ; une ligne `audit_log` enregistree | +| **[OUT-1]** | Redirection vers la liste des produits avec message de succes | --- ### 8.3 DELETE_PRODUCT -**Corresponds to MCT section 8.3** +**Correspond a la section 8.3 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[PRE-1]** | Actor authenticated, holds permission `product.delete` | -| **[PRE-2]** | Target `product.id` exists | -| **[RG-1]** | Pre-check (PHP): is the product referenced in `menu_slot_option.product_id`? If yes, display blocking message listing the menus. | -| **[RG-2]** | Pre-check (PHP): is the product the `burger_product_id` of any `menu`? If yes, block with message to delete or reassign the menu first. | -| **[RG-3]** | Pre-check (PHP): is the product referenced in `order_item.product_id` (historical orders)? FK `ON DELETE RESTRICT` blocks at DB level. Recommended response: propose deactivation (`is_available=0`) rather than deletion. | -| **[RG-4]** | FK constraints (`menu_slot_option.product_id ON DELETE RESTRICT`, `order_item.product_id ON DELETE RESTRICT`) enforce the constraint even if the PHP check is bypassed. | -| **[RG-5 — PIN + audit]** | Deletion is a sensitive action: it requires the per-staff PIN (RG-T13) and writes one `audit_log` row (RG-T14) with `action_code='product.delete'`, `entity_type='product'`, `entity_id=:id`, `summary` capturing the product name before deletion (recorded before the row is removed). | -| **[POST-1]** | Product deleted if no FK constraint was blocking; one `audit_log` row recorded | -| **[OUT-1]** | Redirect to product list with success message | -| **[ERR-1]** | Product in menu slot: HTTP 422 or inline message with blocking menu list | -| **[ERR-2]** | Product in historical orders: message proposing deactivation instead | +| **[PRE-1]** | Acteur authentifie, detient la permission `product.delete` | +| **[PRE-2]** | Le `product.id` cible existe | +| **[RG-1]** | Pre-verification (PHP) : le produit est-il reference dans `menu_slot_option.product_id` ? Si oui, afficher un message bloquant listant les menus. | +| **[RG-2]** | Pre-verification (PHP) : le produit est-il le `burger_product_id` d'un `menu` ? Si oui, bloquer avec un message invitant a supprimer ou reaffecter le menu d'abord. | +| **[RG-3]** | Pre-verification (PHP) : le produit est-il reference dans `order_item.product_id` (commandes historiques) ? La FK `ON DELETE RESTRICT` bloque au niveau DB. Reponse recommandee : proposer la desactivation (`is_available=0`) plutot que la suppression. | +| **[RG-4]** | Les contraintes FK (`menu_slot_option.product_id ON DELETE RESTRICT`, `order_item.product_id ON DELETE RESTRICT`) appliquent la contrainte meme si la verification PHP est contournee. | +| **[RG-5 — PIN + audit]** | La suppression est une action sensible : elle requiert le PIN propre a chaque membre du personnel (RG-T13) et ecrit une ligne `audit_log` (RG-T14) avec `action_code='product.delete'`, `entity_type='product'`, `entity_id=:id`, `summary` capturant le nom du produit avant suppression (enregistre avant que la ligne ne soit retiree). | +| **[POST-1]** | Produit supprime si aucune contrainte FK ne bloquait ; une ligne `audit_log` enregistree | +| **[OUT-1]** | Redirection vers la liste des produits avec message de succes | +| **[ERR-1]** | Produit dans un slot de menu : HTTP 422 ou message en ligne avec la liste des menus bloquants | +| **[ERR-2]** | Produit dans des commandes historiques : message proposant la desactivation a la place | --- ### 8.4 CREATE_MENU -**Corresponds to MCT section 8.4** +**Correspond a la section 8.4 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[PRE-1]** | Actor authenticated, holds permission `menu.create` | -| **[PRE-2]** | `burger_product_id` references an existing, available product | -| **[PRE-3]** | At least one `menu_slot` is defined with at least one `menu_slot_option` | -| **[RG-1]** | Validation: `name` non-empty, `price_normal_cents > 0`, `price_maxi_cents > 0`, `burger_product_id` valid, all `product_id` values in slot options exist | -| **[RG-2]** | Transaction: INSERT `menu`, then INSERT `menu_slot` rows (name, slot_type, is_required, display_order), then INSERT `menu_slot_option` rows (menu_slot_id, product_id) | -| **[RG-3]** | Valid `slot_type` values (from dictionary ENUM): `drink`, `side`, `sauce`, `dessert`, `extra` | -| **[POST-1]** | One `menu` row, N `menu_slot` rows, M `menu_slot_option` rows in the database | -| **[OUT-1]** | Redirect to menu list with success message | -| **[ERR-1]** | Invalid configuration (no slot, no option): business error message | -| **[ERR-2]** | Slot option product unavailable: warning (menu can be created; product availability is checked at order time) | +| **[PRE-1]** | Acteur authentifie, detient la permission `menu.create` | +| **[PRE-2]** | `burger_product_id` reference un produit existant et disponible | +| **[PRE-3]** | Au moins un `menu_slot` est defini avec au moins une `menu_slot_option` | +| **[RG-1]** | Validation : `name` non vide, `price_normal_cents > 0`, `price_maxi_cents > 0`, `burger_product_id` valide, toutes les valeurs `product_id` des options de slot existent | +| **[RG-2]** | Transaction : INSERT `menu`, puis INSERT des lignes `menu_slot` (name, slot_type, is_required, display_order), puis INSERT des lignes `menu_slot_option` (menu_slot_id, product_id) | +| **[RG-3]** | Valeurs `slot_type` valides (depuis l'ENUM du dictionnaire) : `drink`, `side`, `sauce`, `dessert`, `extra` | +| **[POST-1]** | Une ligne `menu`, N lignes `menu_slot`, M lignes `menu_slot_option` dans la base | +| **[OUT-1]** | Redirection vers la liste des menus avec message de succes | +| **[ERR-1]** | Configuration invalide (pas de slot, pas d'option) : message d'erreur metier | +| **[ERR-2]** | Produit d'option de slot indisponible : avertissement (le menu peut etre cree ; la disponibilite du produit est verifiee au moment de la commande) | --- ### 8.5 UPDATE_MENU -**Corresponds to MCT section 8.5** +**Correspond a la section 8.5 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[PRE-1]** | Actor authenticated, holds permission `menu.update` | -| **[PRE-2]** | Target `menu.id` exists | -| **[RG-1]** | Same validations as CREATE_MENU on modified fields | -| **[RG-2]** | If slot configuration is modified: `DELETE FROM menu_slot_option WHERE menu_slot_id IN (SELECT id FROM menu_slot WHERE menu_id = :id)`, then `DELETE FROM menu_slot WHERE menu_id = :id`, then re-INSERT (delete-and-reinsert pattern, atomic in transaction) | -| **[RG-3]** | `label_snapshot` values in historical `order_item_selection` rows are not affected (see RG-T05) | -| **[POST-1]** | `menu` updated; `menu_slot` and `menu_slot_option` rebuilt | -| **[OUT-1]** | Redirect with success message | +| **[PRE-1]** | Acteur authentifie, detient la permission `menu.update` | +| **[PRE-2]** | Le `menu.id` cible existe | +| **[RG-1]** | Memes validations que CREATE_MENU sur les champs modifies | +| **[RG-2]** | Si la configuration de slot est modifiee : `DELETE FROM menu_slot_option WHERE menu_slot_id IN (SELECT id FROM menu_slot WHERE menu_id = :id)`, puis `DELETE FROM menu_slot WHERE menu_id = :id`, puis re-INSERT (pattern delete-and-reinsert, atomique en transaction) | +| **[RG-3]** | Les valeurs `label_snapshot` dans les lignes `order_item_selection` historiques ne sont pas affectees (voir RG-T05) | +| **[POST-1]** | `menu` mis a jour ; `menu_slot` et `menu_slot_option` reconstruits | +| **[OUT-1]** | Redirection avec message de succes | --- ### 8.6 DELETE_MENU -**Corresponds to MCT section 8.6** +**Correspond a la section 8.6 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[PRE-1]** | Actor authenticated, holds permission `menu.delete` | -| **[PRE-2]** | Target `menu.id` exists | -| **[RG-1]** | Pre-check (PHP): is the menu referenced in `order_item.menu_id`? FK `ON DELETE RESTRICT`. If yes, propose deactivation (`is_available=0`) instead of deletion. | -| **[RG-2]** | If no historical reference: DELETE `menu` triggers CASCADE to `menu_slot` (which cascades to `menu_slot_option`) | -| **[RG-3 — PIN + audit]** | Deletion is a sensitive action: per-staff PIN (RG-T13) + one `audit_log` row (RG-T14), `action_code='menu.delete'`, `entity_type='menu'`, `entity_id=:id`, `summary` capturing the menu name before deletion. | -| **[POST-1]** | `menu`, its `menu_slot` rows, and its `menu_slot_option` rows deleted; one `audit_log` row recorded | -| **[OUT-1]** | Redirect with success message | -| **[ERR-1]** | Menu in historical orders: message proposing deactivation instead | +| **[PRE-1]** | Acteur authentifie, detient la permission `menu.delete` | +| **[PRE-2]** | Le `menu.id` cible existe | +| **[RG-1]** | Pre-verification (PHP) : le menu est-il reference dans `order_item.menu_id` ? FK `ON DELETE RESTRICT`. Si oui, proposer la desactivation (`is_available=0`) au lieu de la suppression. | +| **[RG-2]** | Si aucune reference historique : DELETE `menu` declenche un CASCADE vers `menu_slot` (qui cascade vers `menu_slot_option`) | +| **[RG-3 — PIN + audit]** | La suppression est une action sensible : PIN propre a chaque membre du personnel (RG-T13) + une ligne `audit_log` (RG-T14), `action_code='menu.delete'`, `entity_type='menu'`, `entity_id=:id`, `summary` capturant le nom du menu avant suppression. | +| **[POST-1]** | `menu`, ses lignes `menu_slot` et ses lignes `menu_slot_option` supprimes ; une ligne `audit_log` enregistree | +| **[OUT-1]** | Redirection avec message de succes | +| **[ERR-1]** | Menu dans des commandes historiques : message proposant la desactivation a la place | --- ### 8.7 MANAGE_CATEGORY -**Corresponds to MCT section 8.7** +**Correspond a la section 8.7 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[PRE-1]** | Actor authenticated, holds permission `category.manage` | -| **[RG-CREATE]** | `name` and `slug` non-empty and unique in the database; `display_order` set to MAX + 1 | +| **[PRE-1]** | Acteur authentifie, detient la permission `category.manage` | +| **[RG-CREATE]** | `name` et `slug` non vides et uniques dans la base ; `display_order` defini a MAX + 1 | | **[RG-UPDATE]** | UPDATE `name`, `slug`, `image_path`, `display_order`, `is_active` | -| **[RG-DEACTIVATE]** | Deactivation (`is_active=0`) does not auto-deactivate child products/menus in the DB (no CASCADE on `is_active`). PHP layer proposes to the admin to also deactivate child products/menus, or the kiosk filter on `category.is_active = 1` implicitly hides them. | -| **[RG-DELETE]** | Physical deletion blocked if `product.category_id` or `menu.category_id` references this category (FK `ON DELETE RESTRICT`). Propose deactivation. | -| **[POST-CREATE]** | New `category` row in database | -| **[POST-UPDATE]** | `category` updated, `updated_at` refreshed | -| **[OUT-1]** | Confirmation, redirect to category list | +| **[RG-DEACTIVATE]** | La desactivation (`is_active=0`) ne desactive pas automatiquement les produits/menus enfants dans la DB (pas de CASCADE sur `is_active`). La couche PHP propose a l'admin de desactiver aussi les produits/menus enfants, ou le filtre kiosk sur `category.is_active = 1` les masque implicitement. | +| **[RG-DELETE]** | Suppression physique bloquee si `product.category_id` ou `menu.category_id` reference cette categorie (FK `ON DELETE RESTRICT`). Proposer la desactivation. | +| **[POST-CREATE]** | Nouvelle ligne `category` dans la base | +| **[POST-UPDATE]** | `category` mise a jour, `updated_at` rafraichi | +| **[OUT-1]** | Confirmation, redirection vers la liste des categories | --- ### 8.8 MANAGE_INGREDIENT -**Corresponds to MCT section 8.8** +**Correspond a la section 8.8 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[PRE-1]** | Actor authenticated, holds permission `ingredient.manage` | -| **[RG-CREATE-ING]** | `name` non-empty and UNIQUE; `unit` non-empty; `pack_size >= 1`; `stock_capacity >= 1` (the 100% reference); `low_stock_pct` and `critical_stock_pct` in 0-100 with `critical_stock_pct < low_stock_pct` (defaults 10 / 5); `stock_quantity` defaults to 0 at creation | +| **[PRE-1]** | Acteur authentifie, detient la permission `ingredient.manage` | +| **[RG-CREATE-ING]** | `name` non vide et UNIQUE ; `unit` non vide ; `pack_size >= 1` ; `stock_capacity >= 1` (la reference 100%) ; `low_stock_pct` et `critical_stock_pct` dans 0-100 avec `critical_stock_pct < low_stock_pct` (defauts 10 / 5) ; `stock_quantity` par defaut a 0 a la creation | | **[RG-UPDATE-ING]** | UPDATE `name`, `unit`, `pack_size`, `pack_label`, `stock_capacity`, `low_stock_pct`, `critical_stock_pct`, `is_active` | -| **[RG-DEACTIVATE-ING]** | `is_active=0` hides ingredient from configurator. Physical deletion blocked if referenced in `product_ingredient` (FK `ON DELETE RESTRICT`) or `stock_movement` (FK `ON DELETE RESTRICT`). | -| **[RG-COMPOSITION]** | UPDATE `product_ingredient`: for each ingredient in a product's recipe, set `quantity_normal`, `quantity_maxi`, `is_removable`, `is_addable`, `extra_price_cents`. Delete-and-reinsert pattern within transaction. | -| **[RG-ALLERGEN]** | Manage `ingredient_allergen`: INSERT or DELETE `(ingredient_id, allergen_id)` pairs. Allergen list is read-only (14 rows fixed by EU regulation 1169/2011). | -| **[POST-1]** | `ingredient` / `product_ingredient` / `ingredient_allergen` rows updated | -| **[OUT-1]** | Confirmation, redirect to ingredient list or product composition form | +| **[RG-DEACTIVATE-ING]** | `is_active=0` masque l'ingredient du configurateur. Suppression physique bloquee si reference dans `product_ingredient` (FK `ON DELETE RESTRICT`) ou `stock_movement` (FK `ON DELETE RESTRICT`). | +| **[RG-COMPOSITION]** | UPDATE `product_ingredient` : pour chaque ingredient de la recette d'un produit, definir `quantity_normal`, `quantity_maxi`, `is_removable`, `is_addable`, `extra_price_cents`. Pattern delete-and-reinsert en transaction. | +| **[RG-ALLERGEN]** | Gerer `ingredient_allergen` : INSERT ou DELETE des paires `(ingredient_id, allergen_id)`. La liste des allergenes est en lecture seule (14 lignes fixees par le reglement UE 1169/2011). | +| **[POST-1]** | Lignes `ingredient` / `product_ingredient` / `ingredient_allergen` mises a jour | +| **[OUT-1]** | Confirmation, redirection vers la liste des ingredients ou le formulaire de composition de produit | --- -## 9. Domain 7 — Stock management +## 9. Domaine 7 — Gestion du stock ### 9.1 RESTOCK -**Corresponds to MCT section 9.1** +**Correspond a la section 9.1 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[PRE-1]** | Actor authenticated, holds permission `stock.manage` | -| **[PRE-2]** | Target ingredient exists and `is_active = 1` | -| **[PRE-3]** | Number of packs `N >= 1` | +| **[PRE-1]** | Acteur authentifie, detient la permission `stock.manage` | +| **[PRE-2]** | L'ingredient cible existe et `is_active = 1` | +| **[PRE-3]** | Nombre de packs `N >= 1` | | **[RG-1]** | `delta = N * ingredient.pack_size` | -| **[RG-2]** | Transaction: `UPDATE ingredient SET stock_quantity = stock_quantity + :delta WHERE id = :id`; INSERT `stock_movement` (ingredient_id, movement_type=`restock`, delta=+delta, order_id=NULL, user_id=actor, note=optional) | -| **[RG-3]** | `stock_movement` is append-only: no UPDATE or DELETE on this table (corrections are new rows) | -| **[POST-1]** | `ingredient.stock_quantity` incremented by `delta`. One `stock_movement` row of type `restock` inserted. | -| **[OUT-1]** | Confirmation with new stock level displayed | +| **[RG-2]** | Transaction : `UPDATE ingredient SET stock_quantity = stock_quantity + :delta WHERE id = :id` ; INSERT `stock_movement` (ingredient_id, movement_type=`restock`, delta=+delta, order_id=NULL, user_id=acteur, note=optionnelle) | +| **[RG-3]** | `stock_movement` est append-only : aucun UPDATE ou DELETE sur cette table (les corrections sont de nouvelles lignes) | +| **[POST-1]** | `ingredient.stock_quantity` incremente de `delta`. Une ligne `stock_movement` de type `restock` inseree. | +| **[OUT-1]** | Confirmation avec le nouveau niveau de stock affiche | --- ### 9.2 INVENTORY_COUNT -**Corresponds to MCT section 9.2** +**Correspond a la section 9.2 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[PRE-1]** | Actor authenticated, holds permission `stock.count` | -| **[PRE-2]** | Target ingredient exists | -| **[PRE-3]** | `actual_quantity >= 0` (physical count is non-negative) | -| **[RG-1]** | `delta = actual_quantity - ingredient.stock_quantity` (may be negative if actual < theoretical) | -| **[RG-2]** | Transaction: `UPDATE ingredient SET stock_quantity = :actual_quantity WHERE id = :id`; INSERT `stock_movement` (ingredient_id, movement_type=`inventory_correction`, delta=computed, order_id=NULL, user_id=actor, note=optional) | -| **[RG-3]** | `delta = 0` is a valid correction (physical count matches theoretical); a movement row is still inserted for audit completeness | -| **[RG-4 — PIN attribution]** | An inventory correction can mask shrinkage, so it requires the per-staff PIN (RG-T13). The PIN-captured `user_id` is written to `stock_movement.user_id`, making the correction attributable to a person even on a shared workstation. No separate `audit_log` row (the `stock_movement` trail already records it). | -| **[POST-1]** | `ingredient.stock_quantity = actual_quantity`. One `stock_movement` row of type `inventory_correction` inserted with the acting `user_id`. | -| **[OUT-1]** | Confirmation with reconciled stock level and discrepancy displayed | +| **[PRE-1]** | Acteur authentifie, detient la permission `stock.count` | +| **[PRE-2]** | L'ingredient cible existe | +| **[PRE-3]** | `actual_quantity >= 0` (le comptage physique est non negatif) | +| **[RG-1]** | `delta = actual_quantity - ingredient.stock_quantity` (peut etre negatif si actual < theorique) | +| **[RG-2]** | Transaction : `UPDATE ingredient SET stock_quantity = :actual_quantity WHERE id = :id` ; INSERT `stock_movement` (ingredient_id, movement_type=`inventory_correction`, delta=calcule, order_id=NULL, user_id=acteur, note=optionnelle) | +| **[RG-3]** | `delta = 0` est une correction valide (le comptage physique correspond au theorique) ; une ligne de mouvement est tout de meme inseree pour la completude de l'audit | +| **[RG-4 — PIN attribution]** | Une correction d'inventaire peut masquer de la demarque, elle requiert donc le PIN propre a chaque membre du personnel (RG-T13). Le `user_id` capture par PIN est ecrit dans `stock_movement.user_id`, rendant la correction imputable a une personne meme sur un poste de travail partage. Pas de ligne `audit_log` separee (la trace `stock_movement` l'enregistre deja). | +| **[POST-1]** | `ingredient.stock_quantity = actual_quantity`. Une ligne `stock_movement` de type `inventory_correction` inseree avec le `user_id` agissant. | +| **[OUT-1]** | Confirmation avec le niveau de stock reconcilie et l'ecart affiches | --- ### 9.3 READ_STOCK -**Corresponds to MCT section 9.3** +**Correspond a la section 9.3 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[PRE-1]** | Actor authenticated, holds permission `stock.read` | +| **[PRE-1]** | Acteur authentifie, detient la permission `stock.read` | | **[RG-1]** | `SELECT * FROM ingredient WHERE is_active = 1 ORDER BY name ASC` | -| **[RG-2]** | Stock bands computed at render time from the percentage thresholds: `low_stock: true` when `stock_quantity <= stock_capacity * low_stock_pct / 100`, `critical_stock: true` when `stock_quantity <= stock_capacity * critical_stock_pct / 100`; `stock_pct = ROUND(stock_quantity / stock_capacity * 100)` is also returned. Not stored as columns. | -| **[RG-3]** | Optional movement history for a given ingredient: `SELECT * FROM stock_movement WHERE ingredient_id = :id ORDER BY created_at DESC LIMIT :n` | -| **[RG-4 — attribution visibility]** | The `stock_movement.user_id` (who restocked / who corrected) is included for `manager`/`admin` only; line staff (`kitchen`/`counter`/`drive`) see the movement deltas without the actor identity. This limits intra-team exposure while preserving accountability for those who manage. The `details` allowlist is applied at the query/serialisation layer. | -| **[POST-1]** | No database write | -| **[OUT-1]** | Ingredient list with `stock_quantity`, `stock_capacity`, computed `stock_pct`, `low_stock_pct`, `critical_stock_pct`, `pack_size`, `pack_label`, `low_stock` / `critical_stock` flags; movement history with actor visible to manager/admin only | +| **[RG-2]** | Bandes de stock calculees au rendu depuis les seuils en pourcentage : `low_stock: true` quand `stock_quantity <= stock_capacity * low_stock_pct / 100`, `critical_stock: true` quand `stock_quantity <= stock_capacity * critical_stock_pct / 100` ; `stock_pct = ROUND(stock_quantity / stock_capacity * 100)` est aussi retourne. Non stockees comme colonnes. | +| **[RG-3]** | Historique optionnel des mouvements pour un ingredient donne : `SELECT * FROM stock_movement WHERE ingredient_id = :id ORDER BY created_at DESC LIMIT :n` | +| **[RG-4 — attribution visibility]** | Le `stock_movement.user_id` (qui a reapprovisionne / qui a corrige) est inclus pour `manager`/`admin` uniquement ; le personnel de ligne (`kitchen`/`counter`/`drive`) voit les deltas de mouvement sans l'identite de l'acteur. Cela limite l'exposition intra-equipe tout en preservant l'imputabilite pour ceux qui gerent. L'allowlist `details` est appliquee a la couche de requete/serialisation. | +| **[POST-1]** | Aucune ecriture en base | +| **[OUT-1]** | Liste des ingredients avec `stock_quantity`, `stock_capacity`, `stock_pct` calcule, `low_stock_pct`, `critical_stock_pct`, `pack_size`, `pack_label`, drapeaux `low_stock` / `critical_stock` ; historique des mouvements avec l'acteur visible pour manager/admin uniquement | --- -## 10. Domain 8 — User and role management +## 10. Domaine 8 — Gestion des utilisateurs et des roles ### 10.1 CREATE_USER -**Corresponds to MCT section 10.1** +**Correspond a la section 10.1 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[PRE-1]** | Actor authenticated, holds permission `user.create` | -| **[PRE-2]** | Email does not already exist in `user.email` (UNIQUE constraint) | -| **[PRE-3]** | `role_id` references an existing, active role | -| **[RG-1]** | Validation: `email` conforms to RFC 5321 (PHP `FILTER_VALIDATE_EMAIL`), `first_name` and `last_name` non-empty, `role_id` valid | -| **[RG-2]** | Password hash: `password_hash($password, PASSWORD_ARGON2ID)`. Minimum password length: 8 characters. | -| **[RG-3]** | `is_active = 1` by default; `last_login_at = NULL` at creation | -| **[RG-4 — PIN + audit + allowlist]** | Creating a back-office account is a sensitive action: per-staff PIN (RG-T13) + one `audit_log` row (RG-T14), `action_code='user.create'`, `entity_type='user'`, `entity_id=:new_id`, `details` recording the assigned `role_id` (field names/role, not the password). Only the allowlisted columns are bound (RG-T16): `email`, `first_name`, `last_name`, `role_id` (+ the hashed password); `is_active` and any other field are server-set, not request-bound. | -| **[POST-1]** | One `user` row with argon2id `password_hash`, valid `role_id`; one `audit_log` row recorded | -| **[OUT-1]** | Redirect to user list with success message | -| **[ERR-1]** | Duplicate email: message "This email is already in use" | -| **[ERR-2]** | Password too short: inline validation message | +| **[PRE-1]** | Acteur authentifie, detient la permission `user.create` | +| **[PRE-2]** | L'email n'existe pas deja dans `user.email` (contrainte UNIQUE) | +| **[PRE-3]** | `role_id` reference un role existant et actif | +| **[RG-1]** | Validation : `email` conforme a la RFC 5321 (PHP `FILTER_VALIDATE_EMAIL`), `first_name` et `last_name` non vides, `role_id` valide | +| **[RG-2]** | Hachage du mot de passe : `password_hash($password, PASSWORD_ARGON2ID)`. Longueur minimale du mot de passe : 8 caracteres. | +| **[RG-3]** | `is_active = 1` par defaut ; `last_login_at = NULL` a la creation | +| **[RG-4 — PIN + audit + allowlist]** | Creer un compte back-office est une action sensible : PIN propre a chaque membre du personnel (RG-T13) + une ligne `audit_log` (RG-T14), `action_code='user.create'`, `entity_type='user'`, `entity_id=:new_id`, `details` enregistrant le `role_id` assigne (noms de champs/role, pas le mot de passe). Seules les colonnes en allowlist sont liees (RG-T16) : `email`, `first_name`, `last_name`, `role_id` (+ le mot de passe hache) ; `is_active` et tout autre champ sont definis cote serveur, pas lies a la requete. | +| **[POST-1]** | Une ligne `user` avec `password_hash` argon2id, `role_id` valide ; une ligne `audit_log` enregistree | +| **[OUT-1]** | Redirection vers la liste des utilisateurs avec message de succes | +| **[ERR-1]** | Email en doublon : message "Cet email est deja utilise" | +| **[ERR-2]** | Mot de passe trop court : message de validation en ligne | --- ### 10.2 UPDATE_USER -**Corresponds to MCT section 10.2** +**Correspond a la section 10.2 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[PRE-1]** | Actor authenticated, holds permission `user.update` | -| **[PRE-2]** | Target `user.id` exists | -| **[RG-1]** | If a new password is supplied (non-empty field): rehash via `PASSWORD_ARGON2ID` and replace existing hash | -| **[RG-2]** | If password field is empty: existing hash is preserved unchanged | -| **[RG-3]** | Email update subject to UNIQUE constraint (pre-check before UPDATE) | -| **[RG-4 — PIN + audit + allowlist]** | Editing an account (incl. `role_id`, the privilege-escalation vector) is sensitive: per-staff PIN (RG-T13) + one `audit_log` row (RG-T14), `action_code='user.update'`, `entity_type='user'`, `entity_id=:id`, `details` listing changed field names (not values, no PII). Only the allowlisted columns are bound (RG-T16): `first_name`, `last_name`, `email`, `role_id`, `is_active` (+ optional password rehash). | -| **[POST-1]** | `user` updated, `updated_at` refreshed; one `audit_log` row recorded | -| **[OUT-1]** | Redirect with success message | +| **[PRE-1]** | Acteur authentifie, detient la permission `user.update` | +| **[PRE-2]** | Le `user.id` cible existe | +| **[RG-1]** | Si un nouveau mot de passe est fourni (champ non vide) : re-hacher via `PASSWORD_ARGON2ID` et remplacer le hash existant | +| **[RG-2]** | Si le champ mot de passe est vide : le hash existant est preserve inchange | +| **[RG-3]** | Mise a jour d'email soumise a la contrainte UNIQUE (pre-verification avant l'UPDATE) | +| **[RG-4 — PIN + audit + allowlist]** | Editer un compte (incl. `role_id`, le vecteur d'escalade de privileges) est sensible : PIN propre a chaque membre du personnel (RG-T13) + une ligne `audit_log` (RG-T14), `action_code='user.update'`, `entity_type='user'`, `entity_id=:id`, `details` listant les noms des champs modifies (pas les valeurs, pas de PII). Seules les colonnes en allowlist sont liees (RG-T16) : `first_name`, `last_name`, `email`, `role_id`, `is_active` (+ re-hachage optionnel du mot de passe). | +| **[POST-1]** | `user` mis a jour, `updated_at` rafraichi ; une ligne `audit_log` enregistree | +| **[OUT-1]** | Redirection avec message de succes | --- ### 10.3 DEACTIVATE_USER -**Corresponds to MCT section 10.3** +**Correspond a la section 10.3 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[PRE-1]** | Actor authenticated, holds permission `user.deactivate` | -| **[PRE-2]** | Actor is not targeting their own account (`$targetUserId !== $currentUserId`) | +| **[PRE-1]** | Acteur authentifie, detient la permission `user.deactivate` | +| **[PRE-2]** | L'acteur ne cible pas son propre compte (`$targetUserId !== $currentUserId`) | | **[RG-1]** | `UPDATE user SET is_active = 0, updated_at = NOW() WHERE id = :id` | -| **[RG-2]** | The user's potentially active session is invalidated on next request: middleware checks `user.is_active = 1` on each authenticated request | -| **[RG-3 — PIN + audit]** | Sensitive action: per-staff PIN (RG-T13) + one `audit_log` row (RG-T14), `action_code='user.deactivate'`, `entity_type='user'`, `entity_id=:id`. | -| **[POST-1]** | `user.is_active = 0`; user cannot log in; history remains intact; one `audit_log` row recorded | -| **[OUT-1]** | Redirect with success message | -| **[ERR-1]** | Self-deactivation attempt: HTTP 403, `{error: {code: "SELF_DEACTIVATION_FORBIDDEN"}}` | +| **[RG-2]** | La session potentiellement active de l'utilisateur est invalidee a la requete suivante : le middleware verifie `user.is_active = 1` a chaque requete authentifiee | +| **[RG-3 — PIN + audit]** | Action sensible : PIN propre a chaque membre du personnel (RG-T13) + une ligne `audit_log` (RG-T14), `action_code='user.deactivate'`, `entity_type='user'`, `entity_id=:id`. | +| **[POST-1]** | `user.is_active = 0` ; l'utilisateur ne peut plus se connecter ; l'historique reste intact ; une ligne `audit_log` enregistree | +| **[OUT-1]** | Redirection avec message de succes | +| **[ERR-1]** | Tentative d'auto-desactivation : HTTP 403, `{error: {code: "SELF_DEACTIVATION_FORBIDDEN"}}` | --- ### 10.4 MANAGE_RBAC -**Corresponds to MCT section 10.4** +**Correspond a la section 10.4 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[PRE-1]** | Actor authenticated, holds permission `role.manage` | -| **[PRE-2]** | Target `role.id` exists (for permission update) or role fields are valid (for role creation) | -| **[PRE-3]** | All submitted `permission_id` values exist in the `permission` catalogue | -| **[RG-1 — permissions]** | Transaction: `DELETE FROM role_permission WHERE role_id = :id`; INSERT new `(role_id, permission_id)` pairs for each selected permission | -| **[RG-2]** | Permissions are not modifiable via this operation: they are read-only to populate the selection form. Permission catalogue is frozen at seed. | -| **[RG-3]** | Effect is immediate for new requests; sessions of users bearing this role see the change on the next permission check (sessions store `role_id`; permissions are reloaded from DB on each check). | -| **[RG-4 — custom role]** | Creating a custom role: INSERT `role` (code UNIQUE, label, description, default_route nullable, order_source nullable); INSERT `role_visible_source` rows as needed. | -| **[RG-5 — order_source]** | `role.order_source` controls the auto-tagging of `customer_order.source` when this role creates an order. NULL for admin and manager (they can create on behalf of any channel). | -| **[RG-6 — PIN + audit change-log]** | RBAC changes are high-impact (privilege escalation): per-staff PIN (RG-T13) + one `audit_log` row (RG-T14) per change, `action_code='role.manage'`, `entity_type='role'`, `entity_id=:role_id`. Because permissions are rewritten delete-and-reinsert (RG-1), the `details` JSON records the **diff** — permission codes added and removed — computed before the rewrite, so the trail shows exactly which capabilities a role gained or lost and who granted them. | -| **[POST-1]** | `role_permission` reflects exactly the selected permissions for this role; one `audit_log` row recorded with the permission diff | -| **[OUT-1]** | Redirect with success message | +| **[PRE-1]** | Acteur authentifie, detient la permission `role.manage` | +| **[PRE-2]** | Le `role.id` cible existe (pour la mise a jour des permissions) ou les champs du role sont valides (pour la creation de role) | +| **[PRE-3]** | Toutes les valeurs `permission_id` soumises existent dans le catalogue `permission` | +| **[RG-1 — permissions]** | Transaction : `DELETE FROM role_permission WHERE role_id = :id` ; INSERT des nouvelles paires `(role_id, permission_id)` pour chaque permission selectionnee | +| **[RG-2]** | Les permissions ne sont pas modifiables via cette operation : elles sont en lecture seule pour peupler le formulaire de selection. Le catalogue de permissions est fige au seed. | +| **[RG-3]** | L'effet est immediat pour les nouvelles requetes ; les sessions des utilisateurs portant ce role voient le changement a la prochaine verification de permission (les sessions stockent `role_id` ; les permissions sont rechargees depuis la DB a chaque verification). | +| **[RG-4 — custom role]** | Creer un role personnalise : INSERT `role` (code UNIQUE, label, description, default_route nullable, order_source nullable) ; INSERT des lignes `role_visible_source` selon le besoin. | +| **[RG-5 — order_source]** | `role.order_source` controle l'auto-tagging de `customer_order.source` lorsque ce role cree une commande. NULL pour admin et manager (ils peuvent creer au nom de n'importe quel canal). | +| **[RG-6 — PIN + audit change-log]** | Les changements RBAC sont a fort impact (escalade de privileges) : PIN propre a chaque membre du personnel (RG-T13) + une ligne `audit_log` (RG-T14) par changement, `action_code='role.manage'`, `entity_type='role'`, `entity_id=:role_id`. Comme les permissions sont reecrites en delete-and-reinsert (RG-1), le `details` JSON enregistre le **diff** — codes de permission ajoutes et retires — calcule avant la reecriture, de sorte que la trace montre exactement quelles capacites un role a gagnees ou perdues et qui les a accordees. | +| **[POST-1]** | `role_permission` reflete exactement les permissions selectionnees pour ce role ; une ligne `audit_log` enregistree avec le diff de permissions | +| **[OUT-1]** | Redirection avec message de succes | --- -### 10.5 ERASE_USER_PII (RGPD anonymisation) +### 10.5 ERASE_USER_PII (anonymisation RGPD) -**Security-by-design operation (no v0.1 / v0.2 MCT predecessor). Honours the RGPD right to -erasure (Cr 3.d) without breaking referential integrity or the audit trail (dict. note 13).** +**Operation security-by-design (pas de predecesseur MCT v0.1 / v0.2). Honore le droit a +l'effacement RGPD (Cr 3.d) sans casser l'integrite referentielle ni la trace d'audit (note 13 du dict.).** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[PRE-1]** | Actor authenticated, holds permission `user.update` (erasure is an admin operation) | -| **[PRE-2]** | Per-staff PIN verified (RG-T13) — sensitive action | -| **[PRE-3]** | Target `user.id` exists and `anonymized_at IS NULL` (not already anonymised) | -| **[RG-1 — anonymise, not delete]** | In one transaction: `UPDATE user SET email = CONCAT('anon-', id, '@wakdo.invalid'), first_name = '', last_name = '', password_hash = '', pin_hash = NULL, password_reset_token_hash = NULL, is_active = 0, anonymized_at = NOW() WHERE id = :id`. The placeholder domain is RFC 2606 reserved (`.invalid`), keeps `email` UNIQUE and non-identifying. | -| **[RG-2 — preserve links]** | The row persists, so FKs pointing at it (`stock_movement.user_id`, `customer_order.acting_user_id`, `audit_log.actor_user_id`) stay valid and now resolve to an anonymised principal. Accountability for past actions is preserved in form (who-as-id) without retaining PII. | -| **[RG-3 — audit]** | One `audit_log` row (RG-T14): `action_code='user.erase_pii'`, `entity_type='user'`, `entity_id=:id`. The `summary`/`details` record the erasure event and its legal basis, not the erased values. | -| **[POST-1]** | `user` row anonymised: PII fields cleared/placeholdered, credentials invalidated, `anonymized_at` set, `is_active = 0`. Referential links intact. | -| **[OUT-1]** | Confirmation; the user disappears from active lists, remains as an anonymised tombstone in history. | -| **[ERR-1]** | Already anonymised: HTTP 409, `{error: {code: "ALREADY_ANONYMISED"}}` | +| **[PRE-1]** | Acteur authentifie, detient la permission `user.update` (l'effacement est une operation admin) | +| **[PRE-2]** | PIN propre a chaque membre du personnel verifie (RG-T13) — action sensible | +| **[PRE-3]** | Le `user.id` cible existe et `anonymized_at IS NULL` (pas deja anonymise) | +| **[RG-1 — anonymise, not delete]** | En une transaction : `UPDATE user SET email = CONCAT('anon-', id, '@wakdo.invalid'), first_name = '', last_name = '', password_hash = '', pin_hash = NULL, password_reset_token_hash = NULL, is_active = 0, anonymized_at = NOW() WHERE id = :id`. Le domaine placeholder est reserve par la RFC 2606 (`.invalid`), garde `email` UNIQUE et non identifiant. | +| **[RG-2 — preserve links]** | La ligne persiste, donc les FK pointant vers elle (`stock_movement.user_id`, `customer_order.acting_user_id`, `audit_log.actor_user_id`) restent valides et resolvent desormais vers un principal anonymise. L'imputabilite des actions passees est preservee dans sa forme (qui-en-tant-qu'id) sans conserver de PII. | +| **[RG-3 — audit]** | Une ligne `audit_log` (RG-T14) : `action_code='user.erase_pii'`, `entity_type='user'`, `entity_id=:id`. Le `summary`/`details` enregistrent l'evenement d'effacement et sa base legale, pas les valeurs effacees. | +| **[POST-1]** | Ligne `user` anonymisee : champs PII vides/placeholders, identifiants invalides, `anonymized_at` defini, `is_active = 0`. Liens referentiels intacts. | +| **[OUT-1]** | Confirmation ; l'utilisateur disparait des listes actives, demeure comme tombstone anonymise dans l'historique. | +| **[ERR-1]** | Deja anonymise : HTTP 409, `{error: {code: "ALREADY_ANONYMISED"}}` | --- -## 11. Domain 9 — Stats and KPI +## 11. Domaine 9 — Stats et KPI ### 11.1 READ_STATS -**Corresponds to MCT section 11.1** +**Correspond a la section 11.1 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[PRE-1]** | Actor authenticated, holds permission `stats.read` | -| **[RG-1 — service_day]** | `service_day` expression used in all stats aggregations: `CASE WHEN HOUR(customer_order.created_at) < 10 THEN DATE(customer_order.created_at) - INTERVAL 1 DAY ELSE DATE(customer_order.created_at) END`. Cutoff at 10:00. No stored column. The v0.1 formula with `INTERVAL 4 HOUR 30 MINUTE` is dropped. | -| **[RG-2 — revenue]** | Revenue queries filter `status != 'cancelled'`; they sum `total_ttc_cents` from `customer_order`. Cancelled orders are excluded from revenue but appear in volume counts with `status = 'cancelled'` filter. | +| **[PRE-1]** | Acteur authentifie, detient la permission `stats.read` | +| **[RG-1 — service_day]** | Expression `service_day` utilisee dans toutes les agregations de stats : `CASE WHEN HOUR(customer_order.created_at) < 10 THEN DATE(customer_order.created_at) - INTERVAL 1 DAY ELSE DATE(customer_order.created_at) END`. Coupure a 10:00. Pas de colonne stockee. La formule v0.1 avec `INTERVAL 4 HOUR 30 MINUTE` est abandonnee. | +| **[RG-2 — revenue]** | Les requetes de chiffre d'affaires filtrent `status != 'cancelled'` ; elles somment `total_ttc_cents` depuis `customer_order`. Les commandes annulees sont exclues du chiffre d'affaires mais apparaissent dans les comptes de volume avec le filtre `status = 'cancelled'`. | | **[RG-3 — top products]** | `SELECT label_snapshot, SUM(quantity) AS total_sold FROM order_item JOIN customer_order ON ... WHERE customer_order.status != 'cancelled' GROUP BY label_snapshot ORDER BY total_sold DESC LIMIT 10` | -| **[RG-4 — delivery time KPI]** | Average delivery time: `AVG(TIMESTAMPDIFF(SECOND, paid_at, delivered_at))` on orders with `status = 'delivered'`. SLA reference approx. 10 min (configurable). | -| **[RG-5 — breakdown]** | Breakdowns available by `source` (kiosk/counter/drive) and `service_mode` (dine_in/takeaway/drive) for capacity planning. `service_mode` carries no fiscal role (see dictionary note 9). | -| **[POST-1]** | No database write | -| **[OUT-1]** | Stats dashboard data: revenue by service_day, order counts, top products, cancellation rate, average delivery time, breakdown by source/service_mode | +| **[RG-4 — delivery time KPI]** | Temps de livraison moyen : `AVG(TIMESTAMPDIFF(SECOND, paid_at, delivered_at))` sur les commandes avec `status = 'delivered'`. Reference SLA approx. 10 min (configurable). | +| **[RG-5 — breakdown]** | Ventilations disponibles par `source` (kiosk/counter/drive) et `service_mode` (dine_in/takeaway/drive) pour la planification de capacite. `service_mode` ne porte aucun role fiscal (voir note 9 du dictionnaire). | +| **[POST-1]** | Aucune ecriture en base | +| **[OUT-1]** | Donnees du tableau de bord de stats : chiffre d'affaires par service_day, comptes de commandes, top produits, taux d'annulation, temps de livraison moyen, ventilation par source/service_mode | --- -## 12. Domain 10 — Back-office authentication +## 12. Domaine 10 — Authentification back-office ### 12.1 AUTHENTICATE_USER -**Corresponds to MCT section 12.1** +**Correspond a la section 12.1 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[PRE-1]** | Login form submitted with email and password | -| **[PRE-2]** | CSRF token of the form is valid (anti-CSRF protection) | -| **[PRE-3 — throttle gate]** | If the account is in a throttling window (`user.lockout_until IS NOT NULL AND lockout_until > NOW()`), reject with the generic error before any password check. Throttling is also keyed per source IP via the `login_throttle` table: if a row exists for the source IP with `lockout_until IS NOT NULL AND lockout_until > NOW()`, reject with the same generic error, so distributed attempts on many accounts are slowed too. | -| **[RG-1]** | Lookup: `SELECT * FROM user WHERE email = :email AND is_active = 1 LIMIT 1` | -| **[RG-2]** | Password verification: `password_verify($password, $user->password_hash)`. On failure: same generic error whether the email does not exist or the password is wrong (protection against email enumeration). To keep timing comparable when the email is unknown, a dummy `password_verify` against a fixed decoy hash is run. | -| **[RG-3]** | On success: `session_regenerate(true)` (session ID regeneration, protection against session fixation) | -| **[RG-4]** | Session storage: `$_SESSION['user_id']`, `$_SESSION['role_id']`, `$_SESSION['logged_in_at']` | -| **[RG-5]** | UPDATE: `UPDATE user SET last_login_at = NOW() WHERE id = :id` | -| **[RG-6]** | Session timeouts: idle timeout 4h (detection via last-activity timestamp in session); absolute timeout 10h (detection via `logged_in_at`) | -| **[RG-7]** | Redirect target is `role.default_route` (dynamic; no hardcoded role name in routing logic) | -| **[RG-8 — failure handling, degressive backoff]** | On a failed verification, the per-account counter on `user`: `UPDATE user SET failed_login_attempts = failed_login_attempts + 1, last_failed_login_at = NOW()`, and once a threshold is reached (suggestion: 5) set `lockout_until = NOW() + INTERVAL (base * 2^(attempts - threshold)) SECOND`, capped (suggestion: cap a few minutes). In the same step, the per-IP dimension is recorded in the `login_throttle` table: upsert the row keyed on `ip_address` (insert if absent, else increment `failed_attempts`; reset the window when expired via `window_started_at`), update `last_attempt_at = NOW()`, and once the IP threshold is reached set `lockout_until` with the same degressive backoff. This is a degressive backoff, not an indefinite lock — it slows brute force without letting a fat-finger streak deny service to a kitchen mid-shift. Write one `audit_log` row (`action_code='auth.login_failed'`, `actor_user_id` if the email resolved, else NULL). | -| **[RG-9 — success reset]** | On success, reset the per-account counter `failed_login_attempts = 0`, clear `lockout_until = NULL`, and also clear the per-IP `login_throttle` row for the source IP (reset `failed_attempts = 0`, `lockout_until = NULL`, restart `window_started_at`), then write one `audit_log` row (`action_code='auth.login_success'`, `actor_user_id`, `actor_role_id`). | -| **[POST-1]** | PHP session open with `user_id` and `role_id`; `user.last_login_at` updated; `failed_login_attempts` reset | -| **[OUT-1]** | Redirect to `role.default_route` | -| **[ERR-1]** | Incorrect credentials or inactive account: generic message "Email or password incorrect" (no distinction to prevent enumeration); failure counter incremented (RG-8) | -| **[ERR-2]** | Invalid CSRF token: HTTP 403 | -| **[ERR-3]** | Account in throttling window (PRE-3): same generic message; the attempt does not reveal that the account exists or is locked | +| **[PRE-1]** | Formulaire de connexion soumis avec email et mot de passe | +| **[PRE-2]** | Le token CSRF du formulaire est valide (protection anti-CSRF) | +| **[PRE-3 — throttle gate]** | Si le compte est dans une fenetre de throttling (`user.lockout_until IS NOT NULL AND lockout_until > NOW()`), rejeter avec l'erreur generique avant toute verification de mot de passe. Le throttling est aussi cle par IP source via la table `login_throttle` : si une ligne existe pour l'IP source avec `lockout_until IS NOT NULL AND lockout_until > NOW()`, rejeter avec la meme erreur generique, de sorte que les tentatives distribuees sur de nombreux comptes sont ralenties elles aussi. | +| **[RG-1]** | Recherche : `SELECT * FROM user WHERE email = :email AND is_active = 1 LIMIT 1` | +| **[RG-2]** | Verification du mot de passe : `password_verify($password, $user->password_hash)`. En cas d'echec : meme erreur generique que l'email n'existe pas ou que le mot de passe soit faux (protection contre l'enumeration d'emails). Pour garder un timing comparable lorsque l'email est inconnu, un `password_verify` factice contre un hash leurre fixe est execute. | +| **[RG-3]** | En cas de succes : `session_regenerate(true)` (regeneration de l'ID de session, protection contre la fixation de session) | +| **[RG-4]** | Stockage de session : `$_SESSION['user_id']`, `$_SESSION['role_id']`, `$_SESSION['logged_in_at']` | +| **[RG-5]** | UPDATE : `UPDATE user SET last_login_at = NOW() WHERE id = :id` | +| **[RG-6]** | Timeouts de session : timeout d'inactivite 4h (detection via timestamp de derniere activite en session) ; timeout absolu 10h (detection via `logged_in_at`) | +| **[RG-7]** | La cible de redirection est `role.default_route` (dynamique ; aucun nom de role en dur dans la logique de routage) | +| **[RG-8 — failure handling, degressive backoff]** | A une verification echouee, le compteur par compte sur `user` : `UPDATE user SET failed_login_attempts = failed_login_attempts + 1, last_failed_login_at = NOW()`, et une fois un seuil atteint (suggestion : 5) definir `lockout_until = NOW() + INTERVAL (base * 2^(attempts - threshold)) SECOND`, plafonne (suggestion : plafond de quelques minutes). Dans la meme etape, la dimension par IP est enregistree dans la table `login_throttle` : upsert de la ligne cle sur `ip_address` (insert si absente, sinon incrementer `failed_attempts` ; reinitialiser la fenetre quand elle a expire via `window_started_at`), mettre a jour `last_attempt_at = NOW()`, et une fois le seuil IP atteint definir `lockout_until` avec le meme backoff degressif. C'est un backoff degressif, pas un verrouillage indefini — il ralentit la force brute sans laisser une serie de fautes de frappe priver de service une cuisine en plein rush. Ecrire une ligne `audit_log` (`action_code='auth.login_failed'`, `actor_user_id` si l'email a ete resolu, sinon NULL). | +| **[RG-9 — success reset]** | En cas de succes, reinitialiser le compteur par compte `failed_login_attempts = 0`, effacer `lockout_until = NULL`, et effacer aussi la ligne `login_throttle` par IP pour l'IP source (reinitialiser `failed_attempts = 0`, `lockout_until = NULL`, redemarrer `window_started_at`), puis ecrire une ligne `audit_log` (`action_code='auth.login_success'`, `actor_user_id`, `actor_role_id`). | +| **[POST-1]** | Session PHP ouverte avec `user_id` et `role_id` ; `user.last_login_at` mis a jour ; `failed_login_attempts` reinitialise | +| **[OUT-1]** | Redirection vers `role.default_route` | +| **[ERR-1]** | Identifiants incorrects ou compte inactif : message generique "Email ou mot de passe incorrect" (aucune distinction pour eviter l'enumeration) ; compteur d'echec incremente (RG-8) | +| **[ERR-2]** | Token CSRF invalide : HTTP 403 | +| **[ERR-3]** | Compte dans une fenetre de throttling (PRE-3) : meme message generique ; la tentative ne revele pas que le compte existe ou est verrouille | --- ### 12.2 LOGOUT_USER -**Corresponds to MCT section 12.2** +**Correspond a la section 12.2 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[PRE-1]** | Valid session open (`session_id()` non-empty, `$_SESSION['user_id']` present) | -| **[RG-1]** | `$_SESSION = []` (clear session data) | -| **[RG-2]** | If session cookie exists, expire it: `setcookie(session_name(), '', time() - 3600, '/', '', true, true)` | +| **[PRE-1]** | Session valide ouverte (`session_id()` non vide, `$_SESSION['user_id']` present) | +| **[RG-1]** | `$_SESSION = []` (effacer les donnees de session) | +| **[RG-2]** | Si un cookie de session existe, l'expirer : `setcookie(session_name(), '', time() - 3600, '/', '', true, true)` | | **[RG-3]** | `session_destroy()` | -| **[POST-1]** | PHP session destroyed; no authenticated access possible with the old cookie | -| **[OUT-1]** | Redirect to login page | +| **[POST-1]** | Session PHP detruite ; aucun acces authentifie possible avec l'ancien cookie | +| **[OUT-1]** | Redirection vers la page de connexion | --- ### 12.3 RESET_PASSWORD -**Security-by-design operation (no v0.1 predecessor). Two phases: request, then confirm.** +**Operation security-by-design (pas de predecesseur v0.1). Deux phases : demande, puis confirmation.** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[PRE-1]** | Request phase: a `user` submits the "forgot password" form with an email; CSRF token valid | -| **[RG-1 — request, enumeration-safe]** | Look up the email. The same neutral response ("if the account exists, an email has been sent") is returned whether or not the email exists, to avoid account enumeration. | -| **[RG-2 — token generation]** | If the email resolves to an active user: generate a cryptographically random token (e.g. 32 bytes from a CSPRNG); store its **hash** in `password_reset_token_hash` and `password_reset_expires_at = NOW() + INTERVAL 1 HOUR`. The **raw** token is sent once in the reset link (not stored in clear). | -| **[PRE-2]** | Confirm phase: the user opens the reset link with the raw token and submits a new password; CSRF token valid | -| **[RG-3 — confirm]** | Hash the submitted token and match it against `password_reset_token_hash` where `password_reset_expires_at > NOW()`. On match: `password_hash = password_hash($new, PASSWORD_ARGON2ID)` (min length 8), then clear `password_reset_token_hash = NULL` and `password_reset_expires_at = NULL`, and reset `failed_login_attempts = 0`, `lockout_until = NULL`. One-time use. | -| **[RG-4 — audit]** | Write one `audit_log` row (RG-T14), `action_code='auth.password_reset'`, `actor_user_id = :id`. | -| **[POST-1]** | Password replaced with a new argon2id hash; reset token consumed and cleared | -| **[OUT-1]** | Confirmation; redirect to login | -| **[ERR-1]** | Invalid or expired token: generic message inviting a new reset request (no detail on which condition failed) | +| **[PRE-1]** | Phase de demande : un `user` soumet le formulaire "mot de passe oublie" avec un email ; token CSRF valide | +| **[RG-1 — request, enumeration-safe]** | Rechercher l'email. La meme reponse neutre ("si le compte existe, un email a ete envoye") est retournee que l'email existe ou non, pour eviter l'enumeration de compte. | +| **[RG-2 — token generation]** | Si l'email resout vers un utilisateur actif : generer un token aleatoire cryptographique (ex. 32 octets depuis un CSPRNG) ; stocker son **hash** dans `password_reset_token_hash` et `password_reset_expires_at = NOW() + INTERVAL 1 HOUR`. Le token **brut** est envoye une seule fois dans le lien de reinitialisation (pas stocke en clair). | +| **[PRE-2]** | Phase de confirmation : l'utilisateur ouvre le lien de reinitialisation avec le token brut et soumet un nouveau mot de passe ; token CSRF valide | +| **[RG-3 — confirm]** | Hacher le token soumis et le comparer a `password_reset_token_hash` ou `password_reset_expires_at > NOW()`. En cas de correspondance : `password_hash = password_hash($new, PASSWORD_ARGON2ID)` (longueur min 8), puis effacer `password_reset_token_hash = NULL` et `password_reset_expires_at = NULL`, et reinitialiser `failed_login_attempts = 0`, `lockout_until = NULL`. Usage unique. | +| **[RG-4 — audit]** | Ecrire une ligne `audit_log` (RG-T14), `action_code='auth.password_reset'`, `actor_user_id = :id`. | +| **[POST-1]** | Mot de passe remplace par un nouveau hash argon2id ; token de reinitialisation consomme et efface | +| **[OUT-1]** | Confirmation ; redirection vers la connexion | +| **[ERR-1]** | Token invalide ou expire : message generique invitant a une nouvelle demande de reinitialisation (aucun detail sur la condition qui a echoue) | -These treatments are executed by the `wakdo-cron` service container in the maintenance -window 01:30-09:30 (outside active service). They are outside the MCT scope (technical -treatments, no user trigger) but are documented here for consistency with PROJECT_CONTEXT. +Ces traitements sont executes par le conteneur de service `wakdo-cron` dans la fenetre de +maintenance 01:30-09:30 (hors service actif). Ils sont hors du perimetre du MCT (traitements +techniques, pas de declencheur utilisateur) mais sont documentes ici par coherence avec PROJECT_CONTEXT. -### 13.1 Stats aggregation (cron 04:30) +### 13.1 Agregation des stats (cron 04:30) -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[TRIGGER]** | Cron: `30 4 * * *` | -| **[RG-1]** | `service_day` to aggregate: computed per order (see RG-1 of READ_STATS). At 04:30 the service_day in progress is the previous calendar day. | -| **[RG-2]** | Aggregations by `service_day`: order count, TTC revenue (sum `total_ttc_cents` where `status != 'cancelled'`), top products (by `label_snapshot`, COUNT in `order_item`) | -| **[POST-1]** | Stats available for admin dashboard (direct queries on `customer_order` filtered by `service_day`, or an aggregation table if implemented) | +| **[TRIGGER]** | Cron : `30 4 * * *` | +| **[RG-1]** | `service_day` a agreger : calcule par commande (voir RG-1 de READ_STATS). A 04:30 le service_day en cours est le jour calendaire precedent. | +| **[RG-2]** | Agregations par `service_day` : nombre de commandes, chiffre d'affaires TTC (somme `total_ttc_cents` ou `status != 'cancelled'`), top produits (par `label_snapshot`, COUNT dans `order_item`) | +| **[POST-1]** | Stats disponibles pour le tableau de bord admin (requetes directes sur `customer_order` filtrees par `service_day`, ou une table d'agregation si implementee) | -### 13.2 Expired sessions purge (cron every 15 min) +### 13.2 Purge des sessions expirees (cron toutes les 15 min) -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[TRIGGER]** | Cron: `*/15 * * * *` | -| **[RG-1]** | File-based sessions (default): `find /tmp/sessions -mmin +240 -delete` | -| **[RG-2]** | DB-based sessions (option): `DELETE FROM php_sessions WHERE updated_at < NOW() - INTERVAL 4 HOUR` | -| **[POST-1]** | Expired sessions deleted; users inactive for more than 4h are forced to re-login | +| **[TRIGGER]** | Cron : `*/15 * * * *` | +| **[RG-1]** | Sessions basees fichier (par defaut) : `find /tmp/sessions -mmin +240 -delete` | +| **[RG-2]** | Sessions basees DB (option) : `DELETE FROM php_sessions WHERE updated_at < NOW() - INTERVAL 4 HOUR` | +| **[POST-1]** | Sessions expirees supprimees ; les utilisateurs inactifs depuis plus de 4h sont forces de se reconnecter | -### 13.3 DB backup (cron 03:00) +### 13.3 Sauvegarde DB (cron 03:00) -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[TRIGGER]** | Cron: `0 3 * * *` | -| **[RG-1]** | `mysqldump` of the `wakdo` database to a dated file in the backup volume | -| **[RG-2]** | Retention: keep the last 7 dumps; delete older ones | -| **[POST-1]** | SQL dump available for restoration | +| **[TRIGGER]** | Cron : `0 3 * * *` | +| **[RG-1]** | `mysqldump` de la base `wakdo` vers un fichier date dans le volume de sauvegarde | +| **[RG-2]** | Retention : garder les 7 derniers dumps ; supprimer les plus anciens | +| **[POST-1]** | Dump SQL disponible pour restauration | -### 13.4 Audit log retention purge (cron daily) +### 13.4 Purge de retention du journal d'audit (cron quotidien) -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[TRIGGER]** | Cron: `15 4 * * *` (maintenance window) | -| **[RG-1]** | `DELETE FROM audit_log WHERE created_at < NOW() - INTERVAL :retention_months MONTH` (suggestion: 12 months, legitimate-interest / fiscal traceability — configurable in `.env`). | -| **[RG-2]** | The window is decoupled from user PII lifecycle: anonymisation (10.5) removes PII immediately on request, while the audit trail ages out on its own schedule (dict. note 13). | -| **[POST-1]** | `audit_log` rows older than the retention window removed; recent accountability preserved. | +| **[TRIGGER]** | Cron : `15 4 * * *` (fenetre de maintenance) | +| **[RG-1]** | `DELETE FROM audit_log WHERE created_at < NOW() - INTERVAL :retention_months MONTH` (suggestion : 12 mois, interet legitime / tracabilite fiscale — configurable dans `.env`). | +| **[RG-2]** | La fenetre est decouplee du cycle de vie des PII utilisateur : l'anonymisation (10.5) retire les PII immediatement sur demande, tandis que la trace d'audit vieillit selon son propre calendrier (note 13 du dict.). | +| **[POST-1]** | Lignes `audit_log` plus anciennes que la fenetre de retention retirees ; imputabilite recente preservee. | -### 13.5 login_throttle purge (cron daily) +### 13.5 Purge de login_throttle (cron quotidien) -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[TRIGGER]** | Cron: `45 4 * * *` (maintenance window) | -| **[RG-1]** | `DELETE FROM login_throttle WHERE (lockout_until IS NULL OR lockout_until < NOW()) AND last_attempt_at < NOW() - INTERVAL 24 HOUR` — purge rows with no active lockout whose last failed attempt is older than 24h. | -| **[RG-2]** | Rows still serving an active lockout are retained; the per-IP counter (S1) is bounded by this purge so the table does not grow unbounded from one-off attempts. | -| **[POST-1]** | Stale `login_throttle` rows removed; active throttles and recent activity preserved. | +| **[TRIGGER]** | Cron : `45 4 * * *` (fenetre de maintenance) | +| **[RG-1]** | `DELETE FROM login_throttle WHERE (lockout_until IS NULL OR lockout_until < NOW()) AND last_attempt_at < NOW() - INTERVAL 24 HOUR` — purger les lignes sans verrouillage actif dont la derniere tentative echouee est plus ancienne que 24h. | +| **[RG-2]** | Les lignes servant encore un verrouillage actif sont conservees ; le compteur par IP (S1) est borne par cette purge de sorte que la table ne croit pas de maniere illimitee a cause de tentatives ponctuelles. | +| **[POST-1]** | Lignes `login_throttle` obsoletes retirees ; throttles actifs et activite recente preserves. | --- -## 14. State machine — consistency recap (MLT) +## 14. Machine a etats — recapitulatif de coherence (MLT) -Summary of `customer_order.status` transitions covered in the MLT, with corresponding -operations, SQL condition, concurrency protection, and phase timestamp set. +Recapitulatif des transitions de `customer_order.status` couvertes dans le MLT, avec les operations +correspondantes, la condition SQL, la protection de concurrence et le timestamp de phase defini. -| Transition | MLT operation | SQL condition | Concurrency protection | Phase timestamp set | +| Transition | Operation MLT | Condition SQL | Protection concurrence | Timestamp de phase pose | |------------|--------------|---------------|------------------------|---------------------| -| `-> pending_payment` (creation) | CREATE_ORDER (3.3), CREATE_COUNTER_ORDER (4.1) | INSERT with status `pending_payment` | Atomic transaction | `created_at` | -| `pending_payment -> paid` | CREATE_ORDER (3.3), CREATE_COUNTER_ORDER (4.1) | UPDATE in same transaction | Atomic transaction | `paid_at` | -| `paid -> delivered` | DELIVER_ORDER (6.1) | `WHERE status = 'paid'` | AND status in WHERE | `delivered_at` | -| `pending_payment/paid -> cancelled` | CANCEL_ORDER (7.1) | `WHERE status IN ('pending_payment', 'paid')` | AND status IN WHERE | `cancelled_at` | +| `-> pending_payment` (creation) | CREATE_ORDER (3.3), CREATE_COUNTER_ORDER (4.1) | INSERT avec statut `pending_payment` | Transaction atomique | `created_at` | +| `pending_payment -> paid` | CREATE_ORDER (3.3), CREATE_COUNTER_ORDER (4.1) | UPDATE dans la meme transaction | Transaction atomique | `paid_at` | +| `paid -> delivered` | DELIVER_ORDER (6.1) | `WHERE status = 'paid'` | status dans le WHERE (clause AND) | `delivered_at` | +| `pending_payment/paid -> cancelled` | CANCEL_ORDER (7.1) | `WHERE status IN ('pending_payment', 'paid')` | status dans le WHERE (clause AND) | `cancelled_at` | -Terminal statuses (no further transition defined from these states): `delivered`, `cancelled`. +Statuts terminaux (aucune transition ulterieure definie depuis ces etats) : `delivered`, `cancelled`. -**Dropped from v0.1**: -- `paid -> preparing` and `preparing -> ready` transitions — intermediate states removed. -- MARQUER_EN_PREPARATION (v0.1 MLT section 4.2) — dropped. -- MARQUER_PRETE (v0.1 MLT section 4.3) — dropped. -- `preparing` and `ready` in the cancellable state set — the cancellable set is now - `['pending_payment', 'paid']` only. -- `commande_event` table and v0.1 RG-T10 — replaced by phase timestamps on `customer_order`. +**Abandonnes depuis v0.1** : +- Transitions `paid -> preparing` et `preparing -> ready` — etats intermediaires retires. +- MARQUER_EN_PREPARATION (section 4.2 du MLT v0.1) — abandonnee. +- MARQUER_PRETE (section 4.3 du MLT v0.1) — abandonnee. +- `preparing` et `ready` dans l'ensemble des etats annulables — l'ensemble annulable est desormais + `['pending_payment', 'paid']` uniquement. +- Table `commande_event` et RG-T10 v0.1 — remplacees par les timestamps de phase sur `customer_order`. --- -## 15. Residual notes and open points +## 15. Notes residuelles et points ouverts -### 15.1 `service_day` — not materialised as a column +### 15.1 `service_day` — non materialise comme colonne -The `service_day` computation is documented (RG-2 of CREATE_ORDER, RG-1 of READ_STATS): +Le calcul de `service_day` est documente (RG-2 de CREATE_ORDER, RG-1 de READ_STATS) : `CASE WHEN HOUR(created_at) < 10 THEN DATE(created_at) - INTERVAL 1 DAY ELSE DATE(created_at) END` -(cutoff 10:00). It is computed at query time, not stored. For high-frequency stats queries, -a MariaDB generated column `VIRTUAL` or `STORED` could be added at DDL time to avoid -per-row recomputation, but this is not a blocker for the RNCP scope. -The v0.1 formula with `INTERVAL 4 HOUR 30 MINUTE` was incorrect and is dropped. +(coupure 10:00). Il est calcule a l'execution de la requete, pas stocke. Pour les requetes de stats +a haute frequence, une colonne generee MariaDB `VIRTUAL` ou `STORED` pourrait etre ajoutee au moment du DDL pour eviter +un recalcul par ligne, mais ce n'est pas un bloquant pour le perimetre RNCP. +La formule v0.1 avec `INTERVAL 4 HOUR 30 MINUTE` etait incorrecte et est abandonnee. -### 15.2 `order_item_modifier` for menu items +### 15.2 `order_item_modifier` pour les articles menu -For a menu line (`item_type='menu'`), modifiers target the fixed burger identified via -`order_item.menu_id -> menu.burger_product_id`. The constraint that modifiers reference -only ingredients belonging to the burger's `product_ingredient` is enforced at the -application layer, not at the DB FK layer (see dictionary note 10). This is a known -trade-off: a multi-column FK or a DB trigger would be needed to enforce it at DB level. -Documenting it as an application invariant is the retained approach for this project scope. +Pour une ligne de menu (`item_type='menu'`), les modificateurs ciblent le burger fixe identifie via +`order_item.menu_id -> menu.burger_product_id`. La contrainte que les modificateurs ne referencent +que des ingredients appartenant au `product_ingredient` du burger est appliquee a la +couche applicative, pas a la couche FK de la DB (voir note 10 du dictionnaire). C'est un +compromis connu : une FK multi-colonnes ou un trigger DB serait necessaire pour l'appliquer au niveau DB. +Le documenter comme un invariant applicatif est l'approche retenue pour le perimetre de ce projet. -### 15.3 Order number NNN counter — concurrency +### 15.3 Compteur NNN de numero de commande — concurrence -The sequential NNN counter per `(source, service_day)` could produce duplicates under -high concurrency if implemented naively as `SELECT COUNT + 1`. The recommended -implementation at DDL/code time is either: (a) a table-level advisory lock around the -count-and-insert sequence; or (b) a dedicated sequence table with an atomic increment. -The UNIQUE constraint on `order_number` provides the last-resort guard (INSERT would fail -and the application retries). This is not a blocker for the RNCP demo volume. +Le compteur sequentiel NNN par `(source, service_day)` pourrait produire des doublons sous +forte concurrence s'il est implemente naivement comme `SELECT COUNT + 1`. L'implementation +recommandee au moment du DDL/code est soit : (a) un verrou consultatif au niveau table autour de la +sequence count-and-insert ; soit (b) une table de sequence dediee avec un increment atomique. +La contrainte UNIQUE sur `order_number` fournit le garde-fou de dernier recours (l'INSERT echouerait +et l'application reessaie). Ce n'est pas un bloquant pour le volume de la demo RNCP. -- 2.45.3 From 5581d2adea0aa5273356f51331c7c29a094eb810 Mon Sep 17 00:00:00 2001 From: Corentin JOGUET Date: Mon, 15 Jun 2026 15:31:49 +0200 Subject: [PATCH 05/93] ci: opt-in auto-merge job (label-gated, API merge on green) (#7) --- .forgejo/workflows/ci.yml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 63f73b2..fa2da16 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -11,6 +11,8 @@ name: CI on: pull_request: branches: [dev, main] + # `labeled` : permet au job auto-merge de s'evaluer quand on pose le label. + types: [opened, synchronize, reopened, labeled] push: # dev/main : porte de merge. feat|fix|ci|refactor : feedback avant la PR. branches: [dev, main, 'feat/**', 'fix/**', 'ci/**', 'refactor/**'] @@ -80,3 +82,27 @@ jobs: else echo "PHPUnit skipped: no tests/ + phpunit.xml yet (activates in P2)" fi + + auto-merge: + # Fusion automatique OPT-IN : poser le label `auto-merge` sur la PR. + # Ne s'execute que si les 3 checks passent (needs) ET si le label est present. + # Plus fiable que le merge_when_checks_succeed natif de Forgejo (qui ne se + # declenche pas toujours au passage au vert). Fusionne via l'API REST. + needs: [secret-scan, php-lint, static-tests] + if: github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'auto-merge') + runs-on: docker + steps: + - name: Install curl + run: apt-get update -qq && apt-get install -y -qq curl ca-certificates >/dev/null + - name: Merge PR (squash) once CI is green + run: | + API="${{ github.server_url }}/api/v1/repos/${{ github.repository }}" + PR="${{ github.event.pull_request.number }}" + code=$(curl -s -o /tmp/resp -w "%{http_code}" -X POST \ + -H "Authorization: token ${{ secrets.FORGEJO_TOKEN }}" \ + -H "Content-Type: application/json" \ + -d '{"Do":"squash","delete_branch_after_merge":true}' \ + "$API/pulls/$PR/merge") + echo "merge HTTP $code"; cat /tmp/resp || true; echo + [ "$code" = "200" ] || { echo "auto-merge failed (HTTP $code)"; exit 1; } + echo "PR #$PR merged." -- 2.45.3 From 41f9c96d33eb32510cc97e5b02a508e14ac8cfd0 Mon Sep 17 00:00:00 2001 From: Corentin JOGUET Date: Mon, 15 Jun 2026 15:36:05 +0200 Subject: [PATCH 06/93] feat(db): initial schema DDL (21 tables) + migration runner (#6) --- Makefile | 23 +- db/README.md | 38 +++ db/migrate.sh | 68 +++++ db/migrations/0001_init_schema.sql | 465 +++++++++++++++++++++++++++++ db/seed.sh | 69 +++++ 5 files changed, 659 insertions(+), 4 deletions(-) create mode 100644 db/README.md create mode 100755 db/migrate.sh create mode 100644 db/migrations/0001_init_schema.sql create mode 100755 db/seed.sh diff --git a/Makefile b/Makefile index 1c1ee94..d7734cb 100644 --- a/Makefile +++ b/Makefile @@ -156,12 +156,12 @@ wait-db: ## Attend que la base de donnees accepte les connexions (timeout 60s) @echo "[wait-db] OK" .PHONY: migrate -migrate: ## Applique les migrations SQL en attente [a venir] - @echo "[migrate] Pas encore implemente. Les migrations seront dans db/migrations/." +migrate: ## Applique les migrations SQL en attente (db/migrations/) + @bash db/migrate.sh .PHONY: seed -seed: ## Charge les donnees de demo [a venir] - @echo "[seed] Pas encore implemente. Les seeds seront dans db/seeds/." +seed: ## Charge les donnees de demo (db/seeds/) + @bash db/seed.sh .PHONY: backup backup: ## Declenche un dump SQL horodate immediat (via le container cron) @@ -211,6 +211,21 @@ clean: ## Stop + suppression containers + volumes (DESTRUCTIF, demande confirmat clean-force: ## Version non interactive de clean (pour CI uniquement) @$(COMPOSE) down -v +# === Documentation === + +.PHONY: docs-render +docs-render: ## Regenere les diagrammes Mermaid (docs/**/_diagrams/*.mmd -> *.svg) + @echo "[docs-render] Recherche des sources Mermaid sous docs/..." + @count=0; \ + for src in $$(find docs -name '*.mmd' -path '*/_diagrams/*'); do \ + out="$${src%.mmd}.svg"; \ + echo " $$src -> $$out"; \ + npx -y -p @mermaid-js/mermaid-cli mmdc -i "$$src" -o "$$out" >/dev/null 2>&1 \ + || { echo "[docs-render] ECHEC sur $$src"; exit 1; }; \ + count=$$((count + 1)); \ + done; \ + echo "[docs-render] $$count diagramme(s) genere(s)." + # === Hooks Git === .PHONY: install-hooks diff --git a/db/README.md b/db/README.md new file mode 100644 index 0000000..67384bd --- /dev/null +++ b/db/README.md @@ -0,0 +1,38 @@ +# Base de donnees - migrations & seeds + +Transcription executable du MLD (`docs/merise/mld.md`, 21 tables) vers MariaDB 11.4. + +## Arborescence + +``` +db/ + migrations/ migrations SQL versionnees, appliquees dans l'ordre lexicographique + 0001_init_schema.sql schema initial : 21 tables, FK, CHECK, index (InnoDB, utf8mb4) + seeds/ donnees de demonstration (a venir : roles/permissions, allergenes, catalogue) + migrate.sh runner de migrations (idempotent) +``` + +## Appliquer les migrations + +```bash +bash db/migrate.sh # applique les migrations en attente +bash db/migrate.sh --status # liste l'etat sans rien appliquer +``` + +Le runner cible le conteneur `wakdo-db` et lit les identifiants dans `.env` +(`DB_NAME`, `DB_ROOT_PASSWORD`). Il maintient une table `schema_migrations` +(une ligne par fichier applique) : relancer ne rejoue que les nouvelles +migrations. La cible `make migrate` est destinee a appeler ce script. + +## Conventions + +- Une migration = un fichier `NNNN_description.sql`. Un fichier deja applique en + commun n'est plus edite : on ajoute une nouvelle migration pour corriger. +- Pas de `CREATE DATABASE` / `USE` dans les fichiers : la base cible est choisie + par le runner. +- Le schema suit le MLD v0.2 a la lettre : montants en centimes (INT UNSIGNED), + `vat_rate` en pour-mille, `service_day` NON materialise (calcule applicatif, + decision D6), stock signe (survente), journaux append-only (`stock_movement`, + `audit_log`). +- Verification : le DDL a ete applique sur une instance MariaDB 11.4 reelle + (21 tables, 28 FK, 22 CHECK) sans erreur avant integration. diff --git a/db/migrate.sh b/db/migrate.sh new file mode 100755 index 0000000..b05f65b --- /dev/null +++ b/db/migrate.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# +# Wakdo - migration runner. +# +# Applique les fichiers db/migrations/*.sql dans l'ordre lexicographique, +# de maniere idempotente : une table schema_migrations enregistre les fichiers +# deja appliques, donc relancer ne rejoue que les nouvelles migrations. +# +# Cible : le service docker-compose `wakdo-db` (MariaDB). Lance depuis l'hote +# (c'est ce que `make migrate` appellera). Identifiants lus dans .env. +# +# Usage : +# bash db/migrate.sh # applique les migrations en attente +# bash db/migrate.sh --status # liste l'etat sans rien appliquer +# +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +ENV_FILE="$ROOT/.env" +CONTAINER="${WAKDO_DB_CONTAINER:-wakdo-db}" +MIGRATIONS_DIR="$ROOT/db/migrations" + +[ -f "$ENV_FILE" ] || { echo "ERREUR : .env introuvable ($ENV_FILE)" >&2; exit 1; } +DB_NAME="$(grep -E '^DB_NAME=' "$ENV_FILE" | cut -d= -f2- | tr -d '[:space:]')" +DB_ROOT_PASSWORD="$(grep -E '^DB_ROOT_PASSWORD=' "$ENV_FILE" | cut -d= -f2-)" +: "${DB_NAME:?DB_NAME absent de .env}" +: "${DB_ROOT_PASSWORD:?DB_ROOT_PASSWORD absent de .env}" + +# Client mariadb dans le conteneur (root : les migrations sont des operations DDL). +db() { docker exec -i "$CONTAINER" mariadb -uroot -p"$DB_ROOT_PASSWORD" "$@"; } + +# Le conteneur doit etre en marche. +docker exec "$CONTAINER" true 2>/dev/null || { echo "ERREUR : conteneur $CONTAINER non demarre (make up)" >&2; exit 1; } + +# Journal des migrations appliquees. +db "$DB_NAME" -e "CREATE TABLE IF NOT EXISTS schema_migrations ( + filename VARCHAR(255) NOT NULL PRIMARY KEY, + applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;" + +shopt -s nullglob +files=("$MIGRATIONS_DIR"/*.sql) +[ ${#files[@]} -gt 0 ] || { echo "[migrate] aucune migration dans $MIGRATIONS_DIR"; exit 0; } + +if [ "${1:-}" = "--status" ]; then + echo "[migrate] etat des migrations (base $DB_NAME) :" + for f in "${files[@]}"; do + base="$(basename "$f")" + n="$(db "$DB_NAME" -N -s -e "SELECT COUNT(*) FROM schema_migrations WHERE filename='$base';")" + [ "$n" = "0" ] && echo " PENDING $base" || echo " applied $base" + done + exit 0 +fi + +applied=0 +for f in "${files[@]}"; do + base="$(basename "$f")" + n="$(db "$DB_NAME" -N -s -e "SELECT COUNT(*) FROM schema_migrations WHERE filename='$base';")" + if [ "$n" = "0" ]; then + echo "[migrate] application de $base ..." + db "$DB_NAME" < "$f" + db "$DB_NAME" -e "INSERT INTO schema_migrations (filename) VALUES ('$base');" + applied=$((applied + 1)) + else + echo "[migrate] $base deja applique, ignore" + fi +done +echo "[migrate] termine ($applied nouvelle(s) migration(s) appliquee(s))." diff --git a/db/migrations/0001_init_schema.sql b/db/migrations/0001_init_schema.sql new file mode 100644 index 0000000..9e564d8 --- /dev/null +++ b/db/migrations/0001_init_schema.sql @@ -0,0 +1,465 @@ +-- ============================================================================= +-- Wakdo — Initial schema (DDL) +-- ============================================================================= +-- Purpose : Create the 21-table relational schema for the Wakdo fast-food +-- ordering system (catalogue, ingredients/stock, orders, RBAC, +-- security-by-design layer). +-- Source : docs/merise/mld.md (MLD v0.2 — prod-like, 21 tables) + +-- docs/merise/dictionary.md (data dictionary v0.2, types source of truth). +-- Phase : P2 — generated from the validated Logical Data Model (P1 conception). +-- Target : MariaDB 11.4 LTS, engine InnoDB, charset utf8mb4, collation +-- utf8mb4_unicode_ci. +-- +-- Notes derived from the MLD: +-- - All technical PKs are INT UNSIGNED AUTO_INCREMENT. +-- - Monetary amounts are INT UNSIGNED in cents (anti-FLOAT, dict. note 1). +-- - vat_rate stored per-mille (55 = 5.5%, 100 = 10%). +-- - service_day is NOT a stored/generated column (decision D6): computed in +-- the application layer. +-- - No CREATE DATABASE / USE here: the target DB is chosen by the runner. +-- - No seed / INSERT data here (see db/seeds/0001_demo_data.sql). +-- ============================================================================= + +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; +SET @OLD_SQL_MODE = @@SQL_MODE; +SET SQL_MODE = 'STRICT_ALL_TABLES,NO_ENGINE_SUBSTITUTION,NO_AUTO_VALUE_ON_ZERO'; +SET @OLD_FOREIGN_KEY_CHECKS = @@FOREIGN_KEY_CHECKS; +SET FOREIGN_KEY_CHECKS = 0; + +-- ----------------------------------------------------------------------------- +-- 4.1 category — root table for the Catalogue sub-domain (no FK) +-- ----------------------------------------------------------------------------- +CREATE TABLE category ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + name VARCHAR(60) NOT NULL, + slug VARCHAR(60) NOT NULL, + image_path VARCHAR(255) NULL, + display_order SMALLINT UNSIGNED NOT NULL DEFAULT 0, + is_active TINYINT(1) NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_category_name (name), + UNIQUE KEY uk_category_slug (slug) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------------------------------------------------------------- +-- 4.6 ingredient — root table for Ingredients & Stock (no FK) +-- ----------------------------------------------------------------------------- +CREATE TABLE ingredient ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + name VARCHAR(120) NOT NULL, + unit VARCHAR(40) NOT NULL, + stock_quantity INT NOT NULL DEFAULT 0, + stock_capacity INT NOT NULL, + pack_size SMALLINT UNSIGNED NOT NULL DEFAULT 1, + pack_label VARCHAR(80) NULL, + low_stock_pct SMALLINT UNSIGNED NOT NULL DEFAULT 10, + critical_stock_pct SMALLINT UNSIGNED NOT NULL DEFAULT 5, + is_active TINYINT(1) NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_ingredient_name (name), + CONSTRAINT chk_ingredient_stock_capacity CHECK (stock_capacity > 0), + CONSTRAINT chk_ingredient_pack_size CHECK (pack_size > 0), + CONSTRAINT chk_ingredient_low_stock_pct CHECK (low_stock_pct BETWEEN 0 AND 100), + CONSTRAINT chk_ingredient_critical_stock_pct CHECK (critical_stock_pct BETWEEN 0 AND 100), + CONSTRAINT chk_ingredient_critical_lt_low CHECK (critical_stock_pct < low_stock_pct) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------------------------------------------------------------- +-- 4.8 allergen — reference table (INCO EU 1169/2011), no FK +-- ----------------------------------------------------------------------------- +CREATE TABLE allergen ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + code VARCHAR(30) NOT NULL, + name VARCHAR(80) NOT NULL, + description TEXT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_allergen_code (code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------------------------------------------------------------- +-- 4.10 role — root table for RBAC (no FK) +-- ----------------------------------------------------------------------------- +CREATE TABLE role ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + code VARCHAR(40) NOT NULL, + label VARCHAR(80) NOT NULL, + description TEXT NULL, + default_route VARCHAR(120) NULL, + order_source ENUM('kiosk','counter','drive') NULL, + is_active TINYINT(1) NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_role_code (code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------------------------------------------------------------- +-- 4.13 permission — reference table, catalogue frozen at 23 codes (no FK) +-- ----------------------------------------------------------------------------- +CREATE TABLE permission ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + code VARCHAR(60) NOT NULL, + label VARCHAR(120) NOT NULL, + description TEXT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_permission_code (code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------------------------------------------------------------- +-- 4.21 login_throttle — per-source-IP brute-force throttle (no FK) +-- ----------------------------------------------------------------------------- +CREATE TABLE login_throttle ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + ip_address VARCHAR(45) NOT NULL, + failed_attempts SMALLINT UNSIGNED NOT NULL DEFAULT 0, + window_started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + lockout_until DATETIME NULL, + last_attempt_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_login_throttle_ip_address (ip_address), + KEY idx_login_throttle_lockout_until (lockout_until) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------------------------------------------------------------- +-- 4.2 product — depends on category +-- ----------------------------------------------------------------------------- +CREATE TABLE product ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + category_id INT UNSIGNED NOT NULL, + name VARCHAR(120) NOT NULL, + description TEXT NULL, + price_cents INT UNSIGNED NOT NULL, + vat_rate SMALLINT UNSIGNED NOT NULL DEFAULT 100, + image_path VARCHAR(255) NULL, + is_available TINYINT(1) NOT NULL DEFAULT 1, + display_order SMALLINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_product_category_available_order (category_id, is_available, display_order), + CONSTRAINT fk_product_category_id FOREIGN KEY (category_id) + REFERENCES category (id) ON DELETE RESTRICT, + CONSTRAINT chk_product_price_cents CHECK (price_cents > 0), + CONSTRAINT chk_product_vat_rate CHECK (vat_rate IN (55, 100)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------------------------------------------------------------- +-- 4.3 menu — depends on category, product +-- ----------------------------------------------------------------------------- +CREATE TABLE menu ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + category_id INT UNSIGNED NOT NULL, + burger_product_id INT UNSIGNED NOT NULL, + name VARCHAR(120) NOT NULL, + description TEXT NULL, + price_normal_cents INT UNSIGNED NOT NULL, + price_maxi_cents INT UNSIGNED NOT NULL, + image_path VARCHAR(255) NULL, + is_available TINYINT(1) NOT NULL DEFAULT 1, + display_order SMALLINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_menu_category_available_order (category_id, is_available, display_order), + CONSTRAINT fk_menu_category_id FOREIGN KEY (category_id) + REFERENCES category (id) ON DELETE RESTRICT, + CONSTRAINT fk_menu_burger_product_id FOREIGN KEY (burger_product_id) + REFERENCES product (id) ON DELETE RESTRICT, + CONSTRAINT chk_menu_price_normal_cents CHECK (price_normal_cents > 0), + CONSTRAINT chk_menu_price_maxi_cents CHECK (price_maxi_cents > 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------------------------------------------------------------- +-- 4.4 menu_slot — depends on menu (no audit fields) +-- ----------------------------------------------------------------------------- +CREATE TABLE menu_slot ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + menu_id INT UNSIGNED NOT NULL, + name VARCHAR(80) NOT NULL, + slot_type ENUM('drink','side','sauce','dessert','extra') NOT NULL, + is_required TINYINT(1) NOT NULL DEFAULT 1, + display_order SMALLINT UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (id), + KEY idx_menu_slot_menu_order (menu_id, display_order), + CONSTRAINT fk_menu_slot_menu_id FOREIGN KEY (menu_id) + REFERENCES menu (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------------------------------------------------------------- +-- 4.5 menu_slot_option — pure join table, composite PK +-- depends on menu_slot, product +-- ----------------------------------------------------------------------------- +CREATE TABLE menu_slot_option ( + menu_slot_id INT UNSIGNED NOT NULL, + product_id INT UNSIGNED NOT NULL, + PRIMARY KEY (menu_slot_id, product_id), + KEY idx_menu_slot_option_product_id (product_id), + CONSTRAINT fk_menu_slot_option_menu_slot_id FOREIGN KEY (menu_slot_id) + REFERENCES menu_slot (id) ON DELETE CASCADE, + CONSTRAINT fk_menu_slot_option_product_id FOREIGN KEY (product_id) + REFERENCES product (id) ON DELETE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------------------------------------------------------------- +-- 4.7 product_ingredient — join table with attributes, composite PK +-- depends on product, ingredient +-- ----------------------------------------------------------------------------- +CREATE TABLE product_ingredient ( + product_id INT UNSIGNED NOT NULL, + ingredient_id INT UNSIGNED NOT NULL, + quantity_normal SMALLINT UNSIGNED NOT NULL DEFAULT 1, + quantity_maxi SMALLINT UNSIGNED NOT NULL DEFAULT 1, + is_removable TINYINT(1) NOT NULL DEFAULT 1, + is_addable TINYINT(1) NOT NULL DEFAULT 0, + extra_price_cents INT UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (product_id, ingredient_id), + KEY idx_product_ingredient_ingredient_id (ingredient_id), + CONSTRAINT fk_product_ingredient_product_id FOREIGN KEY (product_id) + REFERENCES product (id) ON DELETE CASCADE, + CONSTRAINT fk_product_ingredient_ingredient_id FOREIGN KEY (ingredient_id) + REFERENCES ingredient (id) ON DELETE RESTRICT, + CONSTRAINT chk_product_ingredient_quantity_normal CHECK (quantity_normal > 0), + CONSTRAINT chk_product_ingredient_quantity_maxi CHECK (quantity_maxi >= quantity_normal), + CONSTRAINT chk_product_ingredient_extra_price CHECK (extra_price_cents >= 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------------------------------------------------------------- +-- 4.9 ingredient_allergen — pure join table, composite PK +-- depends on ingredient, allergen +-- ----------------------------------------------------------------------------- +CREATE TABLE ingredient_allergen ( + ingredient_id INT UNSIGNED NOT NULL, + allergen_id INT UNSIGNED NOT NULL, + PRIMARY KEY (ingredient_id, allergen_id), + KEY idx_ingredient_allergen_allergen_id (allergen_id), + CONSTRAINT fk_ingredient_allergen_ingredient_id FOREIGN KEY (ingredient_id) + REFERENCES ingredient (id) ON DELETE CASCADE, + CONSTRAINT fk_ingredient_allergen_allergen_id FOREIGN KEY (allergen_id) + REFERENCES allergen (id) ON DELETE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------------------------------------------------------------- +-- 4.11 user — depends on role +-- ----------------------------------------------------------------------------- +CREATE TABLE user ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + email VARCHAR(254) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + pin_hash VARCHAR(255) NULL, + first_name VARCHAR(60) NOT NULL, + last_name VARCHAR(60) NOT NULL, + role_id INT UNSIGNED NOT NULL, + is_active TINYINT(1) NOT NULL DEFAULT 1, + last_login_at DATETIME NULL, + failed_login_attempts SMALLINT UNSIGNED NOT NULL DEFAULT 0, + last_failed_login_at DATETIME NULL, + lockout_until DATETIME NULL, + password_reset_token_hash VARCHAR(255) NULL, + password_reset_expires_at DATETIME NULL, + anonymized_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_user_email (email), + KEY idx_user_active_role (is_active, role_id), + CONSTRAINT fk_user_role_id FOREIGN KEY (role_id) + REFERENCES role (id) ON DELETE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------------------------------------------------------------- +-- 4.12 role_visible_source — pure join table, composite PK +-- depends on role +-- ----------------------------------------------------------------------------- +CREATE TABLE role_visible_source ( + role_id INT UNSIGNED NOT NULL, + source ENUM('kiosk','counter','drive') NOT NULL, + PRIMARY KEY (role_id, source), + CONSTRAINT fk_role_visible_source_role_id FOREIGN KEY (role_id) + REFERENCES role (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------------------------------------------------------------- +-- 4.14 role_permission — pure join table, composite PK +-- depends on role, permission +-- ----------------------------------------------------------------------------- +CREATE TABLE role_permission ( + role_id INT UNSIGNED NOT NULL, + permission_id INT UNSIGNED NOT NULL, + PRIMARY KEY (role_id, permission_id), + KEY idx_role_permission_permission_id (permission_id), + CONSTRAINT fk_role_permission_role_id FOREIGN KEY (role_id) + REFERENCES role (id) ON DELETE CASCADE, + CONSTRAINT fk_role_permission_permission_id FOREIGN KEY (permission_id) + REFERENCES permission (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------------------------------------------------------------- +-- 4.15 customer_order — depends on user (acting_user_id) +-- ----------------------------------------------------------------------------- +CREATE TABLE customer_order ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + order_number VARCHAR(20) NOT NULL, + idempotency_key VARCHAR(36) NULL, + source ENUM('kiosk','counter','drive') NOT NULL, + acting_user_id INT UNSIGNED NULL, + service_mode ENUM('dine_in','takeaway','drive') NOT NULL, + status ENUM('pending_payment','paid','delivered','cancelled') NOT NULL DEFAULT 'pending_payment', + total_ht_cents INT UNSIGNED NOT NULL, + total_vat_cents INT UNSIGNED NOT NULL, + total_ttc_cents INT UNSIGNED NOT NULL, + paid_at DATETIME NULL, + delivered_at DATETIME NULL, + cancelled_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_customer_order_order_number (order_number), + UNIQUE KEY uk_customer_order_idempotency_key (idempotency_key), + KEY idx_customer_order_status_created (status, created_at), + KEY idx_customer_order_source_created (source, created_at), + KEY idx_customer_order_created (created_at), + CONSTRAINT fk_customer_order_acting_user_id FOREIGN KEY (acting_user_id) + REFERENCES user (id) ON DELETE SET NULL, + CONSTRAINT chk_customer_order_total_ht CHECK (total_ht_cents >= 0), + CONSTRAINT chk_customer_order_total_vat CHECK (total_vat_cents >= 0), + CONSTRAINT chk_customer_order_total_ttc CHECK (total_ttc_cents > 0), + CONSTRAINT chk_customer_order_total_coherent CHECK (total_ttc_cents = total_ht_cents + total_vat_cents), + CONSTRAINT chk_customer_order_drive_mode CHECK (source <> 'drive' OR service_mode = 'drive') +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------------------------------------------------------------- +-- 4.16 order_item — depends on customer_order, product, menu +-- polymorphic line (product XOR menu) +-- ----------------------------------------------------------------------------- +CREATE TABLE order_item ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + order_id INT UNSIGNED NOT NULL, + item_type ENUM('product','menu') NOT NULL, + product_id INT UNSIGNED NULL, + menu_id INT UNSIGNED NULL, + format ENUM('normal','maxi') NOT NULL DEFAULT 'normal', + label_snapshot VARCHAR(120) NOT NULL, + unit_price_cents_snapshot INT UNSIGNED NOT NULL, + vat_rate_snapshot SMALLINT UNSIGNED NOT NULL, + quantity SMALLINT UNSIGNED NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_order_item_order_id (order_id), + KEY idx_order_item_product_id (product_id), + KEY idx_order_item_menu_id (menu_id), + CONSTRAINT fk_order_item_order_id FOREIGN KEY (order_id) + REFERENCES customer_order (id) ON DELETE CASCADE, + CONSTRAINT fk_order_item_product_id FOREIGN KEY (product_id) + REFERENCES product (id) ON DELETE RESTRICT, + CONSTRAINT fk_order_item_menu_id FOREIGN KEY (menu_id) + REFERENCES menu (id) ON DELETE RESTRICT, + CONSTRAINT chk_order_item_unit_price CHECK (unit_price_cents_snapshot > 0), + CONSTRAINT chk_order_item_vat_rate CHECK (vat_rate_snapshot IN (55, 100)), + CONSTRAINT chk_order_item_quantity CHECK (quantity > 0), + CONSTRAINT chk_order_item_polymorphism CHECK ( + (item_type = 'product' AND product_id IS NOT NULL AND menu_id IS NULL) + OR (item_type = 'menu' AND menu_id IS NOT NULL AND product_id IS NULL) + ) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------------------------------------------------------------- +-- 4.17 order_item_selection — depends on order_item, menu_slot, product +-- ----------------------------------------------------------------------------- +CREATE TABLE order_item_selection ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + order_item_id INT UNSIGNED NOT NULL, + menu_slot_id INT UNSIGNED NOT NULL, + product_id INT UNSIGNED NOT NULL, + label_snapshot VARCHAR(120) NOT NULL, + PRIMARY KEY (id), + KEY idx_order_item_selection_order_item_id (order_item_id), + KEY idx_order_item_selection_menu_slot_id (menu_slot_id), + KEY idx_order_item_selection_product_id (product_id), + CONSTRAINT fk_order_item_selection_order_item_id FOREIGN KEY (order_item_id) + REFERENCES order_item (id) ON DELETE CASCADE, + CONSTRAINT fk_order_item_selection_menu_slot_id FOREIGN KEY (menu_slot_id) + REFERENCES menu_slot (id) ON DELETE RESTRICT, + CONSTRAINT fk_order_item_selection_product_id FOREIGN KEY (product_id) + REFERENCES product (id) ON DELETE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------------------------------------------------------------- +-- 4.18 order_item_modifier — depends on order_item, ingredient +-- ----------------------------------------------------------------------------- +CREATE TABLE order_item_modifier ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + order_item_id INT UNSIGNED NOT NULL, + ingredient_id INT UNSIGNED NOT NULL, + action ENUM('remove','add') NOT NULL, + extra_price_cents INT UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (id), + KEY idx_order_item_modifier_order_item_id (order_item_id), + KEY idx_order_item_modifier_ingredient_id (ingredient_id), + CONSTRAINT fk_order_item_modifier_order_item_id FOREIGN KEY (order_item_id) + REFERENCES order_item (id) ON DELETE CASCADE, + CONSTRAINT fk_order_item_modifier_ingredient_id FOREIGN KEY (ingredient_id) + REFERENCES ingredient (id) ON DELETE RESTRICT, + CONSTRAINT chk_order_item_modifier_extra_price CHECK (extra_price_cents >= 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------------------------------------------------------------- +-- 4.19 stock_movement — append-only audit log +-- depends on ingredient, customer_order, user +-- ----------------------------------------------------------------------------- +CREATE TABLE stock_movement ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + ingredient_id INT UNSIGNED NOT NULL, + movement_type ENUM('sale','cancellation','restock','inventory_correction') NOT NULL, + delta INT NOT NULL, + order_id INT UNSIGNED NULL, + user_id INT UNSIGNED NULL, + note VARCHAR(255) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_stock_movement_ingredient_created (ingredient_id, created_at), + KEY idx_stock_movement_type_created (movement_type, created_at), + KEY idx_stock_movement_order_id (order_id), + KEY idx_stock_movement_user_id (user_id), + CONSTRAINT fk_stock_movement_ingredient_id FOREIGN KEY (ingredient_id) + REFERENCES ingredient (id) ON DELETE RESTRICT, + CONSTRAINT fk_stock_movement_order_id FOREIGN KEY (order_id) + REFERENCES customer_order (id) ON DELETE SET NULL, + CONSTRAINT fk_stock_movement_user_id FOREIGN KEY (user_id) + REFERENCES user (id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------------------------------------------------------------- +-- 4.20 audit_log — append-only sensitive-action log +-- depends on user, role +-- ----------------------------------------------------------------------------- +CREATE TABLE audit_log ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + actor_user_id INT UNSIGNED NULL, + actor_role_id INT UNSIGNED NULL, + action_code VARCHAR(60) NOT NULL, + entity_type VARCHAR(40) NULL, + entity_id INT UNSIGNED NULL, + summary VARCHAR(255) NULL, + details JSON NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_audit_log_actor_created (actor_user_id, created_at), + KEY idx_audit_log_entity (entity_type, entity_id), + KEY idx_audit_log_action_created (action_code, created_at), + KEY idx_audit_log_actor_role_id (actor_role_id), + CONSTRAINT fk_audit_log_actor_user_id FOREIGN KEY (actor_user_id) + REFERENCES user (id) ON DELETE SET NULL, + CONSTRAINT fk_audit_log_actor_role_id FOREIGN KEY (actor_role_id) + REFERENCES role (id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ============================================================================= +-- Restore session settings +-- ============================================================================= +SET FOREIGN_KEY_CHECKS = @OLD_FOREIGN_KEY_CHECKS; +SET SQL_MODE = @OLD_SQL_MODE; diff --git a/db/seed.sh b/db/seed.sh new file mode 100755 index 0000000..9b0f398 --- /dev/null +++ b/db/seed.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# +# Wakdo - seed runner. +# +# Applique les fichiers db/seeds/*.sql dans l'ordre lexicographique, de maniere +# idempotente : une table seed_history enregistre les fichiers deja charges. +# Les seeds doivent etre joues APRES les migrations (les tables doivent exister). +# +# Cible : le service docker-compose `wakdo-db`. Identifiants lus dans .env. +# +# Usage : +# bash db/seed.sh # charge les seeds en attente +# bash db/seed.sh --status # liste l'etat sans rien charger +# +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +ENV_FILE="$ROOT/.env" +CONTAINER="${WAKDO_DB_CONTAINER:-wakdo-db}" +SEEDS_DIR="$ROOT/db/seeds" + +[ -f "$ENV_FILE" ] || { echo "ERREUR : .env introuvable ($ENV_FILE)" >&2; exit 1; } +DB_NAME="$(grep -E '^DB_NAME=' "$ENV_FILE" | cut -d= -f2- | tr -d '[:space:]')" +DB_ROOT_PASSWORD="$(grep -E '^DB_ROOT_PASSWORD=' "$ENV_FILE" | cut -d= -f2-)" +: "${DB_NAME:?DB_NAME absent de .env}" +: "${DB_ROOT_PASSWORD:?DB_ROOT_PASSWORD absent de .env}" + +db() { docker exec -i "$CONTAINER" mariadb -uroot -p"$DB_ROOT_PASSWORD" "$@"; } + +docker exec "$CONTAINER" true 2>/dev/null || { echo "ERREUR : conteneur $CONTAINER non demarre (make up)" >&2; exit 1; } + +if [ ! -d "$SEEDS_DIR" ]; then + echo "[seed] aucun repertoire db/seeds/ - rien a charger" + exit 0 +fi + +db "$DB_NAME" -e "CREATE TABLE IF NOT EXISTS seed_history ( + filename VARCHAR(255) NOT NULL PRIMARY KEY, + applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;" + +shopt -s nullglob +files=("$SEEDS_DIR"/*.sql) +[ ${#files[@]} -gt 0 ] || { echo "[seed] aucun fichier seed dans $SEEDS_DIR"; exit 0; } + +if [ "${1:-}" = "--status" ]; then + echo "[seed] etat des seeds (base $DB_NAME) :" + for f in "${files[@]}"; do + base="$(basename "$f")" + n="$(db "$DB_NAME" -N -s -e "SELECT COUNT(*) FROM seed_history WHERE filename='$base';")" + [ "$n" = "0" ] && echo " PENDING $base" || echo " loaded $base" + done + exit 0 +fi + +loaded=0 +for f in "${files[@]}"; do + base="$(basename "$f")" + n="$(db "$DB_NAME" -N -s -e "SELECT COUNT(*) FROM seed_history WHERE filename='$base';")" + if [ "$n" = "0" ]; then + echo "[seed] chargement de $base ..." + db "$DB_NAME" < "$f" + db "$DB_NAME" -e "INSERT INTO seed_history (filename) VALUES ('$base');" + loaded=$((loaded + 1)) + else + echo "[seed] $base deja charge, ignore" + fi +done +echo "[seed] termine ($loaded nouveau(x) seed(s) charge(s))." -- 2.45.3 From fcf52a089574282659a5b157790b273f13747929 Mon Sep 17 00:00:00 2001 From: Imugiii Date: Mon, 15 Jun 2026 13:45:14 +0000 Subject: [PATCH 07/93] feat(db): seed data - RBAC matrix + INCO allergens + admin user + catalogue (9 cat / 53 products / 13 menus + composition) --- db/seeds/0001_rbac_and_reference.sql | 190 ++++++++++++++++++++++++++ db/seeds/0002_catalogue.sql | 195 +++++++++++++++++++++++++++ 2 files changed, 385 insertions(+) create mode 100644 db/seeds/0001_rbac_and_reference.sql create mode 100644 db/seeds/0002_catalogue.sql diff --git a/db/seeds/0001_rbac_and_reference.sql b/db/seeds/0001_rbac_and_reference.sql new file mode 100644 index 0000000..0a4f17c --- /dev/null +++ b/db/seeds/0001_rbac_and_reference.sql @@ -0,0 +1,190 @@ +-- ============================================================================= +-- Wakdo — Seed 0001 : RBAC + reference data + admin user +-- ============================================================================= +-- Purpose : Seed the foundational rows the back-office cannot boot without: +-- the 5 RBAC roles, the frozen catalogue of 23 permissions, the +-- default role/permission matrix, per-role visible order sources, +-- the 14 EU INCO allergens, and a single bootstrap admin user. +-- Source : docs/merise/dictionary.md (3.8 allergen, 3.15 role, 3.16 +-- role_visible_source, 3.17 permission catalogue + default grants, +-- 3.18 role_permission), docs/merise/mct.md (operations 1-28), +-- docs/PROJECT_CONTEXT.md section 7 (role responsibilities) and +-- decision D5 (admin gets order.create / order.deliver ; manager +-- does NOT get order.cancel). +-- Phase : P2 — demo/reference seed, applied AFTER db/migrations/0001_init_schema.sql. +-- Target : MariaDB 11.4 LTS. Fed by db/seed.sh into the already-selected DB. +-- +-- Notes: +-- - Statements are ordered so every FK resolves: role and permission first, +-- then role_permission / role_visible_source, then user (FK -> role). +-- - role_permission rows use subqueries on role.code and permission.code so +-- no surrogate ids are hardcoded (robust to AUTO_INCREMENT gaps). +-- - admin/manager get no role_visible_source rows: they have a global view of +-- all sources (the absence of rows means "no source filter applied"). +-- ============================================================================= + +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- ----------------------------------------------------------------------------- +-- 1. role (5) — dictionary.md 3.15 +-- order_source: counter/drive auto-tag their own source; admin/manager NULL +-- (they may create on behalf of any channel); kitchen NULL (read-only on +-- orders, never creates one). +-- ----------------------------------------------------------------------------- +INSERT INTO role (code, label, description, default_route, order_source, is_active) VALUES + ('admin', 'Administrator', 'Full back-office access: complete catalogue CRUD (incl. deletes), user/role/permission (RBAC) management, stock, stats, order create/deliver/cancel.', '/admin/dashboard', NULL, 1), + ('manager', 'Manager', 'Catalogue create/update, ingredient and stock management (restock + inventory), statistics. No user/RBAC administration, no order cancellation.', '/admin/stats', NULL, 1), + ('kitchen', 'Kitchen Staff', 'Read-only kitchen display (KDS) of paid orders sorted by paid_at ascending, plus inventory counting. Performs no order status transition.', '/kitchen/display', NULL, 1), + ('counter', 'Counter Staff', 'Takes orders at the counter, delivers them to the customer, can cancel. Inventory counting. source auto-tagged as counter.', '/counter/orders', 'counter', 1), + ('drive', 'Drive Staff', 'Takes orders at the drive-thru (intercom + headset), delivers them, can cancel. Inventory counting. source auto-tagged as drive.', '/drive/orders', 'drive', 1); + +-- ----------------------------------------------------------------------------- +-- 2. permission (23) — frozen catalogue, dictionary.md 3.17. +-- code format .. The catalogue is fixed at the seed and +-- never created through the UI (only assigned to roles via MANAGE_RBAC). +-- ----------------------------------------------------------------------------- +INSERT INTO permission (code, label, description) VALUES + ('product.create', 'Create product', 'Create a new catalogue product.'), + ('product.read', 'Read products', 'View products in the back-office and on order screens.'), + ('product.update', 'Update product', 'Edit an existing product (name, price, VAT, availability, etc.).'), + ('product.delete', 'Delete product', 'Permanently delete a product when no FK references block it.'), + ('menu.create', 'Create menu', 'Create a new menu with its slot configuration.'), + ('menu.read', 'Read menus', 'View menus, slots and slot options.'), + ('menu.update', 'Update menu', 'Edit an existing menu and its slot configuration.'), + ('menu.delete', 'Delete menu', 'Permanently delete a menu when no historical order references it.'), + ('category.manage', 'Manage categories', 'Create, update or deactivate product/menu categories.'), + ('ingredient.manage', 'Manage ingredients', 'Manage ingredients, product composition and allergen mapping.'), + ('stock.read', 'Read stock', 'View ingredient stock levels and movement history.'), + ('stock.count', 'Count stock', 'Record a physical inventory count (inventory correction).'), + ('stock.manage', 'Manage stock', 'Record restocks (pack deliveries) and manage stock parameters.'), + ('order.read', 'Read orders', 'View orders and the preparation display.'), + ('order.create', 'Create order', 'Create an order at the counter or drive-thru.'), + ('order.deliver', 'Deliver order', 'Mark a paid order as delivered (single-gesture handover).'), + ('order.cancel', 'Cancel order', 'Cancel a pending or paid order (restocks ingredients if paid).'), + ('user.create', 'Create user', 'Create a new back-office user.'), + ('user.read', 'Read users', 'View the list and details of back-office users.'), + ('user.update', 'Update user', 'Edit a back-office user (incl. password reset, RGPD anonymisation).'), + ('user.deactivate', 'Deactivate user', 'Deactivate a back-office user without deleting the row.'), + ('role.manage', 'Manage roles and RBAC', 'Manage roles, role/permission assignments and visible sources.'), + ('stats.read', 'Read statistics', 'Access the statistics / KPI dashboard.'); + +-- ----------------------------------------------------------------------------- +-- 3. role_permission — default matrix, dictionary.md 3.17 grants + PROJECT_CONTEXT +-- section 7 + decision D5. Subqueries on role.code / permission.code avoid +-- hardcoded ids. +-- ----------------------------------------------------------------------------- + +-- admin: ALL 23 permissions (cross join the admin role with the whole catalogue). +INSERT INTO role_permission (role_id, permission_id) +SELECT r.id, p.id +FROM role r +CROSS JOIN permission p +WHERE r.code = 'admin'; + +-- manager: catalogue create/update + category/ingredient + full stock + stats. +-- NO order.* (incl. no order.cancel per D5), NO user/role admin. +INSERT INTO role_permission (role_id, permission_id) +SELECT r.id, p.id +FROM role r +JOIN permission p ON p.code IN ( + 'product.create', 'product.read', 'product.update', + 'menu.create', 'menu.read', 'menu.update', + 'category.manage', 'ingredient.manage', + 'stock.read', 'stock.count', 'stock.manage', + 'user.read', + 'stats.read' +) +WHERE r.code = 'manager'; + +-- kitchen: read-only orders + read-only catalogue + inventory (read + count). +INSERT INTO role_permission (role_id, permission_id) +SELECT r.id, p.id +FROM role r +JOIN permission p ON p.code IN ( + 'product.read', 'menu.read', + 'stock.read', 'stock.count', + 'order.read' +) +WHERE r.code = 'kitchen'; + +-- counter: read catalogue + full order lifecycle (read/create/deliver/cancel) +-- + inventory (read + count). +INSERT INTO role_permission (role_id, permission_id) +SELECT r.id, p.id +FROM role r +JOIN permission p ON p.code IN ( + 'product.read', 'menu.read', + 'stock.read', 'stock.count', + 'order.read', 'order.create', 'order.deliver', 'order.cancel' +) +WHERE r.code = 'counter'; + +-- drive: identical grant set to counter (read catalogue + full order lifecycle +-- + inventory). The source differs (auto-tagged drive), not the rights. +INSERT INTO role_permission (role_id, permission_id) +SELECT r.id, p.id +FROM role r +JOIN permission p ON p.code IN ( + 'product.read', 'menu.read', + 'stock.read', 'stock.count', + 'order.read', 'order.create', 'order.deliver', 'order.cancel' +) +WHERE r.code = 'drive'; + +-- ----------------------------------------------------------------------------- +-- 4. role_visible_source — dictionary.md 3.16. +-- kitchen sees all 3 sources; counter sees kiosk+counter; drive sees drive. +-- admin/manager: no rows -> global view (no source filter). +-- ----------------------------------------------------------------------------- +INSERT INTO role_visible_source (role_id, source) +SELECT r.id, s.source +FROM role r +JOIN ( + SELECT 'kitchen' AS role_code, 'kiosk' AS source UNION ALL + SELECT 'kitchen', 'counter' UNION ALL + SELECT 'kitchen', 'drive' UNION ALL + SELECT 'counter', 'kiosk' UNION ALL + SELECT 'counter', 'counter' UNION ALL + SELECT 'drive', 'drive' +) s ON s.role_code = r.code; + +-- ----------------------------------------------------------------------------- +-- 5. allergen (14) — EU INCO Regulation (EU) No 1169/2011, Annex II. +-- dictionary.md 3.8. code = machine code (en), name = French display label. +-- ----------------------------------------------------------------------------- +INSERT INTO allergen (code, name, description) VALUES + ('gluten', 'Gluten', 'Cereales contenant du gluten (ble, seigle, orge, avoine, epeautre, kamut) et produits a base de ces cereales.'), + ('crustaceans', 'Crustaces', 'Crustaces et produits a base de crustaces.'), + ('eggs', 'Oeufs', 'Oeufs et produits a base d''oeufs.'), + ('fish', 'Poisson', 'Poissons et produits a base de poissons.'), + ('peanuts', 'Arachides', 'Arachides et produits a base d''arachides.'), + ('soybeans', 'Soja', 'Soja et produits a base de soja.'), + ('milk', 'Lait', 'Lait et produits a base de lait (y compris le lactose).'), + ('nuts', 'Fruits a coque', 'Fruits a coque : amandes, noisettes, noix, noix de cajou, de pecan, du Bresil, pistaches, noix de Macadamia.'), + ('celery', 'Celeri', 'Celeri et produits a base de celeri.'), + ('mustard', 'Moutarde', 'Moutarde et produits a base de moutarde.'), + ('sesame', 'Graines de sesame', 'Graines de sesame et produits a base de graines de sesame.'), + ('sulphites', 'Anhydride sulfureux et sulfites', 'Anhydride sulfureux et sulfites en concentration superieure a 10 mg/kg ou 10 mg/l (exprimes en SO2).'), + ('lupin', 'Lupin', 'Lupin et produits a base de lupin.'), + ('molluscs', 'Mollusques', 'Mollusques et produits a base de mollusques.'); + +-- ----------------------------------------------------------------------------- +-- 6. user (1) — bootstrap administrator. dictionary.md 3.14. +-- role_id resolved from role.code = 'admin'. pin_hash NULL (no PIN set yet). +-- +-- DEV password: WakdoAdmin2026! (argon2id hash below, generated via +-- `docker exec wakdo-app php -r 'echo password_hash("WakdoAdmin2026!", +-- PASSWORD_ARGON2ID);'`). MUST be changed in production — this is a known +-- demo credential and must never reach a real deployment as-is. +-- ----------------------------------------------------------------------------- +INSERT INTO user (email, password_hash, pin_hash, first_name, last_name, role_id, is_active) +SELECT + 'admin@wakdo.local', + '$argon2id$v=19$m=65536,t=4,p=1$V3dVMi55cDVBYVZPMU1TRw$8iMoNyfC12t7V2CU+YgqwvEb3xNywm7PUSIoNMgRdvc', + NULL, + 'Wakdo', + 'Admin', + r.id, + 1 +FROM role r +WHERE r.code = 'admin'; diff --git a/db/seeds/0002_catalogue.sql b/db/seeds/0002_catalogue.sql new file mode 100644 index 0000000..c5c498c --- /dev/null +++ b/db/seeds/0002_catalogue.sql @@ -0,0 +1,195 @@ +-- ============================================================================= +-- Wakdo — Seed 0002 : Catalogue (reference / demo data) +-- ============================================================================= +-- Purpose : Populate the Catalogue sub-domain (category, product, menu, +-- menu_slot, menu_slot_option) from the school JSON sources. +-- Sources : docs/merise/_sources/categories.json (9 categories) +-- docs/merise/_sources/produits.json (menus + 53 products) +-- src/public/borne/data/produits.json (cents prices + clean paths, +-- used for cross-check) +-- Phase : P2 — demo seed, assumes a fresh schema (0001_init_schema.sql). +-- +-- Conventions: +-- - Monetary amounts are INT in CENTS (euros float x 100, rounded). +-- - vat_rate is per-mille: 100 = 10% (default), 55 = 5.5% for products in +-- resealable containers (bottled water, bottled juices) — dictionary note 9. +-- - image_path is a relative path under the public root, normalised to +-- assets/images/produits//.png (dictionary note 8). +-- - Menus go to the `menu` table (NOT `product`); every other category goes +-- to `product`. The "burgers" category items are the anchor products that +-- menus reference via burger_product_id. +-- - price_maxi_cents = price_normal_cents + 150 (Maxi format, +1.50 EUR). +-- - Foreign keys are resolved by subquery on natural keys (slug / name) +-- rather than hardcoded ids. +-- - Insertion order respects FK dependencies: +-- category -> product -> menu -> menu_slot -> menu_slot_option. +-- ============================================================================= + +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- ----------------------------------------------------------------------------- +-- 1. category (9) — root table, source order = display_order +-- ----------------------------------------------------------------------------- +INSERT INTO category (name, slug, image_path, display_order, is_active) VALUES + ('menus', 'menus', 'assets/images/categories/menus.png', 1, 1), + ('boissons', 'boissons', 'assets/images/categories/boissons.png', 2, 1), + ('burgers', 'burgers', 'assets/images/categories/burgers.png', 3, 1), + ('frites', 'frites', 'assets/images/categories/frites.png', 4, 1), + ('encas', 'encas', 'assets/images/categories/encas.png', 5, 1), + ('wraps', 'wraps', 'assets/images/categories/wraps.png', 6, 1), + ('salades', 'salades', 'assets/images/categories/salades.png', 7, 1), + ('desserts', 'desserts', 'assets/images/categories/desserts.png', 8, 1), + ('sauces', 'sauces', 'assets/images/categories/sauces.png', 9, 1); + +-- ----------------------------------------------------------------------------- +-- 2. product — every non-menu item (53 rows) +-- category_id resolved via subquery on category.slug. +-- display_order follows source order within each category. +-- vat_rate defaults to 100; 55 only for resealable-container drinks +-- (Eau, Jus d'Orange, Jus de Pommes Bio) per dictionary note 9. +-- ----------------------------------------------------------------------------- + +-- 2.a burgers (anchor products for menus) +INSERT INTO product (category_id, name, price_cents, vat_rate, image_path, is_available, display_order) VALUES + ((SELECT id FROM category WHERE slug='burgers'), 'Le 280', 680, 100, 'assets/images/produits/burgers/280.png', 1, 1), + ((SELECT id FROM category WHERE slug='burgers'), 'Big Tasty', 860, 100, 'assets/images/produits/burgers/big-tasty-1-viande.png', 1, 2), + ((SELECT id FROM category WHERE slug='burgers'), 'Big Tasty Bacon', 890, 100, 'assets/images/produits/burgers/big-tasty-bacon-1-viande.png', 1, 3), + ((SELECT id FROM category WHERE slug='burgers'), 'Big Mac', 600, 100, 'assets/images/produits/burgers/bigmac.png', 1, 4), + ((SELECT id FROM category WHERE slug='burgers'), 'CBO', 890, 100, 'assets/images/produits/burgers/cbo.png', 1, 5), + ((SELECT id FROM category WHERE slug='burgers'), 'MC Chicken', 730, 100, 'assets/images/produits/burgers/mcchicken.png', 1, 6), + ((SELECT id FROM category WHERE slug='burgers'), 'MC Crispy', 530, 100, 'assets/images/produits/burgers/mccrispy.png', 1, 7), + ((SELECT id FROM category WHERE slug='burgers'), 'MC Fish', 485, 100, 'assets/images/produits/burgers/mcfish.png', 1, 8), + ((SELECT id FROM category WHERE slug='burgers'), 'Royal Bacon', 510, 100, 'assets/images/produits/burgers/royalbacon.png', 1, 9), + ((SELECT id FROM category WHERE slug='burgers'), 'Royal Cheese', 440, 100, 'assets/images/produits/burgers/royalcheese.png', 1, 10), + ((SELECT id FROM category WHERE slug='burgers'), 'Royal Deluxe', 540, 100, 'assets/images/produits/burgers/royaldeluxe.png', 1, 11), + ((SELECT id FROM category WHERE slug='burgers'), 'Signature BBQ Beef 2 viandes', 1140, 100, 'assets/images/produits/burgers/signature-bbq-beef-2-viandes.png', 1, 12), + ((SELECT id FROM category WHERE slug='burgers'), 'Signature Beef BBQ', 1030, 100, 'assets/images/produits/burgers/signature-beef-bbq-burger-1-viande.png', 1, 13); + +-- 2.b boissons (Eau + the two bottled juices are resealable-container = vat_rate 55) +INSERT INTO product (category_id, name, price_cents, vat_rate, image_path, is_available, display_order) VALUES + ((SELECT id FROM category WHERE slug='boissons'), 'Coca Cola', 190, 100, 'assets/images/produits/boissons/coca-cola.png', 1, 1), + ((SELECT id FROM category WHERE slug='boissons'), 'Coca Sans Sucres', 190, 100, 'assets/images/produits/boissons/coca-sans-sucres.png', 1, 2), + ((SELECT id FROM category WHERE slug='boissons'), 'Eau', 100, 55, 'assets/images/produits/boissons/eau.png', 1, 3), + ((SELECT id FROM category WHERE slug='boissons'), 'Fanta Orange', 190, 100, 'assets/images/produits/boissons/fanta.png', 1, 4), + ((SELECT id FROM category WHERE slug='boissons'), 'Ice Tea Peche', 190, 100, 'assets/images/produits/boissons/ice-tea-peche.png', 1, 5), + ((SELECT id FROM category WHERE slug='boissons'), 'Ice Tea Citron', 190, 100, 'assets/images/produits/boissons/the-vert-citron-sans-sucres.png', 1, 6), + ((SELECT id FROM category WHERE slug='boissons'), 'Jus d''Orange', 210, 55, 'assets/images/produits/boissons/jus-orange.png', 1, 7), + ((SELECT id FROM category WHERE slug='boissons'), 'Jus de Pommes Bio', 230, 55, 'assets/images/produits/boissons/jus-pomme-bio.png', 1, 8); + +-- 2.c frites +INSERT INTO product (category_id, name, price_cents, vat_rate, image_path, is_available, display_order) VALUES + ((SELECT id FROM category WHERE slug='frites'), 'Petite Frite', 145, 100, 'assets/images/produits/frites/petite-frite.png', 1, 1), + ((SELECT id FROM category WHERE slug='frites'), 'Moyenne Frite', 275, 100, 'assets/images/produits/frites/moyenne-frite.png', 1, 2), + ((SELECT id FROM category WHERE slug='frites'), 'Grande Frite', 350, 100, 'assets/images/produits/frites/grande-frite.png', 1, 3), + ((SELECT id FROM category WHERE slug='frites'), 'Potatoes', 215, 100, 'assets/images/produits/frites/potatoes.png', 1, 4), + ((SELECT id FROM category WHERE slug='frites'), 'Grande Potatoes', 340, 100, 'assets/images/produits/frites/grande-potatoes.png', 1, 5); + +-- 2.d encas +INSERT INTO product (category_id, name, price_cents, vat_rate, image_path, is_available, display_order) VALUES + ((SELECT id FROM category WHERE slug='encas'), 'Cheeseburger', 260, 100, 'assets/images/produits/encas/cheeseburger.png', 1, 1), + ((SELECT id FROM category WHERE slug='encas'), 'Croc MCdo', 320, 100, 'assets/images/produits/encas/croc-mc-do.png', 1, 2), + ((SELECT id FROM category WHERE slug='encas'), 'Nuggets x4', 420, 100, 'assets/images/produits/encas/nuggets-4.png', 1, 3), + ((SELECT id FROM category WHERE slug='encas'), 'Nuggets x20', 1300, 100, 'assets/images/produits/encas/nuggets-20.png', 1, 4); + +-- 2.e wraps +INSERT INTO product (category_id, name, price_cents, vat_rate, image_path, is_available, display_order) VALUES + ((SELECT id FROM category WHERE slug='wraps'), 'MC Wrap Chevre', 310, 100, 'assets/images/produits/wraps/mcwrap-chevre.png', 1, 1), + ((SELECT id FROM category WHERE slug='wraps'), 'MC Wrap Poulet Bacon', 330, 100, 'assets/images/produits/wraps/mcwrap-poulet-bacon.png', 1, 2), + ((SELECT id FROM category WHERE slug='wraps'), 'Ptit Wrap Chevre', 260, 100, 'assets/images/produits/wraps/ptit-wrap-chevre.png', 1, 3), + ((SELECT id FROM category WHERE slug='wraps'), 'Ptit Wrap Ranch', 260, 100, 'assets/images/produits/wraps/ptit-wrap-ranch.png', 1, 4); + +-- 2.f salades +INSERT INTO product (category_id, name, price_cents, vat_rate, image_path, is_available, display_order) VALUES + ((SELECT id FROM category WHERE slug='salades'), 'Petite Salade', 330, 100, 'assets/images/produits/salades/petite-salade.png', 1, 1), + ((SELECT id FROM category WHERE slug='salades'), 'Cesar Classic', 880, 100, 'assets/images/produits/salades/salade-classic-caesar.png', 1, 2), + ((SELECT id FROM category WHERE slug='salades'), 'Italienne Mozza', 880, 100, 'assets/images/produits/salades/salade-italian-mozza.png', 1, 3); + +-- 2.g desserts +INSERT INTO product (category_id, name, price_cents, vat_rate, image_path, is_available, display_order) VALUES + ((SELECT id FROM category WHERE slug='desserts'), 'Brownie', 260, 100, 'assets/images/produits/desserts/brownies.png', 1, 1), + ((SELECT id FROM category WHERE slug='desserts'), 'Cheesecake chocolat M&M''S', 310, 100, 'assets/images/produits/desserts/cheesecake-choconuts-m&m-s.png', 1, 2), + ((SELECT id FROM category WHERE slug='desserts'), 'Cheesecake Fraise', 310, 100, 'assets/images/produits/desserts/cheesecake-fraise.png', 1, 3), + ((SELECT id FROM category WHERE slug='desserts'), 'Cookie', 320, 100, 'assets/images/produits/desserts/cookie.png', 1, 4), + ((SELECT id FROM category WHERE slug='desserts'), 'Donut', 260, 100, 'assets/images/produits/desserts/doghnut.png', 1, 5), + ((SELECT id FROM category WHERE slug='desserts'), 'Macarons', 270, 100, 'assets/images/produits/desserts/macarons.png', 1, 6), + ((SELECT id FROM category WHERE slug='desserts'), 'MC Fleury', 440, 100, 'assets/images/produits/desserts/mcfleury.png', 1, 7), + ((SELECT id FROM category WHERE slug='desserts'), 'Muffin', 360, 100, 'assets/images/produits/desserts/muffin.png', 1, 8), + ((SELECT id FROM category WHERE slug='desserts'), 'Sunday', 100, 100, 'assets/images/produits/desserts/sunday.png', 1, 9); + +-- 2.h sauces +INSERT INTO product (category_id, name, price_cents, vat_rate, image_path, is_available, display_order) VALUES + ((SELECT id FROM category WHERE slug='sauces'), 'Classic Barbecue', 70, 100, 'assets/images/produits/sauces/classic-barbecue.png', 1, 1), + ((SELECT id FROM category WHERE slug='sauces'), 'Classic Moutarde', 70, 100, 'assets/images/produits/sauces/classic-moutarde.png', 1, 2), + ((SELECT id FROM category WHERE slug='sauces'), 'Creamy Deluxe', 70, 100, 'assets/images/produits/sauces/cremy-deluxe.png', 1, 3), + ((SELECT id FROM category WHERE slug='sauces'), 'Ketchup', 70, 100, 'assets/images/produits/sauces/ketchup.png', 1, 4), + ((SELECT id FROM category WHERE slug='sauces'), 'Chinoise', 70, 100, 'assets/images/produits/sauces/sauce-chinoise.png', 1, 5), + ((SELECT id FROM category WHERE slug='sauces'), 'Curry', 70, 100, 'assets/images/produits/sauces/sauce-curry.png', 1, 6), + ((SELECT id FROM category WHERE slug='sauces'), 'Pommes Frites', 70, 100, 'assets/images/produits/sauces/sauce-pommes-frite.png', 1, 7); + +-- ----------------------------------------------------------------------------- +-- 3. menu (13) — the "menus" category items. +-- category_id = the menus category. +-- burger_product_id resolved by matching the anchor burger name +-- ("Menu Le 280" -> product "Le 280", etc.). +-- price_normal_cents from source; price_maxi_cents = normal + 150. +-- ----------------------------------------------------------------------------- +INSERT INTO menu (category_id, burger_product_id, name, price_normal_cents, price_maxi_cents, image_path, is_available, display_order) VALUES + ((SELECT id FROM category WHERE slug='menus'), (SELECT id FROM product WHERE name='Le 280'), 'Menu Le 280', 880, 1030, 'assets/images/produits/burgers/280.png', 1, 1), + ((SELECT id FROM category WHERE slug='menus'), (SELECT id FROM product WHERE name='Big Tasty'), 'Menu Big Tasty', 1060, 1210, 'assets/images/produits/burgers/big-tasty-1-viande.png', 1, 2), + ((SELECT id FROM category WHERE slug='menus'), (SELECT id FROM product WHERE name='Big Tasty Bacon'), 'Menu Big Tasty Bacon', 1090, 1240, 'assets/images/produits/burgers/big-tasty-bacon-1-viande.png', 1, 3), + ((SELECT id FROM category WHERE slug='menus'), (SELECT id FROM product WHERE name='Big Mac'), 'Menu Big Mac', 800, 950, 'assets/images/produits/burgers/bigmac.png', 1, 4), + ((SELECT id FROM category WHERE slug='menus'), (SELECT id FROM product WHERE name='CBO'), 'Menu CBO', 1090, 1240, 'assets/images/produits/burgers/cbo.png', 1, 5), + ((SELECT id FROM category WHERE slug='menus'), (SELECT id FROM product WHERE name='MC Chicken'), 'Menu MC Chicken', 930, 1080, 'assets/images/produits/burgers/mcchicken.png', 1, 6), + ((SELECT id FROM category WHERE slug='menus'), (SELECT id FROM product WHERE name='MC Crispy'), 'Menu MC Crispy', 720, 870, 'assets/images/produits/burgers/mccrispy.png', 1, 7), + ((SELECT id FROM category WHERE slug='menus'), (SELECT id FROM product WHERE name='MC Fish'), 'Menu MC Fish', 720, 870, 'assets/images/produits/burgers/mcfish.png', 1, 8), + ((SELECT id FROM category WHERE slug='menus'), (SELECT id FROM product WHERE name='Royal Bacon'), 'Menu Royal Bacon', 705, 855, 'assets/images/produits/burgers/royalbacon.png', 1, 9), + ((SELECT id FROM category WHERE slug='menus'), (SELECT id FROM product WHERE name='Royal Cheese'), 'Menu Royal Cheese', 640, 790, 'assets/images/produits/burgers/royalcheese.png', 1, 10), + ((SELECT id FROM category WHERE slug='menus'), (SELECT id FROM product WHERE name='Royal Deluxe'), 'Menu Royal Deluxe', 740, 890, 'assets/images/produits/burgers/royaldeluxe.png', 1, 11), + ((SELECT id FROM category WHERE slug='menus'), (SELECT id FROM product WHERE name='Signature BBQ Beef 2 viandes'), 'Menu Signature BBQ Beef 2 viandes', 1350, 1500, 'assets/images/produits/burgers/signature-bbq-beef-2-viandes.png', 1, 12), + ((SELECT id FROM category WHERE slug='menus'), (SELECT id FROM product WHERE name='Signature Beef BBQ'), 'Menu Signature Beef BBQ', 1190, 1340, 'assets/images/produits/burgers/signature-beef-bbq-burger-1-viande.png', 1, 13); + +-- ----------------------------------------------------------------------------- +-- 4. menu_slot — three standard slots per menu: +-- drink (required), side (required), sauce (optional). +-- One INSERT per slot_type, fanning out over all 13 menus via SELECT. +-- ----------------------------------------------------------------------------- +INSERT INTO menu_slot (menu_id, name, slot_type, is_required, display_order) +SELECT m.id, 'Boisson', 'drink', 1, 1 +FROM menu m +JOIN category c ON c.id = m.category_id AND c.slug = 'menus'; + +INSERT INTO menu_slot (menu_id, name, slot_type, is_required, display_order) +SELECT m.id, 'Accompagnement', 'side', 1, 2 +FROM menu m +JOIN category c ON c.id = m.category_id AND c.slug = 'menus'; + +INSERT INTO menu_slot (menu_id, name, slot_type, is_required, display_order) +SELECT m.id, 'Sauce', 'sauce', 0, 3 +FROM menu m +JOIN category c ON c.id = m.category_id AND c.slug = 'menus'; + +-- ----------------------------------------------------------------------------- +-- 5. menu_slot_option — eligible products per slot: +-- drink slot -> all products in category 'boissons' +-- side slot -> all products in category 'frites' +-- sauce slot -> all products in category 'sauces' +-- Composite PK (menu_slot_id, product_id) is naturally satisfied: each +-- (slot, product) pair is unique because slots are unique per menu. +-- ----------------------------------------------------------------------------- +INSERT INTO menu_slot_option (menu_slot_id, product_id) +SELECT ms.id, p.id +FROM menu_slot ms +JOIN product p ON p.category_id = (SELECT id FROM category WHERE slug='boissons') +WHERE ms.slot_type = 'drink'; + +INSERT INTO menu_slot_option (menu_slot_id, product_id) +SELECT ms.id, p.id +FROM menu_slot ms +JOIN product p ON p.category_id = (SELECT id FROM category WHERE slug='frites') +WHERE ms.slot_type = 'side'; + +INSERT INTO menu_slot_option (menu_slot_id, product_id) +SELECT ms.id, p.id +FROM menu_slot ms +JOIN product p ON p.category_id = (SELECT id FROM category WHERE slug='sauces') +WHERE ms.slot_type = 'sauce'; -- 2.45.3 From 04404dc8c50f426b98c90bf92cf729950e8d80cd Mon Sep 17 00:00:00 2001 From: Imugiii Date: Mon, 15 Jun 2026 13:47:58 +0000 Subject: [PATCH 08/93] docs: clarify manager has read-only user access (user.read), not zero access --- docs/PROJECT_CONTEXT.md | 2 +- docs/uml/use-cases.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index 674c287..3429b94 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -229,7 +229,7 @@ Reseaux : - Authentification sessions securisees (hash bcrypt/argon2, protection CSRF, fixation session) — duree de session adaptee a un poste complet d'equipier (idle timeout 4h, absolute timeout 10h) - 5 roles RBAC seed : `admin`, `manager`, `kitchen`, `counter`, `drive` (RBAC permission-driven, 23 permissions figees au seed ; roles personnalises possibles) - **Admin** : CRUD complet catalogue (+ suppressions), gestion utilisateurs, roles et permissions (RBAC), stats -- **Manager** : catalogue (create/update), stock (reappro + inventaire), statistiques ; pas d'acces utilisateurs ni RBAC +- **Manager** : catalogue (create/update), stock (reappro + inventaire), statistiques ; utilisateurs en **lecture seule** (`user.read`, pas de creation/modification/desactivation), pas d'acces RBAC - **Kitchen** : file des commandes `paid` triee par `paid_at` croissant, en **lecture seule** (KDS visuel) ; inventaire - **Counter** / **Drive** : saisir une commande (comptoir / drive-thru via casque/intercom), bouton "declarer livree" (geste unique `paid -> delivered`), annuler ; `source` auto-tague depuis `role.order_source` ; inventaire - Upload images produits (validation type MIME + taille + stockage dans volume `wakdo_uploads`) diff --git a/docs/uml/use-cases.md b/docs/uml/use-cases.md index 3a615a7..7f7e6ab 100644 --- a/docs/uml/use-cases.md +++ b/docs/uml/use-cases.md @@ -42,7 +42,7 @@ multi-canal. Chaque acteur candidat est confronte au perimetre reel. | **Client (borne kiosk)** | Retenu (acteur `CUSTOMER`) | Acteur central du Bloc 1. Compose et valide une commande sur la borne tactile autonome (canal `kiosk`). **Non authentifie**. | | **Accueil** | **Scinde** en `counter` et `drive` | Le besoin "Accueil" recouvre deux canaux operationnels distincts : le comptoir (`counter`) et le drive (`drive`). Le v0.2 les separe car le tag `source` de la commande et le filtre de dashboard (`role_visible_source`) different. Tous deux saisissent des commandes, les remettent et les annulent. | | **Preparation** | Retenu, renomme `kitchen` | Role RBAC `kitchen`. Voit la file des commandes `paid` triees par `paid_at` croissant. **Lecture seule** : ne declenche aucune transition de statut (le KDS est un dispositif visuel ; la remise revient a `counter`/`drive`). | -| **Administration** | **Scinde** en `admin` et `manager` | Le v0.1 fusionnait "Manager/Admin". Le v0.2 distingue : `admin` (gestion des utilisateurs, des roles et permissions, suppressions catalogue) et `manager` (catalogue create/update, stock/reappro, stats), sans acces aux utilisateurs ni au RBAC. Resout le point ouvert v0.1 "Manager vs Admin". | +| **Administration** | **Scinde** en `admin` et `manager` | Le v0.1 fusionnait "Manager/Admin". Le v0.2 distingue : `admin` (gestion des utilisateurs, des roles et permissions, suppressions catalogue) et `manager` (catalogue create/update, stock/reappro, stats), utilisateurs en lecture seule (`user.read`) et sans acces au RBAC. Resout le point ouvert v0.1 "Manager vs Admin". | | **Caisse** | Ecarte (recouvert par `counter`/`drive`) | Aucun role `caisse` n'existe. L'encaissement est atomique a la creation de commande (saisie du numero = substitut de paiement) ; il est realise par le Client (kiosk) ou par `counter`/`drive` (back-office). Resout le point ouvert v0.1 "Caisse absente du RBAC". | | **Systeme** | Retenu (acteur `SYS`) | Logique interne (generation du numero, reponse API de confirmation). Apparait dans le MCT (3.4 `DISPLAY_CONFIRMATION`) ; non represente comme acteur humain au diagramme. | -- 2.45.3 From 8c93b26ec0911b3b68041cba155f675521f074dc Mon Sep 17 00:00:00 2001 From: Imugiii Date: Mon, 15 Jun 2026 14:13:49 +0000 Subject: [PATCH 09/93] feat(core): from-scratch PHP MVC skeleton (autoloader/config/PDO/router/front controller) + PHPUnit/PHPStan + composer-less CI --- .forgejo/workflows/ci.yml | 42 +++++--- .gitignore | 5 +- phpstan.neon | 22 ++++ phpunit.xml | 26 +++++ src/Controllers/HealthController.php | 49 +++++++++ src/Controllers/HomeController.php | 28 ++++++ src/Core/Autoloader.php | 43 ++++++++ src/Core/Config.php | 80 +++++++++++++++ src/Core/Controller.php | 68 +++++++++++++ src/Core/Database.php | 94 +++++++++++++++++ src/Core/Request.php | 145 +++++++++++++++++++++++++++ src/Core/Response.php | 96 ++++++++++++++++++ src/Core/Router.php | 104 +++++++++++++++++++ src/Views/home.php | 25 +++++ src/Views/layout.php | 32 ++++++ src/public/admin/index.php | 75 ++++++++------ tests/Unit/ConfigTest.php | 125 +++++++++++++++++++++++ tests/Unit/RouterTest.php | 123 +++++++++++++++++++++++ tests/bootstrap.php | 13 +++ 19 files changed, 1151 insertions(+), 44 deletions(-) create mode 100644 phpstan.neon create mode 100644 phpunit.xml create mode 100644 src/Controllers/HealthController.php create mode 100644 src/Controllers/HomeController.php create mode 100644 src/Core/Autoloader.php create mode 100644 src/Core/Config.php create mode 100644 src/Core/Controller.php create mode 100644 src/Core/Database.php create mode 100644 src/Core/Request.php create mode 100644 src/Core/Response.php create mode 100644 src/Core/Router.php create mode 100644 src/Views/home.php create mode 100644 src/Views/layout.php create mode 100644 tests/Unit/ConfigTest.php create mode 100644 tests/Unit/RouterTest.php create mode 100644 tests/bootstrap.php diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index fa2da16..f16db0c 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -58,30 +58,42 @@ jobs: static-tests: runs-on: docker + # COMPOSER-LESS (decision 4 / 5, PROJECT_CONTEXT.md) : PHPStan et PHPUnit + # tournent depuis leur .phar autonome telecharge ici, jamais via Composer. + # Versions epinglees pour des CI reproductibles (pas de "latest"). + env: + PHPUNIT_VERSION: "11.5.2" + PHPSTAN_VERSION: "1.12.27" steps: - uses: actions/checkout@v4 - name: PHPStan (guarded) run: | - if [ -f composer.json ] && [ -f phpstan.neon ]; then - echo "phpstan config detected - running" - apt-get update -qq && apt-get install -y -qq php-cli unzip git >/dev/null - curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer - composer install --no-interaction --no-progress - vendor/bin/phpstan analyse --no-progress - else - echo "PHPStan skipped: no composer.json/phpstan.neon yet (activates in P2)" + set -eu + if [ ! -f phpstan.neon ]; then + echo "PHPStan skipped: no phpstan.neon yet (activates in P2)" + exit 0 fi + echo "phpstan.neon detected - running PHPStan ${PHPSTAN_VERSION} via .phar" + apt-get update -qq && apt-get install -y -qq php-cli curl ca-certificates >/dev/null + # PHPUnit phar present pour que phpstan.neon (scanDirectories phar://phpunit.phar) + # resolve les symboles PHPUnit\Framework\* utilises sous tests/. + curl -sSL "https://phar.phpunit.de/phpunit-${PHPUNIT_VERSION}.phar" -o phpunit.phar + curl -sSL "https://github.com/phpstan/phpstan/releases/download/${PHPSTAN_VERSION}/phpstan.phar" -o phpstan.phar + php phpstan.phar --version + # memory_limit=-1 : l'analyse parallele depasse les 128M par defaut du php-cli. + php -d memory_limit=-1 phpstan.phar analyse --no-progress --error-format=raw - name: PHPUnit (guarded) run: | - if [ -d tests ] && [ -f phpunit.xml ]; then - echo "phpunit config detected - running" - apt-get update -qq && apt-get install -y -qq php-cli >/dev/null - if [ -f vendor/bin/phpunit ]; then vendor/bin/phpunit; \ - elif [ -f phpunit.phar ]; then php phpunit.phar; \ - else echo "phpunit binary missing despite config" && exit 1; fi - else + set -eu + if [ ! -d tests ] || [ ! -f phpunit.xml ]; then echo "PHPUnit skipped: no tests/ + phpunit.xml yet (activates in P2)" + exit 0 fi + echo "phpunit.xml + tests/ detected - running PHPUnit ${PHPUNIT_VERSION} via .phar" + apt-get update -qq && apt-get install -y -qq php-cli curl ca-certificates >/dev/null + curl -sSL "https://phar.phpunit.de/phpunit-${PHPUNIT_VERSION}.phar" -o phpunit.phar + php phpunit.phar --version + php phpunit.phar -c phpunit.xml auto-merge: # Fusion automatique OPT-IN : poser le label `auto-merge` sur la PR. diff --git a/.gitignore b/.gitignore index dffbf98..b876df3 100644 --- a/.gitignore +++ b/.gitignore @@ -28,8 +28,11 @@ vendor/ composer.lock composer.phar -# === Tests === +# === Tests / Analyse statique (tooling via .phar autonome, sans Composer) === .phpunit.result.cache +.phpunit.cache/ +/phpunit.phar +/phpstan.phar /tests/_output/ /tests/_support/_generated/ diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..6e3dabb --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,22 @@ +# Analyse statique sans Composer : lancee via le .phar autonome +# (php phpstan.phar analyse --no-progress --error-format=raw). +# Aucune baseline, aucun vendor/ : colle au "from scratch" (PROJECT_CONTEXT.md decision 4). +parameters: + level: 6 + paths: + - src + - tests + treatPhpDocTypesAsCertain: false + # Les classes de PHPUnit (TestCase, ...) vivent dans le .phar autonome, hors + # de src/. On les expose a PHPStan en scannant le phar telecharge par la CI. + # Si phpunit.phar est absent (analyse de src/ seul en local), la ligne est + # sans effet : on neutralise alors le bruit "classe inconnue" cote tests. + scanDirectories: + - phar://phpunit.phar + ignoreErrors: + # Tolere l'absence de phpunit.phar en local : les symboles PHPUnit ne + # sont alors pas resolus. En CI le phar est present, l'analyse est complete. + - + identifier: class.notFound + path: tests/* + reportUnmatched: false diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..a1f3799 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,26 @@ + + + + + + tests/Unit + + + + + src + + + diff --git a/src/Controllers/HealthController.php b/src/Controllers/HealthController.php new file mode 100644 index 0000000..d9e7750 --- /dev/null +++ b/src/Controllers/HealthController.php @@ -0,0 +1,49 @@ + routeur + * -> controleur -> PDO -> BDD seedee), pas seulement que PHP repond. + */ +final class HealthController extends Controller +{ + /** + * @param array $params + */ + public function index(array $params = []): Response + { + $dbStatus = 'ok'; + $categories = null; + $httpStatus = 200; + + try { + $row = $this->database->fetch('SELECT COUNT(*) AS total FROM category'); + $categories = (int) ($row['total'] ?? 0); + } catch (Throwable) { + // Detail de l'erreur volontairement non expose (information disclosure) ; + // un statut degrade suffit a la sonde, les logs conteneur portent le reste. + $dbStatus = 'error'; + $httpStatus = 503; + } + + return $this->json( + [ + 'status' => $dbStatus === 'ok' ? 'ok' : 'degraded', + 'app_env' => $this->config->appEnv(), + 'php_version' => PHP_VERSION, + 'db' => $dbStatus, + 'categories' => $categories, + ], + $httpStatus, + ); + } +} diff --git a/src/Controllers/HomeController.php b/src/Controllers/HomeController.php new file mode 100644 index 0000000..e29cf2b --- /dev/null +++ b/src/Controllers/HomeController.php @@ -0,0 +1,28 @@ + vue -> layout sans dependre de la BDD. + */ +final class HomeController extends Controller +{ + /** + * @param array $params + */ + public function index(array $params = []): Response + { + return $this->view('home', [ + 'title' => 'Wakdo back-office', + 'appEnv' => $this->config->appEnv(), + ]); + } +} diff --git a/src/Core/Autoloader.php b/src/Core/Autoloader.php new file mode 100644 index 0000000..06f4a10 --- /dev/null +++ b/src/Core/Autoloader.php @@ -0,0 +1,43 @@ + {src}/Core/Router.php + */ +final class Autoloader +{ + private const PREFIX = 'App\\'; + + /** + * Enregistre l'autoloader aupres de la pile SPL. + * + * La racine src/ est calculee depuis l'emplacement de ce fichier + * (src/Core/Autoloader.php) : dirname(__DIR__) remonte de Core/ a src/. + * Aucun chemin code en dur, donc portable host/conteneur. + */ + public static function register(): void + { + $root = dirname(__DIR__); + + spl_autoload_register(static function (string $class) use ($root): void { + if (!str_starts_with($class, self::PREFIX)) { + return; + } + + $relative = substr($class, strlen(self::PREFIX)); + $path = $root . DIRECTORY_SEPARATOR + . str_replace('\\', DIRECTORY_SEPARATOR, $relative) + . '.php'; + + if (is_file($path)) { + require $path; + } + }); + } +} diff --git a/src/Core/Config.php b/src/Core/Config.php new file mode 100644 index 0000000..550a2e9 --- /dev/null +++ b/src/Core/Config.php @@ -0,0 +1,80 @@ +get($key); + + if ($value === null) { + throw new RuntimeException(sprintf('Missing required configuration: %s', $key)); + } + + return $value; + } + + public function int(string $key, int $default = 0): int + { + $value = $this->get($key); + + return $value === null ? $default : (int) $value; + } + + /** + * Interprete les conventions usuelles de booleen textuel d'environnement. + */ + public function bool(string $key, bool $default = false): bool + { + $value = $this->get($key); + + if ($value === null) { + return $default; + } + + return in_array(strtolower($value), ['1', 'true', 'yes', 'on'], true); + } + + public function appEnv(): string + { + return $this->get('APP_ENV', 'production') ?? 'production'; + } + + public function isDebug(): bool + { + return $this->bool('APP_DEBUG', false); + } + + public function timezone(): string + { + return $this->get('APP_TIMEZONE', 'UTC') ?? 'UTC'; + } +} diff --git a/src/Core/Controller.php b/src/Core/Controller.php new file mode 100644 index 0000000..a481fd6 --- /dev/null +++ b/src/Core/Controller.php @@ -0,0 +1,68 @@ + ProductController, etc., demonstration heritage Cr 4.c.1). + * + * Recoit ses dependances par constructeur : la requete courante, la config et + * l'acces BDD, injectes par le Router. + */ +abstract class Controller +{ + public function __construct( + protected readonly Request $request, + protected readonly Config $config, + protected readonly Database $database, + ) { + } + + /** + * @param array $data + */ + protected function json(array $data, int $status = 200): Response + { + return (new Response())->json($data, $status); + } + + /** + * Rend une vue PHP sous src/Views/.php avec ses donnees extraites. + * + * Le rendu est bufferise puis injecte dans le layout via la variable + * $content, ce qui permet aux vues de rester de simples fragments. + * + * @param array $data + */ + protected function view(string $name, array $data = [], int $status = 200): Response + { + $content = $this->render($name, $data); + $html = $this->render('layout', $data + ['content' => $content]); + + return (new Response())->html($html, $status); + } + + /** + * @param array $data + */ + private function render(string $name, array $data): string + { + $file = dirname(__DIR__) . '/Views/' . $name . '.php'; + + if (!is_file($file)) { + throw new RuntimeException(sprintf('View not found: %s', $name)); + } + + // Les cles deviennent des variables locales a la vue ; le buffering + // capture le HTML produit sans l'emettre directement. + extract($data, EXTR_SKIP); + ob_start(); + require $file; + + return (string) ob_get_clean(); + } +} diff --git a/src/Core/Database.php b/src/Core/Database.php new file mode 100644 index 0000000..d149d5e --- /dev/null +++ b/src/Core/Database.php @@ -0,0 +1,94 @@ +pdo === null) { + $dsn = sprintf( + 'mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4', + $this->config->required('DB_HOST'), + $this->config->int('DB_PORT', 3306), + $this->config->required('DB_NAME'), + ); + + $this->pdo = new PDO( + $dsn, + $this->config->required('DB_USER'), + $this->config->required('DB_PASSWORD'), + [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + // Vraies requetes preparees cote serveur (pas d'emulation) : + // le SQL et les valeurs voyagent separement, fermant l'injection. + PDO::ATTR_EMULATE_PREPARES => false, + ], + ); + } + + return $this->pdo; + } + + /** + * Prepare puis execute une requete avec ses parametres lies. + * + * @param array $params + */ + public function query(string $sql, array $params = []): PDOStatement + { + $statement = $this->pdo()->prepare($sql); + $statement->execute($params); + + return $statement; + } + + /** + * @param array $params + * @return array|null + */ + public function fetch(string $sql, array $params = []): ?array + { + $row = $this->query($sql, $params)->fetch(); + + return $row === false ? null : $row; + } + + /** + * @param array $params + * @return array> + */ + public function fetchAll(string $sql, array $params = []): array + { + return $this->query($sql, $params)->fetchAll(); + } + + /** + * Execute une ecriture et renvoie le nombre de lignes affectees. + * + * @param array $params + */ + public function execute(string $sql, array $params = []): int + { + return $this->query($sql, $params)->rowCount(); + } +} diff --git a/src/Core/Request.php b/src/Core/Request.php new file mode 100644 index 0000000..0e124b9 --- /dev/null +++ b/src/Core/Request.php @@ -0,0 +1,145 @@ + $query + * @param array $headers + */ + public function __construct( + private readonly string $method, + private readonly string $path, + private readonly array $query, + private readonly array $headers, + private readonly string $rawBody, + ) { + } + + public static function fromGlobals(): self + { + $method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')); + + // REQUEST_URI inclut la query string ; on isole le chemin seul. + $uri = (string) ($_SERVER['REQUEST_URI'] ?? '/'); + $path = parse_url($uri, PHP_URL_PATH); + $path = is_string($path) ? $path : '/'; + $path = self::normalizePath($path); + + /** @var array $query */ + $query = $_GET; + + return new self( + $method, + $path, + $query, + self::extractHeaders(), + (string) file_get_contents('php://input'), + ); + } + + /** + * Garde un slash de tete et retire le slash de fin (sauf racine) pour + * que "/api/health/" et "/api/health" matchent la meme route. + */ + private static function normalizePath(string $path): string + { + if ($path === '') { + return '/'; + } + + if ($path[0] !== '/') { + $path = '/' . $path; + } + + if ($path !== '/' && str_ends_with($path, '/')) { + $path = rtrim($path, '/'); + } + + return $path; + } + + /** + * @return array + */ + private static function extractHeaders(): array + { + $headers = []; + + foreach ($_SERVER as $key => $value) { + if (str_starts_with($key, 'HTTP_')) { + $name = str_replace('_', '-', substr($key, 5)); + $headers[strtolower($name)] = (string) $value; + } + } + + // Content-Type / Content-Length ne sont pas prefixes HTTP_ par PHP. + if (isset($_SERVER['CONTENT_TYPE'])) { + $headers['content-type'] = (string) $_SERVER['CONTENT_TYPE']; + } + if (isset($_SERVER['CONTENT_LENGTH'])) { + $headers['content-length'] = (string) $_SERVER['CONTENT_LENGTH']; + } + + return $headers; + } + + public function method(): string + { + return $this->method; + } + + public function path(): string + { + return $this->path; + } + + public function query(string $key, ?string $default = null): ?string + { + return $this->query[$key] ?? $default; + } + + /** + * @return array + */ + public function allQuery(): array + { + return $this->query; + } + + public function header(string $name, ?string $default = null): ?string + { + return $this->headers[strtolower($name)] ?? $default; + } + + public function rawBody(): string + { + return $this->rawBody; + } + + /** + * Decode le corps JSON ; renvoie un tableau vide si le corps est vide ou + * invalide, pour laisser la validation metier decider (pas de fatale ici). + * + * @return array + */ + public function json(): array + { + if ($this->rawBody === '') { + return []; + } + + $decoded = json_decode($this->rawBody, true); + + return is_array($decoded) ? $decoded : []; + } +} diff --git a/src/Core/Response.php b/src/Core/Response.php new file mode 100644 index 0000000..294e370 --- /dev/null +++ b/src/Core/Response.php @@ -0,0 +1,96 @@ + */ + private array $headers = []; + + private string $body = ''; + + public function __construct(private int $status = 200) + { + } + + public function setStatus(int $status): self + { + $this->status = $status; + + return $this; + } + + public function status(): int + { + return $this->status; + } + + public function setHeader(string $name, string $value): self + { + $this->headers[$name] = $value; + + return $this; + } + + public function setBody(string $body): self + { + $this->body = $body; + + return $this; + } + + /** + * @param array $headers + */ + public static function make(string $body, int $status, array $headers): self + { + $response = new self($status); + $response->body = $body; + + foreach ($headers as $name => $value) { + $response->setHeader($name, $value); + } + + return $response; + } + + /** + * @param array $data + */ + public function json(array $data, int $status = 200): self + { + $this->status = $status; + $this->setHeader('Content-Type', 'application/json; charset=utf-8'); + $this->body = (string) json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + + return $this; + } + + public function html(string $body, int $status = 200): self + { + $this->status = $status; + $this->setHeader('Content-Type', 'text/html; charset=utf-8'); + $this->body = $body; + + return $this; + } + + public function send(): void + { + http_response_code($this->status); + + foreach ($this->headers as $name => $value) { + header($name . ': ' . $value); + } + + echo $this->body; + } +} diff --git a/src/Core/Router.php b/src/Core/Router.php new file mode 100644 index 0000000..8daf935 --- /dev/null +++ b/src/Core/Router.php @@ -0,0 +1,104 @@ + + */ + private array $routes = []; + + public function __construct( + private readonly Config $config, + private readonly Database $database, + ) { + } + + /** + * @param array{0: class-string, 1: string} $handler [ControllerClass::class, 'action'] + */ + public function add(string $method, string $pattern, array $handler): self + { + $this->routes[] = [ + 'method' => strtoupper($method), + 'regex' => $this->compile($pattern), + 'handler' => $handler, + ]; + + return $this; + } + + /** + * Traduit "/api/orders/{number}" en une regex ancree avec groupes nommes. + * Les segments litteraux sont echappes pour neutraliser tout metacaractere. + */ + private function compile(string $pattern): string + { + $regex = preg_replace_callback( + '/\{([a-zA-Z_][a-zA-Z0-9_]*)\}/', + static fn (array $m): string => '(?P<' . $m[1] . '>[^/]+)', + $pattern, + ); + + // preg_quote n'est pas applicable globalement (il echapperait les groupes + // generes) ; les patterns sont des litteraux de route controles, donc on + // se contente de figer les delimiteurs avec un delimiteur improbable. + return '#^' . $regex . '$#'; + } + + /** + * Resout la requete : instancie le controleur et appelle l'action avec les + * parametres de route extraits, ou renvoie une reponse 404 / 405. + */ + public function dispatch(Request $request): Response + { + $pathMatched = false; + + foreach ($this->routes as $route) { + if (preg_match($route['regex'], $request->path(), $matches) !== 1) { + continue; + } + + $pathMatched = true; + + if ($route['method'] !== $request->method()) { + continue; + } + + $params = array_filter( + $matches, + static fn (int|string $key): bool => is_string($key), + ARRAY_FILTER_USE_KEY, + ); + + [$controllerClass, $action] = $route['handler']; + + /** @var Controller $controller */ + $controller = new $controllerClass($request, $this->config, $this->database); + + return $controller->$action($params); + } + + if ($pathMatched) { + return (new Response())->json( + ['data' => null, 'error' => ['code' => 'METHOD_NOT_ALLOWED', 'message' => 'Method not allowed']], + 405, + ); + } + + return (new Response())->json( + ['data' => null, 'error' => ['code' => 'NOT_FOUND', 'message' => 'Resource not found']], + 404, + ); + } +} diff --git a/src/Views/home.php b/src/Views/home.php new file mode 100644 index 0000000..76e8fab --- /dev/null +++ b/src/Views/home.php @@ -0,0 +1,25 @@ + +
+

Wakdo back-office

+

Le squelette back-end (P2) est en ligne.

+

+ + Coeur MVC from scratch : autoloader PSR-4 manuel, routeur, PDO prepared statements. + Environnement : . + +

+

+ Sonde de sante : GET /api/health +

+
diff --git a/src/Views/layout.php b/src/Views/layout.php new file mode 100644 index 0000000..5bf12f5 --- /dev/null +++ b/src/Views/layout.php @@ -0,0 +1,32 @@ + + + + + + + + <?= $pageTitle ?> + + + + + + diff --git a/src/public/admin/index.php b/src/public/admin/index.php index bea045c..707255f 100644 --- a/src/public/admin/index.php +++ b/src/public/admin/index.php @@ -1,34 +1,53 @@ public -> src) pour atteindre la racine src/. +require dirname(__DIR__, 2) . '/Core/Autoloader.php'; +Autoloader::register(); + +// En-tetes de securite poses tot, valables sur toute reponse y compris une 500. +header('X-Content-Type-Options: nosniff'); header('X-Robots-Tag: noindex, nofollow'); -$phpVersion = htmlspecialchars(PHP_VERSION, ENT_QUOTES, 'UTF-8'); -$now = htmlspecialchars(date('Y-m-d H:i:s'), ENT_QUOTES, 'UTF-8'); -?> - - - - - - Wakdo - back-office - - - -

Wakdo - back-office

-

En construction.

-

Phase P1 - conception Merise en cours. Le back-office sera implemente en phases P2 a P4.

-
-

Diagnostic FastCGI : PHP repond a .

-

TODO P2 : assets partages (logo, images produits) via Apache Alias entre les 2 vhosts.

- - +$config = new Config(); +date_default_timezone_set($config->timezone()); + +try { + // Acces BDD paresseux : la connexion n'est ouverte qu'au premier query(), + // donc la home back-office reste servie meme base indisponible. + $database = new Database($config); + + $router = new Router($config, $database); + $router->add('GET', '/', [HomeController::class, 'index']); + $router->add('GET', '/api/health', [HealthController::class, 'index']); + + $response = $router->dispatch(Request::fromGlobals()); + $response->send(); +} catch (Throwable $exception) { + // En debug on remonte le message pour iterer ; en prod, reponse generique + // pour ne rien divulguer de la pile interne (information disclosure). + $payload = $config->isDebug() + ? ['data' => null, 'error' => ['code' => 'INTERNAL_ERROR', 'message' => $exception->getMessage()]] + : ['data' => null, 'error' => ['code' => 'INTERNAL_ERROR', 'message' => 'Internal server error']]; + + (new Response())->json($payload, 500)->send(); +} diff --git a/tests/Unit/ConfigTest.php b/tests/Unit/ConfigTest.php new file mode 100644 index 0000000..2b3725c --- /dev/null +++ b/tests/Unit/ConfigTest.php @@ -0,0 +1,125 @@ + */ + private array $touchedKeys = []; + + protected function setUp(): void + { + $this->config = new Config(); + } + + protected function tearDown(): void + { + foreach ($this->touchedKeys as $key) { + putenv($key); + } + + $this->touchedKeys = []; + } + + private function setEnv(string $key, string $value): void + { + $this->touchedKeys[] = $key; + putenv($key . '=' . $value); + } + + public function testGetReturnsValueWhenPresent(): void + { + $this->setEnv('WAKDO_TEST_NAME', 'borne'); + + self::assertSame('borne', $this->config->get('WAKDO_TEST_NAME')); + } + + public function testGetReturnsDefaultWhenAbsent(): void + { + self::assertSame('fallback', $this->config->get('WAKDO_TEST_MISSING', 'fallback')); + self::assertNull($this->config->get('WAKDO_TEST_MISSING')); + } + + public function testGetTreatsEmptyStringAsAbsent(): void + { + // Une variable d'env vide n'apporte pas d'information : Config la traite + // comme absente et renvoie le defaut (contrat documente dans Config::get). + $this->setEnv('WAKDO_TEST_EMPTY', ''); + + self::assertSame('def', $this->config->get('WAKDO_TEST_EMPTY', 'def')); + } + + public function testIntCastsValue(): void + { + $this->setEnv('WAKDO_TEST_PORT', '3307'); + + self::assertSame(3307, $this->config->int('WAKDO_TEST_PORT')); + } + + public function testIntReturnsDefaultWhenAbsent(): void + { + self::assertSame(3306, $this->config->int('WAKDO_TEST_PORT_MISSING', 3306)); + } + + /** + * @return list + */ + public static function truthyValuesProvider(): array + { + return [ + ['1', true], + ['true', true], + ['TRUE', true], + ['yes', true], + ['on', true], + ['0', false], + ['false', false], + ['no', false], + ['off', false], + ['anything-else', false], + ]; + } + + #[DataProvider('truthyValuesProvider')] + public function testBoolInterpretsCommonConventions(string $raw, bool $expected): void + { + $this->setEnv('WAKDO_TEST_FLAG', $raw); + + self::assertSame($expected, $this->config->bool('WAKDO_TEST_FLAG')); + } + + public function testBoolReturnsDefaultWhenAbsent(): void + { + self::assertTrue($this->config->bool('WAKDO_TEST_FLAG_MISSING', true)); + self::assertFalse($this->config->bool('WAKDO_TEST_FLAG_MISSING', false)); + } + + public function testRequiredReturnsValueWhenPresent(): void + { + $this->setEnv('WAKDO_TEST_DB', 'wakdo'); + + self::assertSame('wakdo', $this->config->required('WAKDO_TEST_DB')); + } + + public function testRequiredThrowsWhenMissing(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Missing required configuration: WAKDO_TEST_REQUIRED_MISSING'); + + $this->config->required('WAKDO_TEST_REQUIRED_MISSING'); + } +} diff --git a/tests/Unit/RouterTest.php b/tests/Unit/RouterTest.php new file mode 100644 index 0000000..3e9d9e9 --- /dev/null +++ b/tests/Unit/RouterTest.php @@ -0,0 +1,123 @@ + $params + */ +final class RouteProbeController extends Controller +{ + /** @var array */ + public static array $capturedParams = []; + + /** + * @param array $params + */ + public function show(array $params = []): Response + { + self::$capturedParams = $params; + + return (new Response())->json(['data' => $params], 200); + } +} + +final class RouterTest extends TestCase +{ + private Config $config; + private Database $database; + + protected function setUp(): void + { + $this->config = new Config(); + // Le PDO est paresseux (ouvert au premier acces), donc construire la + // Database ne tente aucune connexion : aucune BDD requise pour ces tests. + $this->database = new Database($this->config); + RouteProbeController::$capturedParams = []; + } + + /** + * Fabrique une Request sans toucher aux super-globales : le constructeur + * de Request est public, on injecte directement methode et chemin. + */ + private function request(string $method, string $path): Request + { + return new Request($method, $path, [], [], ''); + } + + private function router(): Router + { + return new Router($this->config, $this->database); + } + + public function testMatchedRouteExtractsNamedParam(): void + { + $router = $this->router(); + $router->add('GET', '/api/orders/{id}', [RouteProbeController::class, 'show']); + + $response = $router->dispatch($this->request('GET', '/api/orders/42')); + + self::assertSame(200, $response->status()); + self::assertSame(['id' => '42'], RouteProbeController::$capturedParams); + } + + public function testMultipleParamsAreAllExtracted(): void + { + $router = $this->router(); + $router->add('GET', '/api/menus/{menu}/options/{option}', [RouteProbeController::class, 'show']); + + $response = $router->dispatch($this->request('GET', '/api/menus/7/options/maxi')); + + self::assertSame(200, $response->status()); + self::assertSame(['menu' => '7', 'option' => 'maxi'], RouteProbeController::$capturedParams); + } + + public function testUnknownPathReturns404(): void + { + $router = $this->router(); + $router->add('GET', '/api/orders/{id}', [RouteProbeController::class, 'show']); + + $response = $router->dispatch($this->request('GET', '/api/nope')); + + self::assertSame(404, $response->status()); + self::assertSame([], RouteProbeController::$capturedParams); + } + + public function testKnownPathWrongMethodReturns405(): void + { + $router = $this->router(); + // Seul GET est enregistre sur ce chemin ; un POST matche le chemin mais + // pas la methode, ce qui doit produire 405 et non 404. + $router->add('GET', '/api/orders/{id}', [RouteProbeController::class, 'show']); + + $response = $router->dispatch($this->request('POST', '/api/orders/42')); + + self::assertSame(405, $response->status()); + self::assertSame([], RouteProbeController::$capturedParams); + } + + public function testMethodMatchingIsCaseInsensitiveOnRegistration(): void + { + $router = $this->router(); + // add() normalise la methode en majuscules ; une route "get" doit donc + // repondre a une requete GET. + $router->add('get', '/api/health', [RouteProbeController::class, 'show']); + + $response = $router->dispatch($this->request('GET', '/api/health')); + + self::assertSame(200, $response->status()); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..4aa2d11 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,13 @@ + Date: Mon, 15 Jun 2026 14:18:10 +0000 Subject: [PATCH 10/93] ci: install php-xml/php-mbstring for phpunit/phpstan (bookworm php-cli lacks dom/mbstring/xml) --- .forgejo/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index f16db0c..8b5bef4 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -74,7 +74,7 @@ jobs: exit 0 fi echo "phpstan.neon detected - running PHPStan ${PHPSTAN_VERSION} via .phar" - apt-get update -qq && apt-get install -y -qq php-cli curl ca-certificates >/dev/null + apt-get update -qq && apt-get install -y -qq php-cli php-xml php-mbstring curl ca-certificates >/dev/null # PHPUnit phar present pour que phpstan.neon (scanDirectories phar://phpunit.phar) # resolve les symboles PHPUnit\Framework\* utilises sous tests/. curl -sSL "https://phar.phpunit.de/phpunit-${PHPUNIT_VERSION}.phar" -o phpunit.phar @@ -90,7 +90,7 @@ jobs: exit 0 fi echo "phpunit.xml + tests/ detected - running PHPUnit ${PHPUNIT_VERSION} via .phar" - apt-get update -qq && apt-get install -y -qq php-cli curl ca-certificates >/dev/null + apt-get update -qq && apt-get install -y -qq php-cli php-xml php-mbstring curl ca-certificates >/dev/null curl -sSL "https://phar.phpunit.de/phpunit-${PHPUNIT_VERSION}.phar" -o phpunit.phar php phpunit.phar --version php phpunit.phar -c phpunit.xml -- 2.45.3 From c8f5370cfd87e9739e43fbcae6c50b71d132ff16 Mon Sep 17 00:00:00 2001 From: Corentin JOGUET Date: Mon, 15 Jun 2026 17:01:10 +0200 Subject: [PATCH 11/93] refactor(core): src/app structure + fix auto-merge label gate (#10) --- .forgejo/workflows/ci.yml | 23 +++++++++++++------ .../Controllers/HealthController.php | 0 src/{ => app}/Controllers/HomeController.php | 0 src/{ => app}/Core/Autoloader.php | 8 +++---- src/{ => app}/Core/Config.php | 0 src/{ => app}/Core/Controller.php | 2 +- src/{ => app}/Core/Database.php | 0 src/{ => app}/Core/Request.php | 0 src/{ => app}/Core/Response.php | 0 src/{ => app}/Core/Router.php | 0 src/{ => app}/Views/home.php | 0 src/{ => app}/Views/layout.php | 0 src/public/admin/index.php | 2 +- tests/bootstrap.php | 2 +- 14 files changed, 23 insertions(+), 14 deletions(-) rename src/{ => app}/Controllers/HealthController.php (100%) rename src/{ => app}/Controllers/HomeController.php (100%) rename src/{ => app}/Core/Autoloader.php (81%) rename src/{ => app}/Core/Config.php (100%) rename src/{ => app}/Core/Controller.php (95%) rename src/{ => app}/Core/Database.php (100%) rename src/{ => app}/Core/Request.php (100%) rename src/{ => app}/Core/Response.php (100%) rename src/{ => app}/Core/Router.php (100%) rename src/{ => app}/Views/home.php (100%) rename src/{ => app}/Views/layout.php (100%) diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 8b5bef4..b2a6530 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -97,24 +97,33 @@ jobs: auto-merge: # Fusion automatique OPT-IN : poser le label `auto-merge` sur la PR. - # Ne s'execute que si les 3 checks passent (needs) ET si le label est present. - # Plus fiable que le merge_when_checks_succeed natif de Forgejo (qui ne se - # declenche pas toujours au passage au vert). Fusionne via l'API REST. + # Ne s'execute que si les 3 checks passent (needs). + # IMPORTANT : le filtrage par label se fait DANS le step via l'API, pas dans + # `if:` — l'expression contains(github.event.pull_request.labels.*.name, ...) + # de Forgejo n'est pas fiable (elle s'evalue a vrai meme sans label, ce qui + # fusionnait toute PR verte). La verification shell sur l'API est le vrai gate. needs: [secret-scan, php-lint, static-tests] - if: github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'auto-merge') + if: github.event_name == 'pull_request' runs-on: docker steps: - name: Install curl run: apt-get update -qq && apt-get install -y -qq curl ca-certificates >/dev/null - - name: Merge PR (squash) once CI is green + - name: Merge PR (squash) si label auto-merge present et CI verte run: | API="${{ github.server_url }}/api/v1/repos/${{ github.repository }}" PR="${{ github.event.pull_request.number }}" + TOKEN="${{ secrets.FORGEJO_TOKEN }}" + labels=$(curl -s -H "Authorization: token $TOKEN" "$API/issues/$PR/labels") + if ! printf '%s' "$labels" | grep -q '"name"[[:space:]]*:[[:space:]]*"auto-merge"'; then + echo "Pas de label 'auto-merge' sur la PR #$PR -> relecture manuelle, pas de fusion auto." + exit 0 + fi + echo "Label 'auto-merge' present + CI verte -> fusion de la PR #$PR" code=$(curl -s -o /tmp/resp -w "%{http_code}" -X POST \ - -H "Authorization: token ${{ secrets.FORGEJO_TOKEN }}" \ + -H "Authorization: token $TOKEN" \ -H "Content-Type: application/json" \ -d '{"Do":"squash","delete_branch_after_merge":true}' \ "$API/pulls/$PR/merge") echo "merge HTTP $code"; cat /tmp/resp || true; echo [ "$code" = "200" ] || { echo "auto-merge failed (HTTP $code)"; exit 1; } - echo "PR #$PR merged." + echo "PR #$PR mergee." diff --git a/src/Controllers/HealthController.php b/src/app/Controllers/HealthController.php similarity index 100% rename from src/Controllers/HealthController.php rename to src/app/Controllers/HealthController.php diff --git a/src/Controllers/HomeController.php b/src/app/Controllers/HomeController.php similarity index 100% rename from src/Controllers/HomeController.php rename to src/app/Controllers/HomeController.php diff --git a/src/Core/Autoloader.php b/src/app/Core/Autoloader.php similarity index 81% rename from src/Core/Autoloader.php rename to src/app/Core/Autoloader.php index 06f4a10..dc1ecf8 100644 --- a/src/Core/Autoloader.php +++ b/src/app/Core/Autoloader.php @@ -7,8 +7,8 @@ namespace App\Core; /** * PSR-4 autoloader manuel, sans Composer (exigence "from scratch" Cr 4.c.3). * - * Mappe le prefixe de namespace racine "App\" sur le dossier src/. - * Exemple : App\Core\Router -> {src}/Core/Router.php + * Mappe le prefixe de namespace racine "App\" sur le dossier src/app/. + * Exemple : App\Core\Router -> {src/app}/Core/Router.php */ final class Autoloader { @@ -17,8 +17,8 @@ final class Autoloader /** * Enregistre l'autoloader aupres de la pile SPL. * - * La racine src/ est calculee depuis l'emplacement de ce fichier - * (src/Core/Autoloader.php) : dirname(__DIR__) remonte de Core/ a src/. + * La racine src/app/ est calculee depuis l'emplacement de ce fichier + * (src/app/Core/Autoloader.php) : dirname(__DIR__) remonte de Core/ a src/app/. * Aucun chemin code en dur, donc portable host/conteneur. */ public static function register(): void diff --git a/src/Core/Config.php b/src/app/Core/Config.php similarity index 100% rename from src/Core/Config.php rename to src/app/Core/Config.php diff --git a/src/Core/Controller.php b/src/app/Core/Controller.php similarity index 95% rename from src/Core/Controller.php rename to src/app/Core/Controller.php index a481fd6..b2bab84 100644 --- a/src/Core/Controller.php +++ b/src/app/Core/Controller.php @@ -31,7 +31,7 @@ abstract class Controller } /** - * Rend une vue PHP sous src/Views/.php avec ses donnees extraites. + * Rend une vue PHP sous src/app/Views/.php avec ses donnees extraites. * * Le rendu est bufferise puis injecte dans le layout via la variable * $content, ce qui permet aux vues de rester de simples fragments. diff --git a/src/Core/Database.php b/src/app/Core/Database.php similarity index 100% rename from src/Core/Database.php rename to src/app/Core/Database.php diff --git a/src/Core/Request.php b/src/app/Core/Request.php similarity index 100% rename from src/Core/Request.php rename to src/app/Core/Request.php diff --git a/src/Core/Response.php b/src/app/Core/Response.php similarity index 100% rename from src/Core/Response.php rename to src/app/Core/Response.php diff --git a/src/Core/Router.php b/src/app/Core/Router.php similarity index 100% rename from src/Core/Router.php rename to src/app/Core/Router.php diff --git a/src/Views/home.php b/src/app/Views/home.php similarity index 100% rename from src/Views/home.php rename to src/app/Views/home.php diff --git a/src/Views/layout.php b/src/app/Views/layout.php similarity index 100% rename from src/Views/layout.php rename to src/app/Views/layout.php diff --git a/src/public/admin/index.php b/src/public/admin/index.php index 707255f..50279f4 100644 --- a/src/public/admin/index.php +++ b/src/public/admin/index.php @@ -21,7 +21,7 @@ use App\Core\Router; // src/public/admin/index.php : __DIR__ = src/public/admin ; remonter de deux // niveaux (admin -> public -> src) pour atteindre la racine src/. -require dirname(__DIR__, 2) . '/Core/Autoloader.php'; +require dirname(__DIR__, 2) . '/app/Core/Autoloader.php'; Autoloader::register(); // En-tetes de securite poses tot, valables sur toute reponse y compris une 500. diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 4aa2d11..5810c5f 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -8,6 +8,6 @@ declare(strict_types=1); * (src/public/admin/index.php). Les tests resolvent ainsi App\... via PSR-4. */ -require __DIR__ . '/../src/Core/Autoloader.php'; +require __DIR__ . '/../src/app/Core/Autoloader.php'; App\Core\Autoloader::register(); -- 2.45.3 From 1b0b20c12d9e801d0534ca9a6d00a703f7438719 Mon Sep 17 00:00:00 2001 From: Corentin JOGUET Date: Mon, 15 Jun 2026 20:18:59 +0200 Subject: [PATCH 12/93] feat: authentification back-office P2 (login/logout/reset, throttle, audit) (#11) --- docker-compose.yml | 16 + docs/api/conventions.md | 328 ++++++++++++++++++ phpunit.xml | 4 + src/app/Auth/AuthResult.php | 35 ++ src/app/Auth/AuthService.php | 280 +++++++++++++++ src/app/Auth/Csrf.php | 60 ++++ src/app/Auth/GuardResult.php | 22 ++ src/app/Auth/LogMailer.php | 18 + src/app/Auth/Mailer.php | 16 + src/app/Auth/PasswordHasher.php | 73 ++++ src/app/Auth/PasswordResetService.php | 127 +++++++ src/app/Auth/SessionGuard.php | 67 ++++ src/app/Auth/SessionManager.php | 172 +++++++++ src/app/Auth/ThrottlePolicy.php | 85 +++++ src/app/Controllers/AuthController.php | 127 +++++++ .../Controllers/PasswordResetController.php | 150 ++++++++ src/app/Core/Database.php | 29 +- src/app/Core/DatabaseInterface.php | 43 +++ src/app/Core/Request.php | 69 ++++ src/app/Core/Response.php | 18 + src/app/Views/auth/forgot.php | 35 ++ src/app/Views/auth/login.php | 47 +++ src/app/Views/auth/reset.php | 43 +++ src/public/admin/index.php | 17 + tests/Integration/AuthServiceDbTest.php | 189 ++++++++++ tests/Support/FakeDatabase.php | 155 +++++++++ tests/Support/SpyMailer.php | 22 ++ tests/Unit/Auth/AuthControllerTest.php | 197 +++++++++++ tests/Unit/Auth/AuthServiceTest.php | 315 +++++++++++++++++ tests/Unit/Auth/CsrfTest.php | 74 ++++ tests/Unit/Auth/PasswordHasherTest.php | 87 +++++ .../Unit/Auth/PasswordResetControllerTest.php | 175 ++++++++++ tests/Unit/Auth/PasswordResetServiceTest.php | 154 ++++++++ tests/Unit/Auth/SessionGuardTest.php | 121 +++++++ tests/Unit/Auth/ThrottlePolicyTest.php | 133 +++++++ tests/Unit/Core/RequestFormBodyTest.php | 128 +++++++ tests/bootstrap.php | 18 + 37 files changed, 3648 insertions(+), 1 deletion(-) create mode 100644 docs/api/conventions.md create mode 100644 src/app/Auth/AuthResult.php create mode 100644 src/app/Auth/AuthService.php create mode 100644 src/app/Auth/Csrf.php create mode 100644 src/app/Auth/GuardResult.php create mode 100644 src/app/Auth/LogMailer.php create mode 100644 src/app/Auth/Mailer.php create mode 100644 src/app/Auth/PasswordHasher.php create mode 100644 src/app/Auth/PasswordResetService.php create mode 100644 src/app/Auth/SessionGuard.php create mode 100644 src/app/Auth/SessionManager.php create mode 100644 src/app/Auth/ThrottlePolicy.php create mode 100644 src/app/Controllers/AuthController.php create mode 100644 src/app/Controllers/PasswordResetController.php create mode 100644 src/app/Core/DatabaseInterface.php create mode 100644 src/app/Views/auth/forgot.php create mode 100644 src/app/Views/auth/login.php create mode 100644 src/app/Views/auth/reset.php create mode 100644 tests/Integration/AuthServiceDbTest.php create mode 100644 tests/Support/FakeDatabase.php create mode 100644 tests/Support/SpyMailer.php create mode 100644 tests/Unit/Auth/AuthControllerTest.php create mode 100644 tests/Unit/Auth/AuthServiceTest.php create mode 100644 tests/Unit/Auth/CsrfTest.php create mode 100644 tests/Unit/Auth/PasswordHasherTest.php create mode 100644 tests/Unit/Auth/PasswordResetControllerTest.php create mode 100644 tests/Unit/Auth/PasswordResetServiceTest.php create mode 100644 tests/Unit/Auth/SessionGuardTest.php create mode 100644 tests/Unit/Auth/ThrottlePolicyTest.php create mode 100644 tests/Unit/Core/RequestFormBodyTest.php diff --git a/docker-compose.yml b/docker-compose.yml index d131af4..589abe9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -135,6 +135,22 @@ services: SESSION_NAME: ${SESSION_NAME} CORS_ALLOWED_ORIGIN: ${CORS_ALLOWED_ORIGIN} PASSWORD_ALGO: ${PASSWORD_ALGO} + # Cout argon2id (password_hash) : aligne sur .env.example / OWASP. Sert au + # hash du mot de passe ET du PIN equipier (actions sensibles, P3). + ARGON2_MEMORY_COST: ${ARGON2_MEMORY_COST} + ARGON2_TIME_COST: ${ARGON2_TIME_COST} + ARGON2_THREADS: ${ARGON2_THREADS} + # Anti brute-force : backoff degressif par compte (user.lockout_until) et + # par IP source (table login_throttle). Voir mlt.md 12.1 RG-8/RG-9. + ACCOUNT_LOCKOUT_THRESHOLD: ${ACCOUNT_LOCKOUT_THRESHOLD} + ACCOUNT_LOCKOUT_BASE_SECONDS: ${ACCOUNT_LOCKOUT_BASE_SECONDS} + ACCOUNT_LOCKOUT_MAX_SECONDS: ${ACCOUNT_LOCKOUT_MAX_SECONDS} + IP_THROTTLE_WINDOW_SECONDS: ${IP_THROTTLE_WINDOW_SECONDS} + IP_THROTTLE_MAX_ATTEMPTS: ${IP_THROTTLE_MAX_ATTEMPTS} + # Longueur minimale du PIN equipier (actions sensibles, P3). + STAFF_PIN_MIN_LENGTH: ${STAFF_PIN_MIN_LENGTH} + # Expiration du token de reinitialisation de mot de passe (mlt.md 12.3). + PASSWORD_RESET_TTL: ${PASSWORD_RESET_TTL} UPLOAD_MAX_SIZE_MB: ${UPLOAD_MAX_SIZE_MB} UPLOAD_ALLOWED_MIME: ${UPLOAD_ALLOWED_MIME} diff --git a/docs/api/conventions.md b/docs/api/conventions.md new file mode 100644 index 0000000..3fbe29c --- /dev/null +++ b/docs/api/conventions.md @@ -0,0 +1,328 @@ +# API Wakdo - conventions de nommage, structure et listing + +**Statut** : v0.2 - convention de casse arbitree (snake_case, voir section 4) +**Perimetre** : back-office admin (rendu serveur) + API REST sous `/api/*` +**Auteur methodologie** : BYAN +**A lire avec** : `docs/PROJECT_CONTEXT.md`, `docs/merise/dictionary.md` (source de verite des +noms de champs), `docs/merise/mct.md` + `mlt.md` (operations metier), `db/seeds/0001_rbac_and_reference.sql` +(catalogue des 23 permissions). NB : `docs/api/byan-api.md` documente l'API de la plateforme BYAN, +distincte de l'API Wakdo decrite ici. + +--- + +## 1. Objet + +Fixer les conventions de nommage, la structure des points d'entree HTTP de Wakdo, et tenir le +listing des endpoints (en service et prevus). Objectif : que chaque endpoint ajoute suive le meme +moule. Les choix sont des conventions de projet (coherence, lisibilite), pas des regles universelles ; +une convention peut evoluer, auquel cas ce document est mis a jour en premier. + +--- + +## 2. Par quoi passe une requete + +Deux hotes distincts, un seul conteneur web (Apache), routes par le Traefik de l'hote : + +``` +Client (borne / navigateur back-office) + -> Traefik (TLS, ajoute X-Forwarded-For, route par Host) + -> wakdo-web (Apache, vhost selon le Host) + - vhost kiosk : DocumentRoot src/public/borne (statique + futur appel /api) + - vhost admin : DocumentRoot src/public/admin + - fichier existant (login.html, *.css) : servi tel quel + - sinon RewriteRule -> index.php (front controller) + -> wakdo-app (PHP-FPM, via proxy FastCGI sur *.php) + front controller -> Router -> Controller -> Response + -> wakdo-db (MariaDB, requetes preparees PDO uniquement) +``` + +Consequence de nommage : le DocumentRoot du vhost admin est `src/public/admin`, donc le +`REQUEST_URI` arrive **sans prefixe** `/admin`. Le Router voit `/login`, `/api/health`, etc. +On n'ajoute pas de segment `/admin` dans les chemins de routes. + +Code de reference : routes dans `src/public/admin/index.php`, controleurs dans +`src/app/Controllers/`, enveloppe de reponse dans `src/app/Core/Response.php`, resolution +(404 / 405) dans `src/app/Core/Router.php`. + +--- + +## 3. Deux familles d'endpoints + +| Famille | Prefixe | Rendu | Authentification | Exemple | +|---|---|---|---|---| +| Pages back-office | aucun | HTML (vue serveur + `layout.php`) | session admin | `/login`, `/forgot_password` | +| API REST | `/api/` | JSON (enveloppe section 7) | selon la ressource (section 10) | `/api/health`, `/api/categories` (prevu) | + +La borne (kiosk) consommera l'API REST `/api/*` (P4). En attendant, elle lit un repli JSON +statique sous `src/public/borne/data/` (voir section 8.3). + +--- + +## 4. Nommage des chemins (URL) + +Deux decisions, dont une sourcee et une de coherence : + +- **Minuscules** sur tout le chemin. Sourced : RFC 3986 §6.2.2.1 - seuls le scheme et l'hote sont + insensibles a la casse, le path est sensible a la casse ; le minuscule evite les bugs de casse. +- **Separateur de mots : `_` (snake_case)**. Aucun standard n'impose `-` ou `_` dans un segment + (les deux sont des caracteres `unreserved`, RFC 3986 §2.3). On retient `_` pour n'avoir **qu'une + seule convention de casse** sur tout le projet : colonnes DB, champs JSON (section 8) et chemins + d'URL partagent le snake_case. Cela calque les noms de tables (`order_item` -> `/api/order_items`) + et reduit la charge a memoriser (Rasoir d'Ockham, mantra #37). + +Autres regles : + +- **Noms de ressources au pluriel** pour les collections : `/api/categories`, `/api/products`, + `/api/orders`. +- **Identifiant en segment** pour une ressource unitaire : `/api/orders/{number}`, + `/api/products/{id}`. Parametre dynamique : `{nom}` (groupe nomme cote Router). +- **Sous-ressource** par imbrication : `/api/orders/{id}/items` (prevu). +- **Action non-CRUD** par sous-chemin verbe : `POST /api/orders/{id}/cancel` + (cf. `docs/uml/security-sequence.md`). +- Pas de barre oblique finale signifiante : `Request::normalizePath` aligne `/api/health/` et + `/api/health`. + +--- + +## 5. Listing des endpoints + +### 5.1 En service (P2) + +| Methode | Chemin | Auth | Rendu | Role | +|---|---|---|---|---| +| GET | `/` | (session en P3) | HTML | accueil back-office (squelette) | +| GET | `/api/health` | public | JSON (plat) | sonde de sante (DB reelle) | +| GET | `/login` | public | HTML | formulaire de connexion | +| POST | `/login` | public + CSRF | 302 / HTML | authentification (mlt 12.1) | +| POST | `/logout` | session + CSRF | 302 | deconnexion (mlt 12.2) | +| GET | `/forgot_password` | public | HTML | demande de reinitialisation | +| POST | `/forgot_password` | public + CSRF | HTML (neutre) | envoi du lien (mlt 12.3) | +| GET | `/reset_password` | public (token en query) | HTML | formulaire nouveau mot de passe | +| POST | `/reset_password` | public + CSRF | 302 / HTML | confirmation (mlt 12.3) | + +### 5.2 API kiosk - lecture catalogue + commande (prevu P4, public) + +La borne est publique (aucune session) ; cf. `mlt.md` CREATE_ORDER, declencheur kiosk. + +| Methode | Chemin | Permission | Op MCT | Statut | +|---|---|---|---|---| +| GET | `/api/categories` | (lecture publique) | READ_CATALOGUE | prevu | +| GET | `/api/products` | (lecture publique) | READ_CATALOGUE | prevu | +| GET | `/api/products/{id}` | (lecture publique) | READ_CATALOGUE | prevu | +| GET | `/api/menus` | (lecture publique) | READ_CATALOGUE | prevu | +| GET | `/api/menus/{id}` | (lecture publique) | READ_CATALOGUE | prevu | +| POST | `/api/orders` | (kiosk public) | CREATE_ORDER (mlt 3.3) | prevu (idempotency_key, RG-T19) | + +### 5.3 API / pages back-office (prevu P3-P4, session + permission) + +Provisoire : le choix entre endpoints JSON `/api/*` et pages rendues serveur pour les ecritures +admin est tranche phase par phase (P3 CRUD). Les colonnes Permission renvoient au catalogue fige +des 23 permissions (`db/seeds/0001_rbac_and_reference.sql`) ; l'imputabilite et le PIN suivent +`mlt.md` RG-T13/RG-T14. + +Commandes (cote equipier) : + +| Methode | Chemin | Permission | Op MCT | Note | +|---|---|---|---|---| +| GET | `/api/orders` | `order.read` | READ_ORDERS | filtre par `role_visible_source` (RG-T12) | +| GET | `/api/orders/{number}` | `order.read` | READ_ORDERS | | +| POST | `/api/orders` (comptoir/drive) | `order.create` | CREATE_COUNTER_ORDER (mlt 4.1) | source auto-taggee | +| POST | `/api/orders/{id}/deliver` | `order.deliver` | DELIVER_ORDER (mlt 6.1) | | +| POST | `/api/orders/{id}/cancel` | `order.cancel` | CANCEL_ORDER (mlt 7.1) | PIN + audit_log (RG-T13/14) | + +Catalogue (produits, menus, categories) : + +| Methode | Chemin | Permission | Op MCT | +|---|---|---|---| +| POST | `/api/products` | `product.create` | CREATE_PRODUCT (mlt 8.1) | +| PUT | `/api/products/{id}` | `product.update` | UPDATE_PRODUCT (mlt 8.2) - PIN sur prix/TVA | +| DELETE | `/api/products/{id}` | `product.delete` | DELETE_PRODUCT (mlt 8.3) - PIN | +| POST | `/api/menus` | `menu.create` | CREATE_MENU | +| PUT | `/api/menus/{id}` | `menu.update` | UPDATE_MENU | +| DELETE | `/api/menus/{id}` | `menu.delete` | DELETE_MENU - PIN | +| POST/PUT/DELETE | `/api/categories[/{id}]` | `category.manage` | MANAGE_CATEGORY | + +Stock et ingredients : + +| Methode | Chemin | Permission | Op MCT | +|---|---|---|---| +| GET | `/api/ingredients` | `ingredient.manage` | READ_INGREDIENTS | +| GET | `/api/stock` | `stock.read` | READ_STOCK | +| POST | `/api/stock/restock` | `stock.manage` | RESTOCK (mlt 9.1) | +| POST | `/api/stock/count` | `stock.count` | INVENTORY_COUNT (mlt 9.2) - PIN | + +Utilisateurs et RBAC : + +| Methode | Chemin | Permission | Op MCT | +|---|---|---|---| +| GET | `/api/users` | `user.read` | READ_USERS | +| POST | `/api/users` | `user.create` | CREATE_USER (mlt 10.1) - PIN | +| PUT | `/api/users/{id}` | `user.update` | UPDATE_USER (mlt 10.2) - PIN | +| POST | `/api/users/{id}/deactivate` | `user.deactivate` | DEACTIVATE_USER (mlt 10.3) - PIN | +| GET/PUT | `/api/roles[/{id}/permissions]` | `role.manage` | MANAGE_RBAC (mlt 10.4) - PIN | + +Statistiques : + +| Methode | Chemin | Permission | Op MCT | +|---|---|---|---| +| GET | `/api/stats` | `stats.read` | READ_STATS (mlt 11.x) | + +> Les chemins exacts en 5.2/5.3 sont une projection a partir des operations MCT et des permissions +> seedees ; ils sont confirmes au moment d'ecrire chaque endpoint. Seule la section 5.1 est en service. + +--- + +## 6. Methodes HTTP + +| Methode | Usage | +|---|---| +| GET | lecture, sans effet de bord | +| POST | creation, ou action de formulaire back-office (login, logout, reset) | +| PUT | mise a jour d'une ressource (prevu, CRUD admin P3) | +| DELETE | suppression d'une ressource (prevu) | + +Le Router fait une correspondance exacte de la methode : methode connue sur chemin connu mais non +enregistree -> `405` ; chemin inconnu -> `404` (`Router::dispatch`). Une requete `HEAD` sur une +route `GET` renvoie aujourd'hui `405` (correspondance exacte) ; un assouplissement reste possible +si un besoin apparait. + +--- + +## 7. Enveloppe de reponse JSON + +L'API enveloppe ses reponses pour qu'un client distingue donnees et erreur de maniere uniforme. + +Succes - ressource unitaire : + +```json +{ "data": { "id": 3, "name": "Big Mac", "price_cents": 590 } } +``` + +Succes - collection (`total` optionnel pour la pagination future) : + +```json +{ "data": [ { "id": 1 }, { "id": 2 } ], "total": 2 } +``` + +Erreur : + +```json +{ "data": null, "error": { "code": "NOT_FOUND", "message": "Resource not found" } } +``` + +Exception documentee : `GET /api/health` renvoie un objet de diagnostic plat (`status`, `app_env`, +`php_version`, `db`, `categories`), hors enveloppe, car il sert le monitoring et non un client +applicatif. + +Type de contenu : `application/json; charset=utf-8` (`Response::json`). Les pages back-office +renvoient `text/html; charset=utf-8`. + +--- + +## 8. Normalisation des noms de champs + +### 8.1 Regle generale : snake_case aligne sur le dictionnaire + +Les champs JSON reprennent les noms du dictionnaire (`docs/merise/dictionary.md`), source de verite, +ce qui evite une couche de traduction entre base, code et contrat HTTP. + +| Categorie | Convention | Exemple | +|---|---|---| +| Champ simple | snake_case, anglais | `display_order`, `image_path` | +| Montant monetaire | entier en centimes, suffixe `_cents` | `price_cents`, `total_ttc_cents` | +| Taux de TVA | entier pour mille | `vat_rate` (55 = 5,5 % ; 100 = 10 %) | +| Booleen | prefixe `is_` | `is_available`, `is_active` | +| Horodatage | suffixe `_at`, ISO 8601 en sortie API | `created_at`, `paid_at` | +| Cle etrangere | suffixe `_id` | `category_id`, `role_id` | +| Valeur d'enumeration | minuscules snake_case | `pending_payment`, `dine_in`, `kiosk` | +| Identifiant | `id` (entier) ou `order_number` (chaine metier) | `id`, `order_number` | + +Les horodatages sont stockes en `DATETIME` ; leur exposition API se fait en ISO 8601 (a cadrer +au moment d'ecrire les endpoints de lecture P4). + +### 8.2 Codes d'erreur + +SCREAMING_SNAKE_CASE, stables (un client peut s'y fier) ; le `message` reste lisible (non garanti +stable). + +| Code | HTTP | Sens | +|---|---|---| +| `NOT_FOUND` | 404 | ressource introuvable | +| `METHOD_NOT_ALLOWED` | 405 | methode non autorisee sur ce chemin | +| `VALIDATION_ERROR` | 422 | entree invalide (champ, longueur, enum) | +| `CONFLICT` | 409 | conflit d'etat (ex. transition de commande concurrente) | +| `AUTH_REQUIRED` | 401 | authentification requise (prevu, API admin) | +| `FORBIDDEN` | 403 | permission insuffisante, ou jeton CSRF invalide cote formulaire | +| `RATE_LIMITED` | 429 | throttling (prevu) | +| `INTERNAL_ERROR` | 500 | erreur interne, message generique (pas de divulgation) | + +Codes specifiques nommes par le MLT, en surcharge du socle : `CANNOT_CANCEL_IN_STATE` (422) et +`INVALID_TRANSITION` (409) pour l'annulation (`mlt.md` 7.1, `security-sequence.md`). Meme format +d'enveloppe. + +### 8.3 Divergence connue : repli JSON de la borne + +Le repli statique de la borne (`src/public/borne/data/categories.json`, `produits.json`) provient +des sources de l'ecole et porte un nommage different et heterogene (`title`/`nom`, `prix`, `image`, +`type`). Ce contrat est fige par le brief ecole et consomme tel quel par le JS de la borne via +`data.js`. + +La convention canonique reste celle de 8.1. Le rapprochement se fait en un point unique : la couche +`data.js` (bascule prevue en P4). Quand l'API exposera `/api/categories` et `/api/products`, elle +servira la forme canonique ; `data.js` mappera vers ce que la borne attend. + +| Repli borne | Canonique API / dictionnaire | +|---|---| +| `title` (categorie) | `name` | +| `nom` (produit) | `name` | +| `prix` | `price_cents` | +| `image` | `image_path` | +| `type` | `item_type` (`product` / `menu`) | + +--- + +## 9. Authentification et sessions + +- **Cookie de session** : `WAKDO_SID` (`SESSION_NAME`), attributs `secure`, `HttpOnly`, + `SameSite=Strict`. Bornes de validite appliquees cote application (idle 4h, absolue 10h), + pas par la duree du cookie. +- **Formulaires back-office** : jeton CSRF synchroniseur en champ cache `_csrf`, verifie sur chaque + POST (`/login`, `/logout`, `/forgot_password`, `/reset_password`). Jeton invalide -> `403`. +- **API REST** : endpoints kiosk de lecture catalogue et creation de commande publics (pas de + session ; `mlt.md` CREATE_ORDER). Endpoints d'administration sous `/api` (P3/P4) : session admin + + verification de permission via `role_permission` ; actions sensibles avec re-autorisation PIN + (`mlt.md` RG-T13). + +Le schema `ApiKey` / `Bearer` de l'API plateforme BYAN (`docs/api/byan-api.md`) ne s'applique pas +ici. + +--- + +## 10. CORS + +L'API admin sous `/api/*` autorise l'origine du kiosk via `CORS_ALLOWED_ORIGIN` (valeur exacte, +sans joker), configuree dans `docker/apache/vhost.conf`. L'origine doit correspondre a +`APP_URL_KIOSK`. + +--- + +## 11. Versionnement + +Demarrage sans segment de version (`/api/...`), ce qui correspond a une v1 implicite. En cas de +changement de contrat non retrocompatible, l'option retenue est un prefixe explicite `/api/v2/...` +introduit a ce moment-la, en gardant `/api/...` pour la v1 tant que des clients en dependent. + +--- + +## 12. Ou est defini quoi (recap code) + +| Element | Fichier | +|---|---| +| Declaration des routes | `src/public/admin/index.php` | +| Resolution / 404 / 405 | `src/app/Core/Router.php` | +| Enveloppe `data` / `error` / contenu JSON | `src/app/Core/Response.php` | +| Lecture de la requete (chemin, query, corps, IP) | `src/app/Core/Request.php` | +| Controleurs | `src/app/Controllers/` | +| Acces base (requetes preparees, transaction) | `src/app/Core/Database.php` | +| Noms de champs (source de verite) | `docs/merise/dictionary.md` | +| Operations metier et permissions | `docs/merise/mct.md`, `mlt.md`, `db/seeds/0001_rbac_and_reference.sql` | diff --git a/phpunit.xml b/phpunit.xml index a1f3799..9b2fbe7 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -17,6 +17,10 @@ tests/Unit + + + tests/Integration + diff --git a/src/app/Auth/AuthResult.php b/src/app/Auth/AuthResult.php new file mode 100644 index 0000000..29c107a --- /dev/null +++ b/src/app/Auth/AuthResult.php @@ -0,0 +1,35 @@ + PRE-3 gate compte+IP -> RG-2 verify (leurre si miss). + * Succes : RG-3 regenerate + rotate CSRF, RG-4 session, RG-5/RG-9 reset+audit + * (une transaction), RG-7 redirection dynamique. Echec : RG-8 backoff degressif + * compte + upsert IP + audit (une transaction). Message d'echec unique (ERR-1/3). + */ + public function authenticate(string $email, string $password, string $ip, ?int $now = null): AuthResult + { + $now ??= time(); + $accountPolicy = ThrottlePolicy::fromConfig($this->config, 'account'); + $ipPolicy = ThrottlePolicy::fromConfig($this->config, 'ip'); + + // RG-1 : recherche systematique (hit ou miss) afin que le cout du SELECT + // soit paye dans les deux cas (limite l'oracle de timing par enumeration). + $user = $this->findActiveUserByEmail($email); + + // PRE-3 : porte de throttling AVANT toute verification de mot de passe. + $accountLockedUntil = $user !== null ? $this->stringOrNull($user['lockout_until'] ?? null) : null; + $accountLocked = $accountPolicy->isLockedUntil($accountLockedUntil, $now); + $ipLocked = $ipPolicy->isLockedUntil($this->ipLockoutUntil($ip), $now); + + if ($accountLocked || $ipLocked) { + // ERR-3 : meme message generique ; ne revele pas l'existence ni le verrou. + // Pas d'increment : le compteur tourne deja, le verrou est actif. + return AuthResult::failure(); + } + + // RG-2 : email inconnu -> verify leurre (timing) puis echec generique. + if ($user === null) { + $this->hasher->verifyDecoy($password); + $this->recordFailure(null, null, 0, $ip, $accountPolicy, $ipPolicy, $now); + + return AuthResult::failure(); + } + + $userId = (int) ($user['id'] ?? 0); + $roleId = (int) ($user['role_id'] ?? 0); + + if (!$this->hasher->verify($password, (string) ($user['password_hash'] ?? ''))) { + $attempts = (int) ($user['failed_login_attempts'] ?? 0); + $this->recordFailure($userId, $roleId, $attempts, $ip, $accountPolicy, $ipPolicy, $now); + + return AuthResult::failure(); + } + + // Succes : RG-3 (anti-fixation) d'abord (change l'ID, pas encore d'identite). + $this->session->regenerate(); + + // RG-5 + RG-9 : reset compteurs + clear IP + audit succes, une transaction. + // Fait AVANT de poser l'identite en session : si la base echoue, aucune + // session authentifiee ne subsiste (fail-closed, D9). + $this->recordSuccess($userId, $roleId, $ip, $now); + + // RG-4 : identite + horodatages pour les bornes idle/absolue (RG-6), + // puis rotation du jeton CSRF anterieur a l'authentification. + $this->session->set('user_id', $userId); + $this->session->set('role_id', $roleId); + $this->session->set('logged_in_at', $now); + $this->session->set('last_activity', $now); + Csrf::rotate($this->session); + + $routeRaw = $user['default_route'] ?? null; + $defaultRoute = is_string($routeRaw) && $routeRaw !== '' ? $routeRaw : '/'; + + return AuthResult::success($userId, $roleId, $defaultRoute); + } + + /** + * LOGOUT_USER (12.2) : efface puis detruit la session. Aucune I/O base. + */ + public function logout(): void + { + $this->session->clear(); + $this->session->destroy(); + } + + /** + * RG-1 : utilisateur actif par email, joint a son role pour la route de + * redirection dynamique (RG-7). Requete preparee (RG-T06). + * + * @return array|null + */ + private function findActiveUserByEmail(string $email): ?array + { + return $this->db->fetch( + 'SELECT u.id, u.password_hash, u.role_id, u.failed_login_attempts, u.lockout_until, r.default_route ' + . 'FROM user u JOIN role r ON r.id = u.role_id ' + . 'WHERE u.email = :email AND u.is_active = 1 LIMIT 1', + ['email' => $email], + ); + } + + private function ipLockoutUntil(string $ip): ?string + { + $row = $this->db->fetch( + 'SELECT lockout_until FROM login_throttle WHERE ip_address = :ip', + ['ip' => $ip], + ); + + return $row === null ? null : $this->stringOrNull($row['lockout_until'] ?? null); + } + + /** + * RG-8 : enregistre l'echec sur les deux dimensions (compte si connu + IP) + * et une ligne audit_log, le tout dans une seule transaction atomique (RG-T08). + */ + private function recordFailure( + ?int $userId, + ?int $roleId, + int $currentAttempts, + string $ip, + ThrottlePolicy $accountPolicy, + ThrottlePolicy $ipPolicy, + int $now, + ): void { + $nowDt = date('Y-m-d H:i:s', $now); + $windowSeconds = $this->config->int('IP_THROTTLE_WINDOW_SECONDS', 900); + + $windowCutoff = date('Y-m-d H:i:s', $now - $windowSeconds); + + $this->db->transaction(function (DatabaseInterface $db) use ( + $userId, + $roleId, + $currentAttempts, + $ip, + $accountPolicy, + $ipPolicy, + $now, + $nowDt, + $windowCutoff, + ): void { + // Dimension compte. Pour ne pas reveler par le timing si l'email existe + // (anti-enumeration, RG-2), on emet la MEME requete dans les deux cas : + // sur email inconnu, un UPDATE sur id = 0 (aucune ligne touchee car les + // PK user sont AUTO_INCREMENT >= 1), donc meme profil d'I/O, effet nul. + if ($userId !== null) { + $newAttempts = $currentAttempts + 1; + $lockSeconds = $accountPolicy->lockoutSeconds($newAttempts); + $lockUntil = $lockSeconds > 0 ? date('Y-m-d H:i:s', $now + $lockSeconds) : null; + + $db->execute( + 'UPDATE user SET failed_login_attempts = :attempts, last_failed_login_at = :now, ' + . 'lockout_until = :lock WHERE id = :id', + ['attempts' => $newAttempts, 'now' => $nowDt, 'lock' => $lockUntil, 'id' => $userId], + ); + } else { + $db->execute( + 'UPDATE user SET failed_login_attempts = :attempts, last_failed_login_at = :now, ' + . 'lockout_until = :lock WHERE id = :id', + ['attempts' => 0, 'now' => $nowDt, 'lock' => null, 'id' => 0], + ); + } + + // Dimension IP : increment ATOMIQUE cote SQL (failed_attempts + 1) pour + // eviter le lost-update sous concurrence ; la fenetre glissante est + // reinitialisee en SQL si elle a expire. Le verrou de ligne pris par + // l'upsert serialise les tentatives concurrentes sur la meme IP. + // Placeholders distincts : en prepare reelle (EMULATE_PREPARES = false) + // un meme nom ne peut pas etre lie plusieurs fois. + $db->execute( + 'INSERT INTO login_throttle (ip_address, failed_attempts, window_started_at, last_attempt_at) ' + . 'VALUES (:ip, 1, :now_i, :now_li) ' + . 'ON DUPLICATE KEY UPDATE ' + . 'failed_attempts = IF(window_started_at < :cutoff, 1, failed_attempts + 1), ' + . 'window_started_at = IF(window_started_at < :cutoff2, :now_w, window_started_at), ' + . 'last_attempt_at = :now_lu', + [ + 'ip' => $ip, + 'now_i' => $nowDt, + 'now_li' => $nowDt, + 'cutoff' => $windowCutoff, + 'cutoff2' => $windowCutoff, + 'now_w' => $nowDt, + 'now_lu' => $nowDt, + ], + ); + + // Relit le compteur post-increment (valeur autoritaire ecrite ci-dessus, + // ligne deja verrouillee par cette transaction) pour calculer le backoff + // IP en PHP via ThrottlePolicy, puis pose le verrou. + $row = $db->fetch('SELECT failed_attempts FROM login_throttle WHERE ip_address = :ip', ['ip' => $ip]); + $ipAttempts = (int) ($row['failed_attempts'] ?? 1); + $ipLockSeconds = $ipPolicy->lockoutSeconds($ipAttempts); + $ipLockUntil = $ipLockSeconds > 0 ? date('Y-m-d H:i:s', $now + $ipLockSeconds) : null; + + $db->execute( + 'UPDATE login_throttle SET lockout_until = :lock WHERE ip_address = :ip', + ['lock' => $ipLockUntil, 'ip' => $ip], + ); + + $this->writeAudit($db, 'auth.login_failed', $userId, $roleId, 'Echec de connexion'); + }); + } + + /** + * RG-9 : remise a zero du compteur compte + clear du throttle IP + audit du + * succes, une seule transaction (RG-T08). + */ + private function recordSuccess(int $userId, int $roleId, string $ip, int $now): void + { + $nowDt = date('Y-m-d H:i:s', $now); + + $this->db->transaction(function (DatabaseInterface $db) use ($userId, $roleId, $ip, $nowDt): void { + $db->execute( + 'UPDATE user SET failed_login_attempts = 0, lockout_until = NULL, last_login_at = :now WHERE id = :id', + ['now' => $nowDt, 'id' => $userId], + ); + + // Clear de la ligne IP : 0 ligne affectee si aucune n'existait (benin). + // Placeholders distincts (cf. recordFailure : prepare reelle, un nom + // ne peut etre lie qu'une fois). + $db->execute( + 'UPDATE login_throttle SET failed_attempts = 0, lockout_until = NULL, ' + . 'window_started_at = :now_w, last_attempt_at = :now_l WHERE ip_address = :ip', + ['now_w' => $nowDt, 'now_l' => $nowDt, 'ip' => $ip], + ); + + $this->writeAudit($db, 'auth.login_success', $userId, $roleId, 'Connexion reussie'); + }); + } + + /** + * RG-T14 : audit_log strictement en INSERT (jamais d'UPDATE/DELETE). summary + * non personnel ; details laisse NULL pour un evenement d'auth (aucune PII). + */ + private function writeAudit( + DatabaseInterface $db, + string $actionCode, + ?int $userId, + ?int $roleId, + string $summary, + ): void { + $db->execute( + 'INSERT INTO audit_log (actor_user_id, actor_role_id, action_code, entity_type, entity_id, summary) ' + . 'VALUES (:uid, :rid, :code, :etype, :eid, :summary)', + [ + 'uid' => $userId, + 'rid' => $roleId, + 'code' => $actionCode, + 'etype' => $userId !== null ? 'user' : null, + 'eid' => $userId, + 'summary' => $summary, + ], + ); + } + + private function stringOrNull(mixed $value): ?string + { + return is_string($value) ? $value : null; + } +} diff --git a/src/app/Auth/Csrf.php b/src/app/Auth/Csrf.php new file mode 100644 index 0000000..6b2c483 --- /dev/null +++ b/src/app/Auth/Csrf.php @@ -0,0 +1,60 @@ +get(self::KEY); + if (is_string($existing) && $existing !== '') { + return $existing; + } + + return self::rotate($session); + } + + /** + * Vrai uniquement si un jeton existe en session et egale (temps constant) le + * jeton soumis. Toute absence (pas de jeton, soumission vide) renvoie false. + */ + public static function validate(SessionManager $session, ?string $submitted): bool + { + $stored = $session->get(self::KEY); + + if (!is_string($stored) || $stored === '' || $submitted === null || $submitted === '') { + return false; + } + + return hash_equals($stored, $submitted); + } + + /** + * Re-genere le jeton (apres regeneration d'ID de session sur login reussi) : + * invalide tout jeton anterieur a l'authentification. + */ + public static function rotate(SessionManager $session): string + { + $token = bin2hex(random_bytes(32)); + $session->set(self::KEY, $token); + + return $token; + } +} diff --git a/src/app/Auth/GuardResult.php b/src/app/Auth/GuardResult.php new file mode 100644 index 0000000..faf58a0 --- /dev/null +++ b/src/app/Auth/GuardResult.php @@ -0,0 +1,22 @@ + logs du conteneur) pour pouvoir le + * recuperer en dev. Le lien contient le token brut, qui n'est jamais persiste. + */ +final class LogMailer implements Mailer +{ + public function sendPasswordReset(string $email, string $resetUrl): void + { + error_log(sprintf('[wakdo][password-reset] %s -> %s', $email, $resetUrl)); + } +} diff --git a/src/app/Auth/Mailer.php b/src/app/Auth/Mailer.php new file mode 100644 index 0000000..6a21a1d --- /dev/null +++ b/src/app/Auth/Mailer.php @@ -0,0 +1,16 @@ +options()); + } + + public function verify(string $plain, string $hash): bool + { + return password_verify($plain, $hash); + } + + /** + * Verifie le mot de passe soumis contre un leurre argon2id de meme cout, et + * jette le resultat. But : egaliser le temps CPU du chemin "email inconnu" + * avec celui du chemin "mauvais mot de passe", pour ne pas reveler par le + * timing si un compte existe (RG-2). Le leurre est calcule une fois par + * process sur un secret jetable ; il ne correspond a aucun mot de passe reel. + */ + public function verifyDecoy(string $plain): void + { + password_verify($plain, $this->decoyHash()); + } + + /** + * @return array{memory_cost: int, time_cost: int, threads: int} + */ + private function options(): array + { + // Defauts alignes sur .env.example / OWASP (64 MiB, 4 iterations, 1 thread). + return [ + 'memory_cost' => $this->config->int('ARGON2_MEMORY_COST', 65536), + 'time_cost' => $this->config->int('ARGON2_TIME_COST', 4), + 'threads' => $this->config->int('ARGON2_THREADS', 1), + ]; + } + + private function decoyHash(): string + { + // Cache statique par process : le hash argon2id du leurre est couteux et + // n'est calcule qu'une fois par worker, puis reutilise. Sans ce cache, + // comme le PasswordHasher est instancie a chaque requete, chaque tentative + // sur email inconnu paierait un password_hash supplementaire absent du + // chemin email connu -> ecart de timing reintroduisant l'oracle d'enumeration. + if (self::$decoy === null) { + self::$decoy = password_hash(bin2hex(random_bytes(16)), PASSWORD_ARGON2ID, $this->options()); + } + + return self::$decoy; + } +} diff --git a/src/app/Auth/PasswordResetService.php b/src/app/Auth/PasswordResetService.php new file mode 100644 index 0000000..46c81e7 --- /dev/null +++ b/src/app/Auth/PasswordResetService.php @@ -0,0 +1,127 @@ +db->fetch( + 'SELECT id FROM user WHERE email = :email AND is_active = 1 LIMIT 1', + ['email' => $email], + ); + + if ($user === null) { + return; + } + + $userId = (int) ($user['id'] ?? 0); + + // Token a haute entropie (256 bits). Stocke en SHA-256 : un hash rapide + // suffit (la robustesse vient de l'entropie, pas d'un KDF lent), et le + // brut n'est jamais persiste. Voir comment de confirmReset(). + $rawToken = bin2hex(random_bytes(32)); + $tokenHash = hash('sha256', $rawToken); + $ttl = $this->config->int('PASSWORD_RESET_TTL', 3600); + $expiresAt = date('Y-m-d H:i:s', $now + $ttl); + + $this->db->execute( + 'UPDATE user SET password_reset_token_hash = :hash, password_reset_expires_at = :exp WHERE id = :id', + ['hash' => $tokenHash, 'exp' => $expiresAt, 'id' => $userId], + ); + + $resetUrl = rtrim($baseUrl, '/') . '/reset_password?token=' . $rawToken; + $this->mailer->sendPasswordReset($email, $resetUrl); + } + + /** + * Phase confirmation (RG-3/RG-4). Hash du token soumis, recherche par hash + + * expiration future (la recherche par egalite sur un token 256 bits EST la + * comparaison ; pas de souci de temps constant car ce n'est pas un secret a + * faible entropie et la colonne n'est jamais renvoyee au client). Min 8 + * caracteres, nouveau hash argon2id, token efface (usage unique), compteurs + * remis a zero, audit_log : le tout dans une transaction. + */ + public function confirmReset(string $rawToken, string $newPassword, ?int $now = null): AuthResult + { + $now ??= time(); + + if (strlen($newPassword) < 8) { + return AuthResult::failure('Le mot de passe doit contenir au moins 8 caracteres.'); + } + + if ($rawToken === '') { + return AuthResult::failure('Lien invalide ou expire.'); + } + + $tokenHash = hash('sha256', $rawToken); + $nowDt = date('Y-m-d H:i:s', $now); + + $user = $this->db->fetch( + 'SELECT id, role_id, password_reset_token_hash FROM user ' + . 'WHERE password_reset_token_hash = :hash AND password_reset_expires_at > :now ' + . 'AND is_active = 1 LIMIT 1', + ['hash' => $tokenHash, 'now' => $nowDt], + ); + + if ($user === null) { + return AuthResult::failure('Lien invalide ou expire.'); + } + + $userId = (int) ($user['id'] ?? 0); + $roleId = (int) ($user['role_id'] ?? 0); + $newHash = $this->hasher->hash($newPassword); + + $this->db->transaction(function (DatabaseInterface $db) use ($userId, $roleId, $newHash): void { + // Usage unique : on efface token + expiration et on remet les + // compteurs anti brute-force a zero (le compte redevient utilisable). + $db->execute( + 'UPDATE user SET password_hash = :hash, password_reset_token_hash = NULL, ' + . 'password_reset_expires_at = NULL, failed_login_attempts = 0, lockout_until = NULL ' + . 'WHERE id = :id', + ['hash' => $newHash, 'id' => $userId], + ); + + $db->execute( + 'INSERT INTO audit_log (actor_user_id, actor_role_id, action_code, entity_type, entity_id, summary) ' + . 'VALUES (:uid, :rid, :code, :etype, :eid, :summary)', + [ + 'uid' => $userId, + 'rid' => $roleId, + 'code' => 'auth.password_reset', + 'etype' => 'user', + 'eid' => $userId, + 'summary' => 'Reinitialisation du mot de passe', + ], + ); + }); + + return AuthResult::success($userId, $roleId, '/login?reset=ok'); + } +} diff --git a/src/app/Auth/SessionGuard.php b/src/app/Auth/SessionGuard.php new file mode 100644 index 0000000..1f7f45b --- /dev/null +++ b/src/app/Auth/SessionGuard.php @@ -0,0 +1,67 @@ +session->getInt('user_id'); + $roleId = $this->session->getInt('role_id'); + $loggedInAt = $this->session->getInt('logged_in_at'); + $lastActivity = $this->session->getInt('last_activity'); + + if ($userId === null || $roleId === null || $loggedInAt === null) { + return new GuardResult(false, null, null, 'no_session'); + } + + $idleLimit = $this->config->int('SESSION_LIFETIME_IDLE', 14400); + $absoluteLimit = $this->config->int('SESSION_LIFETIME_ABSOLUTE', 36000); + + if ($lastActivity === null || ($now - $lastActivity) > $idleLimit) { + return new GuardResult(false, null, null, 'idle_timeout'); + } + + if (($now - $loggedInAt) > $absoluteLimit) { + return new GuardResult(false, null, null, 'absolute_timeout'); + } + + // RG-T02 : is_active re-verifie a chaque requete (un compte desactive en + // cours de session perd l'acces des la requete suivante). + $row = $this->db->fetch('SELECT is_active FROM user WHERE id = :id', ['id' => $userId]); + + if ($row === null || (int) ($row['is_active'] ?? 0) !== 1) { + return new GuardResult(false, null, null, 'inactive'); + } + + $this->session->set('last_activity', $now); + + return new GuardResult(true, $userId, $roleId, null); + } +} diff --git a/src/app/Auth/SessionManager.php b/src/app/Auth/SessionManager.php new file mode 100644 index 0000000..9ad2f9c --- /dev/null +++ b/src/app/Auth/SessionManager.php @@ -0,0 +1,172 @@ + */ + private array $bag = []; + + public function __construct( + private readonly Config $config, + private readonly bool $testMode = false, + ) { + } + + /** + * Demarre la session du vhost admin avec des cookies durcis. Idempotent : + * le front controller peut l'avoir deja demarree avant le dispatch. + */ + public function start(): void + { + if ($this->testMode) { + return; + } + + if (session_status() === PHP_SESSION_ACTIVE) { + return; + } + + // Defense : ne pas tenter de poser le cookie si la sortie a commence. + if (headers_sent()) { + return; + } + + // lifetime=0 : cookie de session ; les bornes idle 4h / absolue 10h sont + // appliquees applicativement par SessionGuard (RG-6), pas par le cookie. + // secure+httponly+SameSite=Strict : back-office, aucune entree cross-site. + session_set_cookie_params([ + 'lifetime' => 0, + 'path' => '/', + 'secure' => true, + 'httponly' => true, + 'samesite' => 'Strict', + ]); + session_name($this->config->get('SESSION_NAME', 'WAKDO_SID') ?? 'WAKDO_SID'); + session_start(); + } + + /** + * Regenere l'identifiant de session (RG-3) : protege contre la fixation de + * session apres une authentification reussie. + */ + public function regenerate(): void + { + if ($this->testMode) { + return; + } + + if (session_status() === PHP_SESSION_ACTIVE) { + session_regenerate_id(true); + } + } + + public function get(string $key): mixed + { + if ($this->testMode) { + return $this->bag[$key] ?? null; + } + + return $_SESSION[$key] ?? null; + } + + /** + * Accesseur type : evite qu'une valeur mixed de session ne file dans un + * parametre lie PDO ou un calcul d'entier (friction PHPStan L6). + * Les identifiants et timestamps stockes sont des entiers positifs. + */ + public function getInt(string $key): ?int + { + $value = $this->get($key); + + if (is_int($value)) { + return $value; + } + + if (is_string($value) && ctype_digit($value)) { + return (int) $value; + } + + return null; + } + + public function set(string $key, mixed $value): void + { + if ($this->testMode) { + $this->bag[$key] = $value; + + return; + } + + $_SESSION[$key] = $value; + } + + /** + * Efface les donnees de session (RG-1 de LOGOUT_USER). + */ + public function clear(): void + { + if ($this->testMode) { + $this->bag = []; + + return; + } + + $_SESSION = []; + } + + /** + * Expire le cookie de session cote client puis detruit la session serveur + * (RG-2 + RG-3 de LOGOUT_USER). Le cookie reprend les memes attributs durcis. + */ + public function destroy(): void + { + if ($this->testMode) { + $this->bag = []; + + return; + } + + if (ini_get('session.use_cookies') !== false) { + $name = session_name(); + if ($name !== false) { + setcookie($name, '', [ + 'expires' => time() - 3600, + 'path' => '/', + 'secure' => true, + 'httponly' => true, + 'samesite' => 'Strict', + ]); + } + } + + if (session_status() === PHP_SESSION_ACTIVE) { + session_destroy(); + } + } + + public function id(): string + { + if ($this->testMode) { + return 'test-session'; + } + + $id = session_id(); + + return $id === false ? '' : $id; + } +} diff --git a/src/app/Auth/ThrottlePolicy.php b/src/app/Auth/ThrottlePolicy.php new file mode 100644 index 0000000..e513cb3 --- /dev/null +++ b/src/app/Auth/ThrottlePolicy.php @@ -0,0 +1,85 @@ +threshold) { + return 0; + } + + $exponent = $attempts - $this->threshold; + + // Garde anti-debordement : au-dela d'un exposant raisonnable, 2^exposant + // depasserait PHP_INT_MAX. Comme le resultat est de toute facon plafonne, + // on court-circuite des que la valeur ne peut que depasser le plafond. + if ($exponent >= 31) { + return $this->maxSeconds; + } + + $seconds = $this->baseSeconds * (2 ** $exponent); + + return (int) min($seconds, $this->maxSeconds); + } + + /** + * Vrai si le verrou ($lockoutUntil, datetime 'Y-m-d H:i:s' ou null) est + * strictement dans le futur a l'instant $now (timestamp Unix injecte pour + * des comparaisons deterministes en test). null/vide/illisible => pas de verrou. + */ + public function isLockedUntil(?string $lockoutUntil, int $now): bool + { + if ($lockoutUntil === null || $lockoutUntil === '') { + return false; + } + + $until = strtotime($lockoutUntil); + + return $until !== false && $until > $now; + } + + /** + * Construit la politique pour la dimension 'account' (par compte) ou 'ip' + * (par IP source). RG-8 precise "le meme backoff degressif" pour l'IP, donc + * la dimension IP reutilise base/max et prend IP_THROTTLE_MAX_ATTEMPTS comme seuil. + */ + public static function fromConfig(Config $config, string $dimension): self + { + $base = $config->int('ACCOUNT_LOCKOUT_BASE_SECONDS', 60); + $max = $config->int('ACCOUNT_LOCKOUT_MAX_SECONDS', 900); + + if ($dimension === 'ip') { + return new self($config->int('IP_THROTTLE_MAX_ATTEMPTS', 20), $base, $max); + } + + return new self($config->int('ACCOUNT_LOCKOUT_THRESHOLD', 5), $base, $max); + } +} diff --git a/src/app/Controllers/AuthController.php b/src/app/Controllers/AuthController.php new file mode 100644 index 0000000..6a7aee2 --- /dev/null +++ b/src/app/Controllers/AuthController.php @@ -0,0 +1,127 @@ + $params + */ + public function showLogin(array $params = []): Response + { + $notice = $this->request->query('reset') === 'ok' + ? 'Mot de passe reinitialise. Vous pouvez vous connecter.' + : null; + + return $this->renderLogin(null, $notice); + } + + /** + * @param array $params + */ + public function login(array $params = []): Response + { + $form = $this->request->formBody(); + + // PRE-2 / ERR-2 : jeton CSRF valide sinon 403, avant tout traitement. + if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) { + return $this->renderLogin('Session expiree, merci de reessayer.', null, 403); + } + + // RG-T18 : validation et bornes de longueur cote serveur. + $email = trim($form['email'] ?? ''); + $password = $form['password'] ?? ''; + + if ($email === '' || $password === '' || strlen($email) > 254 || strlen($password) > 4096) { + return $this->renderLogin(self::GENERIC_ERROR); + } + + try { + $result = $this->authService()->authenticate($email, $password, $this->request->clientIp()); + } catch (Throwable $exception) { + // Fail-closed : une panne base ne doit jamais authentifier. On ne + // divulgue rien, on re-affiche le formulaire avec le message generique. + error_log('[wakdo][auth] login failure: ' . $exception->getMessage()); + + return $this->renderLogin(self::GENERIC_ERROR); + } + + if ($result->success && $result->redirectTo !== null) { + return $this->redirect($result->redirectTo); + } + + return $this->renderLogin($result->error ?? self::GENERIC_ERROR); + } + + /** + * @param array $params + */ + public function logout(array $params = []): Response + { + $form = $this->request->formBody(); + + // D11 : deconnexion en POST garde par CSRF (un GET forgeable pourrait + // deconnecter un poste en plein service). CSRF invalide -> 403, pas de destroy. + if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) { + return Response::make('Requete invalide.', 403, ['Content-Type' => 'text/plain; charset=utf-8']); + } + + $this->authService()->logout(); + + return $this->redirect('/login'); + } + + protected function sessionManager(): SessionManager + { + return new SessionManager($this->config); + } + + protected function authService(): AuthService + { + return new AuthService( + $this->database, + $this->config, + $this->sessionManager(), + new PasswordHasher($this->config), + ); + } + + private function redirect(string $location, int $status = 302): Response + { + return Response::make('', $status, ['Location' => $location]); + } + + private function renderLogin(?string $error, ?string $notice = null, int $status = 200): Response + { + return $this->view('auth/login', [ + 'title' => 'Connexion - Wakdo Admin', + 'csrfToken' => Csrf::token($this->sessionManager()), + 'error' => $error, + 'notice' => $notice, + ], $status); + } +} diff --git a/src/app/Controllers/PasswordResetController.php b/src/app/Controllers/PasswordResetController.php new file mode 100644 index 0000000..efa0580 --- /dev/null +++ b/src/app/Controllers/PasswordResetController.php @@ -0,0 +1,150 @@ + $params + */ + public function showRequest(array $params = []): Response + { + return $this->view('auth/forgot', [ + 'title' => 'Mot de passe oublie - Wakdo Admin', + 'csrfToken' => Csrf::token($this->sessionManager()), + 'notice' => null, + ]); + } + + /** + * @param array $params + */ + public function submitRequest(array $params = []): Response + { + $form = $this->request->formBody(); + + if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) { + return $this->view('auth/forgot', [ + 'title' => 'Mot de passe oublie - Wakdo Admin', + 'csrfToken' => Csrf::token($this->sessionManager()), + 'notice' => null, + ], 403); + } + + $email = trim($form['email'] ?? ''); + + // Reponse neutre quoi qu'il arrive (existence, validite, meme panne base). + if ($email !== '' && strlen($email) <= 254) { + try { + $this->resetService()->requestReset($email, $this->baseUrl()); + } catch (Throwable $exception) { + error_log('[wakdo][auth] reset request failure: ' . $exception->getMessage()); + } + } + + return $this->view('auth/forgot', [ + 'title' => 'Mot de passe oublie - Wakdo Admin', + 'csrfToken' => Csrf::token($this->sessionManager()), + 'notice' => self::NEUTRAL_NOTICE, + ]); + } + + /** + * @param array $params + */ + public function showConfirm(array $params = []): Response + { + return $this->renderConfirm($this->request->query('token') ?? '', null); + } + + /** + * @param array $params + */ + public function submitConfirm(array $params = []): Response + { + $form = $this->request->formBody(); + $token = $form['token'] ?? ''; + + if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) { + return $this->renderConfirm($token, 'Session expiree, merci de reessayer.', 403); + } + + $password = $form['password'] ?? ''; + $confirm = $form['password_confirm'] ?? ''; + + if ($password !== $confirm) { + return $this->renderConfirm($token, 'Les mots de passe ne correspondent pas.'); + } + + try { + $result = $this->resetService()->confirmReset($token, $password); + } catch (Throwable $exception) { + error_log('[wakdo][auth] reset confirm failure: ' . $exception->getMessage()); + + return $this->renderConfirm($token, self::INVALID_LINK); + } + + if ($result->success && $result->redirectTo !== null) { + return $this->redirect($result->redirectTo); + } + + return $this->renderConfirm($token, $result->error ?? self::INVALID_LINK); + } + + protected function sessionManager(): SessionManager + { + return new SessionManager($this->config); + } + + protected function resetService(): PasswordResetService + { + return new PasswordResetService( + $this->database, + $this->config, + new PasswordHasher($this->config), + new LogMailer(), + ); + } + + private function baseUrl(): string + { + return $this->config->get('APP_URL_ADMIN', '') ?? ''; + } + + private function redirect(string $location, int $status = 302): Response + { + return Response::make('', $status, ['Location' => $location]); + } + + private function renderConfirm(string $token, ?string $error, int $status = 200): Response + { + return $this->view('auth/reset', [ + 'title' => 'Nouveau mot de passe - Wakdo Admin', + 'csrfToken' => Csrf::token($this->sessionManager()), + 'token' => $token, + 'error' => $error, + ], $status); + } +} diff --git a/src/app/Core/Database.php b/src/app/Core/Database.php index d149d5e..4ab5c67 100644 --- a/src/app/Core/Database.php +++ b/src/app/Core/Database.php @@ -6,6 +6,7 @@ namespace App\Core; use PDO; use PDOStatement; +use Throwable; /** * Enveloppe PDO MariaDB, requetes preparees exclusivement (anti-SQLi, Cr 4.e.1). @@ -14,7 +15,7 @@ use PDOStatement; * routes sans BDD (ex : la home back-office) fonctionnent meme si la base est * indisponible. */ -final class Database +final class Database implements DatabaseInterface { private ?PDO $pdo = null; @@ -91,4 +92,30 @@ final class Database { return $this->query($sql, $params)->rowCount(); } + + /** + * Execute $fn dans une transaction atomique (RG-T08) : begin -> $fn -> commit. + * Tout Throwable declenche un rollback complet puis est repropage : jamais + * d'ecriture partielle, jamais d'echec silencieux. Le retour est void (et non + * mixed) pour rester strictement type sous PHPStan ; $fn ecrit via le $this + * qui lui est passe (memes requetes preparees, meme connexion). + * + * @param callable(DatabaseInterface): void $fn + */ + public function transaction(callable $fn): void + { + $pdo = $this->pdo(); + $pdo->beginTransaction(); + + try { + $fn($this); + $pdo->commit(); + } catch (Throwable $exception) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + + throw $exception; + } + } } diff --git a/src/app/Core/DatabaseInterface.php b/src/app/Core/DatabaseInterface.php new file mode 100644 index 0000000..b2b7710 --- /dev/null +++ b/src/app/Core/DatabaseInterface.php @@ -0,0 +1,43 @@ + $params + * @return array|null + */ + public function fetch(string $sql, array $params = []): ?array; + + /** + * @param array $params + * @return array> + */ + public function fetchAll(string $sql, array $params = []): array; + + /** + * @param array $params + */ + public function execute(string $sql, array $params = []): int; + + /** + * Execute $fn dans une transaction atomique : commit si succes, rollback + * complet sur tout Throwable (puis repropagation). + * + * @param callable(DatabaseInterface): void $fn + */ + public function transaction(callable $fn): void; +} diff --git a/src/app/Core/Request.php b/src/app/Core/Request.php index 0e124b9..eb5e1cf 100644 --- a/src/app/Core/Request.php +++ b/src/app/Core/Request.php @@ -22,6 +22,10 @@ final class Request private readonly array $query, private readonly array $headers, private readonly string $rawBody, + // Adresse de la connexion TCP entrante (le proxy Traefik en frontal). + // Defaut vide pour conserver la compatibilite des appels a 5 arguments + // (tests existants). clientIp() s'en sert comme repli derriere X-Forwarded-For. + private readonly string $remoteAddr = '', ) { } @@ -44,6 +48,7 @@ final class Request $query, self::extractHeaders(), (string) file_get_contents('php://input'), + (string) ($_SERVER['REMOTE_ADDR'] ?? ''), ); } @@ -142,4 +147,68 @@ final class Request return is_array($decoded) ? $decoded : []; } + + /** + * Decode un corps application/x-www-form-urlencoded en map cle => valeur. + * Symetrique de json() : renvoie [] si le content-type n'est pas un + * formulaire urlencode, pour laisser la validation metier decider (pas de + * fatale ici). Le back-office se connecte par formulaire POST, pas par JSON. + * + * @return array + */ + public function formBody(): array + { + $contentType = $this->header('content-type') ?? ''; + + if (!str_starts_with($contentType, 'application/x-www-form-urlencoded')) { + return []; + } + + parse_str($this->rawBody, $parsed); + + // parse_str peut produire des valeurs tableau (cle[]=...) ; on ne retient + // que les scalaires convertis en chaine pour tenir le contrat strict + // array (et neutraliser une cle de type "champ[]"). + $form = []; + foreach ($parsed as $key => $value) { + if (is_scalar($value)) { + $form[(string) $key] = (string) $value; + } + } + + return $form; + } + + /** + * IP client reelle derriere le reverse proxy Traefik. REMOTE_ADDR est ici + * toujours l'adresse du proxy, donc on lit X-Forwarded-For et on retient le + * DERNIER hop : c'est celui ajoute par Traefik (proxy de confiance), tandis + * que les entrees de gauche sont fournies par le client et donc falsifiables. + * La valeur est validee par FILTER_VALIDATE_IP et bornee a 45 caracteres + * (taille de login_throttle.ip_address). Repli sur REMOTE_ADDR si l'en-tete + * est absent ou invalide ; sentinelle 0.0.0.0 en dernier recours. + * + * Hypothese de deploiement : un unique proxy de confiance (Traefik) est + * toujours en frontal. Sans lui, X-Forwarded-For serait falsifiable ; le + * verrou par compte (failed_login_attempts) reste alors le garde-fou. + */ + public function clientIp(): string + { + $forwarded = $this->header('x-forwarded-for'); + + if ($forwarded !== null && $forwarded !== '') { + $hops = explode(',', $forwarded); + $candidate = trim((string) end($hops)); + + if (filter_var($candidate, FILTER_VALIDATE_IP) !== false) { + return substr($candidate, 0, 45); + } + } + + if ($this->remoteAddr !== '' && filter_var($this->remoteAddr, FILTER_VALIDATE_IP) !== false) { + return substr($this->remoteAddr, 0, 45); + } + + return '0.0.0.0'; + } } diff --git a/src/app/Core/Response.php b/src/app/Core/Response.php index 294e370..49a11ea 100644 --- a/src/app/Core/Response.php +++ b/src/app/Core/Response.php @@ -47,6 +47,24 @@ final class Response return $this; } + public function body(): string + { + return $this->body; + } + + public function header(string $name): ?string + { + return $this->headers[$name] ?? null; + } + + /** + * @return array + */ + public function headers(): array + { + return $this->headers; + } + /** * @param array $headers */ diff --git a/src/app/Views/auth/forgot.php b/src/app/Views/auth/forgot.php new file mode 100644 index 0000000..fde146b --- /dev/null +++ b/src/app/Views/auth/forgot.php @@ -0,0 +1,35 @@ + +
+

Mot de passe oublie

+ + +

+ + +
+ + +
+ + +
+ + +
+ +

Retour a la connexion

+
diff --git a/src/app/Views/auth/login.php b/src/app/Views/auth/login.php new file mode 100644 index 0000000..187b1d3 --- /dev/null +++ b/src/app/Views/auth/login.php @@ -0,0 +1,47 @@ + +
+

Wakdo Admin

+

Back-office de gestion

+ + +

+ + + +

+ + +
+ + +
+ + +
+ +
+ + +
+ + +
+ +

Mot de passe oublie ?

+
diff --git a/src/app/Views/auth/reset.php b/src/app/Views/auth/reset.php new file mode 100644 index 0000000..e8b8b41 --- /dev/null +++ b/src/app/Views/auth/reset.php @@ -0,0 +1,43 @@ + +
+

Nouveau mot de passe

+ + +

+ + +
+ + + +
+ + +
+ +
+ + +
+ + +
+ +

Retour a la connexion

+
diff --git a/src/public/admin/index.php b/src/public/admin/index.php index 50279f4..dd44d61 100644 --- a/src/public/admin/index.php +++ b/src/public/admin/index.php @@ -10,8 +10,11 @@ declare(strict_types=1); * "/", "/api/health", etc. */ +use App\Auth\SessionManager; +use App\Controllers\AuthController; use App\Controllers\HealthController; use App\Controllers\HomeController; +use App\Controllers\PasswordResetController; use App\Core\Autoloader; use App\Core\Config; use App\Core\Database; @@ -36,10 +39,24 @@ try { // donc la home back-office reste servie meme base indisponible. $database = new Database($config); + // Demarre la session du vhost admin avant le dispatch (effet de bord global, + // hors du Core stateless). Les controleurs y rattachent leur SessionManager. + (new SessionManager($config))->start(); + $router = new Router($config, $database); $router->add('GET', '/', [HomeController::class, 'index']); $router->add('GET', '/api/health', [HealthController::class, 'index']); + // Authentification back-office (mlt.md section 12). Le docroot du vhost admin + // etant src/public/admin, le Router voit "/login" (pas de prefixe "/admin"). + $router->add('GET', '/login', [AuthController::class, 'showLogin']); + $router->add('POST', '/login', [AuthController::class, 'login']); + $router->add('POST', '/logout', [AuthController::class, 'logout']); + $router->add('GET', '/forgot_password', [PasswordResetController::class, 'showRequest']); + $router->add('POST', '/forgot_password', [PasswordResetController::class, 'submitRequest']); + $router->add('GET', '/reset_password', [PasswordResetController::class, 'showConfirm']); + $router->add('POST', '/reset_password', [PasswordResetController::class, 'submitConfirm']); + $response = $router->dispatch(Request::fromGlobals()); $response->send(); } catch (Throwable $exception) { diff --git a/tests/Integration/AuthServiceDbTest.php b/tests/Integration/AuthServiceDbTest.php new file mode 100644 index 0000000..e726ea3 --- /dev/null +++ b/tests/Integration/AuthServiceDbTest.php @@ -0,0 +1,189 @@ +config = new Config(); + $this->db = new Database($this->config); + + try { + $this->db->fetch('SELECT 1'); + } catch (Throwable $exception) { + self::markTestSkipped('Base de donnees injoignable: ' . $exception->getMessage()); + } + + $this->cleanupThrottle(); + $this->userId = $this->createDisposableUser(); + } + + protected function tearDown(): void + { + if ($this->userId === 0) { + return; + } + + // Ordre compatible FK : audit (actor SET NULL mais on retire nos lignes), + // throttle (par IP), puis l'utilisateur jetable. + $this->db->execute('DELETE FROM audit_log WHERE actor_user_id = :id', ['id' => $this->userId]); + $this->cleanupThrottle(); + $this->db->execute('DELETE FROM user WHERE id = :id', ['id' => $this->userId]); + $this->userId = 0; + } + + private function service(): AuthService + { + return new AuthService( + $this->db, + $this->config, + new SessionManager($this->config, true), + new PasswordHasher($this->config), + ); + } + + public function testSuccessfulLoginPersistsResetCountersAndAuditSuccess(): void + { + $result = $this->service()->authenticate($this->email(), self::PASSWORD, self::TEST_IP); + + self::assertTrue($result->success); + + $user = $this->db->fetch( + 'SELECT failed_login_attempts, lockout_until, last_login_at FROM user WHERE id = :id', + ['id' => $this->userId], + ); + self::assertNotNull($user); + self::assertSame(0, (int) ($user['failed_login_attempts'] ?? -1)); + self::assertNull($user['lockout_until']); + self::assertNotNull($user['last_login_at']); + + self::assertSame('auth.login_success', $this->lastAuditAction()); + } + + public function testFailedLoginIncrementsAccountAndCreatesThrottleAndAuditFailure(): void + { + $result = $this->service()->authenticate($this->email(), 'WRONG-PASSWORD', self::TEST_IP); + + self::assertFalse($result->success); + + $user = $this->db->fetch( + 'SELECT failed_login_attempts FROM user WHERE id = :id', + ['id' => $this->userId], + ); + self::assertNotNull($user); + self::assertSame(1, (int) ($user['failed_login_attempts'] ?? -1)); + + $throttle = $this->db->fetch( + 'SELECT failed_attempts FROM login_throttle WHERE ip_address = :ip', + ['ip' => self::TEST_IP], + ); + self::assertNotNull($throttle); + self::assertSame(1, (int) ($throttle['failed_attempts'] ?? -1)); + + self::assertSame('auth.login_failed', $this->lastAuditAction()); + } + + public function testThrottleGateRejectsWhenAccountLocked(): void + { + // Pose un verrou compte dans le futur, puis tente avec le BON mot de passe : + // la porte PRE-3 doit refuser avant toute verification. + $future = date('Y-m-d H:i:s', time() + 600); + $this->db->execute( + 'UPDATE user SET lockout_until = :lock WHERE id = :id', + ['lock' => $future, 'id' => $this->userId], + ); + + $result = $this->service()->authenticate($this->email(), self::PASSWORD, self::TEST_IP); + + self::assertFalse($result->success); + // last_login_at reste nul : aucune authentification n'a abouti. + $user = $this->db->fetch('SELECT last_login_at FROM user WHERE id = :id', ['id' => $this->userId]); + self::assertNotNull($user); + self::assertNull($user['last_login_at']); + } + + private function email(): string + { + return 'it-auth-' . $this->userId . '@wakdo.invalid'; + } + + private function createDisposableUser(): int + { + $roleRow = $this->db->fetch('SELECT id FROM role ORDER BY id LIMIT 1'); + $roleId = (int) ($roleRow['id'] ?? 0); + self::assertGreaterThan(0, $roleId, 'aucun role seede: migration/seed requis'); + + $hash = (new PasswordHasher($this->config))->hash(self::PASSWORD); + // Email provisoire pour obtenir l'id, puis on le rend unique par id. + $this->db->execute( + 'INSERT INTO user (email, password_hash, first_name, last_name, role_id, is_active) ' + . 'VALUES (:email, :hash, :fn, :ln, :role, 1)', + [ + 'email' => 'it-auth-pending-' . bin2hex(random_bytes(6)) . '@wakdo.invalid', + 'hash' => $hash, + 'fn' => 'Integration', + 'ln' => 'Test', + 'role' => $roleId, + ], + ); + + $row = $this->db->fetch('SELECT LAST_INSERT_ID() AS id'); + $id = (int) ($row['id'] ?? 0); + + $this->db->execute( + 'UPDATE user SET email = :email WHERE id = :id', + ['email' => 'it-auth-' . $id . '@wakdo.invalid', 'id' => $id], + ); + + return $id; + } + + private function cleanupThrottle(): void + { + $this->db->execute('DELETE FROM login_throttle WHERE ip_address = :ip', ['ip' => self::TEST_IP]); + } + + private function lastAuditAction(): ?string + { + $row = $this->db->fetch( + 'SELECT action_code FROM audit_log WHERE actor_user_id = :id ORDER BY id DESC LIMIT 1', + ['id' => $this->userId], + ); + + return $row === null ? null : (string) ($row['action_code'] ?? ''); + } +} diff --git a/tests/Support/FakeDatabase.php b/tests/Support/FakeDatabase.php new file mode 100644 index 0000000..3009c38 --- /dev/null +++ b/tests/Support/FakeDatabase.php @@ -0,0 +1,155 @@ +|null + */ + public ?array $userRow = null; + + /** lockout_until renvoye pour la porte de throttling IP ; null = pas de verrou. */ + public ?string $ipLockoutUntil = null; + + /** + * Compteur login_throttle relu apres l'upsert atomique (sert au calcul du + * backoff IP en PHP) ; null => 1 par defaut cote service. + * + * @var array|null + */ + public ?array $throttleRow = null; + + /** + * Reponse de la recherche par token de reinitialisation (12.3) ; null = aucun. + * + * @var array|null + */ + public ?array $resetUserRow = null; + + /** + * Reponse de la recherche par email (phase demande de reinitialisation) ; null = inconnu. + * + * @var array|null + */ + public ?array $emailLookupRow = null; + + /** + * Reponse de la verification is_active du SessionGuard (RG-T02) ; null = absent. + * + * @var array|null + */ + public ?array $guardUserRow = null; + + /** Si non nul, execute() leve cette exception (simulation panne DB -> fail-closed). */ + public ?RuntimeException $failOnExecute = null; + + /** @var list}> */ + public array $writes = []; + + /** @var list */ + public array $transactionEvents = []; + + public function fetch(string $sql, array $params = []): ?array + { + if (str_contains($sql, 'FROM user u JOIN role')) { + return $this->userRow; + } + + if (str_contains($sql, 'password_reset_token_hash')) { + return $this->resetUserRow; + } + + if (str_contains($sql, 'SELECT id FROM user WHERE email')) { + return $this->emailLookupRow; + } + + if (str_contains($sql, 'SELECT is_active FROM user WHERE id')) { + return $this->guardUserRow; + } + + if (str_contains($sql, 'SELECT lockout_until FROM login_throttle')) { + return ['lockout_until' => $this->ipLockoutUntil]; + } + + if (str_contains($sql, 'SELECT failed_attempts FROM login_throttle')) { + return $this->throttleRow; + } + + return null; + } + + public function fetchAll(string $sql, array $params = []): array + { + return []; + } + + public function execute(string $sql, array $params = []): int + { + if ($this->failOnExecute !== null) { + throw $this->failOnExecute; + } + + $this->writes[] = ['sql' => $sql, 'params' => $params]; + + return 1; + } + + public function transaction(callable $fn): void + { + $this->transactionEvents[] = 'begin'; + + try { + $fn($this); + $this->transactionEvents[] = 'commit'; + } catch (\Throwable $exception) { + $this->transactionEvents[] = 'rollback'; + + throw $exception; + } + } + + public function wrote(string $needle): bool + { + foreach ($this->writes as $write) { + if (str_contains($write['sql'], $needle)) { + return true; + } + } + + return false; + } + + /** + * Codes d'action audit_log inseres (dans l'ordre). + * + * @return list + */ + public function auditActions(): array + { + $codes = []; + + foreach ($this->writes as $write) { + if (str_contains($write['sql'], 'INSERT INTO audit_log')) { + $code = $write['params']['code'] ?? null; + $codes[] = is_string($code) ? $code : ''; + } + } + + return $codes; + } +} diff --git a/tests/Support/SpyMailer.php b/tests/Support/SpyMailer.php new file mode 100644 index 0000000..ed981a2 --- /dev/null +++ b/tests/Support/SpyMailer.php @@ -0,0 +1,22 @@ + */ + public array $sent = []; + + public function sendPasswordReset(string $email, string $resetUrl): void + { + $this->sent[] = ['email' => $email, 'resetUrl' => $resetUrl]; + } +} diff --git a/tests/Unit/Auth/AuthControllerTest.php b/tests/Unit/Auth/AuthControllerTest.php new file mode 100644 index 0000000..b62ccd0 --- /dev/null +++ b/tests/Unit/Auth/AuthControllerTest.php @@ -0,0 +1,197 @@ +testSession; + } + + protected function authService(): AuthService + { + return new AuthService($this->fakeDb, $this->config, $this->testSession, new PasswordHasher($this->config)); + } +} + +final class AuthControllerTest extends TestCase +{ + /** @var list */ + private array $touchedKeys = []; + + protected function setUp(): void + { + $this->setEnv('ACCOUNT_LOCKOUT_THRESHOLD', '5'); + $this->setEnv('ACCOUNT_LOCKOUT_BASE_SECONDS', '60'); + $this->setEnv('ACCOUNT_LOCKOUT_MAX_SECONDS', '900'); + $this->setEnv('IP_THROTTLE_MAX_ATTEMPTS', '20'); + $this->setEnv('IP_THROTTLE_WINDOW_SECONDS', '900'); + $this->setEnv('ARGON2_MEMORY_COST', '1024'); + $this->setEnv('ARGON2_TIME_COST', '1'); + $this->setEnv('ARGON2_THREADS', '1'); + } + + protected function tearDown(): void + { + foreach ($this->touchedKeys as $key) { + putenv($key); + } + $this->touchedKeys = []; + } + + private function setEnv(string $key, string $value): void + { + $this->touchedKeys[] = $key; + putenv($key . '=' . $value); + } + + /** + * @param array $form + */ + private function postRequest(array $form, string $path = '/login'): Request + { + return new Request( + 'POST', + $path, + [], + ['content-type' => 'application/x-www-form-urlencoded'], + http_build_query($form), + '203.0.113.5', + ); + } + + private function getRequest(string $path = '/login'): Request + { + return new Request('GET', $path, [], [], '', '203.0.113.5'); + } + + private function controller(Request $request, SessionManager $session, FakeDatabase $db): TestAuthController + { + return new TestAuthController($request, new Config(), new Database(new Config()), $session, $db); + } + + /** + * @param array $overrides + * @return array + */ + private function userRow(string $password, array $overrides = []): array + { + return array_merge([ + 'id' => 7, + 'role_id' => 3, + 'password_hash' => (new PasswordHasher(new Config()))->hash($password), + 'failed_login_attempts' => 0, + 'lockout_until' => null, + 'default_route' => '/admin/dashboard', + ], $overrides); + } + + public function testShowLoginRendersCsrfField(): void + { + $session = new SessionManager(new Config(), true); + $response = $this->controller($this->getRequest(), $session, new FakeDatabase())->showLogin(); + + self::assertSame(200, $response->status()); + self::assertStringContainsString('name="_csrf"', $response->body()); + } + + public function testLoginRejectsInvalidCsrfWith403(): void + { + $session = new SessionManager(new Config(), true); + Csrf::token($session); + $db = new FakeDatabase(); + + $request = $this->postRequest(['_csrf' => 'wrong', 'email' => 'admin@wakdo.local', 'password' => 'x']); + $response = $this->controller($request, $session, $db)->login(); + + self::assertSame(403, $response->status()); + // L'authentification n'a pas tourne : aucune ecriture base. + self::assertSame([], $db->writes); + } + + public function testLoginBadCredentialsRendersGenericErrorWithoutRedirect(): void + { + $session = new SessionManager(new Config(), true); + $token = Csrf::token($session); + $db = new FakeDatabase(); + $db->userRow = $this->userRow('right-password'); + + $request = $this->postRequest(['_csrf' => $token, 'email' => 'admin@wakdo.local', 'password' => 'WRONG']); + $response = $this->controller($request, $session, $db)->login(); + + self::assertSame(200, $response->status()); + self::assertNull($response->header('Location')); + self::assertStringContainsString('Email ou mot de passe incorrect', $response->body()); + } + + public function testLoginSuccessRedirectsToDefaultRoute(): void + { + $session = new SessionManager(new Config(), true); + $token = Csrf::token($session); + $db = new FakeDatabase(); + $db->userRow = $this->userRow('correct-password'); + + $request = $this->postRequest(['_csrf' => $token, 'email' => 'admin@wakdo.local', 'password' => 'correct-password']); + $response = $this->controller($request, $session, $db)->login(); + + self::assertSame(302, $response->status()); + self::assertSame('/admin/dashboard', $response->header('Location')); + self::assertSame(7, $session->getInt('user_id')); + } + + public function testLogoutRequiresValidCsrf(): void + { + $session = new SessionManager(new Config(), true); + Csrf::token($session); + $session->set('user_id', 7); + + $request = $this->postRequest(['_csrf' => 'wrong'], '/logout'); + $response = $this->controller($request, $session, new FakeDatabase())->logout(); + + self::assertSame(403, $response->status()); + // Session intacte : la deconnexion forgee est refusee. + self::assertSame(7, $session->getInt('user_id')); + } + + public function testLogoutWithValidCsrfClearsSessionAndRedirects(): void + { + $session = new SessionManager(new Config(), true); + $token = Csrf::token($session); + $session->set('user_id', 7); + + $request = $this->postRequest(['_csrf' => $token], '/logout'); + $response = $this->controller($request, $session, new FakeDatabase())->logout(); + + self::assertSame(302, $response->status()); + self::assertSame('/login', $response->header('Location')); + self::assertNull($session->getInt('user_id')); + } +} diff --git a/tests/Unit/Auth/AuthServiceTest.php b/tests/Unit/Auth/AuthServiceTest.php new file mode 100644 index 0000000..3f60412 --- /dev/null +++ b/tests/Unit/Auth/AuthServiceTest.php @@ -0,0 +1,315 @@ + */ + private array $touchedKeys = []; + + private FakeDatabase $db; + private SessionManager $session; + private PasswordHasher $hasher; + + protected function setUp(): void + { + // Politique de throttling deterministe + argon2id a cout reduit. + $this->setEnv('ACCOUNT_LOCKOUT_THRESHOLD', '5'); + $this->setEnv('ACCOUNT_LOCKOUT_BASE_SECONDS', '60'); + $this->setEnv('ACCOUNT_LOCKOUT_MAX_SECONDS', '900'); + $this->setEnv('IP_THROTTLE_MAX_ATTEMPTS', '20'); + $this->setEnv('IP_THROTTLE_WINDOW_SECONDS', '900'); + $this->setEnv('ARGON2_MEMORY_COST', '1024'); + $this->setEnv('ARGON2_TIME_COST', '1'); + $this->setEnv('ARGON2_THREADS', '1'); + + $this->db = new FakeDatabase(); + $this->session = new SessionManager(new Config(), true); + $this->hasher = new PasswordHasher(new Config()); + } + + protected function tearDown(): void + { + foreach ($this->touchedKeys as $key) { + putenv($key); + } + $this->touchedKeys = []; + } + + private function setEnv(string $key, string $value): void + { + $this->touchedKeys[] = $key; + putenv($key . '=' . $value); + } + + private function service(): AuthService + { + return new AuthService($this->db, new Config(), $this->session, $this->hasher); + } + + /** + * @param array $overrides + * @return array + */ + private function userRow(array $overrides = []): array + { + return array_merge([ + 'id' => 7, + 'password_hash' => $this->hasher->hash('correct horse'), + 'role_id' => 3, + 'failed_login_attempts' => 0, + 'lockout_until' => null, + 'default_route' => '/admin/dashboard', + ], $overrides); + } + + public function testUnknownEmailFailsAndRecordsIpFailure(): void + { + $this->db->userRow = null; + + $result = $this->service()->authenticate('ghost@wakdo.local', 'whatever', '203.0.113.1', self::NOW); + + self::assertFalse($result->success); + self::assertSame('Email ou mot de passe incorrect', $result->error); + self::assertNull($this->session->getInt('user_id')); + self::assertTrue($this->db->wrote('INSERT INTO login_throttle')); + self::assertSame(['auth.login_failed'], $this->db->auditActions()); + self::assertSame(['begin', 'commit'], $this->db->transactionEvents); + // Anti-enumeration : meme profil d'I/O que le chemin email connu, via un + // UPDATE user no-op sur id = 0 (ne touche aucune ligne, ne revele rien). + self::assertTrue($this->db->wrote('UPDATE user SET failed_login_attempts')); + self::assertSame(0, $this->firstWrite('UPDATE user SET failed_login_attempts')['params']['id'] ?? null); + } + + public function testFailureWriteProfileIsIdenticalForKnownAndUnknownEmail(): void + { + // Email inconnu. + $this->db->userRow = null; + $this->service()->authenticate('ghost@wakdo.local', 'whatever', '203.0.113.9', self::NOW); + $unknownWrites = count($this->db->writes); + + // Email connu, mauvais mot de passe (instances neuves pour isoler le compteur). + $db2 = new FakeDatabase(); + $db2->userRow = $this->userRow(); + $service2 = new AuthService($db2, new Config(), new SessionManager(new Config(), true), $this->hasher); + $service2->authenticate('admin@wakdo.local', 'WRONG', '203.0.113.9', self::NOW); + $knownWrites = count($db2->writes); + + self::assertSame($knownWrites, $unknownWrites, 'meme nombre d ecritures (anti-enumeration)'); + } + + public function testAccountLockedIsRejectedBeforeAnyWrite(): void + { + // lockout_until dans le futur : porte PRE-3, aucun increment ni ecriture. + $this->db->userRow = $this->userRow([ + 'lockout_until' => date('Y-m-d H:i:s', self::NOW + 120), + ]); + + $result = $this->service()->authenticate('admin@wakdo.local', 'correct horse', '203.0.113.1', self::NOW); + + self::assertFalse($result->success); + self::assertSame([], $this->db->writes); + self::assertSame([], $this->db->transactionEvents); + self::assertNull($this->session->getInt('user_id')); + } + + public function testIpLockedIsRejectedBeforeAnyWrite(): void + { + $this->db->userRow = $this->userRow(); + $this->db->ipLockoutUntil = date('Y-m-d H:i:s', self::NOW + 300); + + $result = $this->service()->authenticate('admin@wakdo.local', 'correct horse', '203.0.113.1', self::NOW); + + self::assertFalse($result->success); + self::assertSame([], $this->db->writes); + self::assertNull($this->session->getInt('user_id')); + } + + public function testWrongPasswordRecordsAccountAndIpFailure(): void + { + $this->db->userRow = $this->userRow(['failed_login_attempts' => 0]); + + $result = $this->service()->authenticate('admin@wakdo.local', 'WRONG', '203.0.113.1', self::NOW); + + self::assertFalse($result->success); + self::assertTrue($this->db->wrote('UPDATE user SET failed_login_attempts')); + self::assertTrue($this->db->wrote('INSERT INTO login_throttle')); + self::assertSame(['auth.login_failed'], $this->db->auditActions()); + self::assertSame(['begin', 'commit'], $this->db->transactionEvents); + self::assertNull($this->session->getInt('user_id')); + } + + public function testWrongPasswordSetsLockoutOnceThresholdReached(): void + { + // 4 echecs deja enregistres : le 5e (= seuil) doit poser un lockout_until. + $this->db->userRow = $this->userRow(['failed_login_attempts' => 4]); + + $this->service()->authenticate('admin@wakdo.local', 'WRONG', '203.0.113.1', self::NOW); + + $userUpdate = $this->firstWrite('UPDATE user SET failed_login_attempts'); + self::assertSame(5, $userUpdate['params']['attempts'] ?? null); + self::assertSame(date('Y-m-d H:i:s', self::NOW + 60), $userUpdate['params']['lock'] ?? null); + } + + public function testWrongPasswordBelowThresholdLeavesLockoutNull(): void + { + $this->db->userRow = $this->userRow(['failed_login_attempts' => 0]); + + $this->service()->authenticate('admin@wakdo.local', 'WRONG', '203.0.113.1', self::NOW); + + $userUpdate = $this->firstWrite('UPDATE user SET failed_login_attempts'); + self::assertSame(1, $userUpdate['params']['attempts'] ?? null); + self::assertArrayHasKey('lock', $userUpdate['params']); + self::assertNull($userUpdate['params']['lock']); + } + + public function testIpUpsertUsesAtomicIncrementAndSqlWindowReset(): void + { + $this->db->userRow = $this->userRow(['failed_login_attempts' => 0]); + + $this->service()->authenticate('admin@wakdo.local', 'WRONG', '203.0.113.1', self::NOW); + + $upsert = $this->firstWrite('INSERT INTO login_throttle'); + // Increment atomique cote SQL (pas un literal PHP) -> immunise au lost-update. + self::assertStringContainsString('failed_attempts + 1', $upsert['sql']); + // Reset de fenetre decide en SQL, borne stricte sur window_started_at. + self::assertStringContainsString('IF(window_started_at < :cutoff', $upsert['sql']); + } + + public function testIpThrottleSetsLockWhenThresholdReached(): void + { + // La relecture post-upsert renvoie 20 (= IP_THROTTLE_MAX_ATTEMPTS) : verrou pose. + $this->db->userRow = $this->userRow(['failed_login_attempts' => 0]); + $this->db->throttleRow = ['failed_attempts' => 20]; + + $this->service()->authenticate('admin@wakdo.local', 'WRONG', '203.0.113.1', self::NOW); + + $lockWrite = $this->firstWrite('UPDATE login_throttle SET lockout_until = :lock'); + self::assertSame(date('Y-m-d H:i:s', self::NOW + 60), $lockWrite['params']['lock'] ?? null); + } + + public function testIpThrottleLeavesLockNullBelowThreshold(): void + { + $this->db->userRow = $this->userRow(['failed_login_attempts' => 0]); + $this->db->throttleRow = ['failed_attempts' => 3]; + + $this->service()->authenticate('admin@wakdo.local', 'WRONG', '203.0.113.1', self::NOW); + + $lockWrite = $this->firstWrite('UPDATE login_throttle SET lockout_until = :lock'); + self::assertArrayHasKey('lock', $lockWrite['params']); + self::assertNull($lockWrite['params']['lock']); + } + + public function testCorrectCredentialsSucceedAndOpenSession(): void + { + $this->db->userRow = $this->userRow(); + + $result = $this->service()->authenticate('admin@wakdo.local', 'correct horse', '203.0.113.1', self::NOW); + + self::assertTrue($result->success); + self::assertSame(7, $result->userId); + self::assertSame(3, $result->roleId); + self::assertSame('/admin/dashboard', $result->redirectTo); + + self::assertSame(7, $this->session->getInt('user_id')); + self::assertSame(3, $this->session->getInt('role_id')); + self::assertSame(self::NOW, $this->session->getInt('logged_in_at')); + self::assertSame(self::NOW, $this->session->getInt('last_activity')); + + // RG-5/RG-9 : reset compteur + clear throttle + audit succes, 1 transaction. + self::assertTrue($this->db->wrote('UPDATE user SET failed_login_attempts = 0')); + self::assertTrue($this->db->wrote('UPDATE login_throttle SET failed_attempts = 0')); + self::assertSame(['auth.login_success'], $this->db->auditActions()); + self::assertSame(['begin', 'commit'], $this->db->transactionEvents); + + // RG-5 : last_login_at pose a l'instant fige (assertion explicite, pas + // seulement le prefixe de la requete). + self::assertSame(date('Y-m-d H:i:s', self::NOW), $this->firstWrite('last_login_at')['params']['now'] ?? null); + } + + public function testSuccessRotatesCsrfToken(): void + { + $this->db->userRow = $this->userRow(); + $before = Csrf::token($this->session); + + $this->service()->authenticate('admin@wakdo.local', 'correct horse', '203.0.113.1', self::NOW); + + self::assertFalse(Csrf::validate($this->session, $before)); + } + + public function testFailClosedWhenDatabaseThrowsOnFailurePath(): void + { + $this->db->userRow = $this->userRow(); + $this->db->failOnExecute = new RuntimeException('db down'); + + $threw = false; + try { + $this->service()->authenticate('admin@wakdo.local', 'WRONG', '203.0.113.1', self::NOW); + } catch (RuntimeException) { + $threw = true; + } + + self::assertTrue($threw, 'une panne DB doit remonter, pas etre avalee'); + self::assertSame(['begin', 'rollback'], $this->db->transactionEvents); + self::assertNull($this->session->getInt('user_id')); + } + + public function testFailClosedOnSuccessPathDoesNotOpenSession(): void + { + // Mot de passe correct mais la base echoue pendant recordSuccess : + // l'identite ne doit jamais etre posee en session (ecriture avant identite). + $this->db->userRow = $this->userRow(); + $this->db->failOnExecute = new RuntimeException('db down'); + + $threw = false; + try { + $this->service()->authenticate('admin@wakdo.local', 'correct horse', '203.0.113.1', self::NOW); + } catch (RuntimeException) { + $threw = true; + } + + self::assertTrue($threw); + self::assertNull($this->session->getInt('user_id')); + } + + public function testLogoutClearsSession(): void + { + $this->session->set('user_id', 7); + + $this->service()->logout(); + + self::assertNull($this->session->getInt('user_id')); + } + + /** + * @return array{sql: string, params: array} + */ + private function firstWrite(string $needle): array + { + foreach ($this->db->writes as $write) { + if (str_contains($write['sql'], $needle)) { + return $write; + } + } + + self::fail('aucune ecriture ne contient: ' . $needle); + } +} diff --git a/tests/Unit/Auth/CsrfTest.php b/tests/Unit/Auth/CsrfTest.php new file mode 100644 index 0000000..7f7693a --- /dev/null +++ b/tests/Unit/Auth/CsrfTest.php @@ -0,0 +1,74 @@ +session()); + + // 32 octets CSPRNG en hexadecimal => 64 caracteres. + self::assertSame(64, strlen($token)); + self::assertMatchesRegularExpression('/^[0-9a-f]{64}$/', $token); + } + + public function testTokenIsStableAcrossCalls(): void + { + $session = $this->session(); + + self::assertSame(Csrf::token($session), Csrf::token($session)); + } + + public function testValidateAcceptsCorrectToken(): void + { + $session = $this->session(); + $token = Csrf::token($session); + + self::assertTrue(Csrf::validate($session, $token)); + } + + public function testValidateRejectsWrongOrEmptyToken(): void + { + $session = $this->session(); + Csrf::token($session); + + self::assertFalse(Csrf::validate($session, 'wrong')); + self::assertFalse(Csrf::validate($session, '')); + self::assertFalse(Csrf::validate($session, null)); + } + + public function testValidateFalseWhenNoTokenYet(): void + { + // Aucun token genere en session : meme une soumission non vide echoue. + self::assertFalse(Csrf::validate($this->session(), 'anything')); + } + + public function testRotateChangesTokenAndInvalidatesOld(): void + { + $session = $this->session(); + $old = Csrf::token($session); + + $new = Csrf::rotate($session); + + self::assertNotSame($old, $new); + self::assertFalse(Csrf::validate($session, $old)); + self::assertTrue(Csrf::validate($session, $new)); + } +} diff --git a/tests/Unit/Auth/PasswordHasherTest.php b/tests/Unit/Auth/PasswordHasherTest.php new file mode 100644 index 0000000..4ebe4d7 --- /dev/null +++ b/tests/Unit/Auth/PasswordHasherTest.php @@ -0,0 +1,87 @@ + */ + private array $touchedKeys = []; + + protected function setUp(): void + { + // Cout reduit : les tests ne valident pas la robustesse cryptographique + // (couverte par les valeurs de prod) mais la mecanique hash/verify/cout. + $this->setEnv('ARGON2_MEMORY_COST', '1024'); + $this->setEnv('ARGON2_TIME_COST', '1'); + $this->setEnv('ARGON2_THREADS', '1'); + } + + protected function tearDown(): void + { + foreach ($this->touchedKeys as $key) { + putenv($key); + } + $this->touchedKeys = []; + } + + private function setEnv(string $key, string $value): void + { + $this->touchedKeys[] = $key; + putenv($key . '=' . $value); + } + + private function hasher(): PasswordHasher + { + return new PasswordHasher(new Config()); + } + + public function testHashIsVerifiable(): void + { + $hasher = $this->hasher(); + $hash = $hasher->hash('WakdoAdmin2026!'); + + self::assertTrue($hasher->verify('WakdoAdmin2026!', $hash)); + } + + public function testWrongPasswordIsRejected(): void + { + $hasher = $this->hasher(); + $hash = $hasher->hash('correct horse'); + + self::assertFalse($hasher->verify('battery staple', $hash)); + } + + public function testHashUsesArgon2idAlgorithm(): void + { + $info = password_get_info($this->hasher()->hash('x')); + + self::assertSame('argon2id', $info['algoName']); + } + + public function testHashEmbedsConfiguredCost(): void + { + $info = password_get_info($this->hasher()->hash('x')); + + self::assertSame(1024, $info['options']['memory_cost'] ?? null); + self::assertSame(1, $info['options']['time_cost'] ?? null); + self::assertSame(1, $info['options']['threads'] ?? null); + } + + public function testVerifyDecoyRunsWithoutThrowing(): void + { + // Le leurre ne doit jamais lever ni valider un mot de passe : il ne sert + // qu'a consommer un temps CPU comparable au chemin nominal. + $this->expectNotToPerformAssertions(); + $this->hasher()->verifyDecoy('any-submitted-password'); + } +} diff --git a/tests/Unit/Auth/PasswordResetControllerTest.php b/tests/Unit/Auth/PasswordResetControllerTest.php new file mode 100644 index 0000000..2f8d25c --- /dev/null +++ b/tests/Unit/Auth/PasswordResetControllerTest.php @@ -0,0 +1,175 @@ +testSession; + } + + protected function resetService(): PasswordResetService + { + return new PasswordResetService($this->fakeDb, $this->config, new PasswordHasher($this->config), $this->spyMailer); + } +} + +final class PasswordResetControllerTest extends TestCase +{ + /** @var list */ + private array $touchedKeys = []; + + protected function setUp(): void + { + $this->setEnv('PASSWORD_RESET_TTL', '3600'); + $this->setEnv('APP_URL_ADMIN', 'https://admin.wakdo.test'); + $this->setEnv('ARGON2_MEMORY_COST', '1024'); + $this->setEnv('ARGON2_TIME_COST', '1'); + $this->setEnv('ARGON2_THREADS', '1'); + } + + protected function tearDown(): void + { + foreach ($this->touchedKeys as $key) { + putenv($key); + } + $this->touchedKeys = []; + } + + private function setEnv(string $key, string $value): void + { + $this->touchedKeys[] = $key; + putenv($key . '=' . $value); + } + + /** + * @param array $form + */ + private function post(array $form, string $path): Request + { + return new Request( + 'POST', + $path, + [], + ['content-type' => 'application/x-www-form-urlencoded'], + http_build_query($form), + '203.0.113.5', + ); + } + + private function controller( + Request $request, + SessionManager $session, + FakeDatabase $db, + SpyMailer $mailer, + ): TestPasswordResetController { + return new TestPasswordResetController($request, new Config(), new Database(new Config()), $session, $db, $mailer); + } + + public function testShowRequestRendersCsrfField(): void + { + $session = new SessionManager(new Config(), true); + $request = new Request('GET', '/forgot_password', [], [], '', '203.0.113.5'); + + $response = $this->controller($request, $session, new FakeDatabase(), new SpyMailer())->showRequest(); + + self::assertSame(200, $response->status()); + self::assertStringContainsString('name="_csrf"', $response->body()); + } + + public function testSubmitRequestRejectsInvalidCsrf(): void + { + $session = new SessionManager(new Config(), true); + Csrf::token($session); + $mailer = new SpyMailer(); + + $request = $this->post(['_csrf' => 'wrong', 'email' => 'admin@wakdo.local'], '/forgot_password'); + $response = $this->controller($request, $session, new FakeDatabase(), $mailer)->submitRequest(); + + self::assertSame(403, $response->status()); + self::assertSame([], $mailer->sent); + } + + public function testSubmitRequestUnknownEmailIsNeutralAndSilent(): void + { + $session = new SessionManager(new Config(), true); + $token = Csrf::token($session); + $db = new FakeDatabase(); + $db->emailLookupRow = null; + $mailer = new SpyMailer(); + + $request = $this->post(['_csrf' => $token, 'email' => 'ghost@wakdo.local'], '/forgot_password'); + $response = $this->controller($request, $session, $db, $mailer)->submitRequest(); + + self::assertSame(200, $response->status()); + self::assertStringContainsString('Si un compte', $response->body()); + self::assertSame([], $mailer->sent); + self::assertSame([], $db->writes); + } + + public function testSubmitConfirmPasswordMismatchRendersError(): void + { + $session = new SessionManager(new Config(), true); + $token = Csrf::token($session); + + $request = $this->post([ + '_csrf' => $token, + 'token' => 'raw-token', + 'password' => 'longenough1', + 'password_confirm' => 'different01', + ], '/reset_password'); + $response = $this->controller($request, $session, new FakeDatabase(), new SpyMailer())->submitConfirm(); + + self::assertSame(200, $response->status()); + self::assertStringContainsString('ne correspondent pas', $response->body()); + } + + public function testSubmitConfirmValidTokenRedirectsToLogin(): void + { + $session = new SessionManager(new Config(), true); + $token = Csrf::token($session); + $db = new FakeDatabase(); + $db->resetUserRow = ['id' => 7, 'role_id' => 3, 'password_reset_token_hash' => hash('sha256', 'raw-token')]; + + $request = $this->post([ + '_csrf' => $token, + 'token' => 'raw-token', + 'password' => 'brandnewpassword', + 'password_confirm' => 'brandnewpassword', + ], '/reset_password'); + $response = $this->controller($request, $session, $db, new SpyMailer())->submitConfirm(); + + self::assertSame(302, $response->status()); + self::assertSame('/login?reset=ok', $response->header('Location')); + } +} diff --git a/tests/Unit/Auth/PasswordResetServiceTest.php b/tests/Unit/Auth/PasswordResetServiceTest.php new file mode 100644 index 0000000..ada4e15 --- /dev/null +++ b/tests/Unit/Auth/PasswordResetServiceTest.php @@ -0,0 +1,154 @@ + */ + private array $touchedKeys = []; + + private FakeDatabase $db; + private SpyMailer $mailer; + private PasswordHasher $hasher; + + protected function setUp(): void + { + $this->setEnv('PASSWORD_RESET_TTL', '3600'); + $this->setEnv('ARGON2_MEMORY_COST', '1024'); + $this->setEnv('ARGON2_TIME_COST', '1'); + $this->setEnv('ARGON2_THREADS', '1'); + + $this->db = new FakeDatabase(); + $this->mailer = new SpyMailer(); + $this->hasher = new PasswordHasher(new Config()); + } + + protected function tearDown(): void + { + foreach ($this->touchedKeys as $key) { + putenv($key); + } + $this->touchedKeys = []; + } + + private function setEnv(string $key, string $value): void + { + $this->touchedKeys[] = $key; + putenv($key . '=' . $value); + } + + private function service(): PasswordResetService + { + return new PasswordResetService($this->db, new Config(), $this->hasher, $this->mailer); + } + + public function testRequestUnknownEmailWritesNothingAndSendsNoMail(): void + { + $this->db->emailLookupRow = null; + + $this->service()->requestReset('ghost@wakdo.local', 'https://admin.wakdo.test', self::NOW); + + self::assertSame([], $this->db->writes); + self::assertSame([], $this->mailer->sent); + } + + public function testRequestActiveUserStoresHashAndMailsRawTokenOnce(): void + { + $this->db->emailLookupRow = ['id' => 7]; + + $this->service()->requestReset('admin@wakdo.local', 'https://admin.wakdo.test', self::NOW); + + self::assertCount(1, $this->mailer->sent); + $url = $this->mailer->sent[0]['resetUrl']; + self::assertStringStartsWith('https://admin.wakdo.test/reset_password?token=', $url); + + $query = (string) parse_url($url, PHP_URL_QUERY); + parse_str($query, $parsed); + $rawToken = is_string($parsed['token'] ?? null) ? $parsed['token'] : ''; + self::assertSame(64, strlen($rawToken)); + + $write = $this->firstWrite('password_reset_token_hash = :hash'); + $storedHash = $write['params']['hash'] ?? null; + // Le brut n'est jamais persiste : ce qui est stocke est son SHA-256. + self::assertSame(hash('sha256', $rawToken), $storedHash); + self::assertNotSame($rawToken, $storedHash); + self::assertSame(date('Y-m-d H:i:s', self::NOW + 3600), $write['params']['exp'] ?? null); + } + + public function testConfirmShortPasswordIsRejectedBeforeAnyWrite(): void + { + $this->db->resetUserRow = ['id' => 7, 'role_id' => 3, 'password_reset_token_hash' => hash('sha256', 'tok')]; + + $result = $this->service()->confirmReset('tok', 'short', self::NOW); + + self::assertFalse($result->success); + self::assertSame([], $this->db->writes); + } + + public function testConfirmUnknownOrExpiredTokenFails(): void + { + // resetUserRow null = aucune ligne (token inconnu, expire, ou deja consomme). + $this->db->resetUserRow = null; + + $result = $this->service()->confirmReset('whatever', 'newpassword123', self::NOW); + + self::assertFalse($result->success); + self::assertSame('Lien invalide ou expire.', $result->error); + self::assertSame([], $this->db->writes); + } + + public function testConfirmValidTokenResetsPassword(): void + { + $raw = 'a-valid-raw-token'; + $this->db->resetUserRow = [ + 'id' => 7, + 'role_id' => 3, + 'password_reset_token_hash' => hash('sha256', $raw), + ]; + + $result = $this->service()->confirmReset($raw, 'brandnewpassword', self::NOW); + + self::assertTrue($result->success); + self::assertSame(7, $result->userId); + self::assertSame('/login?reset=ok', $result->redirectTo); + + // Nouveau mot de passe argon2id verifiable + token efface (usage unique). + $write = $this->firstWrite('SET password_hash = :hash'); + $newHash = $write['params']['hash'] ?? ''; + self::assertIsString($newHash); + self::assertTrue($this->hasher->verify('brandnewpassword', $newHash)); + self::assertStringContainsString('password_reset_token_hash = NULL', $write['sql']); + + self::assertSame(['auth.password_reset'], $this->db->auditActions()); + self::assertSame(['begin', 'commit'], $this->db->transactionEvents); + } + + /** + * @return array{sql: string, params: array} + */ + private function firstWrite(string $needle): array + { + foreach ($this->db->writes as $write) { + if (str_contains($write['sql'], $needle)) { + return $write; + } + } + + self::fail('aucune ecriture ne contient: ' . $needle); + } +} diff --git a/tests/Unit/Auth/SessionGuardTest.php b/tests/Unit/Auth/SessionGuardTest.php new file mode 100644 index 0000000..956a367 --- /dev/null +++ b/tests/Unit/Auth/SessionGuardTest.php @@ -0,0 +1,121 @@ + */ + private array $touchedKeys = []; + + private FakeDatabase $db; + private SessionManager $session; + + protected function setUp(): void + { + $this->setEnv('SESSION_LIFETIME_IDLE', (string) self::IDLE); + $this->setEnv('SESSION_LIFETIME_ABSOLUTE', (string) self::ABSOLUTE); + + $this->db = new FakeDatabase(); + $this->session = new SessionManager(new Config(), true); + } + + protected function tearDown(): void + { + foreach ($this->touchedKeys as $key) { + putenv($key); + } + $this->touchedKeys = []; + } + + private function setEnv(string $key, string $value): void + { + $this->touchedKeys[] = $key; + putenv($key . '=' . $value); + } + + private function guard(): SessionGuard + { + return new SessionGuard($this->session, $this->db, new Config()); + } + + private function seedSession(int $loggedInAt, int $lastActivity): void + { + $this->session->set('user_id', 7); + $this->session->set('role_id', 3); + $this->session->set('logged_in_at', $loggedInAt); + $this->session->set('last_activity', $lastActivity); + } + + public function testNoSessionIsRejected(): void + { + $result = $this->guard()->check(self::NOW); + + self::assertFalse($result->authenticated); + self::assertSame('no_session', $result->reason); + } + + public function testValidSessionWithinWindowsRefreshesActivity(): void + { + $this->seedSession(self::NOW - 100, self::NOW - 50); + $this->db->guardUserRow = ['is_active' => 1]; + + $result = $this->guard()->check(self::NOW); + + self::assertTrue($result->authenticated); + self::assertSame(7, $result->userId); + self::assertSame(3, $result->roleId); + self::assertNull($result->reason); + // Fenetre idle glissante : last_activity rafraichi a now. + self::assertSame(self::NOW, $this->session->getInt('last_activity')); + } + + public function testIdleTimeoutIsRejected(): void + { + $this->seedSession(self::NOW - 200, self::NOW - (self::IDLE + 1)); + $this->db->guardUserRow = ['is_active' => 1]; + + $result = $this->guard()->check(self::NOW); + + self::assertFalse($result->authenticated); + self::assertSame('idle_timeout', $result->reason); + } + + public function testAbsoluteTimeoutIsRejected(): void + { + // Activite recente (idle OK) mais session ouverte depuis plus de 10h. + $this->seedSession(self::NOW - (self::ABSOLUTE + 1), self::NOW - 10); + $this->db->guardUserRow = ['is_active' => 1]; + + $result = $this->guard()->check(self::NOW); + + self::assertFalse($result->authenticated); + self::assertSame('absolute_timeout', $result->reason); + } + + public function testInactiveUserIsRejected(): void + { + $this->seedSession(self::NOW - 100, self::NOW - 50); + $this->db->guardUserRow = ['is_active' => 0]; + + $result = $this->guard()->check(self::NOW); + + self::assertFalse($result->authenticated); + self::assertSame('inactive', $result->reason); + } +} diff --git a/tests/Unit/Auth/ThrottlePolicyTest.php b/tests/Unit/Auth/ThrottlePolicyTest.php new file mode 100644 index 0000000..a93702f --- /dev/null +++ b/tests/Unit/Auth/ThrottlePolicyTest.php @@ -0,0 +1,133 @@ + */ + private array $touchedKeys = []; + + protected function tearDown(): void + { + foreach ($this->touchedKeys as $key) { + putenv($key); + } + $this->touchedKeys = []; + } + + private function setEnv(string $key, string $value): void + { + $this->touchedKeys[] = $key; + putenv($key . '=' . $value); + } + + private function policy(int $threshold = 5, int $base = 60, int $max = 900): ThrottlePolicy + { + return new ThrottlePolicy($threshold, $base, $max); + } + + public function testNoLockoutBelowThreshold(): void + { + $policy = $this->policy(); + + self::assertSame(0, $policy->lockoutSeconds(0)); + self::assertSame(0, $policy->lockoutSeconds(4)); + } + + public function testBaseDelayAtThreshold(): void + { + self::assertSame(60, $this->policy()->lockoutSeconds(5)); + } + + /** + * @return list + */ + public static function degressiveCurveProvider(): array + { + // threshold=5, base=60, max=900 : 60, 120, 240, 480, puis plafond 900. + return [ + [5, 60], + [6, 120], + [7, 240], + [8, 480], + [9, 900], // 60*2^4 = 960 -> plafonne a 900 + [10, 900], // au-dela : reste plafonne + [20, 900], + ]; + } + + #[DataProvider('degressiveCurveProvider')] + public function testDegressiveCurveIsCappedAtMax(int $attempts, int $expected): void + { + self::assertSame($expected, $this->policy()->lockoutSeconds($attempts)); + } + + public function testNoIntegerOverflowForHugeAttemptCount(): void + { + // Un compteur enorme ne doit jamais deborder en negatif ni lever : on + // reste plafonne au maximum configure. + self::assertSame(900, $this->policy()->lockoutSeconds(1000)); + self::assertSame(900, $this->policy()->lockoutSeconds(PHP_INT_MAX)); + } + + public function testIsLockedUntilFutureIsTrue(): void + { + $now = 1_000_000; + $future = date('Y-m-d H:i:s', $now + 120); + + self::assertTrue($this->policy()->isLockedUntil($future, $now)); + } + + public function testIsLockedUntilPastOrNullIsFalse(): void + { + $now = 1_000_000; + $past = date('Y-m-d H:i:s', $now - 1); + + self::assertFalse($this->policy()->isLockedUntil($past, $now)); + self::assertFalse($this->policy()->isLockedUntil(null, $now)); + self::assertFalse($this->policy()->isLockedUntil('', $now)); + } + + public function testIsLockedUntilUnparseableIsFalse(): void + { + self::assertFalse($this->policy()->isLockedUntil('not-a-date', 1_000_000)); + } + + public function testFromConfigAccountReadsAccountKeys(): void + { + $this->setEnv('ACCOUNT_LOCKOUT_THRESHOLD', '3'); + $this->setEnv('ACCOUNT_LOCKOUT_BASE_SECONDS', '30'); + $this->setEnv('ACCOUNT_LOCKOUT_MAX_SECONDS', '600'); + + $policy = ThrottlePolicy::fromConfig(new Config(), 'account'); + + self::assertSame(0, $policy->lockoutSeconds(2)); + self::assertSame(30, $policy->lockoutSeconds(3)); + self::assertSame(60, $policy->lockoutSeconds(4)); + self::assertSame(600, $policy->lockoutSeconds(99)); + } + + public function testFromConfigIpUsesIpThresholdWithSharedCurve(): void + { + $this->setEnv('IP_THROTTLE_MAX_ATTEMPTS', '20'); + $this->setEnv('ACCOUNT_LOCKOUT_BASE_SECONDS', '60'); + $this->setEnv('ACCOUNT_LOCKOUT_MAX_SECONDS', '900'); + + $policy = ThrottlePolicy::fromConfig(new Config(), 'ip'); + + self::assertSame(0, $policy->lockoutSeconds(19)); + self::assertSame(60, $policy->lockoutSeconds(20)); + self::assertSame(120, $policy->lockoutSeconds(21)); + } +} diff --git a/tests/Unit/Core/RequestFormBodyTest.php b/tests/Unit/Core/RequestFormBodyTest.php new file mode 100644 index 0000000..ee3667a --- /dev/null +++ b/tests/Unit/Core/RequestFormBodyTest.php @@ -0,0 +1,128 @@ + $headers + */ + private function request(string $method, string $rawBody, array $headers = [], string $remoteAddr = ''): Request + { + return new Request($method, '/login', [], $headers, $rawBody, $remoteAddr); + } + + public function testFormBodyParsesUrlencodedBody(): void + { + $request = $this->request( + 'POST', + 'email=admin%40wakdo.local&password=secret+pass', + ['content-type' => 'application/x-www-form-urlencoded'], + ); + + self::assertSame( + ['email' => 'admin@wakdo.local', 'password' => 'secret pass'], + $request->formBody(), + ); + } + + public function testFormBodyToleratesCharsetSuffixOnContentType(): void + { + $request = $this->request( + 'POST', + 'a=1', + ['content-type' => 'application/x-www-form-urlencoded; charset=UTF-8'], + ); + + self::assertSame(['a' => '1'], $request->formBody()); + } + + public function testFormBodyReturnsEmptyForJsonContentType(): void + { + $request = $this->request('POST', '{"email":"x"}', ['content-type' => 'application/json']); + + self::assertSame([], $request->formBody()); + } + + public function testFormBodyReturnsEmptyWhenContentTypeAbsent(): void + { + $request = $this->request('POST', 'email=x'); + + self::assertSame([], $request->formBody()); + } + + public function testFormBodyDropsArrayShapedValues(): void + { + // parse_str transforme "tags[]=a&tags[]=b" en tableau : on ne garde que + // les scalaires pour tenir le contrat array. + $request = $this->request( + 'POST', + 'name=ok&tags%5B%5D=a&tags%5B%5D=b', + ['content-type' => 'application/x-www-form-urlencoded'], + ); + + self::assertSame(['name' => 'ok'], $request->formBody()); + } + + public function testClientIpUsesLastForwardedHop(): void + { + // Seul le dernier hop (ajoute par Traefik) est de confiance ; les entrees + // de gauche sont fournies par le client et donc falsifiables. + $request = $this->request( + 'POST', + '', + ['x-forwarded-for' => '10.0.0.9, 203.0.113.7'], + '172.18.0.2', + ); + + self::assertSame('203.0.113.7', $request->clientIp()); + } + + public function testClientIpFallsBackToRemoteAddrWhenNoForwardedHeader(): void + { + $request = $this->request('POST', '', [], '198.51.100.4'); + + self::assertSame('198.51.100.4', $request->clientIp()); + } + + public function testClientIpFallsBackWhenForwardedHopIsMalformed(): void + { + $request = $this->request( + 'POST', + '', + ['x-forwarded-for' => 'not-an-ip'], + '198.51.100.4', + ); + + self::assertSame('198.51.100.4', $request->clientIp()); + } + + public function testClientIpAcceptsIpv6(): void + { + $request = $this->request( + 'POST', + '', + ['x-forwarded-for' => '2001:db8::1'], + '172.18.0.2', + ); + + self::assertSame('2001:db8::1', $request->clientIp()); + } + + public function testClientIpReturnsSentinelWhenNothingResolvable(): void + { + $request = $this->request('POST', '', [], ''); + + self::assertSame('0.0.0.0', $request->clientIp()); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 5810c5f..f390e82 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -11,3 +11,21 @@ declare(strict_types=1); require __DIR__ . '/../src/app/Core/Autoloader.php'; App\Core\Autoloader::register(); + +// Autoloader PSR-4 dedie aux classes de support de test (doubles, helpers) : +// App\Tests\... -> tests/... . Permet de partager un FakeDatabase entre suites +// sans le dupliquer dans chaque fichier de test. +spl_autoload_register(static function (string $class): void { + $prefix = 'App\\Tests\\'; + + if (!str_starts_with($class, $prefix)) { + return; + } + + $relative = substr($class, strlen($prefix)); + $path = __DIR__ . '/' . str_replace('\\', '/', $relative) . '.php'; + + if (is_file($path)) { + require $path; + } +}); -- 2.45.3 From f979a2339e7a776ff3308f811c760c2539c4381b Mon Sep 17 00:00:00 2001 From: Corentin JOGUET Date: Mon, 15 Jun 2026 20:45:19 +0200 Subject: [PATCH 13/93] feat: RBAC P2 (autorisation par permission + garde de session + /api/me) (#12) --- docs/api/conventions.md | 7 + src/app/Auth/Authorizer.php | 75 ++++++++++ .../Controllers/AuthenticatedController.php | 36 +++++ src/app/Controllers/MeController.php | 50 +++++++ src/public/admin/index.php | 4 + tests/Integration/AuthorizerDbTest.php | 96 +++++++++++++ tests/Support/FakeDatabase.php | 48 +++++++ tests/Unit/Auth/AuthorizerTest.php | 100 +++++++++++++ tests/Unit/Auth/MeControllerTest.php | 136 ++++++++++++++++++ 9 files changed, 552 insertions(+) create mode 100644 src/app/Auth/Authorizer.php create mode 100644 src/app/Controllers/AuthenticatedController.php create mode 100644 src/app/Controllers/MeController.php create mode 100644 tests/Integration/AuthorizerDbTest.php create mode 100644 tests/Unit/Auth/AuthorizerTest.php create mode 100644 tests/Unit/Auth/MeControllerTest.php diff --git a/docs/api/conventions.md b/docs/api/conventions.md index 3fbe29c..d27cdac 100644 --- a/docs/api/conventions.md +++ b/docs/api/conventions.md @@ -99,6 +99,13 @@ Autres regles : | POST | `/forgot_password` | public + CSRF | HTML (neutre) | envoi du lien (mlt 12.3) | | GET | `/reset_password` | public (token en query) | HTML | formulaire nouveau mot de passe | | POST | `/reset_password` | public + CSRF | 302 / HTML | confirmation (mlt 12.3) | +| GET | `/api/me` | session | JSON | identite + permissions du compte courant (RG-6/RG-T02/RG-T03) | + +`/api/me` est le premier consommateur reel de `SessionGuard` (RG-6 idle/absolu + RG-T02 +is_active) et d'`Authorizer` (RG-T03, permissions rechargees depuis la base). Reponse : +`{ "data": { "user_id", "role_id", "role_code", "permissions": [...] } }` ; `401 AUTH_REQUIRED` +si la session est absente, expiree ou le compte desactive. Les autorisations par operation +(et le PIN des actions sensibles, RG-T13) se cablent quand les operations existent (P3). ### 5.2 API kiosk - lecture catalogue + commande (prevu P4, public) diff --git a/src/app/Auth/Authorizer.php b/src/app/Auth/Authorizer.php new file mode 100644 index 0000000..50369e2 --- /dev/null +++ b/src/app/Auth/Authorizer.php @@ -0,0 +1,75 @@ +db->fetch( + 'SELECT 1 AS granted FROM role_permission rp ' + . 'JOIN permission p ON p.id = rp.permission_id ' + . 'JOIN role r ON r.id = rp.role_id ' + . 'WHERE rp.role_id = :role AND p.code = :code AND r.is_active = 1 LIMIT 1', + ['role' => $roleId, 'code' => $permissionCode], + ); + + return $row !== null; + } + + /** + * Liste des codes de permission du role (pour /api/me et l'affichage UI). + * + * @return list + */ + public function permissionsFor(int $roleId): array + { + $rows = $this->db->fetchAll( + 'SELECT p.code FROM role_permission rp ' + . 'JOIN permission p ON p.id = rp.permission_id ' + . 'JOIN role r ON r.id = rp.role_id ' + . 'WHERE rp.role_id = :role AND r.is_active = 1 ORDER BY p.code', + ['role' => $roleId], + ); + + $codes = []; + foreach ($rows as $row) { + $code = $row['code'] ?? null; + if (is_string($code)) { + $codes[] = $code; + } + } + + return $codes; + } + + /** + * Code du role (ex. 'admin', 'counter'). Lecture de metadonnee de role, + * regroupee ici avec l'acces a role_permission pour un seul seam de donnees. + */ + public function roleCode(int $roleId): ?string + { + // Filtre is_active comme can()/permissionsFor() : un role desactive ne + // doit exposer ni droits ni libelle exploitable (coherence de l'invariant). + $row = $this->db->fetch('SELECT r.code FROM role r WHERE r.id = :id AND r.is_active = 1', ['id' => $roleId]); + + return is_string($row['code'] ?? null) ? $row['code'] : null; + } +} diff --git a/src/app/Controllers/AuthenticatedController.php b/src/app/Controllers/AuthenticatedController.php new file mode 100644 index 0000000..413cff0 --- /dev/null +++ b/src/app/Controllers/AuthenticatedController.php @@ -0,0 +1,36 @@ +config); + } + + protected function sessionGuard(): SessionGuard + { + return new SessionGuard($this->sessionManager(), $this->database, $this->config); + } + + protected function authorizer(): Authorizer + { + return new Authorizer($this->database); + } +} diff --git a/src/app/Controllers/MeController.php b/src/app/Controllers/MeController.php new file mode 100644 index 0000000..f7901ba --- /dev/null +++ b/src/app/Controllers/MeController.php @@ -0,0 +1,50 @@ + $params + */ + public function show(array $params = []): Response + { + $guard = $this->sessionGuard()->check(); + + if (!$guard->authenticated || $guard->userId === null || $guard->roleId === null) { + return $this->json( + ['data' => null, 'error' => ['code' => 'AUTH_REQUIRED', 'message' => 'Authentification requise']], + 401, + ); + } + + $authorizer = $this->authorizer(); + + return $this->json([ + 'data' => [ + 'user_id' => $guard->userId, + 'role_id' => $guard->roleId, + 'role_code' => $authorizer->roleCode($guard->roleId), + 'permissions' => $authorizer->permissionsFor($guard->roleId), + ], + ]); + } +} diff --git a/src/public/admin/index.php b/src/public/admin/index.php index dd44d61..bf6307d 100644 --- a/src/public/admin/index.php +++ b/src/public/admin/index.php @@ -14,6 +14,7 @@ use App\Auth\SessionManager; use App\Controllers\AuthController; use App\Controllers\HealthController; use App\Controllers\HomeController; +use App\Controllers\MeController; use App\Controllers\PasswordResetController; use App\Core\Autoloader; use App\Core\Config; @@ -57,6 +58,9 @@ try { $router->add('GET', '/reset_password', [PasswordResetController::class, 'showConfirm']); $router->add('POST', '/reset_password', [PasswordResetController::class, 'submitConfirm']); + // RBAC : identite + permissions de la session courante (gardee par SessionGuard). + $router->add('GET', '/api/me', [MeController::class, 'show']); + $response = $router->dispatch(Request::fromGlobals()); $response->send(); } catch (Throwable $exception) { diff --git a/tests/Integration/AuthorizerDbTest.php b/tests/Integration/AuthorizerDbTest.php new file mode 100644 index 0000000..78821e2 --- /dev/null +++ b/tests/Integration/AuthorizerDbTest.php @@ -0,0 +1,96 @@ +db = new Database(new Config()); + + try { + $this->db->fetch('SELECT 1'); + } catch (Throwable $exception) { + self::markTestSkipped('Base injoignable: ' . $exception->getMessage()); + } + + // Role jetable cree DESACTIVE, portant la permission product.read. + $this->roleCode = 'it-rbac-' . bin2hex(random_bytes(4)); + $this->db->execute( + 'INSERT INTO role (code, label, is_active) VALUES (:code, :label, 0)', + ['code' => $this->roleCode, 'label' => 'IT RBAC'], + ); + $this->roleId = (int) ($this->db->fetch('SELECT LAST_INSERT_ID() AS id')['id'] ?? 0); + $this->db->execute( + 'INSERT INTO role_permission (role_id, permission_id) ' + . 'SELECT :rid, id FROM permission WHERE code = :pc', + ['rid' => $this->roleId, 'pc' => 'product.read'], + ); + } + + protected function tearDown(): void + { + if ($this->roleId === 0) { + return; + } + + $this->db->execute('DELETE FROM role_permission WHERE role_id = :id', ['id' => $this->roleId]); + $this->db->execute('DELETE FROM role WHERE id = :id', ['id' => $this->roleId]); + $this->roleId = 0; + } + + public function testInactiveRoleGrantsNothingThenActiveGrants(): void + { + $authz = new Authorizer($this->db); + + // is_active = 0 : aucun droit ni libelle, malgre la ligne role_permission. + self::assertFalse($authz->can($this->roleId, 'product.read')); + self::assertSame([], $authz->permissionsFor($this->roleId)); + self::assertNull($authz->roleCode($this->roleId)); + + // On active le role : le meme grant devient effectif -> c'est bien le + // predicat is_active qui gate (et non l'absence de role_permission). + $this->db->execute('UPDATE role SET is_active = 1 WHERE id = :id', ['id' => $this->roleId]); + + self::assertTrue($authz->can($this->roleId, 'product.read')); + self::assertSame(['product.read'], $authz->permissionsFor($this->roleId)); + self::assertSame($this->roleCode, $authz->roleCode($this->roleId)); + } + + public function testSeededAdminRoleFiltersByPermissionCode(): void + { + $authz = new Authorizer($this->db); + $adminId = (int) ($this->db->fetch("SELECT id FROM role WHERE code = 'admin'")['id'] ?? 0); + self::assertGreaterThan(0, $adminId, 'role admin seede attendu'); + + // RG-T03 : filtrage par code (admin detient product.create, pas une permission inventee). + self::assertTrue($authz->can($adminId, 'product.create')); + self::assertFalse($authz->can($adminId, 'totally.fake.permission')); + self::assertContains('role.manage', $authz->permissionsFor($adminId)); + } +} diff --git a/tests/Support/FakeDatabase.php b/tests/Support/FakeDatabase.php index 3009c38..3c27e54 100644 --- a/tests/Support/FakeDatabase.php +++ b/tests/Support/FakeDatabase.php @@ -55,6 +55,34 @@ final class FakeDatabase implements DatabaseInterface */ public ?array $guardUserRow = null; + /** Resultat de Authorizer::can() (true = permission accordee). */ + public bool $canResult = false; + + /** Etat role.is_active modelise pour can()/permissionsFor() ; false => rien accorde. */ + public bool $roleActive = true; + + /** + * Trace des lectures (fetch/fetchAll) pour asserter les parametres lies + * (ex. liaison par code de permission, RG-T03), pendant que $writes trace les ecritures. + * + * @var list}> + */ + public array $reads = []; + + /** + * Codes de permission renvoyes par Authorizer::permissionsFor(). + * + * @var list + */ + public array $permissionCodes = []; + + /** + * Ligne role renvoyee pour la lecture du code de role (/api/me) ; null = absent. + * + * @var array|null + */ + public ?array $roleRow = null; + /** Si non nul, execute() leve cette exception (simulation panne DB -> fail-closed). */ public ?RuntimeException $failOnExecute = null; @@ -66,6 +94,8 @@ final class FakeDatabase implements DatabaseInterface public function fetch(string $sql, array $params = []): ?array { + $this->reads[] = ['sql' => $sql, 'params' => $params]; + if (str_contains($sql, 'FROM user u JOIN role')) { return $this->userRow; } @@ -82,6 +112,14 @@ final class FakeDatabase implements DatabaseInterface return $this->guardUserRow; } + if (str_contains($sql, 'SELECT 1 AS granted FROM role_permission')) { + return ($this->canResult && $this->roleActive) ? ['granted' => 1] : null; + } + + if (str_contains($sql, 'FROM role r WHERE r.id')) { + return $this->roleRow; + } + if (str_contains($sql, 'SELECT lockout_until FROM login_throttle')) { return ['lockout_until' => $this->ipLockoutUntil]; } @@ -95,6 +133,16 @@ final class FakeDatabase implements DatabaseInterface public function fetchAll(string $sql, array $params = []): array { + $this->reads[] = ['sql' => $sql, 'params' => $params]; + + if (str_contains($sql, 'SELECT p.code FROM role_permission')) { + if (!$this->roleActive) { + return []; + } + + return array_map(static fn (string $code): array => ['code' => $code], $this->permissionCodes); + } + return []; } diff --git a/tests/Unit/Auth/AuthorizerTest.php b/tests/Unit/Auth/AuthorizerTest.php new file mode 100644 index 0000000..24663c7 --- /dev/null +++ b/tests/Unit/Auth/AuthorizerTest.php @@ -0,0 +1,100 @@ +db = new FakeDatabase(); + } + + private function authorizer(): Authorizer + { + return new Authorizer($this->db); + } + + public function testCanReturnsTrueWhenPermissionGranted(): void + { + $this->db->canResult = true; + + self::assertTrue($this->authorizer()->can(1, 'product.create')); + // RG-T03 : la verification lie le CODE de permission + le role_id (jamais + // un nom de role). On asserte les parametres reellement lies a la requete. + self::assertSame(['role' => 1, 'code' => 'product.create'], $this->lastRead()['params']); + } + + public function testCanReturnsFalseWhenNotGranted(): void + { + $this->db->canResult = false; + + self::assertFalse($this->authorizer()->can(3, 'order.cancel')); + } + + public function testPermissionsForReturnsCodes(): void + { + $this->db->permissionCodes = ['order.read', 'product.read', 'stock.read']; + + self::assertSame( + ['order.read', 'product.read', 'stock.read'], + $this->authorizer()->permissionsFor(4), + ); + self::assertSame(['role' => 4], $this->lastRead()['params']); + } + + public function testPermissionsForReturnsEmptyWhenNone(): void + { + $this->db->permissionCodes = []; + + self::assertSame([], $this->authorizer()->permissionsFor(9)); + } + + public function testRoleCodeReturnsCodeOrNull(): void + { + $this->db->roleRow = ['code' => 'admin']; + self::assertSame('admin', $this->authorizer()->roleCode(1)); + + $this->db->roleRow = null; + self::assertNull($this->authorizer()->roleCode(999)); + } + + public function testCanDeniesWhenRoleInactive(): void + { + // Le role detient la permission (canResult) mais il est desactive : refus. + $this->db->canResult = true; + $this->db->roleActive = false; + + self::assertFalse($this->authorizer()->can(1, 'product.create')); + } + + public function testPermissionsForEmptyWhenRoleInactive(): void + { + $this->db->permissionCodes = ['order.read', 'product.read']; + $this->db->roleActive = false; + + self::assertSame([], $this->authorizer()->permissionsFor(4)); + } + + /** + * @return array{sql: string, params: array} + */ + private function lastRead(): array + { + $reads = $this->db->reads; + self::assertNotEmpty($reads, 'aucune lecture enregistree'); + + return $reads[array_key_last($reads)]; + } +} diff --git a/tests/Unit/Auth/MeControllerTest.php b/tests/Unit/Auth/MeControllerTest.php new file mode 100644 index 0000000..760fdb3 --- /dev/null +++ b/tests/Unit/Auth/MeControllerTest.php @@ -0,0 +1,136 @@ +testSession; + } + + protected function sessionGuard(): SessionGuard + { + return new SessionGuard($this->testSession, $this->fakeDb, $this->config); + } + + protected function authorizer(): Authorizer + { + return new Authorizer($this->fakeDb); + } +} + +final class MeControllerTest extends TestCase +{ + /** @var list */ + private array $touchedKeys = []; + + protected function setUp(): void + { + $this->setEnv('SESSION_LIFETIME_IDLE', '14400'); + $this->setEnv('SESSION_LIFETIME_ABSOLUTE', '36000'); + } + + protected function tearDown(): void + { + foreach ($this->touchedKeys as $key) { + putenv($key); + } + $this->touchedKeys = []; + } + + private function setEnv(string $key, string $value): void + { + $this->touchedKeys[] = $key; + putenv($key . '=' . $value); + } + + private function controller(SessionManager $session, FakeDatabase $db): TestMeController + { + $request = new Request('GET', '/api/me', [], [], '', '203.0.113.5'); + + return new TestMeController($request, new Config(), new Database(new Config()), $session, $db); + } + + public function testNoSessionReturns401(): void + { + $response = $this->controller(new SessionManager(new Config(), true), new FakeDatabase())->show(); + + self::assertSame(401, $response->status()); + + $body = json_decode($response->body(), true); + self::assertIsArray($body); + self::assertSame('AUTH_REQUIRED', $body['error']['code'] ?? null); + } + + public function testAuthenticatedReturnsIdentityAndPermissions(): void + { + $session = new SessionManager(new Config(), true); + // Horodatages relatifs a l'instant reel : MeController appelle check() sans + // injecter de temps (now = time()). + $now = time(); + $session->set('user_id', 7); + $session->set('role_id', 3); + $session->set('logged_in_at', $now - 100); + $session->set('last_activity', $now - 50); + + $db = new FakeDatabase(); + $db->guardUserRow = ['is_active' => 1]; + $db->roleRow = ['code' => 'manager']; + $db->permissionCodes = ['product.read', 'stats.read']; + + $response = $this->controller($session, $db)->show(); + + self::assertSame(200, $response->status()); + + $body = json_decode($response->body(), true); + self::assertIsArray($body); + self::assertSame(7, $body['data']['user_id'] ?? null); + self::assertSame(3, $body['data']['role_id'] ?? null); + self::assertSame('manager', $body['data']['role_code'] ?? null); + self::assertSame(['product.read', 'stats.read'], $body['data']['permissions'] ?? null); + } + + public function testInactiveUserSessionReturns401(): void + { + $session = new SessionManager(new Config(), true); + $now = time(); + $session->set('user_id', 7); + $session->set('role_id', 3); + $session->set('logged_in_at', $now - 100); + $session->set('last_activity', $now - 50); + + $db = new FakeDatabase(); + $db->guardUserRow = ['is_active' => 0]; + + $response = $this->controller($session, $db)->show(); + + self::assertSame(401, $response->status()); + } +} -- 2.45.3 From 7c35f8e2dc8c2bb8ed0ad2ba23101ae6a97017a5 Mon Sep 17 00:00:00 2001 From: Corentin JOGUET Date: Mon, 15 Jun 2026 21:00:11 +0200 Subject: [PATCH 14/93] feat: PIN d action sensible P2 (PinVerifier RG-T13) (#13) --- .env.example | 4 +- docker-compose.yml | 3 +- src/app/Auth/PinVerifier.php | 79 +++++++++++++++++ tests/Integration/PinVerifierDbTest.php | 102 +++++++++++++++++++++ tests/Support/FakeDatabase.php | 13 +++ tests/Unit/Auth/PinVerifierTest.php | 112 ++++++++++++++++++++++++ 6 files changed, 311 insertions(+), 2 deletions(-) create mode 100644 src/app/Auth/PinVerifier.php create mode 100644 tests/Integration/PinVerifierDbTest.php create mode 100644 tests/Unit/Auth/PinVerifierTest.php diff --git a/.env.example b/.env.example index 10d3c84..27853b2 100644 --- a/.env.example +++ b/.env.example @@ -93,8 +93,10 @@ ACCOUNT_LOCKOUT_MAX_SECONDS=900 # plafond du backoff (15 min) IP_THROTTLE_WINDOW_SECONDS=900 # 15 min IP_THROTTLE_MAX_ATTEMPTS=20 # par IP sur la fenetre -# PIN equipier pour actions sensibles (annulation, override). Longueur minimale. +# PIN equipier pour actions sensibles (annulation, override). Chiffres uniquement, +# bornes min ET max (RG-T18 : validation serveur + longueur bornee). STAFF_PIN_MIN_LENGTH=4 +STAFF_PIN_MAX_LENGTH=12 # Expiration du token de reinitialisation de mot de passe (secondes). PASSWORD_RESET_TTL=3600 # 1h diff --git a/docker-compose.yml b/docker-compose.yml index 589abe9..303f000 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -147,8 +147,9 @@ services: ACCOUNT_LOCKOUT_MAX_SECONDS: ${ACCOUNT_LOCKOUT_MAX_SECONDS} IP_THROTTLE_WINDOW_SECONDS: ${IP_THROTTLE_WINDOW_SECONDS} IP_THROTTLE_MAX_ATTEMPTS: ${IP_THROTTLE_MAX_ATTEMPTS} - # Longueur minimale du PIN equipier (actions sensibles, P3). + # Bornes du PIN equipier (actions sensibles, P3) : longueur min ET max. STAFF_PIN_MIN_LENGTH: ${STAFF_PIN_MIN_LENGTH} + STAFF_PIN_MAX_LENGTH: ${STAFF_PIN_MAX_LENGTH} # Expiration du token de reinitialisation de mot de passe (mlt.md 12.3). PASSWORD_RESET_TTL: ${PASSWORD_RESET_TTL} UPLOAD_MAX_SIZE_MB: ${UPLOAD_MAX_SIZE_MB} diff --git a/src/app/Auth/PinVerifier.php b/src/app/Auth/PinVerifier.php new file mode 100644 index 0000000..85c2b9a --- /dev/null +++ b/src/app/Auth/PinVerifier.php @@ -0,0 +1,79 @@ +db->fetch( + 'SELECT pin_hash FROM user WHERE id = :id AND is_active = 1', + ['id' => $userId], + ); + + $hash = is_string($row['pin_hash'] ?? null) ? (string) $row['pin_hash'] : ''; + + if ($hash === '') { + // Egalise le timing avec le chemin mauvais-PIN (verify argon2id) : sans + // ce leurre, un compte sans PIN (ou inactif/absent) repondrait plus vite, + // revelant par la latence quels comptes ont un PIN defini (anti-enumeration, + // meme posture que AuthService RG-2). Le leurre est mis en cache process. + $this->hasher->verifyDecoy($pin); + + return false; + } + + return $this->hasher->verify($pin, $hash); + } + + /** + * Politique de PIN a verifier cote serveur avant de hacher un nouveau PIN + * (P3, definition du PIN) : chiffres ASCII uniquement, bornes min ET max + * (RG-T18). ctype_digit garantit le charset numerique, ce qui rend strlen + * fiable comme nombre de caracteres. + */ + public function meetsLengthPolicy(string $pin): bool + { + $min = $this->config->int('STAFF_PIN_MIN_LENGTH', 4); + $max = $this->config->int('STAFF_PIN_MAX_LENGTH', 12); + + return $pin !== '' && ctype_digit($pin) && strlen($pin) >= $min && strlen($pin) <= $max; + } +} diff --git a/tests/Integration/PinVerifierDbTest.php b/tests/Integration/PinVerifierDbTest.php new file mode 100644 index 0000000..c52a9af --- /dev/null +++ b/tests/Integration/PinVerifierDbTest.php @@ -0,0 +1,102 @@ +config = new Config(); + $this->db = new Database($this->config); + + try { + $this->db->fetch('SELECT 1'); + } catch (Throwable $exception) { + self::markTestSkipped('Base injoignable: ' . $exception->getMessage()); + } + + $roleRow = $this->db->fetch('SELECT id FROM role ORDER BY id LIMIT 1'); + $roleId = (int) ($roleRow['id'] ?? 0); + self::assertGreaterThan(0, $roleId, 'role seede attendu'); + + $hasher = new PasswordHasher($this->config); + $this->db->execute( + 'INSERT INTO user (email, password_hash, pin_hash, first_name, last_name, role_id, is_active) ' + . 'VALUES (:email, :pwd, :pin, :fn, :ln, :role, 1)', + [ + 'email' => 'it-pin-' . bin2hex(random_bytes(6)) . '@wakdo.invalid', + 'pwd' => $hasher->hash('IntegrationPass1'), + 'pin' => $hasher->hash(self::PIN), + 'fn' => 'Integration', + 'ln' => 'Pin', + 'role' => $roleId, + ], + ); + $this->userId = (int) ($this->db->fetch('SELECT LAST_INSERT_ID() AS id')['id'] ?? 0); + } + + protected function tearDown(): void + { + if ($this->userId === 0) { + return; + } + + $this->db->execute('DELETE FROM user WHERE id = :id', ['id' => $this->userId]); + $this->userId = 0; + } + + private function verifier(): PinVerifier + { + return new PinVerifier($this->db, $this->config, new PasswordHasher($this->config)); + } + + public function testVerifyAgainstRealPinHash(): void + { + $verifier = $this->verifier(); + + self::assertTrue($verifier->verify($this->userId, self::PIN)); + self::assertFalse($verifier->verify($this->userId, '0000')); + } + + public function testVerifyFalseWhenPinHashNull(): void + { + $this->db->execute('UPDATE user SET pin_hash = NULL WHERE id = :id', ['id' => $this->userId]); + + self::assertFalse($this->verifier()->verify($this->userId, self::PIN)); + } + + public function testVerifyFalseWhenUserInactive(): void + { + // Compte desactive mais pin_hash encore valide : le filtre is_active = 1 + // doit refuser (un equipier desactive ne re-autorise plus d'action sensible). + $this->db->execute('UPDATE user SET is_active = 0 WHERE id = :id', ['id' => $this->userId]); + + self::assertFalse($this->verifier()->verify($this->userId, self::PIN)); + } +} diff --git a/tests/Support/FakeDatabase.php b/tests/Support/FakeDatabase.php index 3c27e54..f6ad7d2 100644 --- a/tests/Support/FakeDatabase.php +++ b/tests/Support/FakeDatabase.php @@ -83,6 +83,13 @@ final class FakeDatabase implements DatabaseInterface */ public ?array $roleRow = null; + /** + * Ligne user renvoyee pour la verification du PIN (RG-T13) ; null = absent/inactif. + * + * @var array|null + */ + public ?array $pinUserRow = null; + /** Si non nul, execute() leve cette exception (simulation panne DB -> fail-closed). */ public ?RuntimeException $failOnExecute = null; @@ -120,6 +127,12 @@ final class FakeDatabase implements DatabaseInterface return $this->roleRow; } + // Exige le predicat is_active = 1 : si la production le retirait, le double + // renverrait null et le test verify-true virerait au rouge (garde RG-T13). + if (str_contains($sql, 'SELECT pin_hash FROM user WHERE id') && str_contains($sql, 'is_active = 1')) { + return $this->pinUserRow; + } + if (str_contains($sql, 'SELECT lockout_until FROM login_throttle')) { return ['lockout_until' => $this->ipLockoutUntil]; } diff --git a/tests/Unit/Auth/PinVerifierTest.php b/tests/Unit/Auth/PinVerifierTest.php new file mode 100644 index 0000000..969fd76 --- /dev/null +++ b/tests/Unit/Auth/PinVerifierTest.php @@ -0,0 +1,112 @@ + */ + private array $touchedKeys = []; + + private FakeDatabase $db; + private PasswordHasher $hasher; + + protected function setUp(): void + { + $this->setEnv('STAFF_PIN_MIN_LENGTH', '4'); + $this->setEnv('STAFF_PIN_MAX_LENGTH', '12'); + $this->setEnv('ARGON2_MEMORY_COST', '1024'); + $this->setEnv('ARGON2_TIME_COST', '1'); + $this->setEnv('ARGON2_THREADS', '1'); + + $this->db = new FakeDatabase(); + $this->hasher = new PasswordHasher(new Config()); + } + + protected function tearDown(): void + { + foreach ($this->touchedKeys as $key) { + putenv($key); + } + $this->touchedKeys = []; + } + + private function setEnv(string $key, string $value): void + { + $this->touchedKeys[] = $key; + putenv($key . '=' . $value); + } + + private function verifier(): PinVerifier + { + return new PinVerifier($this->db, new Config(), $this->hasher); + } + + public function testVerifyTrueWhenPinMatches(): void + { + $this->db->pinUserRow = ['pin_hash' => $this->hasher->hash('4729')]; + + self::assertTrue($this->verifier()->verify(7, '4729')); + // Garde RG-T13 : la lecture filtre bien is_active = 1 (retirer le predicat + // ferait echouer ce cas via le routage durci du FakeDatabase). + self::assertStringContainsString('is_active = 1', $this->db->reads[0]['sql']); + } + + public function testVerifyFalseWhenPinWrong(): void + { + $this->db->pinUserRow = ['pin_hash' => $this->hasher->hash('4729')]; + + self::assertFalse($this->verifier()->verify(7, '0000')); + } + + public function testVerifyFalseWhenPinHashNull(): void + { + // PIN non defini sur le compte. + $this->db->pinUserRow = ['pin_hash' => null]; + + self::assertFalse($this->verifier()->verify(7, '4729')); + } + + public function testVerifyFalseWhenUserAbsentOrInactive(): void + { + // La requete filtre is_active = 1 : un compte inactif/absent ne renvoie rien. + $this->db->pinUserRow = null; + + self::assertFalse($this->verifier()->verify(7, '4729')); + } + + public function testVerifyFalseWhenPinEmpty(): void + { + $this->db->pinUserRow = ['pin_hash' => $this->hasher->hash('4729')]; + + self::assertFalse($this->verifier()->verify(7, '')); + } + + public function testMeetsLengthPolicy(): void + { + $verifier = $this->verifier(); + + // Sous le minimum / au minimum / dans les bornes. + self::assertFalse($verifier->meetsLengthPolicy('123')); + self::assertTrue($verifier->meetsLengthPolicy('1234')); + self::assertTrue($verifier->meetsLengthPolicy('123456')); + // Au max (12) accepte, au-dela refuse (RG-T18 borne haute). + self::assertTrue($verifier->meetsLengthPolicy('123456789012')); + self::assertFalse($verifier->meetsLengthPolicy('1234567890123')); + // Charset : chiffres uniquement ; vide refuse. + self::assertFalse($verifier->meetsLengthPolicy('abcd')); + self::assertFalse($verifier->meetsLengthPolicy('12ab')); + self::assertFalse($verifier->meetsLengthPolicy('')); + } +} -- 2.45.3 From 2bc22ab5c8b15b666a2073dd2c10c3d0e19018ab Mon Sep 17 00:00:00 2001 From: Corentin JOGUET Date: Mon, 15 Jun 2026 21:25:06 +0200 Subject: [PATCH 15/93] feat: shell back-office P3 (pages rendues serveur + garde) (#14) --- src/app/Auth/UserDirectory.php | 40 ++++ src/app/Controllers/AdminController.php | 79 +++++++ src/app/Controllers/DashboardController.php | 34 +++ src/app/Core/Controller.php | 16 +- src/app/Views/admin/dashboard.php | 26 +++ src/app/Views/admin/forbidden.php | 19 ++ src/app/Views/admin/layout.php | 137 ++++++++++++ src/public/admin/index.php | 4 + tests/Support/FakeDatabase.php | 13 ++ tests/Unit/Admin/DashboardControllerTest.php | 208 +++++++++++++++++++ tests/Unit/Auth/UserDirectoryTest.php | 46 ++++ 11 files changed, 619 insertions(+), 3 deletions(-) create mode 100644 src/app/Auth/UserDirectory.php create mode 100644 src/app/Controllers/AdminController.php create mode 100644 src/app/Controllers/DashboardController.php create mode 100644 src/app/Views/admin/dashboard.php create mode 100644 src/app/Views/admin/forbidden.php create mode 100644 src/app/Views/admin/layout.php create mode 100644 tests/Unit/Admin/DashboardControllerTest.php create mode 100644 tests/Unit/Auth/UserDirectoryTest.php diff --git a/src/app/Auth/UserDirectory.php b/src/app/Auth/UserDirectory.php new file mode 100644 index 0000000..0a61da0 --- /dev/null +++ b/src/app/Auth/UserDirectory.php @@ -0,0 +1,40 @@ +db->fetch( + 'SELECT u.first_name, u.last_name, r.label AS role_label ' + . 'FROM user u JOIN role r ON r.id = u.role_id WHERE u.id = :id', + ['id' => $userId], + ); + + $first = is_string($row['first_name'] ?? null) ? $row['first_name'] : ''; + $last = is_string($row['last_name'] ?? null) ? $row['last_name'] : ''; + $name = trim($first . ' ' . $last); + + return [ + 'name' => $name !== '' ? $name : 'Utilisateur', + 'role_label' => is_string($row['role_label'] ?? null) ? $row['role_label'] : '', + ]; + } +} diff --git a/src/app/Controllers/AdminController.php b/src/app/Controllers/AdminController.php new file mode 100644 index 0000000..130c0d2 --- /dev/null +++ b/src/app/Controllers/AdminController.php @@ -0,0 +1,79 @@ +sessionGuard()->check(); + + if (!$result->authenticated || $result->userId === null || $result->roleId === null) { + return Response::make('', 302, ['Location' => '/login']); + } + + if ($permission !== null && !$this->authorizer()->can($result->roleId, $permission)) { + return $this->adminView('admin/forbidden', ['title' => 'Acces refuse', 'activeNav' => ''], $result, 403); + } + + return $result; + } + + /** + * Rend une vue dans le shell admin en injectant le contexte commun + * (nom/role de l'utilisateur, permissions pour la navigation, jeton CSRF). + * Les cles passees dans $data ont priorite (ex. activeNav). + * + * @param array $data + */ + protected function adminView(string $name, array $data, GuardResult $guard, int $status = 200): Response + { + $userId = $guard->userId ?? 0; + $roleId = $guard->roleId ?? 0; + $info = $this->userDirectory()->displayInfo($userId); + + $context = [ + 'currentUserName' => $info['name'], + 'currentUserRole' => $info['role_label'], + 'permissions' => $this->authorizer()->permissionsFor($roleId), + 'csrfToken' => Csrf::token($this->sessionManager()), + 'activeNav' => '', + ]; + + return $this->view($name, $data + $context, $status); + } + + protected function userDirectory(): UserDirectory + { + return new UserDirectory($this->database); + } +} diff --git a/src/app/Controllers/DashboardController.php b/src/app/Controllers/DashboardController.php new file mode 100644 index 0000000..e85511c --- /dev/null +++ b/src/app/Controllers/DashboardController.php @@ -0,0 +1,34 @@ + $params + */ + public function index(array $params = []): Response + { + $guard = $this->guard(); + if ($guard instanceof Response) { + return $guard; + } + + return $this->adminView( + 'admin/dashboard', + ['title' => 'Tableau de bord - Wakdo Admin', 'activeNav' => 'dashboard'], + $guard, + ); + } +} diff --git a/src/app/Core/Controller.php b/src/app/Core/Controller.php index b2bab84..dc05746 100644 --- a/src/app/Core/Controller.php +++ b/src/app/Core/Controller.php @@ -7,8 +7,9 @@ namespace App\Core; use RuntimeException; /** - * Controleur de base. Toute la hierarchie de controleurs en herite - * (BaseController -> ProductController, etc., demonstration heritage Cr 4.c.1). + * Controleur de base. Toute la hierarchie de controleurs en herite (demonstration + * heritage Cr 4.c.1) : Controller -> AuthenticatedController -> AdminController -> + * DashboardController (et les futurs CRUD), ou directement HomeController / HealthController. * * Recoit ses dependances par constructeur : la requete courante, la config et * l'acces BDD, injectes par le Router. @@ -41,11 +42,20 @@ abstract class Controller protected function view(string $name, array $data = [], int $status = 200): Response { $content = $this->render($name, $data); - $html = $this->render('layout', $data + ['content' => $content]); + $html = $this->render($this->layoutName(), $data + ['content' => $content]); return (new Response())->html($html, $status); } + /** + * Gabarit enveloppant les vues. Defaut : le layout minimal. Les controleurs + * back-office surchargent ce hook pour rendre dans le shell admin. + */ + protected function layoutName(): string + { + return 'layout'; + } + /** * @param array $data */ diff --git a/src/app/Views/admin/dashboard.php b/src/app/Views/admin/dashboard.php new file mode 100644 index 0000000..50f9e89 --- /dev/null +++ b/src/app/Views/admin/dashboard.php @@ -0,0 +1,26 @@ + + + +
+

Le back-office est en ligne. Utilisez la navigation pour gerer le catalogue, + les commandes et les utilisateurs selon vos permissions.

+

Les indicateurs (ventes, commandes du jour) seront ajoutes prochainement.

+
diff --git a/src/app/Views/admin/forbidden.php b/src/app/Views/admin/forbidden.php new file mode 100644 index 0000000..b9bfa91 --- /dev/null +++ b/src/app/Views/admin/forbidden.php @@ -0,0 +1,19 @@ + + + +
+

Retour au tableau de bord

+
diff --git a/src/app/Views/admin/layout.php b/src/app/Views/admin/layout.php new file mode 100644 index 0000000..8f2695c --- /dev/null +++ b/src/app/Views/admin/layout.php @@ -0,0 +1,137 @@ + $permissions + * @var string $csrfToken + * @var string $activeNav + */ + +$pageTitle = htmlspecialchars($title ?? 'Wakdo Admin', ENT_QUOTES, 'UTF-8'); +$userName = htmlspecialchars($currentUserName ?? 'Utilisateur', ENT_QUOTES, 'UTF-8'); +$userRole = htmlspecialchars($currentUserRole ?? '', ENT_QUOTES, 'UTF-8'); +$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8'); +$active = is_string($activeNav ?? null) ? $activeNav : ''; + +/** @var list $perms */ +$perms = isset($permissions) && is_array($permissions) ? $permissions : []; +$can = static fn (string $code): bool => in_array($code, $perms, true); + +// Initiales pour l'avatar (2 lettres max), derivees du nom affiche. Fonctions +// multibyte (UTF-8) : un prenom a initiale accentuee (frequent en francais) doit +// produire une lettre valide, pas un octet de tete isole qui viderait l'echappement. +$initials = ''; +foreach (preg_split('/\s+/', trim((string) ($currentUserName ?? ''))) ?: [] as $word) { + if ($word !== '' && mb_strlen($initials, 'UTF-8') < 2) { + $initials .= mb_strtoupper(mb_substr($word, 0, 1, 'UTF-8'), 'UTF-8'); + } +} +$initials = $initials !== '' ? $initials : 'U'; + +/** + * @param string $code cle de nav active + * @param string $current + */ +$navClass = static function (string $code, string $current): string { + return $code === $current ? 'sidebar-item active' : 'sidebar-item'; +}; +?> + + + + + + <?= $pageTitle ?> + + + +
+
+ + +
+
+ + +
+
+
+ + + +
+ +
+
+ + + diff --git a/src/public/admin/index.php b/src/public/admin/index.php index bf6307d..027f9f8 100644 --- a/src/public/admin/index.php +++ b/src/public/admin/index.php @@ -12,6 +12,7 @@ declare(strict_types=1); use App\Auth\SessionManager; use App\Controllers\AuthController; +use App\Controllers\DashboardController; use App\Controllers\HealthController; use App\Controllers\HomeController; use App\Controllers\MeController; @@ -61,6 +62,9 @@ try { // RBAC : identite + permissions de la session courante (gardee par SessionGuard). $router->add('GET', '/api/me', [MeController::class, 'show']); + // Back-office (P3) : pages rendues serveur sous /admin, gardees par SessionGuard. + $router->add('GET', '/admin/dashboard', [DashboardController::class, 'index']); + $response = $router->dispatch(Request::fromGlobals()); $response->send(); } catch (Throwable $exception) { diff --git a/tests/Support/FakeDatabase.php b/tests/Support/FakeDatabase.php index f6ad7d2..a68841e 100644 --- a/tests/Support/FakeDatabase.php +++ b/tests/Support/FakeDatabase.php @@ -90,6 +90,13 @@ final class FakeDatabase implements DatabaseInterface */ public ?array $pinUserRow = null; + /** + * Ligne renvoyee pour UserDirectory::displayInfo (nom + libelle role) ; null = absent. + * + * @var array|null + */ + public ?array $userDisplayRow = null; + /** Si non nul, execute() leve cette exception (simulation panne DB -> fail-closed). */ public ?RuntimeException $failOnExecute = null; @@ -103,6 +110,12 @@ final class FakeDatabase implements DatabaseInterface { $this->reads[] = ['sql' => $sql, 'params' => $params]; + // Doit passer AVANT le lookup auth : la requete displayInfo contient aussi + // 'FROM user u JOIN role' mais selectionne 'AS role_label'. + if (str_contains($sql, 'AS role_label')) { + return $this->userDisplayRow; + } + if (str_contains($sql, 'FROM user u JOIN role')) { return $this->userRow; } diff --git a/tests/Unit/Admin/DashboardControllerTest.php b/tests/Unit/Admin/DashboardControllerTest.php new file mode 100644 index 0000000..e161e8d --- /dev/null +++ b/tests/Unit/Admin/DashboardControllerTest.php @@ -0,0 +1,208 @@ +testSession; + } + + protected function sessionGuard(): SessionGuard + { + return new SessionGuard($this->testSession, $this->fakeDb, $this->config); + } + + protected function authorizer(): Authorizer + { + return new Authorizer($this->fakeDb); + } + + protected function userDirectory(): UserDirectory + { + return new UserDirectory($this->fakeDb); + } + + /** + * Expose le chemin garde par permission d'AdminController::guard() (RG-T03), + * que le dashboard (auth seule) n'exerce pas. + */ + public function gated(): GuardResult|Response + { + $guard = $this->guard('user.read'); + if ($guard instanceof Response) { + return $guard; + } + + return $this->adminView('admin/dashboard', ['title' => 't', 'activeNav' => ''], $guard); + } +} + +final class DashboardControllerTest extends TestCase +{ + /** @var list */ + private array $touchedKeys = []; + + protected function setUp(): void + { + $this->setEnv('SESSION_LIFETIME_IDLE', '14400'); + $this->setEnv('SESSION_LIFETIME_ABSOLUTE', '36000'); + } + + protected function tearDown(): void + { + foreach ($this->touchedKeys as $key) { + putenv($key); + } + $this->touchedKeys = []; + } + + private function setEnv(string $key, string $value): void + { + $this->touchedKeys[] = $key; + putenv($key . '=' . $value); + } + + private function controller(SessionManager $session, FakeDatabase $db): TestDashboardController + { + $request = new Request('GET', '/admin/dashboard', [], [], '', '203.0.113.5'); + + return new TestDashboardController($request, new Config(), new Database(new Config()), $session, $db); + } + + private function authedSession(): SessionManager + { + $session = new SessionManager(new Config(), true); + $now = time(); + $session->set('user_id', 1); + $session->set('role_id', 1); + $session->set('logged_in_at', $now - 100); + $session->set('last_activity', $now - 50); + + return $session; + } + + public function testRedirectsToLoginWithoutSession(): void + { + $response = $this->controller(new SessionManager(new Config(), true), new FakeDatabase())->index(); + + self::assertSame(302, $response->status()); + self::assertSame('/login', $response->header('Location')); + } + + public function testInactiveUserRedirectsToLogin(): void + { + $db = new FakeDatabase(); + $db->guardUserRow = ['is_active' => 0]; + + $response = $this->controller($this->authedSession(), $db)->index(); + + self::assertSame(302, $response->status()); + self::assertSame('/login', $response->header('Location')); + } + + public function testRendersShellWhenAuthenticated(): void + { + $db = new FakeDatabase(); + $db->guardUserRow = ['is_active' => 1]; + $db->userDisplayRow = ['first_name' => 'Corentin', 'last_name' => 'J', 'role_label' => 'Administrateur']; + $db->permissionCodes = ['product.read', 'user.read']; + + $response = $this->controller($this->authedSession(), $db)->index(); + + self::assertSame(200, $response->status()); + $body = $response->body(); + // Shell rendu (topbar/sidebar) + identite + page. + self::assertStringContainsString('admin-layout', $body); + self::assertStringContainsString('Tableau de bord', $body); + self::assertStringContainsString('Corentin J', $body); + self::assertStringContainsString('Administrateur', $body); + // Marqueur present UNIQUEMENT dans le fragment dashboard (absent du layout) : + // verifie que le contenu est bien compose DANS le shell (pas un $content vide). + self::assertStringContainsString('Bienvenue, Corentin J', $body); + // Navigation conditionnee aux permissions. + self::assertStringContainsString('/admin/products', $body); // product.read present + self::assertStringContainsString('/admin/users', $body); // user.read present + self::assertStringNotContainsString('/admin/roles', $body); // role.manage absent + // Deconnexion = formulaire POST avec CSRF. + self::assertStringContainsString('action="/logout"', $body); + self::assertStringContainsString('name="_csrf"', $body); + } + + public function testForbiddenWhenPermissionDenied(): void + { + // Authentifie mais sans la permission requise (RG-T03) -> 403 + page forbidden. + $db = new FakeDatabase(); + $db->guardUserRow = ['is_active' => 1]; + $db->userDisplayRow = ['first_name' => 'Corentin', 'last_name' => 'J', 'role_label' => 'Equipier']; + $db->canResult = false; + + $response = $this->controller($this->authedSession(), $db)->gated(); + + self::assertSame(403, $response->status()); + self::assertStringContainsString('Acces refuse', $response->body()); + } + + public function testGatedPageRendersWhenPermitted(): void + { + $db = new FakeDatabase(); + $db->guardUserRow = ['is_active' => 1]; + $db->userDisplayRow = ['first_name' => 'Corentin', 'last_name' => 'J', 'role_label' => 'Administrateur']; + $db->canResult = true; + $db->permissionCodes = ['user.read']; + + $response = $this->controller($this->authedSession(), $db)->gated(); + + self::assertSame(200, $response->status()); + } + + public function testEscapesUserIdentity(): void + { + // Donnees user-editables (nom/role) : doivent etre echappees (RG-T15). + $db = new FakeDatabase(); + $db->guardUserRow = ['is_active' => 1]; + $db->userDisplayRow = [ + 'first_name' => '', + 'last_name' => 'x', + 'role_label' => 'Admin & co', + ]; + $db->permissionCodes = ['user.read']; + + $body = $this->controller($this->authedSession(), $db)->index()->body(); + + self::assertStringContainsString('<script>', $body); + self::assertStringNotContainsString('', $body); + self::assertStringContainsString('& co', $body); + self::assertStringNotContainsString('Admin ', $body); + } +} diff --git a/tests/Unit/Auth/UserDirectoryTest.php b/tests/Unit/Auth/UserDirectoryTest.php new file mode 100644 index 0000000..be0b993 --- /dev/null +++ b/tests/Unit/Auth/UserDirectoryTest.php @@ -0,0 +1,46 @@ +db = new FakeDatabase(); + } + + public function testDisplayInfoReturnsNameAndRoleLabel(): void + { + $this->db->userDisplayRow = [ + 'first_name' => 'Corentin', + 'last_name' => 'J', + 'role_label' => 'Administrateur', + ]; + + self::assertSame( + ['name' => 'Corentin J', 'role_label' => 'Administrateur'], + (new UserDirectory($this->db))->displayInfo(7), + ); + } + + public function testDisplayInfoDefaultsWhenAbsent(): void + { + $this->db->userDisplayRow = null; + + self::assertSame( + ['name' => 'Utilisateur', 'role_label' => ''], + (new UserDirectory($this->db))->displayInfo(999), + ); + } +} -- 2.45.3 From 8290ceabc478545a9b7829fe1eea92927c93551a Mon Sep 17 00:00:00 2001 From: Corentin JOGUET Date: Mon, 15 Jun 2026 21:45:31 +0200 Subject: [PATCH 16/93] feat: CRUD categories P3 (rendu serveur, garde + CSRF + validation) (#15) --- src/app/Catalogue/CategoryRepository.php | 105 +++++ src/app/Controllers/AdminController.php | 22 + src/app/Controllers/CategoryController.php | 279 +++++++++++++ src/app/Views/admin/categories/form.php | 64 +++ src/app/Views/admin/categories/index.php | 73 ++++ src/app/Views/admin/layout.php | 5 + src/app/Views/admin/not_found.php | 18 + src/public/admin/index.php | 9 + .../Integration/CategoryRepositoryDbTest.php | 97 +++++ tests/Support/FakeDatabase.php | 42 +- tests/Unit/Admin/CategoryControllerTest.php | 383 ++++++++++++++++++ 11 files changed, 1094 insertions(+), 3 deletions(-) create mode 100644 src/app/Catalogue/CategoryRepository.php create mode 100644 src/app/Controllers/CategoryController.php create mode 100644 src/app/Views/admin/categories/form.php create mode 100644 src/app/Views/admin/categories/index.php create mode 100644 src/app/Views/admin/not_found.php create mode 100644 tests/Integration/CategoryRepositoryDbTest.php create mode 100644 tests/Unit/Admin/CategoryControllerTest.php diff --git a/src/app/Catalogue/CategoryRepository.php b/src/app/Catalogue/CategoryRepository.php new file mode 100644 index 0000000..7859b79 --- /dev/null +++ b/src/app/Catalogue/CategoryRepository.php @@ -0,0 +1,105 @@ +> + */ + public function all(): array + { + return $this->db->fetchAll( + 'SELECT id, name, slug, image_path, display_order, is_active ' + . 'FROM category ORDER BY display_order, name', + ); + } + + /** + * @return array|null + */ + public function find(int $id): ?array + { + return $this->db->fetch( + 'SELECT id, name, slug, image_path, display_order, is_active FROM category WHERE id = :id', + ['id' => $id], + ); + } + + public function nameExists(string $name, int $exceptId = 0): bool + { + return $this->db->fetch( + 'SELECT id FROM category WHERE name = :name AND id <> :id LIMIT 1', + ['name' => $name, 'id' => $exceptId], + ) !== null; + } + + public function slugExists(string $slug, int $exceptId = 0): bool + { + return $this->db->fetch( + 'SELECT id FROM category WHERE slug = :slug AND id <> :id LIMIT 1', + ['slug' => $slug, 'id' => $exceptId], + ) !== null; + } + + /** + * @param array{name: string, slug: string, image_path: ?string, display_order: int, is_active: int} $data + */ + public function create(array $data): void + { + $this->db->execute( + 'INSERT INTO category (name, slug, image_path, display_order, is_active) ' + . 'VALUES (:name, :slug, :image, :ord, :active)', + [ + 'name' => $data['name'], + 'slug' => $data['slug'], + 'image' => $data['image_path'], + 'ord' => $data['display_order'], + 'active' => $data['is_active'], + ], + ); + } + + /** + * @param array{name: string, slug: string, image_path: ?string, display_order: int} $data + */ + public function update(int $id, array $data): void + { + $this->db->execute( + 'UPDATE category SET name = :name, slug = :slug, image_path = :image, display_order = :ord WHERE id = :id', + [ + 'name' => $data['name'], + 'slug' => $data['slug'], + 'image' => $data['image_path'], + 'ord' => $data['display_order'], + 'id' => $id, + ], + ); + } + + public function setActive(int $id, bool $active): void + { + $this->db->execute( + 'UPDATE category SET is_active = :active WHERE id = :id', + ['active' => $active ? 1 : 0, 'id' => $id], + ); + } +} diff --git a/src/app/Controllers/AdminController.php b/src/app/Controllers/AdminController.php index 130c0d2..54ca114 100644 --- a/src/app/Controllers/AdminController.php +++ b/src/app/Controllers/AdminController.php @@ -67,6 +67,7 @@ abstract class AdminController extends AuthenticatedController 'permissions' => $this->authorizer()->permissionsFor($roleId), 'csrfToken' => Csrf::token($this->sessionManager()), 'activeNav' => '', + 'flash' => $this->takeFlash(), ]; return $this->view($name, $data + $context, $status); @@ -76,4 +77,25 @@ abstract class AdminController extends AuthenticatedController { return new UserDirectory($this->database); } + + /** + * Message de confirmation a afficher apres une redirection (pose avant le 302, + * consomme au rendu suivant). Stocke en session pour survivre a la redirection. + */ + protected function setFlash(string $message): void + { + $this->sessionManager()->set('_flash', $message); + } + + private function takeFlash(): ?string + { + $flash = $this->sessionManager()->get('_flash'); + if ($flash === null) { + return null; + } + + $this->sessionManager()->set('_flash', null); + + return is_string($flash) ? $flash : null; + } } diff --git a/src/app/Controllers/CategoryController.php b/src/app/Controllers/CategoryController.php new file mode 100644 index 0000000..85d64da --- /dev/null +++ b/src/app/Controllers/CategoryController.php @@ -0,0 +1,279 @@ + $params + */ + public function index(array $params = []): Response + { + $guard = $this->guard(self::PERMISSION); + if ($guard instanceof Response) { + return $guard; + } + + return $this->adminView('admin/categories/index', [ + 'title' => 'Categories - Wakdo Admin', + 'activeNav' => 'categories', + 'categories' => $this->categoryRepository()->all(), + ], $guard); + } + + /** + * @param array $params + */ + public function create(array $params = []): Response + { + $guard = $this->guard(self::PERMISSION); + if ($guard instanceof Response) { + return $guard; + } + + return $this->renderForm($guard, 0, [], []); + } + + /** + * @param array $params + */ + public function store(array $params = []): Response + { + $guard = $this->guard(self::PERMISSION); + if ($guard instanceof Response) { + return $guard; + } + + $form = $this->request->formBody(); + if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) { + return $this->invalidCsrf(); + } + + $repo = $this->categoryRepository(); + [$data, $errors] = $this->validate($form, $repo, 0); + if ($errors !== []) { + return $this->renderForm($guard, 0, $form, $errors, 422); + } + + try { + $repo->create($data); + } catch (PDOException $exception) { + return $this->onWriteConflict($exception, $guard, 0, $form); + } + + $this->setFlash('Categorie creee.'); + + return $this->redirect('/admin/categories'); + } + + /** + * @param array $params + */ + public function edit(array $params): Response + { + $guard = $this->guard(self::PERMISSION); + if ($guard instanceof Response) { + return $guard; + } + + $id = (int) ($params['id'] ?? 0); + $category = $this->categoryRepository()->find($id); + if ($category === null) { + return $this->notFound($guard); + } + + return $this->renderForm($guard, $id, $category, []); + } + + /** + * @param array $params + */ + public function update(array $params): Response + { + $guard = $this->guard(self::PERMISSION); + if ($guard instanceof Response) { + return $guard; + } + + $form = $this->request->formBody(); + if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) { + return $this->invalidCsrf(); + } + + $id = (int) ($params['id'] ?? 0); + $repo = $this->categoryRepository(); + if ($repo->find($id) === null) { + return $this->notFound($guard); + } + + [$data, $errors] = $this->validate($form, $repo, $id); + if ($errors !== []) { + return $this->renderForm($guard, $id, $form, $errors, 422); + } + + try { + $repo->update($id, $data); + } catch (PDOException $exception) { + return $this->onWriteConflict($exception, $guard, $id, $form); + } + + $this->setFlash('Categorie mise a jour.'); + + return $this->redirect('/admin/categories'); + } + + /** + * @param array $params + */ + public function toggle(array $params): Response + { + $guard = $this->guard(self::PERMISSION); + if ($guard instanceof Response) { + return $guard; + } + + $form = $this->request->formBody(); + if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) { + return $this->invalidCsrf(); + } + + $id = (int) ($params['id'] ?? 0); + $repo = $this->categoryRepository(); + $category = $repo->find($id); + if ($category === null) { + return $this->notFound($guard); + } + + $newActive = (int) ($category['is_active'] ?? 0) !== 1; + $repo->setActive($id, $newActive); + $this->setFlash($newActive ? 'Categorie affichee.' : 'Categorie masquee.'); + + return $this->redirect('/admin/categories'); + } + + protected function categoryRepository(): CategoryRepository + { + return new CategoryRepository($this->database); + } + + /** + * Validation serveur (RG-T18) + unicite. Renvoie [donnees normalisees, erreurs]. + * + * @param array $form + * @return array{0: array{name: string, slug: string, image_path: ?string, display_order: int, is_active: int}, 1: array} + */ + private function validate(array $form, CategoryRepository $repo, int $exceptId): array + { + $name = trim($form['name'] ?? ''); + $slug = trim($form['slug'] ?? ''); + $image = trim($form['image_path'] ?? ''); + $orderRaw = trim($form['display_order'] ?? '0'); + + $errors = []; + + if ($name === '' || mb_strlen($name) > 60) { + $errors['name'] = 'Le libelle est requis (60 caracteres max).'; + } elseif ($repo->nameExists($name, $exceptId)) { + $errors['name'] = 'Ce libelle existe deja.'; + } + + if ($slug === '' || mb_strlen($slug) > 60 || preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $slug) !== 1) { + $errors['slug'] = 'Slug requis : minuscules, chiffres et tirets (60 max).'; + } elseif ($repo->slugExists($slug, $exceptId)) { + $errors['slug'] = 'Ce slug existe deja.'; + } + + if ($image !== '' && mb_strlen($image) > 255) { + $errors['image_path'] = 'Chemin image trop long (255 max).'; + } + + // Borne haute = SMALLINT UNSIGNED (0..65535) : refuse cote serveur (RG-T18) + // plutot que de laisser un debordement remonter en 500 depuis la base. + if (!ctype_digit($orderRaw) || (int) $orderRaw > 65535) { + $errors['display_order'] = 'L ordre d affichage doit etre un entier entre 0 et 65535.'; + } + + $data = [ + 'name' => $name, + 'slug' => $slug, + 'image_path' => $image !== '' ? $image : null, + 'display_order' => (ctype_digit($orderRaw) && (int) $orderRaw <= 65535) ? (int) $orderRaw : 0, + 'is_active' => 1, + ]; + + return [$data, $errors]; + } + + /** + * @param array $values + * @param array $errors + */ + private function renderForm(GuardResult $guard, int $id, array $values, array $errors, int $status = 200): Response + { + return $this->adminView('admin/categories/form', [ + 'title' => ($id !== 0 ? 'Modifier' : 'Nouvelle') . ' categorie - Wakdo Admin', + 'activeNav' => 'categories', + 'categoryId' => $id, + 'values' => [ + 'name' => (string) ($values['name'] ?? ''), + 'slug' => (string) ($values['slug'] ?? ''), + 'image_path' => (string) ($values['image_path'] ?? ''), + 'display_order' => (string) ($values['display_order'] ?? '0'), + ], + 'errors' => $errors, + ], $guard, $status); + } + + /** + * Traduit une violation de contrainte d'unicite (SQLSTATE 23000) en + * re-affichage 422 du formulaire plutot qu'en 500. Couvre la fenetre de + * concurrence entre le controle nameExists/slugExists et l'ecriture. Tout + * autre code d'erreur est repropage (vrai incident interne). + * + * @param array $form + */ + private function onWriteConflict(PDOException $exception, GuardResult $guard, int $id, array $form): Response + { + // getCode() rend la chaine SQLSTATE pour une vraie PDOException ; le cast + // couvre aussi un code entier (23000 = violation de contrainte d'integrite). + if ((string) $exception->getCode() === '23000') { + return $this->renderForm($guard, $id, $form, ['slug' => 'Ce libelle ou ce slug existe deja.'], 422); + } + + throw $exception; + } + + private function notFound(GuardResult $guard): Response + { + return $this->adminView('admin/not_found', ['title' => 'Introuvable', 'activeNav' => 'categories'], $guard, 404); + } + + private function redirect(string $location): Response + { + return Response::make('', 302, ['Location' => $location]); + } + + private function invalidCsrf(): Response + { + return Response::make('Requete invalide.', 403, ['Content-Type' => 'text/plain; charset=utf-8']); + } +} diff --git a/src/app/Views/admin/categories/form.php b/src/app/Views/admin/categories/form.php new file mode 100644 index 0000000..09b938f --- /dev/null +++ b/src/app/Views/admin/categories/form.php @@ -0,0 +1,64 @@ + $values + * @var array $errors + * @var string $csrfToken + */ + +$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8'); +$id = (int) ($categoryId ?? 0); +$action = $id !== 0 ? '/admin/categories/' . $id : '/admin/categories'; + +/** @var array $vals */ +$vals = isset($values) && is_array($values) ? $values : []; +/** @var array $errs */ +$errs = isset($errors) && is_array($errors) ? $errors : []; + +$val = static fn (string $k): string => htmlspecialchars((string) ($vals[$k] ?? ''), ENT_QUOTES, 'UTF-8'); +$err = static fn (string $k): string => isset($errs[$k]) && is_string($errs[$k]) ? $errs[$k] : ''; +?> + + +
+ + +
+ + +

+
+ +
+ + +

+
+ +
+ + +

+
+ +
+ + +

+
+ +
+ + Annuler +
+
diff --git a/src/app/Views/admin/categories/index.php b/src/app/Views/admin/categories/index.php new file mode 100644 index 0000000..0131ddf --- /dev/null +++ b/src/app/Views/admin/categories/index.php @@ -0,0 +1,73 @@ +> $categories + * @var string $csrfToken + */ + +$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8'); +/** @var array> $rows */ +$rows = isset($categories) && is_array($categories) ? $categories : []; + +$esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8'); +?> + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
LibelleSlugOrdreStatut
Aucune categorie.
+ + Visible + + Masquee + + + Modifier +
+ + +
+
+
+
diff --git a/src/app/Views/admin/layout.php b/src/app/Views/admin/layout.php index 8f2695c..ea84009 100644 --- a/src/app/Views/admin/layout.php +++ b/src/app/Views/admin/layout.php @@ -18,6 +18,7 @@ declare(strict_types=1); * @var list $permissions * @var string $csrfToken * @var string $activeNav + * @var string|null $flash */ $pageTitle = htmlspecialchars($title ?? 'Wakdo Admin', ENT_QUOTES, 'UTF-8'); @@ -129,6 +130,10 @@ $navClass = static function (string $code, string $current): string {
+ + +
+
diff --git a/src/app/Views/admin/not_found.php b/src/app/Views/admin/not_found.php new file mode 100644 index 0000000..5bf525d --- /dev/null +++ b/src/app/Views/admin/not_found.php @@ -0,0 +1,18 @@ + + + +
+

Retour au tableau de bord

+
diff --git a/src/public/admin/index.php b/src/public/admin/index.php index 027f9f8..9140055 100644 --- a/src/public/admin/index.php +++ b/src/public/admin/index.php @@ -12,6 +12,7 @@ declare(strict_types=1); use App\Auth\SessionManager; use App\Controllers\AuthController; +use App\Controllers\CategoryController; use App\Controllers\DashboardController; use App\Controllers\HealthController; use App\Controllers\HomeController; @@ -65,6 +66,14 @@ try { // Back-office (P3) : pages rendues serveur sous /admin, gardees par SessionGuard. $router->add('GET', '/admin/dashboard', [DashboardController::class, 'index']); + // CRUD Categories (permission category.manage). Pas de suppression dure : toggle is_active. + $router->add('GET', '/admin/categories', [CategoryController::class, 'index']); + $router->add('GET', '/admin/categories/new', [CategoryController::class, 'create']); + $router->add('POST', '/admin/categories', [CategoryController::class, 'store']); + $router->add('GET', '/admin/categories/{id}/edit', [CategoryController::class, 'edit']); + $router->add('POST', '/admin/categories/{id}', [CategoryController::class, 'update']); + $router->add('POST', '/admin/categories/{id}/toggle', [CategoryController::class, 'toggle']); + $response = $router->dispatch(Request::fromGlobals()); $response->send(); } catch (Throwable $exception) { diff --git a/tests/Integration/CategoryRepositoryDbTest.php b/tests/Integration/CategoryRepositoryDbTest.php new file mode 100644 index 0000000..0ca914b --- /dev/null +++ b/tests/Integration/CategoryRepositoryDbTest.php @@ -0,0 +1,97 @@ +db = new Database(new Config()); + + try { + $this->db->fetch('SELECT 1'); + } catch (Throwable $exception) { + self::markTestSkipped('Base injoignable: ' . $exception->getMessage()); + } + + $suffix = bin2hex(random_bytes(4)); + $this->slug = 'it-cat-' . $suffix; + $this->name = 'IT Cat ' . $suffix; + } + + protected function tearDown(): void + { + if ($this->slug !== '') { + $this->db->execute('DELETE FROM category WHERE slug = :slug', ['slug' => $this->slug]); + } + } + + public function testCreateFindUpdateAndToggle(): void + { + $repo = new CategoryRepository($this->db); + + $repo->create([ + 'name' => $this->name, + 'slug' => $this->slug, + 'image_path' => null, + 'display_order' => 99, + 'is_active' => 1, + ]); + + $idRow = $this->db->fetch('SELECT id FROM category WHERE slug = :slug', ['slug' => $this->slug]); + $id = (int) ($idRow['id'] ?? 0); + self::assertGreaterThan(0, $id); + + $found = $repo->find($id); + self::assertNotNull($found); + self::assertSame($this->name, $found['name']); + self::assertSame(1, (int) ($found['is_active'] ?? 0)); + + // Unicite : present sauf si on s'exclut soi-meme. + self::assertTrue($repo->nameExists($this->name)); + self::assertFalse($repo->nameExists($this->name, $id)); + self::assertTrue($repo->slugExists($this->slug)); + self::assertFalse($repo->slugExists($this->slug, $id)); + + $repo->update($id, [ + 'name' => $this->name . ' (maj)', + 'slug' => $this->slug, + 'image_path' => 'x.png', + 'display_order' => 100, + ]); + $updated = $repo->find($id); + self::assertNotNull($updated); + self::assertSame($this->name . ' (maj)', $updated['name']); + self::assertSame('x.png', $updated['image_path']); + + $repo->setActive($id, false); + $toggled = $repo->find($id); + self::assertNotNull($toggled); + self::assertSame(0, (int) ($toggled['is_active'] ?? 1)); + + // all() renvoie la categorie creee. + $slugs = array_map(static fn (array $r): string => (string) ($r['slug'] ?? ''), $repo->all()); + self::assertContains($this->slug, $slugs); + } +} diff --git a/tests/Support/FakeDatabase.php b/tests/Support/FakeDatabase.php index a68841e..f2e012c 100644 --- a/tests/Support/FakeDatabase.php +++ b/tests/Support/FakeDatabase.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace App\Tests\Support; use App\Core\DatabaseInterface; -use RuntimeException; +use Throwable; /** * Double de test de DatabaseInterface : aucune connexion reelle. Les lectures @@ -97,8 +97,28 @@ final class FakeDatabase implements DatabaseInterface */ public ?array $userDisplayRow = null; - /** Si non nul, execute() leve cette exception (simulation panne DB -> fail-closed). */ - public ?RuntimeException $failOnExecute = null; + /** + * Lignes renvoyees par CategoryRepository::all(). + * + * @var list> + */ + public array $categoriesRows = []; + + /** + * Ligne renvoyee par CategoryRepository::find() ; null = introuvable. + * + * @var array|null + */ + public ?array $categoryRow = null; + + /** Resultat de CategoryRepository::nameExists(). */ + public bool $categoryNameTaken = false; + + /** Resultat de CategoryRepository::slugExists(). */ + public bool $categorySlugTaken = false; + + /** Si non nul, execute() leve cette exception (simulation panne DB / violation de contrainte). */ + public ?Throwable $failOnExecute = null; /** @var list}> */ public array $writes = []; @@ -146,6 +166,18 @@ final class FakeDatabase implements DatabaseInterface return $this->pinUserRow; } + if (str_contains($sql, 'FROM category WHERE id = :id')) { + return $this->categoryRow; + } + + if (str_contains($sql, 'FROM category WHERE name = :name')) { + return $this->categoryNameTaken ? ['id' => 1] : null; + } + + if (str_contains($sql, 'FROM category WHERE slug = :slug')) { + return $this->categorySlugTaken ? ['id' => 1] : null; + } + if (str_contains($sql, 'SELECT lockout_until FROM login_throttle')) { return ['lockout_until' => $this->ipLockoutUntil]; } @@ -161,6 +193,10 @@ final class FakeDatabase implements DatabaseInterface { $this->reads[] = ['sql' => $sql, 'params' => $params]; + if (str_contains($sql, 'FROM category ORDER BY')) { + return $this->categoriesRows; + } + if (str_contains($sql, 'SELECT p.code FROM role_permission')) { if (!$this->roleActive) { return []; diff --git a/tests/Unit/Admin/CategoryControllerTest.php b/tests/Unit/Admin/CategoryControllerTest.php new file mode 100644 index 0000000..6362291 --- /dev/null +++ b/tests/Unit/Admin/CategoryControllerTest.php @@ -0,0 +1,383 @@ +testSession; + } + + protected function sessionGuard(): SessionGuard + { + return new SessionGuard($this->testSession, $this->fakeDb, $this->config); + } + + protected function authorizer(): Authorizer + { + return new Authorizer($this->fakeDb); + } + + protected function userDirectory(): UserDirectory + { + return new UserDirectory($this->fakeDb); + } + + protected function categoryRepository(): CategoryRepository + { + return new CategoryRepository($this->fakeDb); + } +} + +final class CategoryControllerTest extends TestCase +{ + /** @var list */ + private array $touchedKeys = []; + + private SessionManager $session; + private string $csrf = ''; + + protected function setUp(): void + { + $this->setEnv('SESSION_LIFETIME_IDLE', '14400'); + $this->setEnv('SESSION_LIFETIME_ABSOLUTE', '36000'); + + $this->session = new SessionManager(new Config(), true); + $now = time(); + $this->session->set('user_id', 1); + $this->session->set('role_id', 1); + $this->session->set('logged_in_at', $now - 100); + $this->session->set('last_activity', $now - 50); + $this->csrf = Csrf::token($this->session); + } + + protected function tearDown(): void + { + foreach ($this->touchedKeys as $key) { + putenv($key); + } + $this->touchedKeys = []; + } + + private function setEnv(string $key, string $value): void + { + $this->touchedKeys[] = $key; + putenv($key . '=' . $value); + } + + private function permittedDb(): FakeDatabase + { + $db = new FakeDatabase(); + $db->guardUserRow = ['is_active' => 1]; + $db->userDisplayRow = ['first_name' => 'Corentin', 'last_name' => 'J', 'role_label' => 'Administrateur']; + $db->canResult = true; + $db->permissionCodes = ['category.manage']; + + return $db; + } + + private function get(string $path): Request + { + return new Request('GET', $path, [], [], '', '203.0.113.5'); + } + + /** + * @param array $form + */ + private function post(array $form, string $path): Request + { + return new Request( + 'POST', + $path, + [], + ['content-type' => 'application/x-www-form-urlencoded'], + http_build_query($form), + '203.0.113.5', + ); + } + + private function controller(Request $request, FakeDatabase $db): TestCategoryController + { + return new TestCategoryController($request, new Config(), new Database(new Config()), $this->session, $db); + } + + private function wroteContaining(FakeDatabase $db, string $needle): bool + { + return $db->wrote($needle); + } + + public function testGuardDeniesWithoutPermission(): void + { + $db = $this->permittedDb(); + $db->canResult = false; + + $response = $this->controller($this->get('/admin/categories'), $db)->index(); + + self::assertSame(403, $response->status()); + self::assertStringContainsString('Acces refuse', $response->body()); + } + + public function testIndexListsCategories(): void + { + $db = $this->permittedDb(); + $db->categoriesRows = [ + ['id' => 1, 'name' => 'Burgers', 'slug' => 'burgers', 'image_path' => null, 'display_order' => 2, 'is_active' => 1], + ['id' => 2, 'name' => 'Sauces', 'slug' => 'sauces', 'image_path' => null, 'display_order' => 9, 'is_active' => 0], + ]; + + $response = $this->controller($this->get('/admin/categories'), $db)->index(); + $body = $response->body(); + + self::assertSame(200, $response->status()); + self::assertStringContainsString('Nouvelle categorie', $body); + self::assertStringContainsString('Burgers', $body); + self::assertStringContainsString('Visible', $body); // is_active = 1 + self::assertStringContainsString('Masquee', $body); // is_active = 0 + } + + public function testCreateShowsForm(): void + { + $response = $this->controller($this->get('/admin/categories/new'), $this->permittedDb())->create(); + + self::assertSame(200, $response->status()); + self::assertStringContainsString('name="slug"', $response->body()); + self::assertStringContainsString('action="/admin/categories"', $response->body()); + } + + public function testStoreValidCreatesAndRedirects(): void + { + $db = $this->permittedDb(); + $request = $this->post( + ['_csrf' => $this->csrf, 'name' => 'Desserts', 'slug' => 'desserts', 'display_order' => '7'], + '/admin/categories', + ); + + $response = $this->controller($request, $db)->store(); + + self::assertSame(302, $response->status()); + self::assertSame('/admin/categories', $response->header('Location')); + self::assertTrue($this->wroteContaining($db, 'INSERT INTO category')); + self::assertSame('Categorie creee.', $this->session->get('_flash')); + } + + public function testStoreInvalidRerendersWithErrorsAndNoWrite(): void + { + $db = $this->permittedDb(); + $request = $this->post( + ['_csrf' => $this->csrf, 'name' => '', 'slug' => 'INVALID SLUG', 'display_order' => '7'], + '/admin/categories', + ); + + $response = $this->controller($request, $db)->store(); + + self::assertSame(422, $response->status()); + self::assertStringContainsString('Le libelle est requis', $response->body()); + self::assertStringContainsString('Slug requis', $response->body()); + self::assertFalse($this->wroteContaining($db, 'INSERT INTO category')); + } + + public function testStoreRejectsDuplicateName(): void + { + $db = $this->permittedDb(); + $db->categoryNameTaken = true; + $request = $this->post( + ['_csrf' => $this->csrf, 'name' => 'Desserts', 'slug' => 'desserts', 'display_order' => '7'], + '/admin/categories', + ); + + $response = $this->controller($request, $db)->store(); + + self::assertSame(422, $response->status()); + self::assertStringContainsString('Ce libelle existe deja', $response->body()); + self::assertFalse($this->wroteContaining($db, 'INSERT INTO category')); + } + + public function testStoreRejectsOverRangeDisplayOrder(): void + { + $db = $this->permittedDb(); + $request = $this->post( + ['_csrf' => $this->csrf, 'name' => 'Desserts', 'slug' => 'desserts', 'display_order' => '70000'], + '/admin/categories', + ); + + $response = $this->controller($request, $db)->store(); + + self::assertSame(422, $response->status()); + self::assertStringContainsString('entre 0 et 65535', $response->body()); + self::assertFalse($this->wroteContaining($db, 'INSERT INTO category')); + } + + public function testStoreTranslatesUniqueViolationTo422(): void + { + // Fenetre de concurrence : la base leve une violation 23000 a l'insertion ; + // le controleur doit re-afficher le formulaire (422), pas remonter un 500. + $db = $this->permittedDb(); + $db->failOnExecute = new \PDOException('duplicate', 23000); + $request = $this->post( + ['_csrf' => $this->csrf, 'name' => 'Desserts', 'slug' => 'desserts', 'display_order' => '7'], + '/admin/categories', + ); + + $response = $this->controller($request, $db)->store(); + + self::assertSame(422, $response->status()); + self::assertStringContainsString('existe deja', $response->body()); + } + + public function testStoreRejectsDuplicateSlug(): void + { + $db = $this->permittedDb(); + $db->categorySlugTaken = true; + $request = $this->post( + ['_csrf' => $this->csrf, 'name' => 'Desserts', 'slug' => 'desserts', 'display_order' => '7'], + '/admin/categories', + ); + + $response = $this->controller($request, $db)->store(); + + self::assertSame(422, $response->status()); + self::assertStringContainsString('Ce slug existe deja', $response->body()); + self::assertFalse($this->wroteContaining($db, 'INSERT INTO category')); + } + + public function testStoreRejectsInvalidCsrf(): void + { + $db = $this->permittedDb(); + $request = $this->post( + ['_csrf' => 'wrong', 'name' => 'Desserts', 'slug' => 'desserts', 'display_order' => '7'], + '/admin/categories', + ); + + $response = $this->controller($request, $db)->store(); + + self::assertSame(403, $response->status()); + self::assertFalse($this->wroteContaining($db, 'INSERT INTO category')); + } + + public function testEditNotFoundReturns404(): void + { + $db = $this->permittedDb(); + $db->categoryRow = null; + + $response = $this->controller($this->get('/admin/categories/999/edit'), $db)->edit(['id' => '999']); + + self::assertSame(404, $response->status()); + self::assertStringContainsString('Introuvable', $response->body()); + } + + public function testUpdateValidRedirects(): void + { + $db = $this->permittedDb(); + $db->categoryRow = ['id' => 5, 'name' => 'Wraps', 'slug' => 'wraps', 'image_path' => null, 'display_order' => 3, 'is_active' => 1]; + $request = $this->post( + ['_csrf' => $this->csrf, 'name' => 'Wraps & Co', 'slug' => 'wraps', 'display_order' => '3'], + '/admin/categories/5', + ); + + $response = $this->controller($request, $db)->update(['id' => '5']); + + self::assertSame(302, $response->status()); + self::assertTrue($this->wroteContaining($db, 'UPDATE category SET name')); + } + + public function testToggleFlipsActiveAndRedirects(): void + { + $db = $this->permittedDb(); + $db->categoryRow = ['id' => 5, 'name' => 'Wraps', 'slug' => 'wraps', 'image_path' => null, 'display_order' => 3, 'is_active' => 1]; + $request = $this->post(['_csrf' => $this->csrf], '/admin/categories/5/toggle'); + + $response = $this->controller($request, $db)->toggle(['id' => '5']); + + self::assertSame(302, $response->status()); + self::assertTrue($this->wroteContaining($db, 'UPDATE category SET is_active')); + // Etait visible (1) -> on masque (0). + $write = null; + foreach ($db->writes as $w) { + if (str_contains($w['sql'], 'UPDATE category SET is_active')) { + $write = $w; + } + } + self::assertNotNull($write); + self::assertSame(0, $write['params']['active'] ?? null); + self::assertSame('Categorie masquee.', $this->session->get('_flash')); + } + + public function testToggleFromMaskedMakesVisible(): void + { + $db = $this->permittedDb(); + $db->categoryRow = ['id' => 5, 'name' => 'Wraps', 'slug' => 'wraps', 'image_path' => null, 'display_order' => 3, 'is_active' => 0]; + $request = $this->post(['_csrf' => $this->csrf], '/admin/categories/5/toggle'); + + $response = $this->controller($request, $db)->toggle(['id' => '5']); + + self::assertSame(302, $response->status()); + $write = null; + foreach ($db->writes as $w) { + if (str_contains($w['sql'], 'UPDATE category SET is_active')) { + $write = $w; + } + } + self::assertNotNull($write); + self::assertSame(1, $write['params']['active'] ?? null); + self::assertSame('Categorie affichee.', $this->session->get('_flash')); + } + + public function testUpdateNotFoundReturns404(): void + { + $db = $this->permittedDb(); + $db->categoryRow = null; + $request = $this->post( + ['_csrf' => $this->csrf, 'name' => 'Wraps', 'slug' => 'wraps', 'display_order' => '3'], + '/admin/categories/999', + ); + + $response = $this->controller($request, $db)->update(['id' => '999']); + + self::assertSame(404, $response->status()); + self::assertStringContainsString('Introuvable', $response->body()); + self::assertFalse($this->wroteContaining($db, 'UPDATE category SET name')); + } + + public function testToggleNotFoundReturns404(): void + { + $db = $this->permittedDb(); + $db->categoryRow = null; + $request = $this->post(['_csrf' => $this->csrf], '/admin/categories/999/toggle'); + + $response = $this->controller($request, $db)->toggle(['id' => '999']); + + self::assertSame(404, $response->status()); + self::assertStringContainsString('Introuvable', $response->body()); + self::assertFalse($this->wroteContaining($db, 'UPDATE category SET is_active')); + } +} -- 2.45.3 From f63ac9873c3386569ca68fe1b3f011c6fd3fbd1e Mon Sep 17 00:00:00 2001 From: Corentin JOGUET Date: Mon, 15 Jun 2026 22:04:14 +0200 Subject: [PATCH 17/93] feat: PIN self-service P3 (/admin/profile/pin) (#16) --- src/app/Auth/UserRepository.php | 37 ++++ src/app/Controllers/ProfileController.php | 116 +++++++++++ src/app/Views/admin/profile/pin.php | 49 +++++ src/public/admin/index.php | 5 + tests/Integration/UserRepositoryDbTest.php | 80 ++++++++ tests/Support/FakeDatabase.php | 12 +- tests/Unit/Admin/ProfileControllerTest.php | 217 +++++++++++++++++++++ 7 files changed, 515 insertions(+), 1 deletion(-) create mode 100644 src/app/Auth/UserRepository.php create mode 100644 src/app/Controllers/ProfileController.php create mode 100644 src/app/Views/admin/profile/pin.php create mode 100644 tests/Integration/UserRepositoryDbTest.php create mode 100644 tests/Unit/Admin/ProfileControllerTest.php diff --git a/src/app/Auth/UserRepository.php b/src/app/Auth/UserRepository.php new file mode 100644 index 0000000..4648235 --- /dev/null +++ b/src/app/Auth/UserRepository.php @@ -0,0 +1,37 @@ +db->execute('UPDATE user SET pin_hash = :hash WHERE id = :id', ['hash' => $hash, 'id' => $userId]); + } + + public function pinIsSet(int $userId): bool + { + return $this->db->fetch( + 'SELECT id FROM user WHERE id = :id AND pin_hash IS NOT NULL', + ['id' => $userId], + ) !== null; + } +} diff --git a/src/app/Controllers/ProfileController.php b/src/app/Controllers/ProfileController.php new file mode 100644 index 0000000..2b88ea8 --- /dev/null +++ b/src/app/Controllers/ProfileController.php @@ -0,0 +1,116 @@ + $params + */ + public function showPin(array $params = []): Response + { + $guard = $this->guard(); + if ($guard instanceof Response) { + return $guard; + } + + $userId = $guard->userId; + if ($userId === null) { + return Response::make('', 302, ['Location' => '/login']); + } + + return $this->adminView('admin/profile/pin', [ + 'title' => 'Mon PIN - Wakdo Admin', + 'activeNav' => '', + 'pinIsSet' => $this->userRepository()->pinIsSet($userId), + 'error' => null, + ], $guard); + } + + /** + * @param array $params + */ + public function updatePin(array $params = []): Response + { + $guard = $this->guard(); + if ($guard instanceof Response) { + return $guard; + } + + $userId = $guard->userId; + if ($userId === null) { + return Response::make('', 302, ['Location' => '/login']); + } + + $form = $this->request->formBody(); + if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) { + return Response::make('Requete invalide.', 403, ['Content-Type' => 'text/plain; charset=utf-8']); + } + + $pin = $form['pin'] ?? ''; + $confirm = $form['pin_confirm'] ?? ''; + $error = null; + + if (!$this->pinVerifier()->meetsLengthPolicy($pin)) { + $error = 'Le PIN doit etre uniquement numerique et respecter la longueur requise.'; + } elseif ($pin !== $confirm) { + $error = 'Les PIN ne correspondent pas.'; + } + + if ($error !== null) { + return $this->renderPinForm($guard, $userId, $error, 422); + } + + // Gate sur 1 ligne affectee : une cible inexistante (0 ligne) ne doit pas + // produire un faux "PIN enregistre" (defense en profondeur). + if ($this->userRepository()->setPinHash($userId, $this->passwordHasher()->hash($pin)) !== 1) { + return $this->renderPinForm($guard, $userId, 'Echec de l enregistrement du PIN.', 500); + } + + $this->setFlash('PIN enregistre.'); + + return Response::make('', 302, ['Location' => '/admin/profile/pin']); + } + + private function renderPinForm(GuardResult $guard, int $userId, ?string $error, int $status): Response + { + return $this->adminView('admin/profile/pin', [ + 'title' => 'Mon PIN - Wakdo Admin', + 'activeNav' => '', + 'pinIsSet' => $this->userRepository()->pinIsSet($userId), + 'error' => $error, + ], $guard, $status); + } + + protected function userRepository(): UserRepository + { + return new UserRepository($this->database); + } + + protected function pinVerifier(): PinVerifier + { + return new PinVerifier($this->database, $this->config, $this->passwordHasher()); + } + + protected function passwordHasher(): PasswordHasher + { + return new PasswordHasher($this->config); + } +} diff --git a/src/app/Views/admin/profile/pin.php b/src/app/Views/admin/profile/pin.php new file mode 100644 index 0000000..c854c18 --- /dev/null +++ b/src/app/Views/admin/profile/pin.php @@ -0,0 +1,49 @@ + + + +
+

Statut :

+ + +

+ + +
+ + +
+ + +
+ +
+ + +
+ +
+ +
+
+
diff --git a/src/public/admin/index.php b/src/public/admin/index.php index 9140055..7352aad 100644 --- a/src/public/admin/index.php +++ b/src/public/admin/index.php @@ -18,6 +18,7 @@ use App\Controllers\HealthController; use App\Controllers\HomeController; use App\Controllers\MeController; use App\Controllers\PasswordResetController; +use App\Controllers\ProfileController; use App\Core\Autoloader; use App\Core\Config; use App\Core\Database; @@ -74,6 +75,10 @@ try { $router->add('POST', '/admin/categories/{id}', [CategoryController::class, 'update']); $router->add('POST', '/admin/categories/{id}/toggle', [CategoryController::class, 'toggle']); + // Profil self-service : definition du PIN d'action sensible (RG-T13). + $router->add('GET', '/admin/profile/pin', [ProfileController::class, 'showPin']); + $router->add('POST', '/admin/profile/pin', [ProfileController::class, 'updatePin']); + $response = $router->dispatch(Request::fromGlobals()); $response->send(); } catch (Throwable $exception) { diff --git a/tests/Integration/UserRepositoryDbTest.php b/tests/Integration/UserRepositoryDbTest.php new file mode 100644 index 0000000..2b9757a --- /dev/null +++ b/tests/Integration/UserRepositoryDbTest.php @@ -0,0 +1,80 @@ +config = new Config(); + $this->db = new Database($this->config); + + try { + $this->db->fetch('SELECT 1'); + } catch (Throwable $exception) { + self::markTestSkipped('Base injoignable: ' . $exception->getMessage()); + } + + $roleId = (int) ($this->db->fetch('SELECT id FROM role ORDER BY id LIMIT 1')['id'] ?? 0); + $hasher = new PasswordHasher($this->config); + $this->db->execute( + 'INSERT INTO user (email, password_hash, first_name, last_name, role_id, is_active) ' + . 'VALUES (:email, :pwd, :fn, :ln, :role, 1)', + [ + 'email' => 'it-userrepo-' . bin2hex(random_bytes(6)) . '@wakdo.invalid', + 'pwd' => $hasher->hash('IntegrationPass1'), + 'fn' => 'Integration', + 'ln' => 'UserRepo', + 'role' => $roleId, + ], + ); + $this->userId = (int) ($this->db->fetch('SELECT LAST_INSERT_ID() AS id')['id'] ?? 0); + } + + protected function tearDown(): void + { + if ($this->userId !== 0) { + $this->db->execute('DELETE FROM user WHERE id = :id', ['id' => $this->userId]); + $this->userId = 0; + } + } + + public function testSetPinHashAndPinIsSet(): void + { + $repo = new UserRepository($this->db); + $hasher = new PasswordHasher($this->config); + + // Aucun PIN au depart. + self::assertFalse($repo->pinIsSet($this->userId)); + + $repo->setPinHash($this->userId, $hasher->hash('4729')); + + self::assertTrue($repo->pinIsSet($this->userId)); + + // Le hash stocke est verifiable et n'est pas le PIN en clair. + $stored = (string) ($this->db->fetch('SELECT pin_hash FROM user WHERE id = :id', ['id' => $this->userId])['pin_hash'] ?? ''); + self::assertNotSame('4729', $stored); + self::assertTrue($hasher->verify('4729', $stored)); + } +} diff --git a/tests/Support/FakeDatabase.php b/tests/Support/FakeDatabase.php index f2e012c..e5bb09a 100644 --- a/tests/Support/FakeDatabase.php +++ b/tests/Support/FakeDatabase.php @@ -117,9 +117,15 @@ final class FakeDatabase implements DatabaseInterface /** Resultat de CategoryRepository::slugExists(). */ public bool $categorySlugTaken = false; + /** Resultat de UserRepository::pinIsSet() (true = un PIN est defini). */ + public bool $userPinSet = false; + /** Si non nul, execute() leve cette exception (simulation panne DB / violation de contrainte). */ public ?Throwable $failOnExecute = null; + /** Nombre de lignes affectees renvoye par execute() (1 par defaut). */ + public int $executeRowCount = 1; + /** @var list}> */ public array $writes = []; @@ -166,6 +172,10 @@ final class FakeDatabase implements DatabaseInterface return $this->pinUserRow; } + if (str_contains($sql, 'FROM user WHERE id = :id AND pin_hash IS NOT NULL')) { + return $this->userPinSet ? ['id' => 1] : null; + } + if (str_contains($sql, 'FROM category WHERE id = :id')) { return $this->categoryRow; } @@ -216,7 +226,7 @@ final class FakeDatabase implements DatabaseInterface $this->writes[] = ['sql' => $sql, 'params' => $params]; - return 1; + return $this->executeRowCount; } public function transaction(callable $fn): void diff --git a/tests/Unit/Admin/ProfileControllerTest.php b/tests/Unit/Admin/ProfileControllerTest.php new file mode 100644 index 0000000..ca4a77f --- /dev/null +++ b/tests/Unit/Admin/ProfileControllerTest.php @@ -0,0 +1,217 @@ +testSession; + } + + protected function sessionGuard(): SessionGuard + { + return new SessionGuard($this->testSession, $this->fakeDb, $this->config); + } + + protected function authorizer(): Authorizer + { + return new Authorizer($this->fakeDb); + } + + protected function userDirectory(): UserDirectory + { + return new UserDirectory($this->fakeDb); + } + + protected function userRepository(): UserRepository + { + return new UserRepository($this->fakeDb); + } +} + +final class ProfileControllerTest extends TestCase +{ + /** @var list */ + private array $touchedKeys = []; + + private SessionManager $session; + private string $csrf = ''; + + protected function setUp(): void + { + $this->setEnv('SESSION_LIFETIME_IDLE', '14400'); + $this->setEnv('SESSION_LIFETIME_ABSOLUTE', '36000'); + $this->setEnv('STAFF_PIN_MIN_LENGTH', '4'); + $this->setEnv('STAFF_PIN_MAX_LENGTH', '12'); + $this->setEnv('ARGON2_MEMORY_COST', '1024'); + $this->setEnv('ARGON2_TIME_COST', '1'); + $this->setEnv('ARGON2_THREADS', '1'); + + $this->session = new SessionManager(new Config(), true); + $now = time(); + $this->session->set('user_id', 1); + $this->session->set('role_id', 1); + $this->session->set('logged_in_at', $now - 100); + $this->session->set('last_activity', $now - 50); + $this->csrf = Csrf::token($this->session); + } + + protected function tearDown(): void + { + foreach ($this->touchedKeys as $key) { + putenv($key); + } + $this->touchedKeys = []; + } + + private function setEnv(string $key, string $value): void + { + $this->touchedKeys[] = $key; + putenv($key . '=' . $value); + } + + private function permittedDb(): FakeDatabase + { + $db = new FakeDatabase(); + $db->guardUserRow = ['is_active' => 1]; + $db->userDisplayRow = ['first_name' => 'Corentin', 'last_name' => 'J', 'role_label' => 'Administrateur']; + $db->canResult = true; + $db->permissionCodes = ['category.manage']; + + return $db; + } + + /** + * @param array $form + */ + private function post(array $form): Request + { + return new Request( + 'POST', + '/admin/profile/pin', + [], + ['content-type' => 'application/x-www-form-urlencoded'], + http_build_query($form), + '203.0.113.5', + ); + } + + private function controller(Request $request, FakeDatabase $db): TestProfileController + { + return new TestProfileController($request, new Config(), new Database(new Config()), $this->session, $db); + } + + public function testRedirectsToLoginWithoutSession(): void + { + $request = new Request('GET', '/admin/profile/pin', [], [], '', '203.0.113.5'); + $response = $this->controller($request, new FakeDatabase())->showPin(); + + self::assertSame(302, $response->status()); + self::assertSame('/login', $response->header('Location')); + } + + public function testShowPinReflectsStatus(): void + { + $request = new Request('GET', '/admin/profile/pin', [], [], '', '203.0.113.5'); + + $db = $this->permittedDb(); + $db->userPinSet = false; + $response = $this->controller($request, $db)->showPin(); + self::assertSame(200, $response->status()); + self::assertStringContainsString('name="pin"', $response->body()); + self::assertStringContainsString('aucun PIN defini', $response->body()); + + $db2 = $this->permittedDb(); + $db2->userPinSet = true; + self::assertStringContainsString('un PIN est defini', $this->controller($request, $db2)->showPin()->body()); + } + + public function testUpdatePinValidStoresHashAndRedirects(): void + { + $db = $this->permittedDb(); + $response = $this->controller($this->post(['_csrf' => $this->csrf, 'pin' => '4729', 'pin_confirm' => '4729']), $db)->updatePin(); + + self::assertSame(302, $response->status()); + self::assertSame('/admin/profile/pin', $response->header('Location')); + self::assertSame('PIN enregistre.', $this->session->get('_flash')); + + // Invariant central : la cible est l'utilisateur de la SESSION (1, pose en + // setUp), jamais un champ de formulaire ; et c'est un hash, pas le PIN clair. + $write = null; + foreach ($db->writes as $w) { + if (str_contains($w['sql'], 'UPDATE user SET pin_hash')) { + $write = $w; + break; + } + } + self::assertNotNull($write); + self::assertSame(1, $write['params']['id'] ?? null); + self::assertNotSame('4729', $write['params']['hash'] ?? null); + } + + public function testUpdatePinFailsWhenNoRowAffected(): void + { + // Cible inexistante (0 ligne affectee) : pas de faux succes, pas de flash. + $db = $this->permittedDb(); + $db->executeRowCount = 0; + + $response = $this->controller($this->post(['_csrf' => $this->csrf, 'pin' => '4729', 'pin_confirm' => '4729']), $db)->updatePin(); + + self::assertSame(500, $response->status()); + self::assertNull($this->session->get('_flash')); + } + + public function testUpdatePinMismatchRerenders422(): void + { + $db = $this->permittedDb(); + $response = $this->controller($this->post(['_csrf' => $this->csrf, 'pin' => '4729', 'pin_confirm' => '0000']), $db)->updatePin(); + + self::assertSame(422, $response->status()); + self::assertStringContainsString('ne correspondent pas', $response->body()); + self::assertFalse($db->wrote('UPDATE user SET pin_hash')); + } + + public function testUpdatePinTooShortRerenders422(): void + { + $db = $this->permittedDb(); + $response = $this->controller($this->post(['_csrf' => $this->csrf, 'pin' => '12', 'pin_confirm' => '12']), $db)->updatePin(); + + self::assertSame(422, $response->status()); + self::assertFalse($db->wrote('UPDATE user SET pin_hash')); + } + + public function testUpdatePinRejectsInvalidCsrf(): void + { + $db = $this->permittedDb(); + $response = $this->controller($this->post(['_csrf' => 'wrong', 'pin' => '4729', 'pin_confirm' => '4729']), $db)->updatePin(); + + self::assertSame(403, $response->status()); + self::assertFalse($db->wrote('UPDATE user SET pin_hash')); + } +} -- 2.45.3 From 2756fb408051eb5af950ce27396400d6970502ba Mon Sep 17 00:00:00 2001 From: Corentin JOGUET Date: Mon, 15 Jun 2026 22:35:50 +0200 Subject: [PATCH 18/93] feat(admin): CRUD produits avec PIN conditionnel et audit (#17) --- src/app/Auth/PinVerifier.php | 35 ++ src/app/Catalogue/ProductRepository.php | 103 +++++ src/app/Controllers/AdminController.php | 2 +- .../Controllers/AuthenticatedController.php | 15 +- src/app/Controllers/ProductController.php | 417 ++++++++++++++++++ src/app/Views/admin/products/delete.php | 52 +++ src/app/Views/admin/products/form.php | 119 +++++ src/app/Views/admin/products/index.php | 70 +++ src/public/admin/index.php | 11 + tests/Integration/ProductRepositoryDbTest.php | 98 ++++ tests/Support/FakeDatabase.php | 49 ++ tests/Unit/Admin/ProductControllerTest.php | 360 +++++++++++++++ tests/Unit/Auth/PinVerifierTest.php | 30 ++ 13 files changed, 1358 insertions(+), 3 deletions(-) create mode 100644 src/app/Catalogue/ProductRepository.php create mode 100644 src/app/Controllers/ProductController.php create mode 100644 src/app/Views/admin/products/delete.php create mode 100644 src/app/Views/admin/products/form.php create mode 100644 src/app/Views/admin/products/index.php create mode 100644 tests/Integration/ProductRepositoryDbTest.php create mode 100644 tests/Unit/Admin/ProductControllerTest.php diff --git a/src/app/Auth/PinVerifier.php b/src/app/Auth/PinVerifier.php index 85c2b9a..99e1d7c 100644 --- a/src/app/Auth/PinVerifier.php +++ b/src/app/Auth/PinVerifier.php @@ -63,6 +63,41 @@ final class PinVerifier return $this->hasher->verify($pin, $hash); } + /** + * Modele "identifiant equipier + PIN" (RG-T13) : sur un poste a session + * partagee, l'individu qui realise l'action sensible se ré-authentifie par + * email + PIN. Resout l'utilisateur ACTIF par email, verifie le PIN contre son + * pin_hash, et renvoie son identite {id, role_id} (l'acteur ecrit dans + * audit_log) ou null. Email/PIN absent ou inconnu : verify leurre (timing). + * + * @return array{id: int, role_id: int}|null + */ + public function resolveActingUser(string $email, string $pin): ?array + { + if ($pin === '' || $email === '') { + $this->hasher->verifyDecoy($pin); + + return null; + } + + $row = $this->db->fetch( + 'SELECT id, role_id, pin_hash FROM user WHERE email = :email AND is_active = 1 LIMIT 1', + ['email' => $email], + ); + + $hash = is_string($row['pin_hash'] ?? null) ? (string) $row['pin_hash'] : ''; + + if ($hash === '' || !$this->hasher->verify($pin, $hash)) { + if ($hash === '') { + $this->hasher->verifyDecoy($pin); + } + + return null; + } + + return ['id' => (int) ($row['id'] ?? 0), 'role_id' => (int) ($row['role_id'] ?? 0)]; + } + /** * Politique de PIN a verifier cote serveur avant de hacher un nouveau PIN * (P3, definition du PIN) : chiffres ASCII uniquement, bornes min ET max diff --git a/src/app/Catalogue/ProductRepository.php b/src/app/Catalogue/ProductRepository.php new file mode 100644 index 0000000..790bf2c --- /dev/null +++ b/src/app/Catalogue/ProductRepository.php @@ -0,0 +1,103 @@ + + * 422, plutot que de pre-tester chaque reference. + */ +final class ProductRepository +{ + public function __construct(private readonly DatabaseInterface $db) + { + } + + /** + * Liste pour le back-office, avec le libelle de categorie. + * + * @return array> + */ + public function all(): array + { + return $this->db->fetchAll( + 'SELECT p.id, p.category_id, p.name, p.price_cents, p.vat_rate, p.is_available, ' + . 'p.display_order, c.name AS category_name ' + . 'FROM product p JOIN category c ON c.id = p.category_id ' + . 'ORDER BY p.display_order, p.name', + ); + } + + /** + * @return array|null + */ + public function find(int $id): ?array + { + return $this->db->fetch( + 'SELECT id, category_id, name, description, price_cents, vat_rate, image_path, ' + . 'is_available, display_order FROM product WHERE id = :id', + ['id' => $id], + ); + } + + public function categoryExists(int $categoryId): bool + { + return $this->db->fetch('SELECT id FROM category WHERE id = :id', ['id' => $categoryId]) !== null; + } + + /** + * @param array{category_id: int, name: string, description: ?string, price_cents: int, vat_rate: int, image_path: ?string, is_available: int, display_order: int} $data + */ + public function create(array $data): void + { + $this->db->execute( + 'INSERT INTO product (category_id, name, description, price_cents, vat_rate, image_path, is_available, display_order) ' + . 'VALUES (:category, :name, :description, :price, :vat, :image, :available, :ord)', + $this->bind($data), + ); + } + + /** + * @param array{category_id: int, name: string, description: ?string, price_cents: int, vat_rate: int, image_path: ?string, is_available: int, display_order: int} $data + */ + public function update(int $id, array $data): void + { + $this->db->execute( + 'UPDATE product SET category_id = :category, name = :name, description = :description, ' + . 'price_cents = :price, vat_rate = :vat, image_path = :image, is_available = :available, ' + . 'display_order = :ord WHERE id = :id', + $this->bind($data) + ['id' => $id], + ); + } + + public function delete(int $id): int + { + return $this->db->execute('DELETE FROM product WHERE id = :id', ['id' => $id]); + } + + /** + * Allowlist d'affectation de masse (RG-T16) : seules ces colonnes sont liees. + * + * @param array{category_id: int, name: string, description: ?string, price_cents: int, vat_rate: int, image_path: ?string, is_available: int, display_order: int} $data + * @return array + */ + private function bind(array $data): array + { + return [ + 'category' => $data['category_id'], + 'name' => $data['name'], + 'description' => $data['description'], + 'price' => $data['price_cents'], + 'vat' => $data['vat_rate'], + 'image' => $data['image_path'], + 'available' => $data['is_available'], + 'ord' => $data['display_order'], + ]; + } +} diff --git a/src/app/Controllers/AdminController.php b/src/app/Controllers/AdminController.php index 54ca114..3ebf4bc 100644 --- a/src/app/Controllers/AdminController.php +++ b/src/app/Controllers/AdminController.php @@ -75,7 +75,7 @@ abstract class AdminController extends AuthenticatedController protected function userDirectory(): UserDirectory { - return new UserDirectory($this->database); + return new UserDirectory($this->db()); } /** diff --git a/src/app/Controllers/AuthenticatedController.php b/src/app/Controllers/AuthenticatedController.php index 413cff0..0121f00 100644 --- a/src/app/Controllers/AuthenticatedController.php +++ b/src/app/Controllers/AuthenticatedController.php @@ -8,6 +8,7 @@ use App\Auth\Authorizer; use App\Auth\SessionGuard; use App\Auth\SessionManager; use App\Core\Controller; +use App\Core\DatabaseInterface; /** * Base des controleurs proteges : fournit la session, la garde de session @@ -24,13 +25,23 @@ abstract class AuthenticatedController extends Controller return new SessionManager($this->config); } + /** + * Acces aux donnees via l'interface. Centralise le seam pour que toutes les + * dependances DB (garde, autorisation, repositories, transactions, audit) + * passent par un point unique surchargeable en test. + */ + protected function db(): DatabaseInterface + { + return $this->database; + } + protected function sessionGuard(): SessionGuard { - return new SessionGuard($this->sessionManager(), $this->database, $this->config); + return new SessionGuard($this->sessionManager(), $this->db(), $this->config); } protected function authorizer(): Authorizer { - return new Authorizer($this->database); + return new Authorizer($this->db()); } } diff --git a/src/app/Controllers/ProductController.php b/src/app/Controllers/ProductController.php new file mode 100644 index 0000000..72a49a0 --- /dev/null +++ b/src/app/Controllers/ProductController.php @@ -0,0 +1,417 @@ + 422 sinon). + * Le PIN suit le modele "identifiant equipier + PIN" : email + PIN resolus en un + * acting_user_id ecrit dans audit_log, dans la meme transaction que l'effet (RG-T08). + * + * Non `final` : les tests sous-classent pour injecter des doubles. + */ +class ProductController extends AdminController +{ + /** + * @param array $params + */ + public function index(array $params = []): Response + { + $guard = $this->guard('product.read'); + if ($guard instanceof Response) { + return $guard; + } + + return $this->adminView('admin/products/index', [ + 'title' => 'Produits - Wakdo Admin', + 'activeNav' => 'products', + 'products' => $this->productRepository()->all(), + ], $guard); + } + + /** + * @param array $params + */ + public function create(array $params = []): Response + { + $guard = $this->guard('product.create'); + if ($guard instanceof Response) { + return $guard; + } + + return $this->renderForm($guard, 0, [], []); + } + + /** + * @param array $params + */ + public function store(array $params = []): Response + { + $guard = $this->guard('product.create'); + if ($guard instanceof Response) { + return $guard; + } + + $form = $this->request->formBody(); + if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) { + return $this->invalidCsrf(); + } + + [$data, $errors] = $this->validate($form); + if ($errors !== []) { + return $this->renderForm($guard, 0, $form, $errors, 422); + } + + $this->productRepository()->create($data); + $this->setFlash('Produit cree.'); + + return $this->redirect('/admin/products'); + } + + /** + * @param array $params + */ + public function edit(array $params): Response + { + $guard = $this->guard('product.update'); + if ($guard instanceof Response) { + return $guard; + } + + $id = (int) ($params['id'] ?? 0); + $product = $this->productRepository()->find($id); + if ($product === null) { + return $this->notFound($guard); + } + + return $this->renderForm($guard, $id, $product, []); + } + + /** + * @param array $params + */ + public function update(array $params): Response + { + $guard = $this->guard('product.update'); + if ($guard instanceof Response) { + return $guard; + } + + $form = $this->request->formBody(); + if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) { + return $this->invalidCsrf(); + } + + $id = (int) ($params['id'] ?? 0); + $current = $this->productRepository()->find($id); + if ($current === null) { + return $this->notFound($guard); + } + + [$data, $errors] = $this->validate($form); + if ($errors !== []) { + return $this->renderForm($guard, $id, $form, $errors, 422); + } + + // RG-T13/8.2 : seul un changement de prix ou de TVA est une action sensible. + $priceChanged = $data['price_cents'] !== (int) ($current['price_cents'] ?? 0); + $vatChanged = $data['vat_rate'] !== (int) ($current['vat_rate'] ?? 0); + + if (!$priceChanged && !$vatChanged) { + $this->productRepository()->update($id, $data); + $this->setFlash('Produit mis a jour.'); + + return $this->redirect('/admin/products'); + } + + // Changement sensible : exige email + PIN (modele equipier + PIN, RG-T13). + $actor = $this->pinVerifier()->resolveActingUser(trim($form['pin_email'] ?? ''), $form['pin'] ?? ''); + if ($actor === null) { + $this->logFailedPin(trim($form['pin_email'] ?? ''), $id); + + return $this->renderForm($guard, $id, $form, ['pin' => 'Email ou PIN invalide (requis pour modifier prix/TVA).'], 422); + } + + $summary = $this->changeSummary($current, $data, $priceChanged, $vatChanged); + + $this->db()->transaction(function (DatabaseInterface $db) use ($id, $data, $actor, $summary): void { + (new ProductRepository($db))->update($id, $data); + $this->writeAudit($db, 'product.update', $actor['id'], $actor['role_id'], $id, $summary); + }); + + $this->setFlash('Produit mis a jour (changement de prix/TVA trace).'); + + return $this->redirect('/admin/products'); + } + + /** + * @param array $params + */ + public function confirmDelete(array $params): Response + { + $guard = $this->guard('product.delete'); + if ($guard instanceof Response) { + return $guard; + } + + $id = (int) ($params['id'] ?? 0); + $product = $this->productRepository()->find($id); + if ($product === null) { + return $this->notFound($guard); + } + + return $this->renderDelete($guard, $id, $product, null); + } + + /** + * @param array $params + */ + public function destroy(array $params): Response + { + $guard = $this->guard('product.delete'); + if ($guard instanceof Response) { + return $guard; + } + + $form = $this->request->formBody(); + if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) { + return $this->invalidCsrf(); + } + + $id = (int) ($params['id'] ?? 0); + $product = $this->productRepository()->find($id); + if ($product === null) { + return $this->notFound($guard); + } + + $actor = $this->pinVerifier()->resolveActingUser(trim($form['pin_email'] ?? ''), $form['pin'] ?? ''); + if ($actor === null) { + $this->logFailedPin(trim($form['pin_email'] ?? ''), $id); + + return $this->renderDelete($guard, $id, $product, 'Email ou PIN invalide (requis pour supprimer).'); + } + + $name = (string) ($product['name'] ?? ''); + + try { + $this->db()->transaction(function (DatabaseInterface $db) use ($id, $actor, $name): void { + $deleted = (new ProductRepository($db))->delete($id); + if ($deleted === 1) { + $this->writeAudit($db, 'product.delete', $actor['id'], $actor['role_id'], $id, 'Suppression produit: ' . $name); + } + }); + } catch (PDOException $exception) { + if ((string) $exception->getCode() === '23000') { + return $this->renderDelete($guard, $id, $product, 'Produit reference par des commandes ou menus : suppression impossible. Masquez-le plutot.'); + } + + throw $exception; + } + + $this->setFlash('Produit supprime.'); + + return $this->redirect('/admin/products'); + } + + protected function productRepository(): ProductRepository + { + return new ProductRepository($this->db()); + } + + protected function categoryRepository(): CategoryRepository + { + return new CategoryRepository($this->db()); + } + + protected function pinVerifier(): PinVerifier + { + return new PinVerifier($this->db(), $this->config, $this->passwordHasher()); + } + + protected function passwordHasher(): PasswordHasher + { + return new PasswordHasher($this->config); + } + + /** + * Validation serveur (RG-T18) + allowlist (RG-T16). Renvoie [donnees, erreurs]. + * + * @param array $form + * @return array{0: array{category_id: int, name: string, description: ?string, price_cents: int, vat_rate: int, image_path: ?string, is_available: int, display_order: int}, 1: array} + */ + private function validate(array $form): array + { + $errors = []; + + $categoryRaw = trim($form['category_id'] ?? ''); + $categoryId = ctype_digit($categoryRaw) ? (int) $categoryRaw : 0; + if ($categoryId === 0 || !$this->productRepository()->categoryExists($categoryId)) { + $errors['category_id'] = 'Categorie requise et valide.'; + } + + $name = trim($form['name'] ?? ''); + if ($name === '' || mb_strlen($name) > 120) { + $errors['name'] = 'Le nom est requis (120 caracteres max).'; + } + + $priceRaw = trim($form['price_cents'] ?? ''); + $priceValid = ctype_digit($priceRaw) && (int) $priceRaw > 0 && (int) $priceRaw <= 4294967295; + if (!$priceValid) { + $errors['price_cents'] = 'Le prix (en centimes) doit etre un entier strictement positif.'; + } + + $vat = ctype_digit(trim($form['vat_rate'] ?? '')) ? (int) trim($form['vat_rate'] ?? '') : 0; + if ($vat !== 55 && $vat !== 100) { + $errors['vat_rate'] = 'La TVA doit valoir 55 (5,5%) ou 100 (10%).'; + } + + $image = trim($form['image_path'] ?? ''); + if ($image !== '' && mb_strlen($image) > 255) { + $errors['image_path'] = 'Chemin image trop long (255 max).'; + } + + $orderRaw = trim($form['display_order'] ?? '0'); + if (!ctype_digit($orderRaw) || (int) $orderRaw > 65535) { + $errors['display_order'] = 'L ordre d affichage doit etre un entier entre 0 et 65535.'; + } + + $description = trim($form['description'] ?? ''); + + $data = [ + 'category_id' => $categoryId, + 'name' => $name, + 'description' => $description !== '' ? $description : null, + 'price_cents' => $priceValid ? (int) $priceRaw : 0, + 'vat_rate' => ($vat === 55 || $vat === 100) ? $vat : 100, + 'image_path' => $image !== '' ? $image : null, + 'is_available' => ($form['is_available'] ?? '') !== '' ? 1 : 0, + 'display_order' => (ctype_digit($orderRaw) && (int) $orderRaw <= 65535) ? (int) $orderRaw : 0, + ]; + + return [$data, $errors]; + } + + /** + * @param array $current + * @param array{price_cents: int, vat_rate: int} $data + */ + private function changeSummary(array $current, array $data, bool $priceChanged, bool $vatChanged): string + { + $parts = []; + if ($priceChanged) { + $parts[] = sprintf('price_cents %d -> %d', (int) ($current['price_cents'] ?? 0), $data['price_cents']); + } + if ($vatChanged) { + $parts[] = sprintf('vat_rate %d -> %d', (int) ($current['vat_rate'] ?? 0), $data['vat_rate']); + } + + return implode(', ', $parts); + } + + /** + * Trace une tentative de PIN echouee sur une action sensible (RG-T14) : rend + * le brute-force d'attribution detectable/alertable (un pic de pin.failed pour + * un email cible est visible en revue). Acteur inconnu (PIN non resolu). + * + * NB : ce n'est PAS un verrou. Un throttling degressif du PIN (par compte/IP) + * reste a ajouter en hardening dedie (decision de schema, cf. SESSION_RESUME). + */ + private function logFailedPin(string $email, int $productId): void + { + $this->db()->execute( + 'INSERT INTO audit_log (actor_user_id, actor_role_id, action_code, entity_type, entity_id, summary) ' + . 'VALUES (:uid, :rid, :code, :etype, :eid, :summary)', + [ + 'uid' => null, + 'rid' => null, + 'code' => 'pin.failed', + 'etype' => 'product', + 'eid' => $productId, + 'summary' => 'Echec PIN action sensible (email tente: ' . $email . ')', + ], + ); + } + + private function writeAudit(DatabaseInterface $db, string $action, int $userId, int $roleId, int $entityId, string $summary): void + { + $db->execute( + 'INSERT INTO audit_log (actor_user_id, actor_role_id, action_code, entity_type, entity_id, summary) ' + . 'VALUES (:uid, :rid, :code, :etype, :eid, :summary)', + ['uid' => $userId, 'rid' => $roleId, 'code' => $action, 'etype' => 'product', 'eid' => $entityId, 'summary' => $summary], + ); + } + + /** + * @param array $values + * @param array $errors + */ + private function renderForm(GuardResult $guard, int $id, array $values, array $errors, int $status = 200): Response + { + return $this->adminView('admin/products/form', [ + 'title' => ($id !== 0 ? 'Modifier' : 'Nouveau') . ' produit - Wakdo Admin', + 'activeNav' => 'products', + 'productId' => $id, + 'categories' => $this->categoryRepository()->all(), + 'values' => [ + 'category_id' => (string) ($values['category_id'] ?? ''), + 'name' => (string) ($values['name'] ?? ''), + 'description' => (string) ($values['description'] ?? ''), + 'price_cents' => (string) ($values['price_cents'] ?? ''), + 'vat_rate' => (string) ($values['vat_rate'] ?? '100'), + 'image_path' => (string) ($values['image_path'] ?? ''), + // Defaut coche a la creation (errors vide + values vide) ; sur un + // re-rendu POST (erreurs), refleter la presence reelle du champ + // (case decochee = absente = non cochee), pas le defaut a 1. + 'is_available' => $errors === [] ? ((int) ($values['is_available'] ?? 1) === 1) : array_key_exists('is_available', $values), + 'display_order' => (string) ($values['display_order'] ?? '0'), + ], + 'errors' => $errors, + ], $guard, $status); + } + + /** + * @param array $product + */ + private function renderDelete(GuardResult $guard, int $id, array $product, ?string $error): Response + { + return $this->adminView('admin/products/delete', [ + 'title' => 'Supprimer un produit - Wakdo Admin', + 'activeNav' => 'products', + 'productId' => $id, + 'name' => (string) ($product['name'] ?? ''), + 'error' => $error, + ], $guard, $error !== null ? 422 : 200); + } + + private function notFound(GuardResult $guard): Response + { + return $this->adminView('admin/not_found', ['title' => 'Introuvable', 'activeNav' => 'products'], $guard, 404); + } + + private function redirect(string $location): Response + { + return Response::make('', 302, ['Location' => $location]); + } + + private function invalidCsrf(): Response + { + return Response::make('Requete invalide.', 403, ['Content-Type' => 'text/plain; charset=utf-8']); + } +} diff --git a/src/app/Views/admin/products/delete.php b/src/app/Views/admin/products/delete.php new file mode 100644 index 0000000..d844179 --- /dev/null +++ b/src/app/Views/admin/products/delete.php @@ -0,0 +1,52 @@ + + + +
+ +

+ + +
+ + +

La suppression est tracee (audit). Renseignez votre email et votre PIN.

+ +
+ + +
+ +
+ + +
+ +
+ + Annuler +
+
+
diff --git a/src/app/Views/admin/products/form.php b/src/app/Views/admin/products/form.php new file mode 100644 index 0000000..2df115c --- /dev/null +++ b/src/app/Views/admin/products/form.php @@ -0,0 +1,119 @@ +> $categories + * @var array $values + * @var array $errors + * @var string $csrfToken + */ + +$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8'); +$id = (int) ($productId ?? 0); +$action = $id !== 0 ? '/admin/products/' . $id : '/admin/products'; + +/** @var array $vals */ +$vals = isset($values) && is_array($values) ? $values : []; +/** @var array $errs */ +$errs = isset($errors) && is_array($errors) ? $errors : []; +/** @var array> $cats */ +$cats = isset($categories) && is_array($categories) ? $categories : []; + +$val = static fn (string $k): string => htmlspecialchars((string) ($vals[$k] ?? ''), ENT_QUOTES, 'UTF-8'); +$err = static fn (string $k): string => isset($errs[$k]) && is_string($errs[$k]) ? $errs[$k] : ''; +$selectedCat = (string) ($vals['category_id'] ?? ''); +$selectedVat = (string) ($vals['vat_rate'] ?? '100'); +$available = (bool) ($vals['is_available'] ?? true); +?> + + +
+ + +
+ + +

+
+ +
+ + +

+
+ +
+ + +
+ +
+ + +

+
+ +
+ + +

+
+ +
+ + +

+
+ +
+ + +

+
+ +
+ +
+ + +
+ Changement de prix ou de TVA : confirmation par PIN +

Renseignez votre email et votre PIN uniquement si vous modifiez le prix ou la TVA (action tracee).

+
+ + +
+
+ + +
+

+
+ + +
+ + Annuler +
+
diff --git a/src/app/Views/admin/products/index.php b/src/app/Views/admin/products/index.php new file mode 100644 index 0000000..cb57b85 --- /dev/null +++ b/src/app/Views/admin/products/index.php @@ -0,0 +1,70 @@ +> $products + */ + +/** @var array> $rows */ +$rows = isset($products) && is_array($products) ? $products : []; +$esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8'); +$euros = static fn (int $cents): string => number_format($cents / 100, 2, ',', ' ') . ' EUR'; +?> + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
NomCategoriePrixTVAStatut
Aucun produit.
+ + Disponible + + Indisponible + + + Modifier + Supprimer +
+
+
diff --git a/src/public/admin/index.php b/src/public/admin/index.php index 7352aad..7259dac 100644 --- a/src/public/admin/index.php +++ b/src/public/admin/index.php @@ -18,6 +18,7 @@ use App\Controllers\HealthController; use App\Controllers\HomeController; use App\Controllers\MeController; use App\Controllers\PasswordResetController; +use App\Controllers\ProductController; use App\Controllers\ProfileController; use App\Core\Autoloader; use App\Core\Config; @@ -79,6 +80,16 @@ try { $router->add('GET', '/admin/profile/pin', [ProfileController::class, 'showPin']); $router->add('POST', '/admin/profile/pin', [ProfileController::class, 'updatePin']); + // CRUD Produits (product.read/create/update/delete). PIN equipier + audit sur + // changement prix/TVA (update) et suppression (delete). + $router->add('GET', '/admin/products', [ProductController::class, 'index']); + $router->add('GET', '/admin/products/new', [ProductController::class, 'create']); + $router->add('POST', '/admin/products', [ProductController::class, 'store']); + $router->add('GET', '/admin/products/{id}/edit', [ProductController::class, 'edit']); + $router->add('POST', '/admin/products/{id}', [ProductController::class, 'update']); + $router->add('GET', '/admin/products/{id}/delete', [ProductController::class, 'confirmDelete']); + $router->add('POST', '/admin/products/{id}/delete', [ProductController::class, 'destroy']); + $response = $router->dispatch(Request::fromGlobals()); $response->send(); } catch (Throwable $exception) { diff --git a/tests/Integration/ProductRepositoryDbTest.php b/tests/Integration/ProductRepositoryDbTest.php new file mode 100644 index 0000000..18474e0 --- /dev/null +++ b/tests/Integration/ProductRepositoryDbTest.php @@ -0,0 +1,98 @@ +db = new Database(new Config()); + + try { + $this->db->fetch('SELECT 1'); + } catch (Throwable $exception) { + self::markTestSkipped('Base injoignable: ' . $exception->getMessage()); + } + + $this->categoryId = (int) ($this->db->fetch('SELECT id FROM category ORDER BY id LIMIT 1')['id'] ?? 0); + $this->name = 'it-prod-' . bin2hex(random_bytes(4)); + } + + protected function tearDown(): void + { + if ($this->name !== '') { + $this->db->execute('DELETE FROM product WHERE name = :name', ['name' => $this->name]); + } + } + + public function testCreateFindUpdateDelete(): void + { + $repo = new ProductRepository($this->db); + + self::assertGreaterThan(0, $this->categoryId); + self::assertTrue($repo->categoryExists($this->categoryId)); + self::assertFalse($repo->categoryExists(0)); + + $repo->create([ + 'category_id' => $this->categoryId, + 'name' => $this->name, + 'description' => null, + 'price_cents' => 999, + 'vat_rate' => 100, + 'image_path' => null, + 'is_available' => 1, + 'display_order' => 99, + ]); + + $id = (int) ($this->db->fetch('SELECT id FROM product WHERE name = :name', ['name' => $this->name])['id'] ?? 0); + self::assertGreaterThan(0, $id); + + $found = $repo->find($id); + self::assertNotNull($found); + self::assertSame(999, (int) ($found['price_cents'] ?? 0)); + + $repo->update($id, [ + 'category_id' => $this->categoryId, + 'name' => $this->name, + 'description' => 'maj', + 'price_cents' => 1099, + 'vat_rate' => 55, + 'image_path' => null, + 'is_available' => 0, + 'display_order' => 100, + ]); + $updated = $repo->find($id); + self::assertNotNull($updated); + self::assertSame(1099, (int) ($updated['price_cents'] ?? 0)); + self::assertSame(55, (int) ($updated['vat_rate'] ?? 0)); + + // all() porte le libelle de categorie joint. + $names = array_map(static fn (array $r): string => (string) ($r['name'] ?? ''), $repo->all()); + self::assertContains($this->name, $names); + + // Produit non reference : suppression dure OK. + self::assertSame(1, $repo->delete($id)); + self::assertNull($repo->find($id)); + } +} diff --git a/tests/Support/FakeDatabase.php b/tests/Support/FakeDatabase.php index e5bb09a..4db6b5e 100644 --- a/tests/Support/FakeDatabase.php +++ b/tests/Support/FakeDatabase.php @@ -120,6 +120,28 @@ final class FakeDatabase implements DatabaseInterface /** Resultat de UserRepository::pinIsSet() (true = un PIN est defini). */ public bool $userPinSet = false; + /** + * Lignes renvoyees par ProductRepository::all(). + * + * @var list> + */ + public array $productsRows = []; + + /** + * Ligne renvoyee par ProductRepository::find() ; null = introuvable. + * + * @var array|null + */ + public ?array $productRow = null; + + /** + * Ligne renvoyee pour PinVerifier::resolveActingUser (id, role_id, pin_hash) ; + * null = email inconnu/inactif. + * + * @var array|null + */ + public ?array $actingUserRow = null; + /** Si non nul, execute() leve cette exception (simulation panne DB / violation de contrainte). */ public ?Throwable $failOnExecute = null; @@ -132,6 +154,15 @@ final class FakeDatabase implements DatabaseInterface /** @var list */ public array $transactionEvents = []; + /** + * Journal ordonne entrelacant ecritures et bornes de transaction, pour + * verifier qu'une ecriture (ex. audit_log) tombe bien ENTRE begin et commit + * (atomicite RG-T08), ce que deux listes disjointes ne prouvent pas. + * + * @var list + */ + public array $eventLog = []; + public function fetch(string $sql, array $params = []): ?array { $this->reads[] = ['sql' => $sql, 'params' => $params]; @@ -176,6 +207,16 @@ final class FakeDatabase implements DatabaseInterface return $this->userPinSet ? ['id' => 1] : null; } + // Exige is_active = 1 (garde RG-T13) : retirer le predicat en production + // ferait virer au rouge les tests de resolveActingUser. + if (str_contains($sql, 'pin_hash FROM user WHERE email') && str_contains($sql, 'is_active = 1')) { + return $this->actingUserRow; + } + + if (str_contains($sql, 'FROM product WHERE id = :id')) { + return $this->productRow; + } + if (str_contains($sql, 'FROM category WHERE id = :id')) { return $this->categoryRow; } @@ -207,6 +248,10 @@ final class FakeDatabase implements DatabaseInterface return $this->categoriesRows; } + if (str_contains($sql, 'FROM product p JOIN category')) { + return $this->productsRows; + } + if (str_contains($sql, 'SELECT p.code FROM role_permission')) { if (!$this->roleActive) { return []; @@ -225,6 +270,7 @@ final class FakeDatabase implements DatabaseInterface } $this->writes[] = ['sql' => $sql, 'params' => $params]; + $this->eventLog[] = 'write:' . substr($sql, 0, 24); return $this->executeRowCount; } @@ -232,12 +278,15 @@ final class FakeDatabase implements DatabaseInterface public function transaction(callable $fn): void { $this->transactionEvents[] = 'begin'; + $this->eventLog[] = 'begin'; try { $fn($this); $this->transactionEvents[] = 'commit'; + $this->eventLog[] = 'commit'; } catch (\Throwable $exception) { $this->transactionEvents[] = 'rollback'; + $this->eventLog[] = 'rollback'; throw $exception; } diff --git a/tests/Unit/Admin/ProductControllerTest.php b/tests/Unit/Admin/ProductControllerTest.php new file mode 100644 index 0000000..9d98fc6 --- /dev/null +++ b/tests/Unit/Admin/ProductControllerTest.php @@ -0,0 +1,360 @@ +testSession; + } + + protected function db(): DatabaseInterface + { + return $this->fakeDb; + } +} + +final class ProductControllerTest extends TestCase +{ + /** @var list */ + private array $touchedKeys = []; + + private SessionManager $session; + private string $csrf = ''; + + protected function setUp(): void + { + $this->setEnv('SESSION_LIFETIME_IDLE', '14400'); + $this->setEnv('SESSION_LIFETIME_ABSOLUTE', '36000'); + $this->setEnv('STAFF_PIN_MIN_LENGTH', '4'); + $this->setEnv('STAFF_PIN_MAX_LENGTH', '12'); + $this->setEnv('ARGON2_MEMORY_COST', '1024'); + $this->setEnv('ARGON2_TIME_COST', '1'); + $this->setEnv('ARGON2_THREADS', '1'); + + $this->session = new SessionManager(new Config(), true); + $now = time(); + $this->session->set('user_id', 1); + $this->session->set('role_id', 1); + $this->session->set('logged_in_at', $now - 100); + $this->session->set('last_activity', $now - 50); + $this->csrf = Csrf::token($this->session); + } + + protected function tearDown(): void + { + foreach ($this->touchedKeys as $key) { + putenv($key); + } + $this->touchedKeys = []; + } + + private function setEnv(string $key, string $value): void + { + $this->touchedKeys[] = $key; + putenv($key . '=' . $value); + } + + private function permittedDb(): FakeDatabase + { + $db = new FakeDatabase(); + $db->guardUserRow = ['is_active' => 1]; + $db->userDisplayRow = ['first_name' => 'Corentin', 'last_name' => 'J', 'role_label' => 'Administrateur']; + $db->canResult = true; + $db->permissionCodes = ['product.read', 'product.create', 'product.update', 'product.delete']; + $db->categoryRow = ['id' => 3, 'name' => 'Burgers']; // categoryExists -> true + return $db; + } + + private function get(string $path): Request + { + return new Request('GET', $path, [], [], '', '203.0.113.5'); + } + + /** + * @param array $form + */ + private function post(array $form, string $path): Request + { + return new Request('POST', $path, [], ['content-type' => 'application/x-www-form-urlencoded'], http_build_query($form), '203.0.113.5'); + } + + private function controller(Request $request, FakeDatabase $db): TestProductController + { + return new TestProductController($request, new Config(), new Database(new Config()), $this->session, $db); + } + + /** + * @param array $overrides + * @return array + */ + private function validForm(array $overrides = []): array + { + return array_merge([ + '_csrf' => $this->csrf, + 'category_id' => '3', + 'name' => 'Big Mac', + 'price_cents' => '590', + 'vat_rate' => '100', + 'display_order' => '1', + 'is_available' => '1', + ], $overrides); + } + + private function actingPin(FakeDatabase $db): void + { + // Equipier dont le PIN '4729' est valide (modele identifiant + PIN). + $db->actingUserRow = ['id' => 9, 'role_id' => 4, 'pin_hash' => (new \App\Auth\PasswordHasher(new Config()))->hash('4729')]; + } + + public function testIndexRequiresProductRead(): void + { + $db = $this->permittedDb(); + $db->canResult = false; + + self::assertSame(403, $this->controller($this->get('/admin/products'), $db)->index()->status()); + } + + public function testIndexListsProducts(): void + { + $db = $this->permittedDb(); + $db->productsRows = [ + ['id' => 1, 'category_id' => 3, 'name' => 'Big Mac', 'price_cents' => 590, 'vat_rate' => 100, 'is_available' => 1, 'category_name' => 'Burgers'], + ]; + + $response = $this->controller($this->get('/admin/products'), $db)->index(); + self::assertSame(200, $response->status()); + self::assertStringContainsString('Big Mac', $response->body()); + self::assertStringContainsString('Nouveau produit', $response->body()); + } + + public function testStoreCreatesWithoutPin(): void + { + $db = $this->permittedDb(); + $response = $this->controller($this->post($this->validForm(), '/admin/products'), $db)->store(); + + self::assertSame(302, $response->status()); + self::assertTrue($db->wrote('INSERT INTO product')); + self::assertFalse($db->wrote('INSERT INTO audit_log')); // create = pas d'action sensible + self::assertSame('Produit cree.', $this->session->get('_flash')); + } + + public function testStoreValidationErrorNoWrite(): void + { + $db = $this->permittedDb(); + $response = $this->controller($this->post($this->validForm(['name' => '', 'price_cents' => '0']), '/admin/products'), $db)->store(); + + self::assertSame(422, $response->status()); + self::assertFalse($db->wrote('INSERT INTO product')); + } + + public function testUpdateWithoutPriceChangeNeedsNoPin(): void + { + $db = $this->permittedDb(); + $db->productRow = ['id' => 5, 'category_id' => 3, 'name' => 'Old', 'description' => null, 'price_cents' => 590, 'vat_rate' => 100, 'image_path' => null, 'is_available' => 1, 'display_order' => 1]; + + // Nom change, prix/TVA inchanges -> pas de PIN, pas d'audit. + $response = $this->controller($this->post($this->validForm(['name' => 'Renamed']), '/admin/products/5'), $db)->update(['id' => '5']); + + self::assertSame(302, $response->status()); + self::assertTrue($db->wrote('UPDATE product SET')); + self::assertFalse($db->wrote('INSERT INTO audit_log')); + self::assertSame([], $db->transactionEvents); + } + + public function testUpdatePriceChangeRequiresPin(): void + { + $db = $this->permittedDb(); + $db->productRow = ['id' => 5, 'category_id' => 3, 'name' => 'Big Mac', 'description' => null, 'price_cents' => 590, 'vat_rate' => 100, 'image_path' => null, 'is_available' => 1, 'display_order' => 1]; + + // Prix change sans email/PIN -> 422, pas de mise a jour. + $response = $this->controller($this->post($this->validForm(['price_cents' => '620']), '/admin/products/5'), $db)->update(['id' => '5']); + + self::assertSame(422, $response->status()); + self::assertStringContainsString('PIN', $response->body()); + self::assertFalse($db->wrote('UPDATE product SET')); + // PIN echoue trace (detectabilite du brute-force, RG-T14). + self::assertSame(['pin.failed'], $db->auditActions()); + } + + public function testUpdateVatChangeRequiresPin(): void + { + $db = $this->permittedDb(); + $db->productRow = ['id' => 5, 'category_id' => 3, 'name' => 'Big Mac', 'description' => null, 'price_cents' => 590, 'vat_rate' => 100, 'image_path' => null, 'is_available' => 1, 'display_order' => 1]; + + // Prix inchange (590), TVA 100 -> 55 : sensible -> PIN requis. + $response = $this->controller($this->post($this->validForm(['vat_rate' => '55']), '/admin/products/5'), $db)->update(['id' => '5']); + + self::assertSame(422, $response->status()); + self::assertFalse($db->wrote('UPDATE product SET')); + } + + public function testUpdateVatChangeWithValidPinAudits(): void + { + $db = $this->permittedDb(); + $db->productRow = ['id' => 5, 'category_id' => 3, 'name' => 'Big Mac', 'description' => null, 'price_cents' => 590, 'vat_rate' => 100, 'image_path' => null, 'is_available' => 1, 'display_order' => 1]; + $this->actingPin($db); + + $form = $this->validForm(['vat_rate' => '55', 'pin_email' => 'staff@wakdo.local', 'pin' => '4729']); + $response = $this->controller($this->post($form, '/admin/products/5'), $db)->update(['id' => '5']); + + self::assertSame(302, $response->status()); + self::assertSame(['begin', 'commit'], $db->transactionEvents); + $audit = $this->firstAudit($db); + self::assertNotNull($audit); + self::assertSame('product.update', $audit['params']['code'] ?? null); + self::assertStringContainsString('vat_rate 100 -> 55', (string) ($audit['params']['summary'] ?? '')); + } + + public function testUpdatePriceChangeWithValidPinAuditsInTransaction(): void + { + $db = $this->permittedDb(); + $db->productRow = ['id' => 5, 'category_id' => 3, 'name' => 'Big Mac', 'description' => null, 'price_cents' => 590, 'vat_rate' => 100, 'image_path' => null, 'is_available' => 1, 'display_order' => 1]; + $this->actingPin($db); + + $form = $this->validForm(['price_cents' => '620', 'pin_email' => 'staff@wakdo.local', 'pin' => '4729']); + $response = $this->controller($this->post($form, '/admin/products/5'), $db)->update(['id' => '5']); + + self::assertSame(302, $response->status()); + self::assertSame(['begin', 'commit'], $db->transactionEvents); + self::assertTrue($db->wrote('UPDATE product SET')); + // Acteur = utilisateur RESOLU PAR PIN (id 9, role 4), pas la session (id 1). + $audit = $this->firstAudit($db); + self::assertNotNull($audit); + self::assertSame('product.update', $audit['params']['code'] ?? null); + self::assertSame(9, $audit['params']['uid'] ?? null); + self::assertSame(4, $audit['params']['rid'] ?? null); + // Audit ecrit DANS la transaction (RG-T08), entre begin et commit. + $this->assertAuditWithinTransaction($db); + } + + public function testEditNotFoundReturns404(): void + { + $db = $this->permittedDb(); + $db->productRow = null; + + self::assertSame(404, $this->controller($this->get('/admin/products/999/edit'), $db)->edit(['id' => '999'])->status()); + } + + public function testConfirmDeleteShowsPinForm(): void + { + $db = $this->permittedDb(); + $db->productRow = ['id' => 5, 'name' => 'Big Mac']; + + $response = $this->controller($this->get('/admin/products/5/delete'), $db)->confirmDelete(['id' => '5']); + self::assertSame(200, $response->status()); + self::assertStringContainsString('name="pin"', $response->body()); + } + + public function testDestroyRequiresValidPin(): void + { + $db = $this->permittedDb(); + $db->productRow = ['id' => 5, 'name' => 'Big Mac']; + $db->actingUserRow = null; // email/PIN invalide + + $response = $this->controller($this->post(['_csrf' => $this->csrf, 'pin_email' => 'x@y.z', 'pin' => '0000'], '/admin/products/5/delete'), $db)->destroy(['id' => '5']); + + self::assertSame(422, $response->status()); + self::assertFalse($db->wrote('DELETE FROM product')); + self::assertSame(['pin.failed'], $db->auditActions()); + } + + public function testDestroyWithValidPinDeletesAndAudits(): void + { + $db = $this->permittedDb(); + $db->productRow = ['id' => 5, 'name' => 'Big Mac']; + $this->actingPin($db); + + $response = $this->controller($this->post(['_csrf' => $this->csrf, 'pin_email' => 'staff@wakdo.local', 'pin' => '4729'], '/admin/products/5/delete'), $db)->destroy(['id' => '5']); + + self::assertSame(302, $response->status()); + self::assertTrue($db->wrote('DELETE FROM product')); + $audit = $this->firstAudit($db); + self::assertNotNull($audit); + self::assertSame('product.delete', $audit['params']['code'] ?? null); + self::assertSame(9, $audit['params']['uid'] ?? null); // acteur = PIN, pas la session (1) + self::assertSame(4, $audit['params']['rid'] ?? null); + $this->assertAuditWithinTransaction($db); + } + + public function testDestroyReferencedReturns422(): void + { + $db = $this->permittedDb(); + $db->productRow = ['id' => 5, 'name' => 'Big Mac']; + $this->actingPin($db); + $db->failOnExecute = new \PDOException('fk', 23000); // FK RESTRICT a la suppression + + $response = $this->controller($this->post(['_csrf' => $this->csrf, 'pin_email' => 'staff@wakdo.local', 'pin' => '4729'], '/admin/products/5/delete'), $db)->destroy(['id' => '5']); + + self::assertSame(422, $response->status()); + self::assertStringContainsString('reference', $response->body()); + } + + public function testStoreRejectsInvalidCsrf(): void + { + $db = $this->permittedDb(); + $response = $this->controller($this->post($this->validForm(['_csrf' => 'wrong']), '/admin/products'), $db)->store(); + + self::assertSame(403, $response->status()); + self::assertFalse($db->wrote('INSERT INTO product')); + } + + /** + * @return array{sql: string, params: array}|null + */ + private function firstAudit(FakeDatabase $db): ?array + { + foreach ($db->writes as $write) { + if (str_contains($write['sql'], 'INSERT INTO audit_log')) { + return $write; + } + } + + return null; + } + + private function assertAuditWithinTransaction(FakeDatabase $db): void + { + $log = $db->eventLog; + $begin = array_search('begin', $log, true); + $commit = array_search('commit', $log, true); + $auditAt = null; + foreach ($log as $i => $event) { + if (str_contains($event, 'INSERT INTO audit_log')) { + $auditAt = $i; + } + } + + self::assertIsInt($begin); + self::assertIsInt($commit); + self::assertNotNull($auditAt); + self::assertTrue($begin < $auditAt && $auditAt < $commit, 'audit_log doit etre ecrit entre begin et commit'); + } +} diff --git a/tests/Unit/Auth/PinVerifierTest.php b/tests/Unit/Auth/PinVerifierTest.php index 969fd76..0080db5 100644 --- a/tests/Unit/Auth/PinVerifierTest.php +++ b/tests/Unit/Auth/PinVerifierTest.php @@ -93,6 +93,36 @@ final class PinVerifierTest extends TestCase self::assertFalse($this->verifier()->verify(7, '')); } + public function testResolveActingUserReturnsIdentityWhenPinMatches(): void + { + $this->db->actingUserRow = ['id' => 7, 'role_id' => 4, 'pin_hash' => $this->hasher->hash('4729')]; + + self::assertSame(['id' => 7, 'role_id' => 4], $this->verifier()->resolveActingUser('staff@wakdo.local', '4729')); + // Garde RG-T13 : la resolution filtre is_active = 1 (retirer le predicat + // ferait echouer ce cas, comme pour verify()). + self::assertStringContainsString('is_active = 1', $this->db->reads[0]['sql']); + } + + public function testResolveActingUserNullWhenPinWrong(): void + { + $this->db->actingUserRow = ['id' => 7, 'role_id' => 4, 'pin_hash' => $this->hasher->hash('4729')]; + + self::assertNull($this->verifier()->resolveActingUser('staff@wakdo.local', '0000')); + } + + public function testResolveActingUserNullWhenEmailUnknown(): void + { + $this->db->actingUserRow = null; + + self::assertNull($this->verifier()->resolveActingUser('ghost@wakdo.local', '4729')); + } + + public function testResolveActingUserNullWhenInputEmpty(): void + { + self::assertNull($this->verifier()->resolveActingUser('', '4729')); + self::assertNull($this->verifier()->resolveActingUser('staff@wakdo.local', '')); + } + public function testMeetsLengthPolicy(): void { $verifier = $this->verifier(); -- 2.45.3 From ad5203d3fcd0ec2aa241a51eef0dbe9b615ce078 Mon Sep 17 00:00:00 2001 From: Corentin JOGUET Date: Tue, 16 Jun 2026 00:06:33 +0200 Subject: [PATCH 19/93] feat(admin): throttle du PIN d action sensible par acteur (RG-T22) (#18) --- .env.example | 9 + db/migrations/0002_pin_throttle.sql | 39 ++++ docker-compose.yml | 5 + .../2026-06-15--p3-throttle-pin-rg-t22.md | 211 ++++++++++++++++++ docs/journal/README.md | 1 + docs/merise/dictionary.md | 38 +++- docs/merise/mcd.md | 43 +++- docs/merise/mct.md | 9 +- docs/merise/mld.md | 58 ++++- docs/merise/mlt.md | 7 +- src/app/Auth/PinThrottle.php | 142 ++++++++++++ src/app/Auth/PinVerifier.php | 12 + src/app/Auth/ThrottlePolicy.php | 26 ++- src/app/Controllers/ProductController.php | 47 +++- tests/Integration/PinThrottleDbTest.php | 123 ++++++++++ tests/Support/FakeDatabase.php | 17 ++ tests/Unit/Admin/ProductControllerTest.php | 82 ++++++- tests/Unit/Auth/PinThrottleTest.php | 169 ++++++++++++++ tests/Unit/Auth/PinVerifierTest.php | 11 + tests/Unit/Auth/ThrottlePolicyTest.php | 24 ++ 20 files changed, 1041 insertions(+), 32 deletions(-) create mode 100644 db/migrations/0002_pin_throttle.sql create mode 100644 docs/journal/2026-06-15--p3-throttle-pin-rg-t22.md create mode 100644 src/app/Auth/PinThrottle.php create mode 100644 tests/Integration/PinThrottleDbTest.php create mode 100644 tests/Unit/Auth/PinThrottleTest.php diff --git a/.env.example b/.env.example index 27853b2..f42aa6c 100644 --- a/.env.example +++ b/.env.example @@ -98,6 +98,15 @@ IP_THROTTLE_MAX_ATTEMPTS=20 # par IP sur la fenetre STAFF_PIN_MIN_LENGTH=4 STAFF_PIN_MAX_LENGTH=12 +# Throttle du PIN d'action sensible (RG-T22) - compteurs SEPARES du login : la +# dimension est l'utilisateur AGISSANT (session), pas l'email cible ni l'IP. Bornes +# volontairement plus permissives que le login (controle de dissuasion) : ne pas +# bloquer un manager en plein rush sur quelques fautes de frappe. +PIN_THROTTLE_THRESHOLD=5 # echecs avant le backoff (par acteur) +PIN_THROTTLE_BASE_SECONDS=30 # 1er palier (vs 60 au login) +PIN_THROTTLE_MAX_SECONDS=300 # plafond du backoff (5 min, vs 900 au login) +PIN_THROTTLE_WINDOW_SECONDS=900 # fenetre glissante (15 min) + # Expiration du token de reinitialisation de mot de passe (secondes). PASSWORD_RESET_TTL=3600 # 1h diff --git a/db/migrations/0002_pin_throttle.sql b/db/migrations/0002_pin_throttle.sql new file mode 100644 index 0000000..3746fc2 --- /dev/null +++ b/db/migrations/0002_pin_throttle.sql @@ -0,0 +1,39 @@ +-- db/migrations/0002_pin_throttle.sql +-- ============================================================================= +-- Wakdo - Migration 0002 : pin_throttle (entite 22, RG-T22) +-- ============================================================================= +-- Purpose : Throttle des tentatives de PIN d'action sensible, par UTILISATEUR +-- AGISSANT (identite de session authentifiee, GuardResult->userId). +-- STRICTEMENT SEPARE des compteurs de connexion +-- (user.failed_login_attempts / user.lockout_until / login_throttle) +-- pour qu'un echec de PIN ne verrouille jamais la CONNEXION d'un +-- compte (pas d'escalade DoS sur la surface plus sensible). Sibling de +-- login_throttle (4.21) : meme forme, dimension differente (l'acteur, +-- pas l'IP). Le runner db/migrate.sh applique *.sql dans l'ordre +-- lexicographique via la table schema_migrations. +-- Target : MariaDB 11.4 LTS, InnoDB, utf8mb4 / utf8mb4_unicode_ci. +-- ============================================================================= + +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +CREATE TABLE pin_throttle ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + actor_user_id INT UNSIGNED NOT NULL, + failed_attempts SMALLINT UNSIGNED NOT NULL DEFAULT 0, + window_started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + lockout_until DATETIME NULL, + last_attempt_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_pin_throttle_actor_user_id (actor_user_id), + KEY idx_pin_throttle_lockout_until (lockout_until), + CONSTRAINT fk_pin_throttle_actor_user_id FOREIGN KEY (actor_user_id) + REFERENCES user (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Note : pas de seed. La cle est l'acteur (un user back-office authentifie), donc +-- la FK ON DELETE CASCADE est sure (contrairement a login_throttle, dont la cle +-- est une IP arbitraire et qui n'a pas de FK). La purge cron des lignes sans +-- verrou actif au-dela de THROTTLE_PURGE_AFTER_HOURS s'aligne sur login_throttle : +-- DELETE FROM pin_throttle +-- WHERE (lockout_until IS NULL OR lockout_until < NOW()) +-- AND last_attempt_at < NOW() - INTERVAL HOUR; diff --git a/docker-compose.yml b/docker-compose.yml index 303f000..d273c17 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -150,6 +150,11 @@ services: # Bornes du PIN equipier (actions sensibles, P3) : longueur min ET max. STAFF_PIN_MIN_LENGTH: ${STAFF_PIN_MIN_LENGTH} STAFF_PIN_MAX_LENGTH: ${STAFF_PIN_MAX_LENGTH} + # Throttle du PIN d'action sensible (RG-T22) : compteurs SEPARES du login. + PIN_THROTTLE_THRESHOLD: ${PIN_THROTTLE_THRESHOLD} + PIN_THROTTLE_BASE_SECONDS: ${PIN_THROTTLE_BASE_SECONDS} + PIN_THROTTLE_MAX_SECONDS: ${PIN_THROTTLE_MAX_SECONDS} + PIN_THROTTLE_WINDOW_SECONDS: ${PIN_THROTTLE_WINDOW_SECONDS} # Expiration du token de reinitialisation de mot de passe (mlt.md 12.3). PASSWORD_RESET_TTL: ${PASSWORD_RESET_TTL} UPLOAD_MAX_SIZE_MB: ${UPLOAD_MAX_SIZE_MB} diff --git a/docs/journal/2026-06-15--p3-throttle-pin-rg-t22.md b/docs/journal/2026-06-15--p3-throttle-pin-rg-t22.md new file mode 100644 index 0000000..48bfc15 --- /dev/null +++ b/docs/journal/2026-06-15--p3-throttle-pin-rg-t22.md @@ -0,0 +1,211 @@ +# P3 securite — throttle du PIN d'action sensible (RG-T22), design multi-agents + verification adversariale + +**Date** : 2026-06-15 (suite de la session CRUD Produits #17) +**Branche** : working tree sur `dev` (chunk non commite ; base `dev` = `2756fb4`) +**PR** : ouverte vers `dev` apres revue de l'implementation (auto-merge sur CI verte) +**Duree estimee** : session longue (finalisation + merge Produits, puis design + build + docs Merise du throttle) + +--- + +## Ce qui a ete fait + +Deux temps dans la session. + +### 1. Finalisation et merge du CRUD Produits (PR #17) + +Le CRUD produits (cas riche : `price_cents`, `vat_rate {55,100}`, `category_id`, suppression FK-safe) +a ete termine, revu (6 findings : 1 HIGH, 1 LOW, 4 MEDIUM de couverture), corrige, puis merge sur `dev` +en auto-merge sur CI verte (squash, `dev` = `2756fb4`). La revue avait remonte un finding **HIGH** : le PIN +d'action sensible (`PinVerifier`) verifie le PIN avec parite de timing mais **sans limitation de +tentatives**. Mitigation shippee dans #17 : chaque echec ecrit une ligne `audit_log` `pin.failed` +(detectable). Le throttle complet a ete arbitre comme chunk dedie — ce qui suit. + +### 2. Throttle du PIN (RG-T22) — conception puis construction + +**Conception via un panel multi-agents** (3 lentilles independantes : Ockham / efficacite-menace / +anti-DoS) -> synthese -> passe adversariale. Le panel a tranche la **dimension** du compteur et a integre +deux correctifs d'emblee. Verdict de l'adversaire : la conception tient (`holds = true`). + +Artefacts produits (tous dans le working tree, **non commites**) : + +- `db/migrations/0002_pin_throttle.sql` — nouvelle table (entite 22), cle sur `actor_user_id` + (UNIQUE, FK -> `user` ON DELETE CASCADE), separee des compteurs de connexion. **Appliquee a la base + dev** via `bash db/migrate.sh`. +- `src/app/Auth/ThrottlePolicy.php` — dimension `'pin'` ajoutee a `fromConfig` (bornes propres + `PIN_THROTTLE_*` : base 30s, plafond 300s). +- `src/app/Auth/PinThrottle.php` (nouveau) — `isLocked` / `recordFailure` (upsert atomique + backoff, + une transaction) / `reset`. +- `src/app/Auth/PinVerifier.php` — methode additive `payTimingDecoy` (parite de timing du chemin + verrouille). +- `src/app/Controllers/ProductController.php` — cablage dans `update` (branche prix/TVA) et `destroy` : + gate avant verification, `recordFailure` sur PIN faux, `reset` apres l'effet reussi. +- Config : `.env.example` + `docker-compose.yml` (`PIN_THROTTLE_THRESHOLD/BASE/MAX/WINDOW`). +- Docs Merise portees de 21 a 22 entites : RG-T22 dans `mlt.md`, entite 22 `pin_throttle` dans + `mcd.md` / `mld.md` / `dictionary.md`, couverture MCT 22/22 dans `mct.md`. +- Tests : +16 (dimension `pin` de `ThrottlePolicy` ; `PinThrottleTest` ; cas de controleur ; leurre de + timing ; integration `PinThrottleDbTest`). **188 tests / 525 assertions verts, PHPStan L6 propre.** + +--- + +## Pourquoi — decisions et alternatives + +### Decision 1 — Compter les echecs par utilisateur AGISSANT (et non par email cible ni par IP) + +- **Decision** : la dimension du throttle est l'identite de session authentifiee qui realise l'action + (`$guard->userId`), stockee dans une table dediee `pin_throttle` cle sur `actor_user_id`. +- **Alternatives considerees** : + - *par email cible* : contournable par rotation des emails (le modele "identifiant equipier + PIN" + verifie un email arbitraire) ; + - *par IP* : sur un poste a session partagee, tous les equipiers sortent par la meme IP ; un verrou IP + priverait de re-autorisation l'ensemble des equipiers honnetes du comptoir ; + - *hybride cible + IP avec delai `usleep`* : ajoute une colonne de portee, ~6 cles de config, un `usleep` + qui retient un worker PHP-FPM, et une surface de blocage d'un collegue ; + - *globale* : un seul attaquant degraderait l'autorisation sensible de tout le magasin. +- **Raison du choix** : la cle "acteur" est la seule non-contournable (changer d'acteur impose une + reconnexion, elle-meme throttlee et auditee cote login) ET sans collateral sur un poste partage + (verrouiller l'attaquant n'affecte aucun autre `user_id`). Elle dissout la tension rotation/collateral + qui force les autres pistes a un delai par IP. Rasoir d'Ockham (#37) : une table, un collaborateur, deux + points d'appel, `PinVerifier` inchange. + +### Decision 2 — Table dediee, separee des compteurs de connexion + +- **Decision** : compteurs `pin_throttle` physiquement distincts de `user.failed_login_attempts` / + `user.lockout_until` / `login_throttle`. +- **Alternative** : reutiliser les colonnes de login existantes. +- **Raison** : un echec de PIN n'incremente aucun compteur de login ; sinon, marteler le PIN d'une victime + verrouillerait sa connexion (escalade de deni de service vers une surface plus sensible). Un test de + regression verifie l'absence d'ecriture vers `user`/`login_throttle` sur le chemin d'echec. + +### Decision 3 — Backoff plus permissif que le login + +- **Decision** : base 30s, plafond 300s (le login est a 60s / 900s). +- **Raison** : RG-T13 cadre le PIN comme un controle de dissuasion (risque residuel Faible) ; un faux + positif bloque un manager en plein rush. Le backoff reste degressif, pas un verrou definitif. + +### Decision 4 — Correctifs adversariaux integres a la conception (pas en second passage) + +- **Anti-flood de l'audit** : sous verrou actif, aucune nouvelle ligne `pin.failed` (les echecs ayant + arme le verrou sont deja audites) — sinon le chemin verrouille, moins couteux, gonflerait le journal + append-only et noierait l'alerte de volume. +- **Parite de timing** : `payTimingDecoy` paie le cout argon2id sur le chemin verrouille, pour que la + latence ne distingue pas "verrouille" de "mauvais PIN". + +### Methodo — pourquoi un panel + une passe adversariale + +Challenge Before Confirm (mantra IA-16) sur un finding de severite HIGH avec migration de schema (peu +reversible) : faire produire trois conceptions independantes, les arbitrer, puis tenter de casser la +retenue. La passe adversariale a confirme que les quatre attaques visees (rotation d'email, falsification +de `X-Forwarded-For`, contamination du compteur de login, collateral de borne partagee) echouent par +construction, et a remonte les deux correctifs ci-dessus. + +--- + +## Comment — points techniques cles + +- **Upsert atomique, miroir de la dimension IP d'`AuthService`** : `INSERT ... ON DUPLICATE KEY UPDATE + failed_attempts = IF(window_started_at < :cutoff, 1, failed_attempts + 1) ...`. L'increment est calcule + cote SQL sous le verrou de ligne pris sur la cle UNIQUE, ce qui serialise les POST concurrents (anti + lost-update). Placeholders nommes distincts car `PDO::ATTR_EMULATE_PREPARES = false` interdit de lier un + meme nom deux fois (`src/app/Auth/PinThrottle.php`). +- **Gate-before-verify** : `isLocked($actorId)` est evalue AVANT `resolveActingUser`. Un acteur verrouille + recoit le meme 422 generique "Email ou PIN invalide" (anti-enumeration) ; meme un PIN correct est bloque + tant que le verrou court. +- **Le piege du `reset`** : a un succes, deux identites sont en portee — l'acteur de session + (`$guard->userId`, celui qui a ete incremente) et l'equipier resolu par le PIN (`$actor['id']`, ecrit + dans `audit_log`). Le `reset` cible l'acteur de **session** ; le confondre laisserait le compteur de + l'agissant sans purge. Un test l'asserte explicitement (`ProductControllerTest`). +- **FK ON DELETE CASCADE** (contrairement a `login_throttle`, sans FK) : la cle est un utilisateur + back-office authentifie, donc supprimer/anonymiser le compte retire proprement sa ligne de throttle + (etat ephemere, par opposition a `audit_log` qui est permanent et en SET NULL). + +--- + +## Criteres RNCP couverts + +- **Bloc 2 - Cr 3.a / 3.b** : extension du modele Merise (dictionnaire/MCD/MLD) — entite 22 `pin_throttle`, + FK et cardinalite (assoc R9), coherence 22/22 verifiee dans les quatre docs. +- **Bloc 2 - Cr 4.e (securite)** : requetes preparees (anti-injection), reponse generique + (anti-enumeration), separation dure des compteurs (anti escalade de DoS), gate avant verification. +- **Bloc 2 - Cr 4.c (POO / namespaces)** : `PinThrottle` (classe dediee), reutilisation de `ThrottlePolicy` + (math pure), cablage via les controleurs heritant d'`AdminController`. +- **Bloc 2 - Cr 4.g (preparation livraison)** : 188 tests PHPUnit verts, PHPStan niveau 6 propre, test + d'integration contre une vraie MariaDB. +- **Bloc 2 - Cr 3.d (RGPD)** : FK ON DELETE CASCADE (l'etat de throttle suit l'anonymisation du compte) et + purge cron documentee (minimisation / limitation de conservation). +- **Bloc 5 - Cr 7.b.3 (cron) / Cr 7.d.2 (tests avant deploiement)** : predicat de purge `pin_throttle` + aligne sur `login_throttle` ; le chunk passera la CI (PHPUnit + PHPStan + secret-scan) avant merge. + +--- + +## Questions anticipees du jury + +- **Q** : "Pourquoi compter les echecs de PIN sur l'utilisateur agissant plutot que sur l'IP, comme pour le login ?" + **R** : Sur une borne a session partagee, tous les equipiers sortent par la meme IP ; un verrou par IP + les priverait tous de re-autorisation. La cle "acteur" verrouille seulement l'individu qui multiplie les + echecs, sans toucher ses collegues, et reste non-contournable (changer d'acteur impose une reconnexion, + deja throttlee cote login). + +- **Q** : "Un attaquant qui martele le PIN d'un collegue peut-il bloquer sa connexion ?" + **R** : Non. Les compteurs du PIN vivent dans une table separee (`pin_throttle`), distincte de + `user.failed_login_attempts` et de `login_throttle`. Un echec de PIN n'ecrit aucun compteur de login ; + un test de regression le verifie. + +- **Q** : "Pourquoi un backoff degressif et pas un verrou definitif ?" + **R** : Le PIN est un controle de dissuasion a risque residuel Faible ; un verrou dur bloquerait un + manager sur quelques fautes de frappe en plein service. Le backoff ralentit la force brute (de quelques + essais a une poignee par fenetre) tout en s'auto-resorbant. + +- **Q** : "Comment avez-vous valide cette conception de securite ?" + **R** : Trois conceptions independantes ont ete produites puis arbitrees, et une passe adversariale a + tente de casser la retenue (rotation d'email, falsification d'en-tete proxy, contamination du login, + collateral de borne). Les quatre echouent par construction ; la passe a aussi remonte deux correctifs + (anti-flood de l'audit, parite de timing) integres avant la fin. + +- **Q** : "Pourquoi ajouter une 22e table plutot que des colonnes sur `user` ?" + **R** : Des colonnes sur `user` devraient porter sur l'utilisateur cible (contournable par rotation) ou + ajouter une 4e dimension de verrou sur la table de comptes. Une table dediee, cle sur l'acteur, garde + `user` epuree et garantit la separation des compteurs par construction. + +--- + +## Points d'amelioration conscients + +- **Couverture CI de l'increment SQL** : les tests unitaires stubbent le compteur relu apres l'upsert + (`FakeDatabase.pinThrottleAttempts` fixe), donc la semantique reelle de l'increment + fenetre glissante + n'est prouvee que par `PinThrottleDbTest` (integration), auto-skippee sans MariaDB. C'est la posture + STANDARD du projet (CI sans Composer ni base : `AuthServiceDbTest`, `PinVerifierDbTest`... skippent de + meme) ; verifiee en local avec `WAKDO_DB_TESTS=1`. A garder en tete si la CI gagne un service DB. +- **Cron de purge non encore etendu** : le predicat de purge `pin_throttle` est documente (`mlt.md` 13.5) + mais le job cron lui-meme (`docker/cron`) n'a pas ete edite. Sans impact fonctionnel (la table tient une + ligne par utilisateur back-office) ; a brancher avec le job `login_throttle` existant. +- **Dimension par IP volontairement absente** : choix documente (collateral de borne partagee). A + reconsiderer seulement si un abus par IP est observe en pratique. +- **Detection** : l'alerte sur le volume de `pin.failed` est le vrai controle detectif ; elle reste a + outiller cote supervision (hors code applicatif). Un PIN de plus de 4 chiffres pour les roles sensibles + est recommande. + +--- + +## Etat a la reprise + +- Chunk throttle PIN complet (source + tests + migration + docs Merise + `.env.example` + compose + ce + journal), vert (188 tests, PHPStan L6), revue adversariale de l'implementation passee (`holds = true`), + commite et pousse cette session avec PR vers `dev` (auto-merge sur CI verte). Migration `0002` deja + appliquee a la base dev. +- **Prochaine action** : suite P3 : Menus (+ slots), Ingredients/stock, Users + matrice RBAC, Stats. + Differe : etendre le cron de purge a `pin_throttle` ; alerte de volume `pin.failed` (supervision). + +--- + +## Liens vers artefacts + +- CRUD Produits merge : commit `49ab77b` -> `dev` `2756fb4` (PR #17, squash). +- Throttle PIN (non commite) : `src/app/Auth/PinThrottle.php`, `src/app/Auth/ThrottlePolicy.php`, + `src/app/Auth/PinVerifier.php`, `src/app/Controllers/ProductController.php`, + `db/migrations/0002_pin_throttle.sql`. +- Tests : `tests/Unit/Auth/PinThrottleTest.php`, `tests/Unit/Auth/ThrottlePolicyTest.php`, + `tests/Unit/Admin/ProductControllerTest.php`, `tests/Integration/PinThrottleDbTest.php`, + `tests/Support/FakeDatabase.php`. +- Docs Merise (RG-T22, entite 22) : `docs/merise/{mlt,mcd,mld,dictionary,mct}.md`. +- Config : `.env.example`, `docker-compose.yml` (`PIN_THROTTLE_*`). +- Resume roulant : `docs/SESSION_RESUME.md` (entree Produits #17 = suite 4). diff --git a/docs/journal/README.md b/docs/journal/README.md index 0b8b5f8..2b7afa7 100644 --- a/docs/journal/README.md +++ b/docs/journal/README.md @@ -31,6 +31,7 @@ Les fichiers sont ordonnes chronologiquement par leur nom. | 2026-04-24 | [infra-docker](2026-04-24--infra-docker.md) | Stack Docker complete (compose + 4 services), referentiel RNCP integre, cross-check mappings Cr 4.f | `feat/infra-docker` | | 2026-04-30 | [smoke-test-infra](2026-04-30--smoke-test-infra.md) | Smoke test bout-en-bout sur serveur reel : fusion .env, switch FQDN sur stark.a3n.fr, subnet explicite RFC 1918, fix init cron + healthz | `feat/infra-docker` | | 2026-06-04 | [conception-prodlike-revision](2026-06-04--conception-prodlike-revision.md) | Revue d'alignement P1 + decisions prod-like du modele de donnees (drop commande_event, nommage EN, TVA par produit apres fact-check BOFiP, perso menus/ingredients, allergenes, ~16 entites) | `feat/p1-conception` | +| 2026-06-15 | [p3-throttle-pin-rg-t22](2026-06-15--p3-throttle-pin-rg-t22.md) | P3 securite : throttle du PIN d'action sensible (RG-T22) — design multi-agents + verification adversariale, dimension "utilisateur agissant", entite 22 `pin_throttle` | `feat/p3-pin-throttle` -> `dev` | *Mis a jour a chaque nouvelle entree.* diff --git a/docs/merise/dictionary.md b/docs/merise/dictionary.md index b818d77..e45529d 100644 --- a/docs/merise/dictionary.md +++ b/docs/merise/dictionary.md @@ -1,7 +1,7 @@ # Dictionnaire de Donnees — Wakdo **Phase Merise** : P1 - Conception, etape 1 (dictionnaire de donnees d'abord, mantra #33) -**Version** : v0.2 — prod-like, 21 entites (19 prod-like + couche security-by-design, incl. la nouvelle entite `login_throttle`) +**Version** : v0.3 — prod-like, 22 entites (19 prod-like + couche security-by-design, incl. les entites `login_throttle` et `pin_throttle`) **Date** : 2026-06-04 (ajouts security-by-design 2026-06-11) **Branche** : `feat/p1-conception` **Statut** : prod-like — toutes les decisions D1-D8 + stock appliquees (voir `docs/notes/revue-alignement-p1.md` §7) ; couche security-by-design en cours (voir note 13) @@ -616,6 +616,29 @@ que 24h. --- +### 3.22 `pin_throttle` + +Throttle du PIN d'action sensible (RG-T22), complement de RG-T13. Une ligne par utilisateur AGISSANT +(l'identite de session qui soumet email+PIN), STRICTEMENT SEPAREE des compteurs de connexion +(`user.failed_login_attempts` / `login_throttle`) : un echec de PIN n'incremente aucun compteur de login. +Ajout security-by-design (voir note 13). + +| Attribut | Type | NULL | Default | Contrainte | Notes | +|---|---|---|---|---|---| +| `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | | +| `actor_user_id` | INT UNSIGNED | NO | — | UNIQUE, FK -> `user(id)` ON DELETE CASCADE | l'utilisateur agissant (session), une ligne par acteur, upsertee | +| `failed_attempts` | SMALLINT UNSIGNED | NO | 0 | — | echecs de PIN consecutifs de cet acteur dans la fenetre courante | +| `window_started_at` | DATETIME | NO | CURRENT_TIMESTAMP | — | debut de la fenetre de comptage courante | +| `lockout_until` | DATETIME | YES | NULL | — | fin de la fenetre de backoff degressif ; NULL = non throttle | +| `last_attempt_at` | DATETIME | NO | CURRENT_TIMESTAMP | — | timestamp de la derniere tentative echouee | + +**FK ON DELETE CASCADE** (contrairement a `login_throttle`) : la cle est un utilisateur back-office +authentifie, donc supprimer/anonymiser le compte purge proprement sa ligne de throttle. Memes bornes de +backoff que RG-8 mais PROPRES au PIN (PIN_THROTTLE_*, plus permissives). Meme purge cron quotidienne que +`login_throttle` (lignes sans lockout actif > 24h). + +--- + ## 4. Notes de modelisation ### Note 1 — Pourquoi `INT UNSIGNED` en centimes pour les prix @@ -878,6 +901,12 @@ une surcharge forte ; un reapprovisionnement au-dessus de la bande critique rend sur `user`. Cela ajoute une seconde dimension de throttling, de sorte qu'une seule IP martelant de nombreux comptes soit ralentie independamment du compteur de n'importe quel compte. Un cron quotidien purge les lignes inactives et non verrouillees. +**Throttle du PIN d'action sensible (par acteur).** `pin_throttle` (3.22) suit `failed_attempts` et +`lockout_until` par utilisateur AGISSANT (l'identite de session qui valide une action sensible), +dans une table separee des compteurs de connexion. La dimension est l'acteur (et non l'email cible, +contournable par rotation, ni l'IP, qui penaliserait tous les equipiers d'un poste partage) ; le verrou +est un backoff degressif aux bornes propres (PIN_THROTTLE_*). Meme purge cron que `login_throttle`. RG-T22. + References : `docs/notes/revue-alignement-p1.md` §7 (decisions D), carte d'impact security-by-design (2026-06-11). Modele de menace et matrice de classification des donnees : `PROJECT_CONTEXT.md` §19 (a venir). @@ -908,18 +937,19 @@ References : `docs/notes/revue-alignement-p1.md` §7 (decisions D), carte d'impa | 19 | `stock_movement` | audit | nouveau — journal d'audit de stock append-only | | 20 | `audit_log` | audit | nouveau (security-by-design) — journal append-only d'actions sensibles | | 21 | `login_throttle` | security | nouveau (security-by-design) - throttle anti-brute-force par IP | +| 22 | `pin_throttle` | security | nouveau (security-by-design) - throttle du PIN d'action sensible par acteur (RG-T22) | **Retire de v0.1** : `commande_event` (remplace par les timestamps de phase sur `customer_order`), `menu_produit` (remplace par le modele `menu_slot` + `menu_slot_option`). -**Total : 21 entites** (19 prod-like v0.2 + `audit_log` et `login_throttle` de la -couche security-by-design). +**Total : 22 entites** (19 prod-like v0.2 + `audit_log`, `login_throttle` et `pin_throttle` +de la couche security-by-design). Le security-by-design ajoute aussi des colonnes (au-dela des deux nouvelles entites) : cycle de vie d'auth de `user` + `pin_hash` + `anonymized_at` (3.14), `customer_order.acting_user_id` + `idempotency_key` (3.10), et le modele de stock en pourcentage sur `ingredient` (3.6) — `stock_capacity`, `critical_stock_pct`, plus le renommage de `low_stock_threshold` en `low_stock_pct`. `login_throttle` (3.21) est la 21e -entite. Voir note 13. +entite et `pin_throttle` (3.22) la 22e. Voir note 13. --- diff --git a/docs/merise/mcd.md b/docs/merise/mcd.md index 16fd019..20a667c 100644 --- a/docs/merise/mcd.md +++ b/docs/merise/mcd.md @@ -1,7 +1,7 @@ # Modele Conceptuel de Donnees (MCD) — Wakdo **Phase Merise** : P1 - Conception, etape 2 (data dictionary first, mantra #33) -**Version** : v0.2 — prod-like, 21 entites (19 prod-like + couche security-by-design) +**Version** : v0.3 — prod-like, 22 entites (19 prod-like + couche security-by-design) **Date** : 2026-06-04 (ajouts security-by-design 2026-06-11) **Branche** : `feat/p1-conception` **Statut** : prod-like — toutes les decisions D1-D8 + stock appliquees (voir `docs/notes/revue-alignement-p1.md` §7) ; couche security-by-design (audit_log + colonnes imputabilite/auth) en cours @@ -21,7 +21,7 @@ structure relationnelle : combien de X par Y, si la participation est obligatoir leurs propres attributs. **Sources** : -- `docs/merise/dictionary.md` (v0.2 — 21 entites, source de verite pour tous les noms, types, ENUMs) +- `docs/merise/dictionary.md` (v0.3 — 22 entites, source de verite pour tous les noms, types, ENUMs) - `docs/notes/revue-alignement-p1.md` §7 (table de decisions D1-D8 + stock) - `docs/PROJECT_CONTEXT.md` (regles metier : composition de menu, flux de commande, RBAC, modes de service) - `docs/merise/_sources/` (donnees de l'ecole : 9 categories, 53 produits, 13 menus) @@ -62,7 +62,7 @@ Les associations N-N qui portent leurs propres attributs deviennent des **entite ## 3. Decomposition par sous-domaine -Le modele de 21 entites est divise en 4 sous-domaines pour la lisibilite. Au-dela d'environ +Le modele de 22 entites est divise en 4 sous-domaines pour la lisibilite. Au-dela d'environ 5 entites, un diagramme plat unique devient difficile a lire ; la decomposition est la pratique Merise standard pour les modeles de cette taille. @@ -71,17 +71,19 @@ Merise standard pour les modeles de cette taille. | Catalogue | category, product, menu, menu_slot, menu_slot_option | 5 | | Ingredients & Stock | ingredient, product_ingredient, allergen, ingredient_allergen, stock_movement | 5 | | Order | customer_order, order_item, order_item_selection, order_item_modifier | 4 | -| RBAC & Audit | user, role, role_visible_source, permission, role_permission, audit_log, login_throttle | 7 | +| RBAC & Audit | user, role, role_visible_source, permission, role_permission, audit_log, login_throttle, pin_throttle | 8 | > **Couche security-by-design (2026-06-11)** : `audit_log` (entite 20) est un journal transverse, > append-only des actions sensibles ; il est place dans le sous-domaine RBAC & Audit parce que > ses references (`actor_user_id`, `actor_role_id`) sont des entites RBAC. `login_throttle` > (entite 21) est un throttle anti-brute-force par IP source, indexe par IP et ne portant aucune FK ; il se situe -> dans le meme sous-domaine parce qu'il protege le chemin d'authentification. Nouvelles colonnes sur des entites existantes : +> dans le meme sous-domaine parce qu'il protege le chemin d'authentification. `pin_throttle` (entite 22, +> RG-T22) est un throttle du PIN d'action sensible par utilisateur AGISSANT (FK `actor_user_id -> user`, +> ON DELETE CASCADE), compteurs separes du login. Nouvelles colonnes sur des entites existantes : > `user` cycle de vie auth + `pin_hash` + `anonymized_at`, `customer_order.acting_user_id` > + `idempotency_key`. Voir note 13 du dictionnaire. -**Note sur l'absence d'un diagramme global** : un unique diagramme ER de 21 entites serait +**Note sur l'absence d'un diagramme global** : un unique diagramme ER de 22 entites serait illisible et impossible a maintenir. La decomposition par sous-domaine ci-dessous est le choix structurel intentionnel. Chaque sous-domaine est un `erDiagram` Mermaid (faisant autorite, rendu nativement) avec un rendu SVG portable dans `docs/merise/_diagrams/` ; voir la section 11 pour les @@ -444,6 +446,14 @@ erDiagram datetime lockout_until datetime last_attempt_at } + pin_throttle { + int id PK + int actor_user_id FK,UK + smallint failed_attempts + datetime window_started_at + datetime lockout_until + datetime last_attempt_at + } user }o--|| role : "holds" role ||--o{ role_visible_source : "sees_source" @@ -451,11 +461,14 @@ erDiagram permission ||--o{ role_permission : "granted_to" user |o--o{ audit_log : "performs" role |o--o{ audit_log : "context_of" + user ||--o{ pin_throttle : "pin_throttled_as" ``` > `login_throttle` est une entite autonome sans association : elle est indexee par IP source > (`ip_address UNIQUE`), pas par un acteur modelise, donc elle ne porte aucune FK et ne se connecte a aucune -> autre entite du diagramme. +> autre entite du diagramme. `pin_throttle` (RG-T22), au contraire, est cle par l'utilisateur AGISSANT +> (`actor_user_id UNIQUE`, FK -> `user` ON DELETE CASCADE) : c'est la dimension qui rend le throttle du PIN +> non contournable par rotation d'email et sans collateral sur un poste partage. ### 7.2 Cardinalites des associations @@ -467,6 +480,7 @@ erDiagram | R4 | granted_to | permission | (0,N) | role_permission | (1,1) | Une permission peut n'etre encore accordee a aucun role (declaree au seed, pas encore distribuee) ou a plusieurs. Chaque ligne de mapping reference une permission. | | R5 | performs | user | (0,1) | audit_log | (0,N) | Une action sensible capturee sous PIN enregistre son utilisateur agissant ; les entrees automatisees/non attribuables portent NULL. Un utilisateur peut avoir journalise un nombre quelconque d'actions. ON DELETE SET NULL preserve la trace lors de l'anonymisation/suppression de l'utilisateur. | | R6 | context_of | role | (0,1) | audit_log | (0,N) | Chaque ligne d'audit peut denormaliser le role de l'acteur au moment de l'action (NULL autorise). Un role peut etre le contexte de nombreuses lignes d'audit. ON DELETE SET NULL preserve la trace. | +| R9 | pin_throttled_as | user | (1,1) | pin_throttle | (0,1) | Throttle du PIN d'action sensible (RG-T22) : au plus une ligne `pin_throttle` par utilisateur agissant (cle UNIQUE `actor_user_id`), creee au premier echec et upsertee ensuite. ON DELETE CASCADE : l'etat de throttle (ephemere) part avec le compte supprime/anonymise. | ### 7.3 Notes sur le sous-domaine RBAC @@ -500,11 +514,19 @@ de la derniere tentative echouee. Elle n'a aucune FK (une IP n'est pas une entit cron quotidien purge les lignes sans lockout actif dont le `last_attempt_at` est plus ancien que 24h. Voir dictionnaire 3.21 et note 13. +**`pin_throttle` (security-by-design, RG-T22)** : throttle du PIN d'action sensible, distinct du throttle +de connexion. La dimension est l'utilisateur AGISSANT (l'identite de session qui soumet email+PIN), pas +l'email cible (contournable par rotation) ni l'IP (qui penaliserait tous les equipiers d'un poste partage). +Une ligne par acteur (`actor_user_id UNIQUE`, FK -> `user` ON DELETE CASCADE), upsertee a chaque echec hors +verrou ; memes colonnes que `login_throttle` mais des bornes propres (PIN_THROTTLE_*, plus permissives). +Compteurs physiquement separes du login : un echec de PIN n'incremente aucun compteur de connexion. Meme +purge cron quotidienne. Association R9 (`user` 1 -- 0,N `pin_throttle`). Voir dictionnaire 3.22 et note 13. + --- ## 8. Validation croisee MCD <-> dictionnaire -Verification que les 21 entites du dictionnaire apparaissent dans le MCD et reciproquement. +Verification que les 22 entites du dictionnaire apparaissent dans le MCD et reciproquement. | # | Entite du dictionnaire (section 3) | Sous-domaine dans le MCD | Presente | |---|---|---|---| @@ -529,8 +551,9 @@ Verification que les 21 entites du dictionnaire apparaissent dans le MCD et reci | 19 | `stock_movement` (3.19) | Ingredients & Stock | Oui | | 20 | `audit_log` (3.20) | RBAC & Audit | Oui | | 21 | `login_throttle` (3.21) | RBAC & Audit | Oui | +| 22 | `pin_throttle` (3.22) | RBAC & Audit | Oui | -**Resultat** : 21/21 entites tracees (19 prod-like + `audit_log` et `login_throttle` +**Resultat** : 22/22 entites tracees (19 prod-like + `audit_log`, `login_throttle` et `pin_throttle` security-by-design). Aucune entite du dictionnaire n'est absente du MCD. Aucune entite du MCD ne tombe en dehors du dictionnaire. @@ -605,7 +628,7 @@ ecritures d'audit, reset/lockout, anonymisation). Les ajouts de la couche traite Le modele graphique faisant autorite est l'ensemble des blocs `erDiagram` Mermaid des sections 4-7, un par sous-domaine. Ils s'affichent nativement sur Forgejo et GitHub. Le MCD est decompose par -sous-domaine a dessein : un unique diagramme de 21 entites ne peut etre dispose sans croisement de +sous-domaine a dessein : un unique diagramme de 22 entites ne peut etre dispose sans croisement de lignes de relation (limite de planarite intrinseque, et `erDiagram` n'offre aucun controle de mise en page manuel). Chaque sous-domaine reste a 5-8 entites, ce que la mise en page automatique gere proprement. La vue integree a travers les sous-domaines est la table de validation croisee de la section 8. diff --git a/docs/merise/mct.md b/docs/merise/mct.md index 1e1dff0..0295db2 100644 --- a/docs/merise/mct.md +++ b/docs/merise/mct.md @@ -668,5 +668,10 @@ peut etre formalisee lorsque l'UI d'audit sera specifiee en P3). il est lu ET ecrit (upserte) par `AUTHENTICATE_USER` (25). Sa purge quotidienne des lignes obsoletes est un cron, documente dans `mlt.md`, hors du perimetre des operations MCT. -**Conclusion** : 21/21 entites couvertes (19 prod-like + `audit_log` + `login_throttle`). Coherence MCT <-> MCD -validee. +(****) `pin_throttle` (entite 22, security-by-design, RG-T22) est le verrou de throttling du PIN d'action +sensible par utilisateur AGISSANT : il est lu (gate avant verification) ET ecrit (upserte sur echec, remis +a zero sur succes) par les operations sensibles sous PIN (ex. UPDATE_PRODUCT prix/TVA, DELETE_PRODUCT). Sa +purge quotidienne suit celle de `login_throttle` (cron, `mlt.md`), hors du perimetre des operations MCT. + +**Conclusion** : 22/22 entites couvertes (19 prod-like + `audit_log` + `login_throttle` + `pin_throttle`). +Coherence MCT <-> MCD validee. diff --git a/docs/merise/mld.md b/docs/merise/mld.md index 7f84821..8d0c57d 100644 --- a/docs/merise/mld.md +++ b/docs/merise/mld.md @@ -1,7 +1,7 @@ # Modele Logique de Donnees (MLD) — Wakdo **Phase Merise** : P1 - Conception, etape 5 (apres MCD, MCT, MLT) -**Version** : v0.2 — prod-like, 21 tables (19 prod-like + couche security-by-design) +**Version** : v0.3 — prod-like, 22 tables (19 prod-like + couche security-by-design) **Date** : 2026-06-04 (ajouts security-by-design 2026-06-11) **Branche** : `feat/p1-conception` **Statut** : prod-like — toutes les decisions D1-D8 + stock appliquees (voir `docs/notes/revue-alignement-p1.md` §7) ; couche security-by-design (audit_log + colonnes imputabilite/auth) en cours @@ -93,14 +93,14 @@ en plus de la PK composite des FK. Applique a `product_ingredient`. --- -## 4. Schema relationnel (21 tables) +## 4. Schema relationnel (22 tables) Les tables sont ordonnees par dependance (tables sans FK d'abord, puis tables qui en dependent). ### Diagrammes relationnels (par sous-domaine) Le schema relationnel est presente sous forme de quatre vues Mermaid `erDiagram`, une par sous-domaine (meme -decomposition que le MCD ; un unique diagramme de 21 tables ne se disposerait pas proprement). Elles different +decomposition que le MCD ; un unique diagramme de 22 tables ne se disposerait pas proprement). Elles different du MCD : les entites associatives sont resolues en tables de jointure avec PK composites, le polymorphisme de `order_item` apparait sous forme de deux FK nullables (`product_id` / `menu_id`), et chaque cle etrangere est explicite. Les horodatages d'audit (`created_at` / `updated_at`) sont presents sur la plupart des @@ -358,6 +358,14 @@ erDiagram datetime lockout_until datetime last_attempt_at } + pin_throttle { + int id PK + int actor_user_id FK,UK + smallint failed_attempts + datetime window_started_at + datetime lockout_until + datetime last_attempt_at + } role ||--o{ user : "role_id (RESTRICT)" role ||--o{ role_visible_source : "role_id (CASCADE)" @@ -365,10 +373,12 @@ erDiagram permission ||--o{ role_permission : "permission_id (CASCADE)" user ||--o{ audit_log : "actor_user_id (SET NULL, nullable)" role ||--o{ audit_log : "actor_role_id (SET NULL, nullable)" + user ||--o{ pin_throttle : "actor_user_id (CASCADE)" ``` > `login_throttle` n'a pas de FK (une IP n'est pas une entite modelisee) ; elle est autonome, cle par -> `ip_address`. +> `ip_address`. `pin_throttle` (RG-T22) est cle par `actor_user_id` (FK -> `user`, ON DELETE CASCADE) : +> le throttle du PIN porte sur l'utilisateur AGISSANT, dimension distincte du login. --- @@ -1083,6 +1093,37 @@ Pas de `updated_at` : les lignes sont upsertees par IP, pas editees via une UI. --- +### 4.22 `pin_throttle` + +Throttle du PIN d'action sensible par utilisateur AGISSANT (security-by-design, RG-T22). Separe des +compteurs de connexion (`user.failed_login_attempts` / `lockout_until` / `login_throttle`) : un echec de +PIN n'incremente aucun compteur de login. + +``` +pin_throttle (id, actor_user_id, failed_attempts, window_started_at, + [lockout_until], last_attempt_at) + + PK : id + UK : actor_user_id + IDX : lockout_until + FK : actor_user_id -> user(id) ON DELETE CASCADE +``` + +| Colonne | Type | NULL | Notes | +|---|---|---|---| +| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | +| `actor_user_id` | INT UNSIGNED | NO | Utilisateur agissant (session), une ligne par acteur, upsertee. UNIQUE. FK -> `user(id)` ON DELETE CASCADE | +| `failed_attempts` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Echecs de PIN consecutifs de cet acteur dans la fenetre courante | +| `window_started_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Debut de la fenetre de comptage courante | +| `lockout_until` | DATETIME | YES | Fin de la fenetre de backoff degressif ; NULL = pas throttle | +| `last_attempt_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Horodatage de la derniere tentative echouee | + +FK ON DELETE CASCADE (contrairement a `login_throttle`) : la cle est un user back-office authentifie, donc +supprimer/anonymiser le compte purge sa ligne de throttle. Append/upsert par acteur ; meme purge cron que +`login_throttle`. Pas de `updated_at` (lignes upsertees, pas editees via une UI). + +--- + ## 5. Resume de l'integrite referentielle | Colonne FK | References | ON DELETE | Justification | @@ -1115,6 +1156,7 @@ Pas de `updated_at` : les lignes sont upsertees par IP, pas editees via une UI. | `customer_order.acting_user_id` | `user(id)` | SET NULL | Attribution du personnel preservee comme principal anonymise ; commande conservee | | `audit_log.actor_user_id` | `user(id)` | SET NULL | Piste d'audit preservee a l'anonymisation de l'utilisateur ; seul le lien est rompu | | `audit_log.actor_role_id` | `role(id)` | SET NULL | Contexte de role conserve jusqu'a la suppression du role ; denormalise donc il survit a l'anonymisation de l'utilisateur | +| `pin_throttle.actor_user_id` | `user(id)` | CASCADE | Etat de throttle ephemere : il part avec le compte agissant supprime/anonymise (contrairement a l'audit, permanent) | **Cle utilisee** : CASCADE = l'enfant n'a pas de sens sans le parent ; RESTRICT = la suppression du parent est bloquee tant que des enfants existent ; SET NULL = l'enfant est preserve, seul le lien est rompu. @@ -1175,6 +1217,7 @@ MCT / MLT. | `audit_log` | `(entity_type, entity_id)` | "qu'est-il arrive a ce produit/commande/utilisateur ?" | | `audit_log` | `(action_code, created_at)` | Audit par type d'action sur une plage de temps | | `login_throttle` | `lockout_until` | Purge cron quotidienne des lignes sans verrouillage actif | +| `pin_throttle` | `lockout_until` | Purge cron quotidienne des lignes sans verrouillage actif (RG-T22) | **Index non ajoutes** (intentionnel) : - `customer_order.order_number` : l'index UK suffit ; aucune requete de plage attendue sur cette colonne. @@ -1186,7 +1229,7 @@ MCT / MLT. ## 8. Validation croisee MLD <-> MCD -Verification que les 21 entites MCD (19 prod-like + 2 security-by-design) correspondent a une table, +Verification que les 22 entites MCD (19 prod-like + 3 security-by-design) correspondent a une table, et que toutes les tables se rattachent au MCD. | Entite MCD | Table MLD | Type de mapping | Notes | @@ -1212,8 +1255,9 @@ et que toutes les tables se rattachent au MCD. | `stock_movement` (C19) | `stock_movement` (4.19) | entite 1:1 | Nouvelle entite (v0.2) | | `audit_log` (R5/R6) | `audit_log` (4.20) | entite 1:1 | Nouvelle entite (security-by-design) | | `login_throttle` (R7) | `login_throttle` (4.21) | entite 1:1 | Nouvelle entite (security-by-design) | +| `pin_throttle` (R9) | `pin_throttle` (4.22) | entite 1:1 | Nouvelle entite (security-by-design, RG-T22) | -**Resultat** : 21/21 entites mappees (19 prod-like + `audit_log` + `login_throttle`). Aucune entite +**Resultat** : 22/22 entites mappees (19 prod-like + `audit_log` + `login_throttle` + `pin_throttle`). Aucune entite sans table ; aucune table hors du MCD. Nouvelles colonnes sur les tables existantes : `user` (cycle de vie auth + `pin_hash` + `anonymized_at`), `customer_order` (`idempotency_key`, `acting_user_id`), `ingredient` (`stock_capacity`, `low_stock_pct`, `critical_stock_pct` ; @@ -1250,6 +1294,7 @@ sur `customer_order` — decision 2.A) ; le modele de composition fixe `menu_pro | `stock_movement` | ~500k | 180 octets | ~90 MB | | `audit_log` | ~5k-10k | 200 octets | ~2 MB | | `login_throttle` | ~100-1k | 80 octets | < 1 MB | +| `pin_throttle` | ~10-100 | 80 octets | < 1 MB (1 ligne par user back-office) | **Total estime** : ~190 MB de donnees + ~60-80 MB pour les index = ~250-270 MB sur 6 mois (`audit_log` est negligeable : les actions sensibles sont d'un ordre de grandeur plus rares que les commandes). @@ -1300,6 +1345,7 @@ ingredient ; il portera une amplification d'ecriture significative a l'echelle. - `stock_movement` (depend de `ingredient`, `customer_order`, `user`) - `audit_log` (depend de `user`, `role`) - `login_throttle` (pas de FK, peut etre cree a n'importe quel moment) + - `pin_throttle` (FK `actor_user_id -> user`, donc apres le bloc `user`) Note : `customer_order` porte desormais `acting_user_id -> user`, donc `user` doit etre cree avant `customer_order` (deja le cas : le bloc RBAC precede `customer_order`). diff --git a/docs/merise/mlt.md b/docs/merise/mlt.md index db30820..37b626f 100644 --- a/docs/merise/mlt.md +++ b/docs/merise/mlt.md @@ -4,7 +4,7 @@ **Version** : v0.2 — prod-like, machine a 4 etats (+ couche security-by-design 2026-06-11) **Date** : 2026-06-04 (ajouts security-by-design 2026-06-11) **Branche** : `feat/p1-conception` -**Statut** : prod-like — toutes les decisions D1-D8 + stock appliquees (voir `docs/notes/revue-alignement-p1.md` §7) ; regles security-by-design ajoutees (RG-T13-T21 : PIN, audit, escaping, allowlists, idempotence, decrement atomique, disponibilite produit calculee (RG-T21) ; ops RESET_PASSWORD, ERASE_USER_PII, throttling d'authentification ; table de throttle par IP `login_throttle`) +**Statut** : prod-like — toutes les decisions D1-D8 + stock appliquees (voir `docs/notes/revue-alignement-p1.md` §7) ; regles security-by-design ajoutees (RG-T13-T22 : PIN, audit, escaping, allowlists, idempotence, decrement atomique, disponibilite produit calculee (RG-T21), throttling du PIN d'action sensible par utilisateur agissant (RG-T22) ; ops RESET_PASSWORD, ERASE_USER_PII, throttling d'authentification ; tables de throttle `login_throttle` (par IP) et `pin_throttle` (par acteur)) **Auteur** : BYAN (couche methodologie) --- @@ -58,6 +58,7 @@ Ces regles s'appliquent a plusieurs operations et sont centralisees ici pour evi | **RG-T19** | **Idempotence** : `POST /api/orders` porte un `idempotency_key` (UUID) genere par le client. Avant de creer, le rechercher sur `customer_order.idempotency_key` (UNIQUE) ; si une ligne existe, retourner cette commande au lieu de creer un doublon (retry reseau rejoue). | 3.3, 4.1 | | **RG-T20** | **Decrement de stock atomique** : pendant la transition `paid`, chaque `ingredient` affecte est decremente par une unique instruction auto-verrouillante `UPDATE ingredient SET stock_quantity = stock_quantity - :units WHERE id = :id` — pas de lecture-gate prealable, pas de `SELECT ... FOR UPDATE`. Les commandes concurrentes sur le meme ingredient appliquent leurs deltas sans perte de mise a jour et sans souci d'ordonnancement de deadlock. `stock_quantity` est signe et peut devenir negatif quand les ventes depassent le stock compte (l'ampleur de la survente est remontee aux managers) ; le decrement ne bloque pas sur un plancher. | 3.3, 4.1 | | **RG-T21** | **Disponibilite produit calculee** : la commandabilite effective d'un produit est calculee, pas stockee. Il est commandable lorsque `product.is_available = 1` ET que chaque ingredient non retirable (`is_removable = 0`) de son `product_ingredient` a `stock_quantity > stock_capacity * critical_stock_pct / 100`. A la bande critique, un ingredient requis met le produit en rupture sans ecriture et sans cascade ; un reapprovisionnement au-dessus de la bande critique le rend commandable a nouveau de lui-meme ; un retrait manuel (`product.is_available = 0`) est une surcharge forte ; un ingredient retirable/optionnel a la bande critique ne bloque pas le produit (seul son supplement devient indisponible). | 3.1, 3.3, 4.1, 5.1 | +| **RG-T22** | **Throttling du PIN d'action sensible** (complement de RG-T13). Les tentatives de PIN echouees sont comptabilisees PAR UTILISATEUR AGISSANT (l'identite de session authentifiee qui soumet email+PIN, RG-T02), dans une table dediee `pin_throttle` (entite 22) STRICTEMENT SEPAREE des compteurs de connexion (`user.failed_login_attempts` / `user.lockout_until` / `login_throttle`) : un echec de PIN n'incremente aucun compteur de login, sinon spammer le PIN d'une victime verrouillerait sa CONNEXION (escalade de DoS sur une surface plus sensible). La dimension est l'AGISSANT et non l'email cible (un compteur par email cible serait contourne par rotation d'emails, RG-T13 verifiant un email arbitraire) ni l'IP (un verrou par IP priverait de re-autorisation tous les equipiers honnetes d'un poste a session partagee). A chaque echec hors verrou : upsert atomique de la ligne cle sur `actor_user_id` (insert sinon increment ; fenetre glissante reinitialisee via `window_started_at` quand elle expire), `last_attempt_at = NOW()`, et au-dela d'un seuil (suggestion 5) pose `lockout_until` avec le meme backoff degressif que RG-8 mais des bornes propres (PIN_THROTTLE_*, plus permissives : base 30s, plafond 300s — ne pas bloquer un manager en plein rush). Backoff degressif, pas verrou definitif. Le verrou est evalue AVANT la verification argon2id ; un acteur verrouille recoit le MEME message generique 'Email ou PIN invalide' (ne revele ni l'existence d'un compte ni l'etat de verrou, RG-2) et l'on paie un leurre de timing pour egaliser la latence avec le chemin mauvais-PIN. Sous verrou actif, aucune nouvelle ligne `audit_log` `pin.failed` n'est ecrite (les echecs ayant arme le verrou sont deja audites), ce qui borne l'amplification de l'audit append-only (RG-T14). En cas d'erreur de lecture du throttle, la requete echoue (fail-closed, pas de contournement silencieux du verrou). Le hook est pose sur la branche de changement sensible dans `update` (prix/TVA) et inconditionnellement dans `delete`. Purge cron des lignes sans verrou actif au-dela de THROTTLE_PURGE_AFTER_HOURS, comme `login_throttle`. Detection : un pic de `pin.failed` reste le controle detectif (alerte de volume) ; un PIN de plus de 4 chiffres pour les roles sensibles est recommande. | 8.2, 8.3, 8.6, 9.2, 10.1-10.5 | --- @@ -659,6 +660,10 @@ techniques, pas de declencheur utilisateur) mais sont documentes ici par coheren | **[RG-2]** | Les lignes servant encore un verrouillage actif sont conservees ; le compteur par IP (S1) est borne par cette purge de sorte que la table ne croit pas de maniere illimitee a cause de tentatives ponctuelles. | | **[POST-1]** | Lignes `login_throttle` obsoletes retirees ; throttles actifs et activite recente preserves. | +La meme purge s'applique a `pin_throttle` (RG-T22), avec le meme predicat et le meme +seuil `THROTTLE_PURGE_AFTER_HOURS` : +`DELETE FROM pin_throttle WHERE (lockout_until IS NULL OR lockout_until < NOW()) AND last_attempt_at < NOW() - INTERVAL 24 HOUR`. + --- ## 14. Machine a etats — recapitulatif de coherence (MLT) diff --git a/src/app/Auth/PinThrottle.php b/src/app/Auth/PinThrottle.php new file mode 100644 index 0000000..0066cba --- /dev/null +++ b/src/app/Auth/PinThrottle.php @@ -0,0 +1,142 @@ + non + * verrouille (defensif). + */ + public function isLocked(int $actorUserId, ?int $now = null): bool + { + if ($actorUserId <= 0) { + return false; + } + + $now ??= time(); + + $row = $this->db->fetch( + 'SELECT lockout_until FROM pin_throttle WHERE actor_user_id = :uid', + ['uid' => $actorUserId], + ); + + $lockoutUntil = is_string($row['lockout_until'] ?? null) ? (string) $row['lockout_until'] : null; + + return ThrottlePolicy::fromConfig($this->config, 'pin')->isLockedUntil($lockoutUntil, $now); + } + + /** + * Enregistre un echec de PIN pour l'utilisateur agissant, en une transaction + * (RG-T08) : upsert atomique du compteur (fenetre glissante reinitialisee en SQL + * si expiree, verrou de ligne anti lost-update) puis pose du verrou degressif. + * Ne touche JAMAIS user ni login_throttle (RG-T22) et n'ecrit pas d'audit_log. + */ + public function recordFailure(int $actorUserId, ?int $now = null): void + { + if ($actorUserId <= 0) { + return; + } + + $now ??= time(); + $nowDt = date('Y-m-d H:i:s', $now); + $windowSeconds = $this->config->int('PIN_THROTTLE_WINDOW_SECONDS', 900); + $windowCutoff = date('Y-m-d H:i:s', $now - $windowSeconds); + $policy = ThrottlePolicy::fromConfig($this->config, 'pin'); + + $this->db->transaction(function (DatabaseInterface $db) use ($actorUserId, $nowDt, $windowCutoff, $policy, $now): void { + // Increment ATOMIQUE cote SQL sous le verrou de ligne pris par l'upsert + // (anti lost-update sous POSTs concurrents). Placeholders distincts : en + // prepare reelle (EMULATE_PREPARES = false) un meme nom ne peut etre lie + // qu'une fois. Meme forme que AuthService (dimension IP). + $db->execute( + 'INSERT INTO pin_throttle (actor_user_id, failed_attempts, window_started_at, last_attempt_at) ' + . 'VALUES (:uid, 1, :now_i, :now_li) ' + . 'ON DUPLICATE KEY UPDATE ' + . 'failed_attempts = IF(window_started_at < :cutoff, 1, failed_attempts + 1), ' + . 'window_started_at = IF(window_started_at < :cutoff2, :now_w, window_started_at), ' + . 'last_attempt_at = :now_lu', + [ + 'uid' => $actorUserId, + 'now_i' => $nowDt, + 'now_li' => $nowDt, + 'cutoff' => $windowCutoff, + 'cutoff2' => $windowCutoff, + 'now_w' => $nowDt, + 'now_lu' => $nowDt, + ], + ); + + // Relit le compteur autoritaire (ligne deja verrouillee par cette tx) + // pour calculer le backoff en PHP, puis pose le verrou. + $row = $db->fetch('SELECT failed_attempts FROM pin_throttle WHERE actor_user_id = :uid', ['uid' => $actorUserId]); + $attempts = (int) ($row['failed_attempts'] ?? 1); + $lockSeconds = $policy->lockoutSeconds($attempts); + $lockUntil = $lockSeconds > 0 ? date('Y-m-d H:i:s', $now + $lockSeconds) : null; + + $db->execute( + 'UPDATE pin_throttle SET lockout_until = :lock WHERE actor_user_id = :uid', + ['lock' => $lockUntil, 'uid' => $actorUserId], + ); + }); + } + + /** + * PIN valide : remet a zero le compteur de l'utilisateur agissant (un manager + * qui s'est trompe puis a reussi n'est pas penalise plus tard). UPDATE simple + * (0 ligne si aucune n'existait, benin), SANS transaction propre : le controleur + * l'appelle apres l'effet reussi, sur sa propre connexion. + */ + public function reset(int $actorUserId, ?int $now = null): void + { + if ($actorUserId <= 0) { + return; + } + + $now ??= time(); + $nowDt = date('Y-m-d H:i:s', $now); + + $this->db->execute( + 'UPDATE pin_throttle SET failed_attempts = 0, lockout_until = NULL, ' + . 'window_started_at = :now_w, last_attempt_at = :now_l WHERE actor_user_id = :uid', + ['now_w' => $nowDt, 'now_l' => $nowDt, 'uid' => $actorUserId], + ); + } +} diff --git a/src/app/Auth/PinVerifier.php b/src/app/Auth/PinVerifier.php index 99e1d7c..4bcf4c7 100644 --- a/src/app/Auth/PinVerifier.php +++ b/src/app/Auth/PinVerifier.php @@ -98,6 +98,18 @@ final class PinVerifier return ['id' => (int) ($row['id'] ?? 0), 'role_id' => (int) ($row['role_id'] ?? 0)]; } + /** + * Paie le cout de hachage d'un leurre argon2id sans verifier de PIN reel. Sert + * au chemin "acteur verrouille" (RG-T22) : quand le throttle bloque AVANT toute + * verification, on paie quand meme ce cout pour egaliser le timing avec le + * chemin mauvais-PIN. Sans lui, une reponse verrouillee reviendrait en + * microsecondes (aucun verify) et trahirait l'etat de verrou par la latence. + */ + public function payTimingDecoy(string $pin): void + { + $this->hasher->verifyDecoy($pin); + } + /** * Politique de PIN a verifier cote serveur avant de hacher un nouveau PIN * (P3, definition du PIN) : chiffres ASCII uniquement, bornes min ET max diff --git a/src/app/Auth/ThrottlePolicy.php b/src/app/Auth/ThrottlePolicy.php index e513cb3..130a970 100644 --- a/src/app/Auth/ThrottlePolicy.php +++ b/src/app/Auth/ThrottlePolicy.php @@ -11,9 +11,11 @@ use App\Core\Config; * superglobale : c'est le calcul de securite le plus delicat (backoff degressif * + evaluation du verrou), donc isole ici pour etre entierement testable. * - * La meme courbe sert aux deux dimensions : par compte (user.lockout_until, - * seuil ACCOUNT_LOCKOUT_THRESHOLD) et par IP source (login_throttle.lockout_until, - * seuil IP_THROTTLE_MAX_ATTEMPTS), instanciees via fromConfig(). + * La meme courbe sert a trois dimensions, instanciees via fromConfig() : par + * compte (user.lockout_until, seuil ACCOUNT_LOCKOUT_THRESHOLD) et par IP source + * (login_throttle.lockout_until, seuil IP_THROTTLE_MAX_ATTEMPTS) pour la connexion + * (RG-8), et par utilisateur agissant (pin_throttle.lockout_until, RG-T22) pour le + * PIN d'action sensible, avec ses propres bornes (PIN_THROTTLE_*, plus permissives). */ final class ThrottlePolicy { @@ -67,12 +69,24 @@ final class ThrottlePolicy } /** - * Construit la politique pour la dimension 'account' (par compte) ou 'ip' - * (par IP source). RG-8 precise "le meme backoff degressif" pour l'IP, donc - * la dimension IP reutilise base/max et prend IP_THROTTLE_MAX_ATTEMPTS comme seuil. + * Construit la politique pour la dimension 'account' (par compte), 'ip' (par IP + * source) ou 'pin' (par utilisateur agissant, RG-T22). RG-8 precise "le meme + * backoff degressif" pour l'IP, donc la dimension IP reutilise base/max et prend + * IP_THROTTLE_MAX_ATTEMPTS comme seuil. La dimension 'pin' a ses PROPRES bornes + * (PIN_THROTTLE_*) : volontairement plus permissives que le login (base 30s, + * plafond 300s) car un faux positif bloque un manager en plein rush et le PIN + * est un controle de dissuasion (residuel Faible). */ public static function fromConfig(Config $config, string $dimension): self { + if ($dimension === 'pin') { + return new self( + $config->int('PIN_THROTTLE_THRESHOLD', 5), + $config->int('PIN_THROTTLE_BASE_SECONDS', 30), + $config->int('PIN_THROTTLE_MAX_SECONDS', 300), + ); + } + $base = $config->int('ACCOUNT_LOCKOUT_BASE_SECONDS', 60); $max = $config->int('ACCOUNT_LOCKOUT_MAX_SECONDS', 900); diff --git a/src/app/Controllers/ProductController.php b/src/app/Controllers/ProductController.php index 72a49a0..6a487f4 100644 --- a/src/app/Controllers/ProductController.php +++ b/src/app/Controllers/ProductController.php @@ -8,6 +8,7 @@ use PDOException; use App\Auth\Csrf; use App\Auth\GuardResult; use App\Auth\PasswordHasher; +use App\Auth\PinThrottle; use App\Auth\PinVerifier; use App\Catalogue\CategoryRepository; use App\Catalogue\ProductRepository; @@ -142,9 +143,22 @@ class ProductController extends AdminController } // Changement sensible : exige email + PIN (modele equipier + PIN, RG-T13). + // RG-T22 : verrou de throttle PIN par UTILISATEUR AGISSANT (session), evalue + // AVANT la verification argon2id. Un acteur verrouille recoit le MEME 422 + // generique ; on paie un leurre de timing (parite avec le chemin mauvais-PIN) + // et on n'ecrit PAS de nouvelle ligne pin.failed (les echecs ayant arme le + // verrou sont deja audites : borne l'amplification de l'audit append-only). + $actorId = $guard->userId ?? 0; + if ($actorId > 0 && $this->pinThrottle()->isLocked($actorId)) { + $this->pinVerifier()->payTimingDecoy($form['pin'] ?? ''); + + return $this->renderForm($guard, $id, $form, ['pin' => 'Email ou PIN invalide (requis pour modifier prix/TVA).'], 422); + } + $actor = $this->pinVerifier()->resolveActingUser(trim($form['pin_email'] ?? ''), $form['pin'] ?? ''); if ($actor === null) { $this->logFailedPin(trim($form['pin_email'] ?? ''), $id); + $this->pinThrottle()->recordFailure($actorId); return $this->renderForm($guard, $id, $form, ['pin' => 'Email ou PIN invalide (requis pour modifier prix/TVA).'], 422); } @@ -156,6 +170,12 @@ class ProductController extends AdminController $this->writeAudit($db, 'product.update', $actor['id'], $actor['role_id'], $id, $summary); }); + // PIN valide : reinitialise le compteur de throttle de l'acteur de SESSION + // (RG-T22), apres l'effet reussi. Cle = $actorId ($guard->userId), la meme + // qu'a l'increment ; surtout PAS $actor['id'] (l'equipier resolu par le PIN, + // un autre individu) sinon le compteur de l'agissant ne serait jamais purge. + $this->pinThrottle()->reset($actorId); + $this->setFlash('Produit mis a jour (changement de prix/TVA trace).'); return $this->redirect('/admin/products'); @@ -201,9 +221,19 @@ class ProductController extends AdminController return $this->notFound($guard); } + // RG-T22 : meme garde que update() (verrou par utilisateur agissant, AVANT + // la verification, leurre de timing, pas de pin.failed sous verrou actif). + $actorId = $guard->userId ?? 0; + if ($actorId > 0 && $this->pinThrottle()->isLocked($actorId)) { + $this->pinVerifier()->payTimingDecoy($form['pin'] ?? ''); + + return $this->renderDelete($guard, $id, $product, 'Email ou PIN invalide (requis pour supprimer).'); + } + $actor = $this->pinVerifier()->resolveActingUser(trim($form['pin_email'] ?? ''), $form['pin'] ?? ''); if ($actor === null) { $this->logFailedPin(trim($form['pin_email'] ?? ''), $id); + $this->pinThrottle()->recordFailure($actorId); return $this->renderDelete($guard, $id, $product, 'Email ou PIN invalide (requis pour supprimer).'); } @@ -225,6 +255,11 @@ class ProductController extends AdminController throw $exception; } + // PIN valide et suppression effective : reinitialise le compteur de l'acteur + // de session (RG-T22, cle = $actorId). Apres le try/catch : non atteint si la + // FK a bloque (422), ce qui est benin (l'acteur n'est pas un attaquant). + $this->pinThrottle()->reset($actorId); + $this->setFlash('Produit supprime.'); return $this->redirect('/admin/products'); @@ -245,6 +280,11 @@ class ProductController extends AdminController return new PinVerifier($this->db(), $this->config, $this->passwordHasher()); } + protected function pinThrottle(): PinThrottle + { + return new PinThrottle($this->db(), $this->config); + } + protected function passwordHasher(): PasswordHasher { return new PasswordHasher($this->config); @@ -330,8 +370,11 @@ class ProductController extends AdminController * le brute-force d'attribution detectable/alertable (un pic de pin.failed pour * un email cible est visible en revue). Acteur inconnu (PIN non resolu). * - * NB : ce n'est PAS un verrou. Un throttling degressif du PIN (par compte/IP) - * reste a ajouter en hardening dedie (decision de schema, cf. SESSION_RESUME). + * NB : cette ligne d'audit n'est PAS le verrou. Le throttle degressif (par + * utilisateur agissant) est porte par PinThrottle / RG-T22 ; il ecrit une + * nouvelle ligne pin.failed UNIQUEMENT hors verrou actif (sous verrou, les + * echecs ayant arme le verrou sont deja audites), ce qui borne l'amplification + * de l'audit append-only (RG-T14). */ private function logFailedPin(string $email, int $productId): void { diff --git a/tests/Integration/PinThrottleDbTest.php b/tests/Integration/PinThrottleDbTest.php new file mode 100644 index 0000000..2549e40 --- /dev/null +++ b/tests/Integration/PinThrottleDbTest.php @@ -0,0 +1,123 @@ +config = new Config(); + $this->db = new Database($this->config); + + try { + $this->db->fetch('SELECT 1'); + } catch (Throwable $exception) { + self::markTestSkipped('Base injoignable: ' . $exception->getMessage()); + } + + $roleRow = $this->db->fetch('SELECT id FROM role ORDER BY id LIMIT 1'); + $roleId = (int) ($roleRow['id'] ?? 0); + self::assertGreaterThan(0, $roleId, 'role seede attendu'); + + $hasher = new PasswordHasher($this->config); + $this->db->execute( + 'INSERT INTO user (email, password_hash, first_name, last_name, role_id, is_active) ' + . 'VALUES (:email, :pwd, :fn, :ln, :role, 1)', + [ + 'email' => 'it-pinthr-' . bin2hex(random_bytes(6)) . '@wakdo.invalid', + 'pwd' => $hasher->hash('IntegrationPass1'), + 'fn' => 'Integration', + 'ln' => 'PinThrottle', + 'role' => $roleId, + ], + ); + $this->userId = (int) ($this->db->fetch('SELECT LAST_INSERT_ID() AS id')['id'] ?? 0); + } + + protected function tearDown(): void + { + if ($this->userId === 0) { + return; + } + + // FK ON DELETE CASCADE : la ligne pin_throttle de cet acteur part avec lui. + $this->db->execute('DELETE FROM user WHERE id = :id', ['id' => $this->userId]); + $this->userId = 0; + } + + private function throttle(): PinThrottle + { + return new PinThrottle($this->db, $this->config); + } + + public function testRecordFailureIncrementsAndLocksWithoutTouchingLoginCounters(): void + { + $now = time(); + $throttle = $this->throttle(); + + for ($i = 0; $i < 5; $i++) { + $throttle->recordFailure($this->userId, $now); + } + + $row = $this->db->fetch('SELECT failed_attempts, lockout_until FROM pin_throttle WHERE actor_user_id = :id', ['id' => $this->userId]); + self::assertNotNull($row); + self::assertSame(5, (int) ($row['failed_attempts'] ?? 0)); + self::assertNotNull($row['lockout_until'] ?? null, 'verrou pose au seuil'); + self::assertTrue(strtotime((string) $row['lockout_until']) > $now, 'verrou dans le futur'); + + self::assertTrue($throttle->isLocked($this->userId, $now)); + + // ISOLATION : aucun compteur de connexion touche par les echecs de PIN. + $userRow = $this->db->fetch('SELECT failed_login_attempts, lockout_until FROM user WHERE id = :id', ['id' => $this->userId]); + self::assertSame(0, (int) ($userRow['failed_login_attempts'] ?? -1)); + self::assertNull($userRow['lockout_until'] ?? null); + } + + public function testResetClearsTheActorRow(): void + { + $now = time(); + $throttle = $this->throttle(); + + for ($i = 0; $i < 5; $i++) { + $throttle->recordFailure($this->userId, $now); + } + $throttle->reset($this->userId, $now); + + $row = $this->db->fetch('SELECT failed_attempts, lockout_until FROM pin_throttle WHERE actor_user_id = :id', ['id' => $this->userId]); + self::assertSame(0, (int) ($row['failed_attempts'] ?? -1)); + self::assertNull($row['lockout_until'] ?? null); + self::assertFalse($throttle->isLocked($this->userId, $now)); + } +} diff --git a/tests/Support/FakeDatabase.php b/tests/Support/FakeDatabase.php index 4db6b5e..c3d0051 100644 --- a/tests/Support/FakeDatabase.php +++ b/tests/Support/FakeDatabase.php @@ -142,6 +142,15 @@ final class FakeDatabase implements DatabaseInterface */ public ?array $actingUserRow = null; + /** + * lockout_until renvoye pour la porte du throttle PIN (RG-T22, PinThrottle::isLocked) ; + * null = pas de verrou. + */ + public ?string $pinThrottleLockoutUntil = null; + + /** Compteur pin_throttle relu apres l'upsert (PinThrottle::recordFailure) ; 1 par defaut. */ + public int $pinThrottleAttempts = 1; + /** Si non nul, execute() leve cette exception (simulation panne DB / violation de contrainte). */ public ?Throwable $failOnExecute = null; @@ -229,6 +238,14 @@ final class FakeDatabase implements DatabaseInterface return $this->categorySlugTaken ? ['id' => 1] : null; } + if (str_contains($sql, 'lockout_until FROM pin_throttle')) { + return ['lockout_until' => $this->pinThrottleLockoutUntil]; + } + + if (str_contains($sql, 'failed_attempts FROM pin_throttle')) { + return ['failed_attempts' => $this->pinThrottleAttempts]; + } + if (str_contains($sql, 'SELECT lockout_until FROM login_throttle')) { return ['lockout_until' => $this->ipLockoutUntil]; } diff --git a/tests/Unit/Admin/ProductControllerTest.php b/tests/Unit/Admin/ProductControllerTest.php index 9d98fc6..1a476eb 100644 --- a/tests/Unit/Admin/ProductControllerTest.php +++ b/tests/Unit/Admin/ProductControllerTest.php @@ -326,13 +326,93 @@ final class ProductControllerTest extends TestCase self::assertFalse($db->wrote('INSERT INTO product')); } + public function testUpdateLockedActorReturnsGeneric422WithoutVerifyingOrAuditing(): void + { + // RG-T22 : acteur de session verrouille. Le verrou est evalue AVANT la + // verification ; meme un PIN valide est bloque, le 422 reste generique, et + // AUCUNE nouvelle ligne pin.failed n'est ecrite (borne anti-flood). + $db = $this->permittedDb(); + $db->productRow = ['id' => 5, 'category_id' => 3, 'name' => 'Big Mac', 'description' => null, 'price_cents' => 590, 'vat_rate' => 100, 'image_path' => null, 'is_available' => 1, 'display_order' => 1]; + $this->actingPin($db); // PIN '4729' valide en base + $db->pinThrottleLockoutUntil = '2099-01-01 00:00:00'; // acteur verrouille + + $form = $this->validForm(['price_cents' => '620', 'pin_email' => 'staff@wakdo.local', 'pin' => '4729']); + $response = $this->controller($this->post($form, '/admin/products/5'), $db)->update(['id' => '5']); + + self::assertSame(422, $response->status()); + self::assertStringContainsString('PIN', $response->body()); + self::assertFalse($db->wrote('UPDATE product SET')); // PIN valide mais verrou prioritaire + self::assertSame([], $db->auditActions()); // pas de pin.failed sous verrou + } + + public function testUpdateWrongPinRecordsFailureOnSessionActor(): void + { + $db = $this->permittedDb(); + $db->productRow = ['id' => 5, 'category_id' => 3, 'name' => 'Big Mac', 'description' => null, 'price_cents' => 590, 'vat_rate' => 100, 'image_path' => null, 'is_available' => 1, 'display_order' => 1]; + $db->actingUserRow = null; // email/PIN invalide + + $form = $this->validForm(['price_cents' => '620', 'pin_email' => 'ghost@wakdo.local', 'pin' => '0000']); + $response = $this->controller($this->post($form, '/admin/products/5'), $db)->update(['id' => '5']); + + self::assertSame(422, $response->status()); + self::assertSame(['pin.failed'], $db->auditActions()); // detectabilite preservee + // RG-T22 : le compteur est incremente sur l'AGISSANT (session id 1), pas sur + // l'email cible tente (qui serait contournable par rotation). + $upsert = $this->findWrite($db, 'INSERT INTO pin_throttle'); + self::assertNotNull($upsert); + self::assertSame(1, $upsert['params']['uid'] ?? null); + } + + public function testUpdateValidPinResetsThrottleOnSessionActorNotResolvedUser(): void + { + $db = $this->permittedDb(); + $db->productRow = ['id' => 5, 'category_id' => 3, 'name' => 'Big Mac', 'description' => null, 'price_cents' => 590, 'vat_rate' => 100, 'image_path' => null, 'is_available' => 1, 'display_order' => 1]; + $this->actingPin($db); + + $form = $this->validForm(['price_cents' => '620', 'pin_email' => 'staff@wakdo.local', 'pin' => '4729']); + $response = $this->controller($this->post($form, '/admin/products/5'), $db)->update(['id' => '5']); + + self::assertSame(302, $response->status()); + // L'audit porte l'acteur RESOLU PAR PIN (id 9)... + $audit = $this->firstAudit($db); + self::assertSame(9, $audit['params']['uid'] ?? null); + // ...mais le reset du throttle porte l'acteur de SESSION (id 1), le seul qui + // a ete incremente. Confondre les deux laisserait le compteur de l'agissant + // jamais purge (must-fix de revue). + $reset = $this->findWrite($db, 'UPDATE pin_throttle SET failed_attempts = 0'); + self::assertNotNull($reset); + self::assertSame(1, $reset['params']['uid'] ?? null); + } + + public function testDestroyLockedActorReturnsGeneric422(): void + { + $db = $this->permittedDb(); + $db->productRow = ['id' => 5, 'name' => 'Big Mac']; + $this->actingPin($db); + $db->pinThrottleLockoutUntil = '2099-01-01 00:00:00'; + + $response = $this->controller($this->post(['_csrf' => $this->csrf, 'pin_email' => 'staff@wakdo.local', 'pin' => '4729'], '/admin/products/5/delete'), $db)->destroy(['id' => '5']); + + self::assertSame(422, $response->status()); + self::assertFalse($db->wrote('DELETE FROM product')); + self::assertSame([], $db->auditActions()); + } + /** * @return array{sql: string, params: array}|null */ private function firstAudit(FakeDatabase $db): ?array + { + return $this->findWrite($db, 'INSERT INTO audit_log'); + } + + /** + * @return array{sql: string, params: array}|null + */ + private function findWrite(FakeDatabase $db, string $needle): ?array { foreach ($db->writes as $write) { - if (str_contains($write['sql'], 'INSERT INTO audit_log')) { + if (str_contains($write['sql'], $needle)) { return $write; } } diff --git a/tests/Unit/Auth/PinThrottleTest.php b/tests/Unit/Auth/PinThrottleTest.php new file mode 100644 index 0000000..7b0fc65 --- /dev/null +++ b/tests/Unit/Auth/PinThrottleTest.php @@ -0,0 +1,169 @@ + */ + private array $touchedKeys = []; + + private FakeDatabase $db; + + protected function setUp(): void + { + $this->setEnv('PIN_THROTTLE_THRESHOLD', '5'); + $this->setEnv('PIN_THROTTLE_BASE_SECONDS', '30'); + $this->setEnv('PIN_THROTTLE_MAX_SECONDS', '300'); + $this->setEnv('PIN_THROTTLE_WINDOW_SECONDS', '900'); + + $this->db = new FakeDatabase(); + } + + protected function tearDown(): void + { + foreach ($this->touchedKeys as $key) { + putenv($key); + } + $this->touchedKeys = []; + } + + private function setEnv(string $key, string $value): void + { + $this->touchedKeys[] = $key; + putenv($key . '=' . $value); + } + + private function throttle(): PinThrottle + { + return new PinThrottle($this->db, new Config()); + } + + /** + * @return array{sql: string, params: array}|null + */ + private function find(string $needle): ?array + { + foreach ($this->db->writes as $write) { + if (str_contains($write['sql'], $needle)) { + return $write; + } + } + + return null; + } + + private function assertNoLoginCounterTouched(): void + { + // Invariant dur RG-T22 : un echec de PIN ne touche JAMAIS les compteurs de + // connexion. Retirer cette separation ferait virer ce test au rouge. + foreach ($this->db->writes as $write) { + self::assertStringNotContainsString('failed_login_attempts', $write['sql']); + self::assertStringNotContainsString('login_throttle', $write['sql']); + self::assertStringNotContainsString('audit_log', $write['sql']); + } + } + + public function testIsLockedTrueWhenLockoutInFuture(): void + { + $now = 1_000_000; + $this->db->pinThrottleLockoutUntil = date('Y-m-d H:i:s', $now + 60); + + self::assertTrue($this->throttle()->isLocked(9, $now)); + } + + public function testIsLockedFalseWhenNoLockOrPast(): void + { + $now = 1_000_000; + + $this->db->pinThrottleLockoutUntil = null; + self::assertFalse($this->throttle()->isLocked(9, $now)); + + $this->db->pinThrottleLockoutUntil = date('Y-m-d H:i:s', $now - 1); + self::assertFalse($this->throttle()->isLocked(9, $now)); + } + + public function testIsLockedFalseWhenNoActor(): void + { + // actorUserId <= 0 (pas de session derriere guard()) : non verrouille, et + // aucune lecture inutile (defensif). + self::assertFalse($this->throttle()->isLocked(0)); + self::assertSame([], $this->db->reads); + } + + public function testRecordFailureOneTransactionUpsertThenLockNoLoginState(): void + { + // Au seuil : le compteur relu vaut 5 -> backoff 30s -> verrou pose. + $this->db->pinThrottleAttempts = 5; + + $this->throttle()->recordFailure(9, 1_000_000); + + // Une seule transaction (RG-T08). + self::assertSame(['begin', 'commit'], $this->db->transactionEvents); + + $upsert = $this->find('INSERT INTO pin_throttle'); + self::assertNotNull($upsert); + self::assertStringContainsString('ON DUPLICATE KEY UPDATE', $upsert['sql']); + self::assertSame(9, $upsert['params']['uid'] ?? null); + + $lock = $this->find('UPDATE pin_throttle SET lockout_until'); + self::assertNotNull($lock); + self::assertSame(date('Y-m-d H:i:s', 1_000_000 + 30), $lock['params']['lock'] ?? null); + self::assertSame(9, $lock['params']['uid'] ?? null); + + $this->assertNoLoginCounterTouched(); + } + + public function testRecordFailureBelowThresholdSetsNoLock(): void + { + $this->db->pinThrottleAttempts = 1; // sous le seuil 5 + + $this->throttle()->recordFailure(9, 1_000_000); + + $lock = $this->find('UPDATE pin_throttle SET lockout_until'); + self::assertNotNull($lock); + self::assertArrayHasKey('lock', $lock['params']); + self::assertNull($lock['params']['lock']); // verrou null sous le seuil + $this->assertNoLoginCounterTouched(); + } + + public function testRecordFailureNoActorIsNoop(): void + { + $this->throttle()->recordFailure(0); + + self::assertSame([], $this->db->writes); + self::assertSame([], $this->db->transactionEvents); + } + + public function testResetClearsActorCounterNoLoginState(): void + { + $this->throttle()->reset(9, 1_000_000); + + $reset = $this->find('UPDATE pin_throttle SET failed_attempts = 0'); + self::assertNotNull($reset); + self::assertStringContainsString('lockout_until = NULL', $reset['sql']); + self::assertSame(9, $reset['params']['uid'] ?? null); + // reset = UPDATE simple, hors transaction propre (inclus dans l'effet controleur). + self::assertSame([], $this->db->transactionEvents); + $this->assertNoLoginCounterTouched(); + } + + public function testResetNoActorIsNoop(): void + { + $this->throttle()->reset(0); + + self::assertSame([], $this->db->writes); + } +} diff --git a/tests/Unit/Auth/PinVerifierTest.php b/tests/Unit/Auth/PinVerifierTest.php index 0080db5..5c74922 100644 --- a/tests/Unit/Auth/PinVerifierTest.php +++ b/tests/Unit/Auth/PinVerifierTest.php @@ -123,6 +123,17 @@ final class PinVerifierTest extends TestCase self::assertNull($this->verifier()->resolveActingUser('staff@wakdo.local', '')); } + public function testPayTimingDecoyHashesWithoutTouchingDatabase(): void + { + // Chemin "acteur verrouille" (RG-T22) : on paie le cout argon2id sans aucune + // lecture/ecriture DB, pour egaliser le timing avec le chemin mauvais-PIN + // sans introduire d'oracle (aucune requete = rien a observer). + $this->verifier()->payTimingDecoy('4729'); + + self::assertSame([], $this->db->reads); + self::assertSame([], $this->db->writes); + } + public function testMeetsLengthPolicy(): void { $verifier = $this->verifier(); diff --git a/tests/Unit/Auth/ThrottlePolicyTest.php b/tests/Unit/Auth/ThrottlePolicyTest.php index a93702f..6d8969b 100644 --- a/tests/Unit/Auth/ThrottlePolicyTest.php +++ b/tests/Unit/Auth/ThrottlePolicyTest.php @@ -130,4 +130,28 @@ final class ThrottlePolicyTest extends TestCase self::assertSame(60, $policy->lockoutSeconds(20)); self::assertSame(120, $policy->lockoutSeconds(21)); } + + public function testFromConfigPinReadsPinKeysWithItsOwnBounds(): void + { + // RG-T22 : la dimension 'pin' a ses propres bornes (PIN_THROTTLE_*), distinctes + // du login, et volontairement plus permissives (base 30s, plafond 300s). + $this->setEnv('PIN_THROTTLE_THRESHOLD', '5'); + $this->setEnv('PIN_THROTTLE_BASE_SECONDS', '30'); + $this->setEnv('PIN_THROTTLE_MAX_SECONDS', '300'); + // Cles du login mises a des valeurs differentes : si 'pin' les lisait par + // erreur, la courbe ci-dessous changerait. + $this->setEnv('ACCOUNT_LOCKOUT_THRESHOLD', '3'); + $this->setEnv('ACCOUNT_LOCKOUT_BASE_SECONDS', '60'); + $this->setEnv('ACCOUNT_LOCKOUT_MAX_SECONDS', '900'); + + $policy = ThrottlePolicy::fromConfig(new Config(), 'pin'); + + self::assertSame(0, $policy->lockoutSeconds(4)); + self::assertSame(30, $policy->lockoutSeconds(5)); + self::assertSame(60, $policy->lockoutSeconds(6)); + self::assertSame(120, $policy->lockoutSeconds(7)); + self::assertSame(240, $policy->lockoutSeconds(8)); + self::assertSame(300, $policy->lockoutSeconds(9)); // plafond PIN (300), pas 480 + self::assertSame(300, $policy->lockoutSeconds(40)); // plafond + garde anti-debordement + } } -- 2.45.3 From e3d08464bbb44afa60bedde035643be57bf29dbb Mon Sep 17 00:00:00 2001 From: Corentin JOGUET Date: Tue, 16 Jun 2026 14:19:42 +0200 Subject: [PATCH 20/93] ci: run DB integration tests against an ephemeral MariaDB service (#21) --- .forgejo/workflows/ci.yml | 53 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 50 insertions(+), 3 deletions(-) diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index b2a6530..02ddf7a 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -61,9 +61,32 @@ jobs: # COMPOSER-LESS (decision 4 / 5, PROJECT_CONTEXT.md) : PHPStan et PHPUnit # tournent depuis leur .phar autonome telecharge ici, jamais via Composer. # Versions epinglees pour des CI reproductibles (pas de "latest"). + # + # Service MariaDB ephemere : le schema (db/migrations) et le seed (db/seeds) + # y sont appliques, puis PHPUnit tourne avec WAKDO_DB_TESTS=1 pour que les + # tests d'integration (tests/Integration/*DbTest) s'executent REELLEMENT. + # Sans base, ils s'auto-skippent et le SQL porteur de securite (throttle, + # RBAC is_active, audit in-transaction, FK) n'est jamais valide en CI. + # Identifiants ci-dessous : ephemeres, CI uniquement, jamais des secrets. + services: + mariadb: + image: mariadb:11.4 + env: + MARIADB_ROOT_PASSWORD: root + MARIADB_DATABASE: wakdo_test + MARIADB_USER: wakdo + MARIADB_PASSWORD: wakdo env: PHPUNIT_VERSION: "11.5.2" PHPSTAN_VERSION: "1.12.27" + # Connexion des tests d'integration au service `mariadb` ci-dessus + # (Database lit ces DB_* via getenv ; cf. src/app/Core/Database.php). + WAKDO_DB_TESTS: "1" + DB_HOST: mariadb + DB_PORT: "3306" + DB_NAME: wakdo_test + DB_USER: wakdo + DB_PASSWORD: wakdo steps: - uses: actions/checkout@v4 - name: PHPStan (guarded) @@ -82,7 +105,7 @@ jobs: php phpstan.phar --version # memory_limit=-1 : l'analyse parallele depasse les 128M par defaut du php-cli. php -d memory_limit=-1 phpstan.phar analyse --no-progress --error-format=raw - - name: PHPUnit (guarded) + - name: PHPUnit (guarded, avec tests d'integration DB) run: | set -eu if [ ! -d tests ] || [ ! -f phpunit.xml ]; then @@ -90,10 +113,34 @@ jobs: exit 0 fi echo "phpunit.xml + tests/ detected - running PHPUnit ${PHPUNIT_VERSION} via .phar" - apt-get update -qq && apt-get install -y -qq php-cli php-xml php-mbstring curl ca-certificates >/dev/null + # php-mysql = pilote pdo_mysql requis par les *DbTest ; mariadb-client + # pour appliquer schema + seed au service mariadb. + apt-get update -qq && apt-get install -y -qq php-cli php-xml php-mbstring php-mysql mariadb-client curl ca-certificates >/dev/null + # Attente active que le service MariaDB reponde (en plus du lien de service). + echo "Attente du service MariaDB ${DB_HOST}:${DB_PORT} ..." + ready=0 + for i in $(seq 1 30); do + if mariadb -h"${DB_HOST}" -P"${DB_PORT}" -u"${DB_USER}" -p"${DB_PASSWORD}" -e "SELECT 1" "${DB_NAME}" >/dev/null 2>&1; then + echo "MariaDB pret (tentative ${i})."; ready=1; break + fi + sleep 2 + done + [ "${ready}" = 1 ] || { echo "ERREUR: MariaDB injoignable apres 60s"; exit 1; } + # Schema (db/migrations) puis seed (db/seeds), ordre lexicographique. + for f in db/migrations/*.sql; do + echo "migrate $(basename "$f")" + mariadb -h"${DB_HOST}" -P"${DB_PORT}" -u"${DB_USER}" -p"${DB_PASSWORD}" "${DB_NAME}" < "$f" + done + for f in db/seeds/*.sql; do + echo "seed $(basename "$f")" + mariadb -h"${DB_HOST}" -P"${DB_PORT}" -u"${DB_USER}" -p"${DB_PASSWORD}" "${DB_NAME}" < "$f" + done curl -sSL "https://phar.phpunit.de/phpunit-${PHPUNIT_VERSION}.phar" -o phpunit.phar php phpunit.phar --version - php phpunit.phar -c phpunit.xml + # --fail-on-skipped : si un *DbTest s'auto-skippe (base injoignable), la + # CI echoue au lieu de masquer le trou derriere un vert. C'est le coeur + # du correctif : plus aucun skip silencieux des chemins securite. + php phpunit.phar -c phpunit.xml --fail-on-skipped auto-merge: # Fusion automatique OPT-IN : poser le label `auto-merge` sur la PR. -- 2.45.3 From 6653a7419dc1274c04ff5f62ff952de0d08d67da Mon Sep 17 00:00:00 2001 From: Corentin JOGUET Date: Tue, 16 Jun 2026 14:19:48 +0200 Subject: [PATCH 21/93] docs(journal): audit reel des livrables P2/P3 (2026-06-16) (#22) --- .../2026-06-16--audit-reel-livrables-p2-p3.md | 187 ++++++++++++++++++ docs/journal/README.md | 1 + 2 files changed, 188 insertions(+) create mode 100644 docs/journal/2026-06-16--audit-reel-livrables-p2-p3.md diff --git a/docs/journal/2026-06-16--audit-reel-livrables-p2-p3.md b/docs/journal/2026-06-16--audit-reel-livrables-p2-p3.md new file mode 100644 index 0000000..67dfac2 --- /dev/null +++ b/docs/journal/2026-06-16--audit-reel-livrables-p2-p3.md @@ -0,0 +1,187 @@ +# Audit reel des livrables P2/P3 — verification sur pieces + +**Date** : 2026-06-16 +**Branche** : `docs/journal-audit-2026-06-16` -> `dev` +**PR** : cette note (PR dediee) ; remediations associees : #19, #20, #21 +**Auteur** : BYAN +**Duree estimee** : 1 session + +--- + +## Ce qui a ete fait + +Verification du travail livre le 2026-06-15 (8 PR : P2 auth/RBAC/PIN, P3 shell + +CRUD categories/produits + set-PIN + throttle PIN), a la demande explicite de +controler "le reel, pas le journal" — suspicion d'un ecart non documente. + +Methode : controle sur pieces uniquement. + +- git : timeline du 2026-06-15, parents de commit, branches reellement presentes + cote Forgejo (2 : `dev`, `main`) ; +- code lu a la ligne (`file:line`) ; +- base MariaDB live interrogee (schema, seed, migrations trackees) ; +- suite de tests rejouee en conteneur ; API CI Forgejo (264 runs analyses) ; +- sweep multi-agents : 10 dimensions (PR #11-#18 + regles SbD RG-T01..T22 + + infra/config), chaque finding re-verifie en adversarial (confirmer le miss ou + le refuter), plus un critique de completude. + +--- + +## Resultat — le socle metier tient + +Confirme enforced dans le code (pas seulement documente), `file:line` a l'appui : +RG-T01 (CSRF sur les mutations), T02 (garde de session + re-verif `is_active`), +T03 (autorisation par permission, pas par nom de role), T06 (requetes preparees), +T08 (mutation + `audit_log` dans une seule transaction), T13 (PIN d'action +sensible), T14 (audit append-only), T16 (allowlist de colonnes), T18 (validation +serveur bornee), T22 (throttle PIN isole du login). + +Base live conforme au seed documente (5 roles / 23 permissions / 57 lignes de +matrice / 14 allergenes / 9 categories / 53 produits / 13 menus) ; migrations +`0001` + `0002` trackees appliquees. 188 tests / PHPStan L6 : reproduits verts en +conteneur. + +--- + +## Miss confirmes (par gravite) + +Severite issue de la passe adversariale, qui a parfois revu a la baisse +l'evaluation initiale. + +### CRITIQUE — durcissement php.ini absent du conteneur en service [OUVERT] + +`docker/php-fpm/php.ini` (durci le 2026-06-15 : `allow_url_fopen=Off`, +`disable_functions`, `cgi.fix_pathinfo=0`, `enable_dl=Off`) n'est pas actif sur +`wakdo-app`. `docker exec wakdo-app php -i` renvoie `allow_url_fopen=On`, +`disable_functions` vide, `enable_dl=On`. Cause : l'image date du 2026-04-30 (le +`php.ini` est `COPY`-e a la build, pas monte) et n'a pas ete reconstruite depuis +le durcissement. Correctif : rebuild de `wakdo-app` puis re-verif via `php -i`. + +### HIGH — la CI n'executait aucun test d'integration DB [CORRIGE, PR #21] + +`static-tests` lancait `phpunit` sans base ni `WAKDO_DB_TESTS=1` : les 7 +`tests/Integration/*DbTest` s'auto-skippaient (13 skips), donc le SQL porteur de +securite (upsert atomique du throttle, predicat `AND r.is_active = 1`, audit +in-transaction, FK RESTRICT/CASCADE) n'etait valide par aucun test en pipeline. +Le double `FakeDatabase` n'execute pas le SQL : une regression de ces requetes +passait la CI au vert. Corrige par un service MariaDB ephemere + application +schema/seed + `WAKDO_DB_TESTS=1` + `--fail-on-skipped`. CI verte verifiee sur le +runner (run #78 push + run #79 PR #21 : `secret-scan` / `php-lint` / +`static-tests` au vert). + +### MEDIUM + +- XSS stockee latente dans la borne (RG-T15) : 3 scripts injectaient + `product.nom` / `item.libelle` / `product.image` dans `innerHTML` sans + echappement (seul `page-product-menu.js` etait conforme). Donnees statiques + aujourd'hui, mais `data.js` documente la bascule P4 vers `/api/products` + (valeurs CRUD admin). [CORRIGE, PR #20] +- Liens de nav admin morts : `/admin/menus|orders|users|roles` exposes dans le + layout (conditionnes par permission) sans route -> 404 JSON. [OUVERT] +- Utilisateur DB applicatif en `GRANT ALL PRIVILEGES` alors que la doc + (compose, `backup-db.sh`) decrit un moindre privilege (SELECT / LOCK TABLES / + SHOW VIEW). [OUVERT] +- Retention RGPD (audit / order) + purge throttle : documentees comme purges + cron mais non implementees (pas de job actif, pas de script, vars non + injectees au conteneur cron). [OUVERT] + +### LOW + +- Enumeration d'email sur le reset de mot de passe : reponse instantanee sur + email inconnu vs travail + ecriture sur email connu. La parite timing/ecritures + tient sur le login, pas sur le reset. +- Suppression produit non entierement FK-safe : `product_ingredient.product_id` + est `ON DELETE CASCADE` (omis du docblock) -> suppression silencieuse de + recette possible, sans trace dans l'audit. Latent : table vide au seed actuel. +- Page `/admin/profile/pin` non liee dans la nav (joignable par URL directe). +- `PASSWORD_ALGO` expose en env mais code en dur (`PASSWORD_ARGON2ID`) : un + changement de valeur serait sans effet. +- Chemin d'echec PIN non atomique (`logFailedPin` hors transaction puis + `recordFailure` dans sa propre transaction), en tension avec RG-T08 (qui tient + sur le chemin de succes). +- `borne/data/produits.json` (66 produits, maquette statique) diverge de la + table `product` (53). + +### Faux positifs ecartes par la passe adversariale + +- "Throttle login partiel / non teste" : la double porte compte + IP est + complete, l'increment atomique et le predicat de fenetre sont couverts (unit + + integration), l'IP du dernier hop `X-Forwarded-For` n'est pas falsifiable. +- "Code mort `userId === null` post-guard" : c'est le narrowing `?int -> int` + requis par PHPStan L6, pas un defaut. + +--- + +## Remediations livrees cette session + +- **PR #19** : suppression des 6 maquettes `.html` du back-office servies sans + authentification (exposition / information disclosure). +- **PR #20** : echappement (`escHtml` centralise dans `state.js`) des chaines + data-derived injectees en `innerHTML` dans les 3 scripts kiosk (RG-T15). +- **PR #21** : execution des tests d'integration DB en CI (service MariaDB + + `WAKDO_DB_TESTS=1` + `--fail-on-skipped`). Recette validee en local (188 tests + / 525 assertions / 0 skip) puis sur le runner. + +--- + +## Reste a traiter (ordre suggere) + +1. **CRITIQUE** : reconstruire l'image `wakdo-app` pour activer le `php.ini` + durci. +2. **MEDIUM** : retirer ou router les liens de nav morts ; appliquer un GRANT de + moindre privilege au user DB ; implementer la purge RGPD / throttle (job cron + + script). +3. **LOW** : decoy de timing sur le reset ; pre-check FK + trace audit a la + suppression produit ; lien de nav vers la page PIN ; honorer ou retirer + `PASSWORD_ALGO` ; atomiser le chemin d'echec PIN. + +--- + +## Criteres RNCP couverts + +- **Bloc 5 - Cr 7.d.2 / 7.d.3** (CI/CD : application testee avant deploiement, + integration continue testee) : PR #21 fait reellement tourner les tests + d'integration en pipeline (avant, ils etaient skippes). +- **Bloc 2 - Cr 4.f.2** (maitrise de l'outil collaboratif : Git, PR, branches, + hooks) : remediation via PR dediees et branches courtes, CI gardee. +- **Securite (transverse)** : verification que les regles SbD documentees sont + effectivement appliquees ; fermeture d'une exposition (maquettes non gardees) + et d'une XSS latente. + +--- + +## Questions anticipees du jury + +- **Q** : "Vos tests etaient verts ; comment un trou a-t-il pu subsister ?" + **R** : la suite unitaire (188 verts) ne touchait pas le SQL reel (double en + memoire), et les tests d'integration s'auto-skippaient en CI. Le badge vert ne + couvrait pas la couche SQL. Corrige (PR #21) et garde par `--fail-on-skipped`. +- **Q** : "Le graphe des branches semble casse." + **R** : workflow squash-merge -> historique `dev` lineaire (1 PR = 1 commit) ; + les branches de feature apparaissent en moignons car le squash ne cree pas le + 2e parent d'un merge classique. Choix assume. +- **Q** : "Pourquoi le durcissement php.ini n'etait-il pas actif ?" + **R** : le `php.ini` est `COPY`-e dans l'image, pas monte ; l'image n'avait pas + ete reconstruite depuis le durcissement. Detecte par `php -i` sur le conteneur, + corrige par un rebuild. + +--- + +## Points d'amelioration conscients + +- Les findings MEDIUM / LOW restants sont traces ici et priorises ; ils ne + bloquent pas la suite P3, mais sont a fermer avant une mise en avant securite + au jury. +- `--fail-on-skipped` est volontairement strict : tout futur test legitimement + skippe devra etre justifie explicitement. + +--- + +## Liens vers artefacts + +- PR : #19 (maquettes), #20 (escHtml RG-T15), #21 (tests DB en CI). +- Fichiers cles : `.forgejo/workflows/ci.yml`, `docker/php-fpm/php.ini`, + `src/public/borne/assets/js/{state,page-products,page-product,page-cart}.js`, + `src/app/Auth/*`, `src/app/Controllers/*`, `db/migrations/`, `db/seeds/`. +- Methode : sweep multi-agents (10 dimensions) + verifications adversariales, + pilote depuis Claude Code. diff --git a/docs/journal/README.md b/docs/journal/README.md index 2b7afa7..73947df 100644 --- a/docs/journal/README.md +++ b/docs/journal/README.md @@ -32,6 +32,7 @@ Les fichiers sont ordonnes chronologiquement par leur nom. | 2026-04-30 | [smoke-test-infra](2026-04-30--smoke-test-infra.md) | Smoke test bout-en-bout sur serveur reel : fusion .env, switch FQDN sur stark.a3n.fr, subnet explicite RFC 1918, fix init cron + healthz | `feat/infra-docker` | | 2026-06-04 | [conception-prodlike-revision](2026-06-04--conception-prodlike-revision.md) | Revue d'alignement P1 + decisions prod-like du modele de donnees (drop commande_event, nommage EN, TVA par produit apres fact-check BOFiP, perso menus/ingredients, allergenes, ~16 entites) | `feat/p1-conception` | | 2026-06-15 | [p3-throttle-pin-rg-t22](2026-06-15--p3-throttle-pin-rg-t22.md) | P3 securite : throttle du PIN d'action sensible (RG-T22) — design multi-agents + verification adversariale, dimension "utilisateur agissant", entite 22 `pin_throttle` | `feat/p3-pin-throttle` -> `dev` | +| 2026-06-16 | [audit-reel-livrables-p2-p3](2026-06-16--audit-reel-livrables-p2-p3.md) | Verification sur pieces des livrables du 2026-06-15 (sweep 10 dimensions + adversarial) : socle SbD confirme, miss confirmes par gravite (php.ini non deploye, CI sans tests DB, XSS kiosk, liens morts...) et remediations | `docs/journal-audit-2026-06-16` -> `dev` (PR #19/#20/#21) | *Mis a jour a chaque nouvelle entree.* -- 2.45.3 From e62a9783b1beced5a62ff2315d6abdd5aa3bc370 Mon Sep 17 00:00:00 2001 From: Corentin JOGUET Date: Tue, 16 Jun 2026 14:19:58 +0200 Subject: [PATCH 22/93] fix(db): moindre privilege pour le user applicatif (drop GRANT ALL) (#24) --- db/init/10-scope-app-user.sh | 25 +++++++++++++++++++++++++ docker-compose.yml | 4 ++++ docker/cron/scripts/backup-db.sh | 7 +++++-- 3 files changed, 34 insertions(+), 2 deletions(-) create mode 100755 db/init/10-scope-app-user.sh diff --git a/db/init/10-scope-app-user.sh b/db/init/10-scope-app-user.sh new file mode 100755 index 0000000..bfe271a --- /dev/null +++ b/db/init/10-scope-app-user.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# +# Wakdo - durcissement du privilege du user applicatif (moindre privilege). +# +# L'image mariadb cree MARIADB_USER avec GRANT ALL PRIVILEGES sur la base +# MARIADB_DATABASE. C'est trop large : le code applicatif expose (back-office) +# n'a besoin que de DML, jamais de DDL (CREATE/ALTER/DROP), de GRANT OPTION ni +# de DROP. Les migrations tournent separement en root (db/migrate.sh). +# +# Ce script s'execute UNIQUEMENT au premier demarrage sur volume vierge +# (/docker-entrypoint-initdb.d). Pour une base deja initialisee, appliquer le +# meme REVOKE/GRANT manuellement en root (voir db/init/README ou la PR). +# +# Set retenu : DML (SELECT/INSERT/UPDATE/DELETE) + ce dont mysqldump peut avoir +# besoin (SHOW VIEW, TRIGGER, LOCK TABLES). Pas de DDL, pas de GRANT, pas de DROP. +set -euo pipefail + +mariadb --protocol=socket -uroot -p"${MARIADB_ROOT_PASSWORD}" <<-EOSQL + REVOKE ALL PRIVILEGES ON \`${MARIADB_DATABASE}\`.* FROM '${MARIADB_USER}'@'%'; + GRANT SELECT, INSERT, UPDATE, DELETE, SHOW VIEW, TRIGGER, LOCK TABLES + ON \`${MARIADB_DATABASE}\`.* TO '${MARIADB_USER}'@'%'; + FLUSH PRIVILEGES; +EOSQL + +echo "[init] privilege du user '${MARIADB_USER}' restreint au moindre privilege sur '${MARIADB_DATABASE}'." diff --git a/docker-compose.yml b/docker-compose.yml index d273c17..6300881 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -93,6 +93,10 @@ services: volumes: - wakdo_db_data:/var/lib/mysql + # Scripts d'init MariaDB, executes UNIQUEMENT au premier demarrage sur + # volume vierge : durcit le privilege du user applicatif (moindre + # privilege, db/init/10-scope-app-user.sh). Lecture seule. + - ./db/init:/docker-entrypoint-initdb.d:ro networks: - wakdo_internal diff --git a/docker/cron/scripts/backup-db.sh b/docker/cron/scripts/backup-db.sh index 57a17db..141e944 100644 --- a/docker/cron/scripts/backup-db.sh +++ b/docker/cron/scripts/backup-db.sh @@ -13,8 +13,11 @@ # - DB_USER (on utilise le user applicatif, pas root) # - DB_PASSWORD # -# Le USER applicatif doit avoir SELECT + LOCK TABLES + SHOW VIEW sur wakdo. -# (GRANT donnes dans les migrations a venir en P2.) +# Le USER applicatif a un privilege restreint (moindre privilege) : DML +# (SELECT/INSERT/UPDATE/DELETE) + SHOW VIEW, TRIGGER, LOCK TABLES sur wakdo, +# sans DDL ni GRANT OPTION. mysqldump --single-transaction (ci-dessous) n'exige +# que SELECT (+ SHOW VIEW/TRIGGER pour ces objets). Privileges poses par +# db/init/10-scope-app-user.sh (volume vierge) ou manuellement (base existante). # # Exit codes : # 0 - backup OK -- 2.45.3 From ee14186a1902d88628e5f7e6c817f65e3e92f97a Mon Sep 17 00:00:00 2001 From: Corentin JOGUET Date: Tue, 16 Jun 2026 14:20:45 +0200 Subject: [PATCH 23/93] fix(admin): remove stale unauthenticated static admin mockups (#19) --- docs/api/conventions.md | 2 +- src/public/admin/catalogue.html | 306 ------------------------ src/public/admin/commandes.html | 254 -------------------- src/public/admin/cuisine.html | 253 -------------------- src/public/admin/dashboard.html | 411 -------------------------------- src/public/admin/login.html | 56 ----- src/public/admin/users.html | 296 ----------------------- 7 files changed, 1 insertion(+), 1577 deletions(-) delete mode 100644 src/public/admin/catalogue.html delete mode 100644 src/public/admin/commandes.html delete mode 100644 src/public/admin/cuisine.html delete mode 100644 src/public/admin/dashboard.html delete mode 100644 src/public/admin/login.html delete mode 100644 src/public/admin/users.html diff --git a/docs/api/conventions.md b/docs/api/conventions.md index d27cdac..3599803 100644 --- a/docs/api/conventions.md +++ b/docs/api/conventions.md @@ -29,7 +29,7 @@ Client (borne / navigateur back-office) -> wakdo-web (Apache, vhost selon le Host) - vhost kiosk : DocumentRoot src/public/borne (statique + futur appel /api) - vhost admin : DocumentRoot src/public/admin - - fichier existant (login.html, *.css) : servi tel quel + - fichier existant (assets/ : css, js, images) : servi tel quel - sinon RewriteRule -> index.php (front controller) -> wakdo-app (PHP-FPM, via proxy FastCGI sur *.php) front controller -> Router -> Controller -> Response diff --git a/src/public/admin/catalogue.html b/src/public/admin/catalogue.html deleted file mode 100644 index d24407e..0000000 --- a/src/public/admin/catalogue.html +++ /dev/null @@ -1,306 +0,0 @@ - - - - - - Catalogue — Wakdo Admin - - - -
- - -
- - -
-
- - -
-
-
- - - - - -
- - - -
- - - -
- - -
-
-
-
- - -
- - -
-
- -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ImageLibelle Categorie Prix Stock
Big MacBurgers6,00 €Disponible
Modifier
Royal BaconBurgers5,10 €Disponible
Modifier
CBOBurgers8,90 €Disponible
Modifier
MC CrispyBurgers5,30 €Indisponible
Modifier
Coca ColaBoissons1,90 €Disponible
Modifier
Moyenne FriteFrites2,75 €Disponible
Modifier
KetchupSauces0,70 €Disponible
Modifier
Nuggets x4Encas4,20 €Disponible
Modifier
-
- -
-
- - -
-
-
- - - - - - - - - - - - - - - - - - - - - -
LibelleProduitsOrdre affichageStatut
Menus131Visible
Modifier
Burgers132Visible
Modifier
Wraps43Visible
Modifier
Salades34Visible
Modifier
Frites55Visible
Modifier
Boissons86Visible
Modifier
Desserts97Visible
Modifier
Encas48Visible
Modifier
Sauces79Masquee
Modifier
-
-
-
- - -
-
-
- - - - - - - - - - - - - - - - - - -
LibellePrixContenuStatut
Menu Le 2808,80 €Burger + Frites + Boisson + SauceDisponible
Modifier
Menu Big Tasty10,60 €Burger + Frites + Boisson + SauceDisponible
Modifier
Menu Big Mac8,00 €Burger + Frites + Boisson + SauceDisponible
Modifier
Menu CBO10,90 €Burger + Frites + Boisson + SauceDisponible
Modifier
Menu Royal Cheese6,40 €Burger + Frites + Boisson + SauceDisponible
Modifier
Menu Royal Bacon7,05 €Burger + Frites + Boisson + SauceIndisponible
Modifier
-
-
-
-
-
- - - - diff --git a/src/public/admin/commandes.html b/src/public/admin/commandes.html deleted file mode 100644 index 8569971..0000000 --- a/src/public/admin/commandes.html +++ /dev/null @@ -1,254 +0,0 @@ - - - - - - Commandes — Wakdo Admin - - - -
- - -
- - -
-
- - -
-
-
- - - - - -
- - - -
-
-
- - -
- - - -
-
- -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Date / Heure ModeSourceStatutLignesTotal
#108709/05/2026 13:42Sur placeBorneLivree318,70 €
#108609/05/2026 13:38A emporterComptoirEn preparation524,30 €
#108509/05/2026 13:31Sur placeBorneLivree211,40 €
#108409/05/2026 13:27A emporterDriveLivree28,80 €
#108309/05/2026 13:19Sur placeBorneAnnulee16,40 €
#108209/05/2026 13:14Sur placeBorneLivree732,10 €
#108109/05/2026 13:08A emporterDriveLivree210,90 €
#108009/05/2026 12:58Sur placeComptoirLivree415,60 €
-
- -
-
-
- - - - diff --git a/src/public/admin/cuisine.html b/src/public/admin/cuisine.html deleted file mode 100644 index 81275a5..0000000 --- a/src/public/admin/cuisine.html +++ /dev/null @@ -1,253 +0,0 @@ - - - - - - Cuisine — Wakdo Admin - - - -
- - -
- - -
-
- - -
-
-
- - - - - -
- - - -
- -
-
-
-
#1086
-
13:38 — 4 min
-
- A emporter -
-
-
- x2Menu Big Mac -
-
- x1Grande Frite -
-
- x2Coca Cola -
-
- x1Nuggets x4 -
-
- -
- -
-
-
-
#1088
-
13:44 — 2 min
-
- Sur place -
-
-
- x1Menu CBO -
-
- x1Fanta Orange -
-
- x1Classic Barbecue -
-
- -
- -
-
-
-
#1089
-
13:45 — 1 min
-
- A emporter -
-
-
- x3Menu Royal Cheese -
-
- x1Petite Salade -
-
- x3Eau -
-
- -
- -
-
-
-
#1090
-
13:46 — maintenant
-
- Sur place -
-
-
- x1Big Tasty Bacon -
-
- x1Grande Frite -
-
- x1Ice Tea Peche -
-
- x2Ketchup -
-
- -
- -
-
-
-
#1091
-
13:46 — maintenant
-
- A emporter -
-
-
- x4Cheeseburger -
-
- x2Moyenne Frite -
-
- x4Coca Cola -
-
- -
- -
-
-
- - - - diff --git a/src/public/admin/dashboard.html b/src/public/admin/dashboard.html deleted file mode 100644 index a9a668b..0000000 --- a/src/public/admin/dashboard.html +++ /dev/null @@ -1,411 +0,0 @@ - - - - - - Tableau de bord — Wakdo Admin - - - -
- - -
- - - - -
-
- - -
-
-
- - - - - -
- - - -
-
-
Ventes du jour
-
2 847,50 €
-
- - - +12,4 % - - vs hier -
-
- -
-
Commandes du jour
-
231
-
- - - +8,2 % - - vs hier -
-
- -
-
Panier moyen
-
12,33 €
-
- - - -1,8 % - - vs hier -
-
- -
-
Produits actifs
-
53
-
- - — - - inchange -
-
-
- - -
-
- Dernieres commandes - - Voir tout - - -
- -
-
-
- - -
- -
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Heure ModeStatutTotal
#108713:42Sur placeLivree18,70 € -
- -
- - - Voir detail - -
- -
-
-
#108613:38A emporterEn preparation24,30 € -
- -
- - - Voir detail - -
- -
-
-
#108513:31Sur placeLivree11,40 € -
- -
- Voir detail -
- -
-
-
#108413:27A emporterLivree8,80 € -
- -
- Voir detail -
- -
-
-
#108313:19Sur placeAnnulee6,40 € -
- - -
-
#108213:14Sur placeLivree32,10 € -
- -
- Voir detail -
- -
-
-
#108113:08A emporterLivree10,90 € -
- -
- Voir detail -
- -
-
-
#108012:58Sur placeLivree15,60 € -
- -
- Voir detail -
- -
-
-
-
- - -
-
-
- - - - diff --git a/src/public/admin/login.html b/src/public/admin/login.html deleted file mode 100644 index e9107c6..0000000 --- a/src/public/admin/login.html +++ /dev/null @@ -1,56 +0,0 @@ - - - - - - Connexion — Wakdo Admin - - - - - - diff --git a/src/public/admin/users.html b/src/public/admin/users.html deleted file mode 100644 index 1f8c1be..0000000 --- a/src/public/admin/users.html +++ /dev/null @@ -1,296 +0,0 @@ - - - - - - Utilisateurs — Wakdo Admin - - - -
- - -
- - -
-
- - -
-
-
- - - - - -
- - -
-
-
- - -
- - -
-
- -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Nom / Email Role StatutDerniere connexion
-
-
CJ
-
-
Corentin Jog
-
corentin@wakdo.fr
-
-
-
AdministrateurActif09/05/2026 13:42 -
- -
- Modifier - -
- -
-
-
-
-
ML
-
-
Marie Laurent
-
marie.laurent@wakdo.fr
-
-
-
ManagerActif09/05/2026 10:15 -
- -
- Modifier - -
- -
-
-
-
-
AD
-
-
Ahmed Diallo
-
ahmed.diallo@wakdo.fr
-
-
-
PreparationActif09/05/2026 11:00 -
- -
- Modifier - -
- -
-
-
-
-
SP
-
-
Sophie Petit
-
sophie.petit@wakdo.fr
-
-
-
AccueilActif09/05/2026 09:58 -
- -
- Modifier - -
- -
-
-
-
-
TM
-
-
Thomas Martin
-
thomas.martin@wakdo.fr
-
-
-
PreparationInactif02/04/2026 17:30 -
- -
- Modifier - -
- -
-
-
-
- -
-
-
- - - - -- 2.45.3 From 9ddb4ccb2739d2014f2576959ae7d4fc529da71a Mon Sep 17 00:00:00 2001 From: Corentin JOGUET Date: Tue, 16 Jun 2026 14:20:50 +0200 Subject: [PATCH 24/93] fix(kiosk): escape data-derived strings in innerHTML (RG-T15) (#20) --- src/public/borne/assets/js/page-cart.js | 24 ++++++++++----------- src/public/borne/assets/js/page-product.js | 10 ++++----- src/public/borne/assets/js/page-products.js | 8 +++---- src/public/borne/assets/js/state.js | 20 +++++++++++++++++ 4 files changed, 41 insertions(+), 21 deletions(-) diff --git a/src/public/borne/assets/js/page-cart.js b/src/public/borne/assets/js/page-cart.js index 5354b83..677c4df 100644 --- a/src/public/borne/assets/js/page-cart.js +++ b/src/public/borne/assets/js/page-cart.js @@ -17,7 +17,7 @@ * requires prices shown to end-consumers to include all taxes. */ -import { getCart, removeFromCart, updateQuantity, getTotalCents, computeMenuLineCents, clearCart, formatPrice } from './state.js'; +import { getCart, removeFromCart, updateQuantity, getTotalCents, computeMenuLineCents, clearCart, formatPrice, escHtml } from './state.js'; import { refreshCartBadge } from './nav.js'; /* TVA rate used for display breakdown only — stored prices are already TTC */ @@ -62,27 +62,27 @@ function renderCart() { row.innerHTML = ` ${item.libelle}
- ${item.libelle} + ${escHtml(item.libelle)} ${formatPrice(item.prix_cents)} / unite${isMenu && (item.supplement_cents ?? 0) > 0 ? ` + ${formatPrice(item.supplement_cents)} suppl.` : ''} ${isMenu && item.composition ? renderCompositionBlock(item) : ''}
-
+
${item.quantite}
@@ -90,7 +90,7 @@ function renderCart() {
- + - - - - - - - +
diff --git a/tests/Unit/Admin/DashboardControllerTest.php b/tests/Unit/Admin/DashboardControllerTest.php index e161e8d..30d4dab 100644 --- a/tests/Unit/Admin/DashboardControllerTest.php +++ b/tests/Unit/Admin/DashboardControllerTest.php @@ -150,10 +150,13 @@ final class DashboardControllerTest extends TestCase // Marqueur present UNIQUEMENT dans le fragment dashboard (absent du layout) : // verifie que le contenu est bien compose DANS le shell (pas un $content vide). self::assertStringContainsString('Bienvenue, Corentin J', $body); - // Navigation conditionnee aux permissions. - self::assertStringContainsString('/admin/products', $body); // product.read present - self::assertStringContainsString('/admin/users', $body); // user.read present - self::assertStringNotContainsString('/admin/roles', $body); // role.manage absent + // Navigation conditionnee aux permissions : un lien n'apparait que si la + // permission est presente ET la page existe. + self::assertStringContainsString('/admin/products', $body); // product.read present + page existante + // user.read est present, mais la page /admin/users n'existe pas encore : + // le lien est retire pour ne pas exposer un 404 (cf. layout.php). + self::assertStringNotContainsString('/admin/users', $body); + self::assertStringNotContainsString('/admin/roles', $body); // pas de page + role.manage absent // Deconnexion = formulaire POST avec CSRF. self::assertStringContainsString('action="/logout"', $body); self::assertStringContainsString('name="_csrf"', $body); -- 2.45.3 From 2cc499dc7163f15b58cd806c2c3b92e20d291435 Mon Sep 17 00:00:00 2001 From: Corentin JOGUET Date: Tue, 16 Jun 2026 14:21:42 +0200 Subject: [PATCH 30/93] fix(admin): lien decouvrable vers la page de definition du PIN (#28) --- src/app/Views/admin/layout.php | 2 ++ tests/Unit/Admin/DashboardControllerTest.php | 3 +++ 2 files changed, 5 insertions(+) diff --git a/src/app/Views/admin/layout.php b/src/app/Views/admin/layout.php index a63f168..d53d036 100644 --- a/src/app/Views/admin/layout.php +++ b/src/app/Views/admin/layout.php @@ -79,6 +79,8 @@ $navClass = static function (string $code, string $current): string { - + diff --git a/src/app/Views/admin/menus/delete.php b/src/app/Views/admin/menus/delete.php new file mode 100644 index 0000000..24d8dc7 --- /dev/null +++ b/src/app/Views/admin/menus/delete.php @@ -0,0 +1,54 @@ + + + +
+ +

+ + + + + +

La suppression est tracee (audit) et retire aussi les slots du menu. Renseignez votre email et votre PIN.

+ +
+ + +
+ +
+ + +
+ +
+ + Annuler +
+ +
diff --git a/src/app/Views/admin/menus/form.php b/src/app/Views/admin/menus/form.php new file mode 100644 index 0000000..de4ba78 --- /dev/null +++ b/src/app/Views/admin/menus/form.php @@ -0,0 +1,138 @@ +> $categories + * @var array> $products + * @var list $slotTypes + * @var array $values + * @var string $slotsJson + * @var array $errors + * @var string $csrfToken + */ + +$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8'); +$id = (int) ($menuId ?? 0); +$action = $id !== 0 ? '/admin/menus/' . $id : '/admin/menus'; + +/** @var array $vals */ +$vals = isset($values) && is_array($values) ? $values : []; +/** @var array $errs */ +$errs = isset($errors) && is_array($errors) ? $errors : []; +/** @var array> $cats */ +$cats = isset($categories) && is_array($categories) ? $categories : []; +/** @var array> $prods */ +$prods = isset($products) && is_array($products) ? $products : []; +/** @var list $types */ +$types = isset($slotTypes) && is_array($slotTypes) ? $slotTypes : []; + +$val = static fn (string $k): string => htmlspecialchars((string) ($vals[$k] ?? ''), ENT_QUOTES, 'UTF-8'); +$err = static fn (string $k): string => isset($errs[$k]) && is_string($errs[$k]) ? $errs[$k] : ''; +$selectedCat = (string) ($vals['category_id'] ?? ''); +$selectedBurger = (string) ($vals['burger_product_id'] ?? ''); +$available = (bool) ($vals['is_available'] ?? true); + +// Donnees pour le builder JS, passees en attributs data-* (CSP 'self' : pas de +// script inline). htmlspecialchars rend le JSON sur-able comme valeur d'attribut. +$slimProducts = array_map( + static fn (array $p): array => ['id' => (int) ($p['id'] ?? 0), 'name' => (string) ($p['name'] ?? '')], + $prods, +); +$attr = static fn (mixed $data): string => htmlspecialchars( + (string) json_encode($data, JSON_UNESCAPED_UNICODE), + ENT_QUOTES, + 'UTF-8', +); +$slotsData = isset($slotsJson) && is_string($slotsJson) && $slotsJson !== '' ? $slotsJson : '[]'; +?> + + + + diff --git a/src/app/Views/admin/menus/index.php b/src/app/Views/admin/menus/index.php new file mode 100644 index 0000000..60e3fdb --- /dev/null +++ b/src/app/Views/admin/menus/index.php @@ -0,0 +1,76 @@ +> $menus + * @var string $csrfToken + */ + +/** @var array> $rows */ +$rows = isset($menus) && is_array($menus) ? $menus : []; +$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8'); +$esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8'); +$euros = static fn (int $cents): string => number_format($cents / 100, 2, ',', ' ') . ' EUR'; +?> + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
NomCategorieBurger de basePrix (Normal – Maxi)Statut
Aucun menu.
+ + Disponible + + Indisponible + + + Modifier +
+ + +
+ Supprimer +
+
+
diff --git a/src/public/admin/assets/js/menu-form.js b/src/public/admin/assets/js/menu-form.js new file mode 100644 index 0000000..a45f909 --- /dev/null +++ b/src/public/admin/assets/js/menu-form.js @@ -0,0 +1,160 @@ +/* + * menu-form.js — Builder de slots du formulaire menu (back-office). + * + * CSP 'self' : script externe (pas d'inline). Les donnees (produits, types, + * slots initiaux) sont lues depuis les attributs data-* de #slot-builder. + * A la soumission, l'etat des slots est serialise en JSON dans le champ cache + * #slots_json (Request::formBody cote serveur ne garde que les scalaires, d'ou + * le passage par une chaine JSON). Le serveur revalide tout (RG-T18). + */ +(function () { + 'use strict'; + + var builder = document.getElementById('slot-builder'); + var form = document.getElementById('menu-form'); + var hidden = document.getElementById('slots_json'); + var addBtn = document.getElementById('add-slot'); + if (!builder || !form || !hidden || !addBtn) { + return; + } + + function parseData(key, fallback) { + try { + var v = JSON.parse(builder.dataset[key] || fallback); + return Array.isArray(v) ? v : JSON.parse(fallback); + } catch (e) { + return JSON.parse(fallback); + } + } + + var products = parseData('products', '[]'); // [{id, name}] + var slotTypes = parseData('slotTypes', '[]'); // ['drink', 'side', ...] + var initialSlots = parseData('slots', '[]'); // [{name, slot_type, is_required, options:[id]}] + + function el(tag, className) { + var e = document.createElement(tag); + if (className) { + e.className = className; + } + return e; + } + + // Construit le bloc DOM d'un slot. `slot` peut etre vide (creation). + function renderSlot(slot) { + slot = slot || {}; + var selectedOptions = Array.isArray(slot.options) ? slot.options.map(Number) : []; + + var block = el('fieldset', 'slot-block form-group'); + block.style.border = '1px solid #ddd'; + block.style.padding = '0.75rem'; + block.style.marginBottom = '0.75rem'; + + var head = el('div'); + + // Nom du slot + var nameLabel = el('label'); + nameLabel.appendChild(document.createTextNode('Nom du slot ')); + var nameInput = el('input', 'form-input slot-name'); + nameInput.type = 'text'; + nameInput.maxLength = 80; + nameInput.value = slot.name ? String(slot.name) : ''; + nameLabel.appendChild(nameInput); + head.appendChild(nameLabel); + + // Type + var typeLabel = el('label'); + typeLabel.appendChild(document.createTextNode(' Type ')); + var typeSelect = el('select', 'form-input slot-type'); + slotTypes.forEach(function (t) { + var opt = el('option'); + opt.value = String(t); + opt.textContent = String(t); + if (String(slot.slot_type) === String(t)) { + opt.selected = true; + } + typeSelect.appendChild(opt); + }); + typeLabel.appendChild(typeSelect); + head.appendChild(typeLabel); + + // Requis + var reqLabel = el('label'); + var reqInput = el('input', 'slot-required'); + reqInput.type = 'checkbox'; + if (Number(slot.is_required) === 1) { + reqInput.checked = true; + } + reqLabel.appendChild(reqInput); + reqLabel.appendChild(document.createTextNode(' Requis')); + head.appendChild(reqLabel); + + // Retirer + var removeBtn = el('button', 'btn btn-secondary slot-remove'); + removeBtn.type = 'button'; + removeBtn.textContent = 'Retirer'; + removeBtn.addEventListener('click', function () { + block.parentNode.removeChild(block); + }); + head.appendChild(removeBtn); + + block.appendChild(head); + + // Options : cases a cocher des produits eligibles + var optWrap = el('div', 'slot-options'); + optWrap.style.maxHeight = '160px'; + optWrap.style.overflowY = 'auto'; + optWrap.style.marginTop = '0.5rem'; + products.forEach(function (p) { + var lab = el('label'); + lab.style.display = 'block'; + var cb = el('input', 'slot-option'); + cb.type = 'checkbox'; + cb.value = String(p.id); + if (selectedOptions.indexOf(Number(p.id)) !== -1) { + cb.checked = true; + } + lab.appendChild(cb); + lab.appendChild(document.createTextNode(' ' + String(p.name))); + optWrap.appendChild(lab); + }); + block.appendChild(optWrap); + + return block; + } + + // Lit l'etat des blocs et le serialise dans #slots_json. + function serialize() { + var slots = []; + var blocks = builder.querySelectorAll('.slot-block'); + Array.prototype.forEach.call(blocks, function (block) { + var name = block.querySelector('.slot-name').value.trim(); + var type = block.querySelector('.slot-type').value; + var required = block.querySelector('.slot-required').checked ? 1 : 0; + var options = []; + Array.prototype.forEach.call(block.querySelectorAll('.slot-option'), function (cb) { + if (cb.checked) { + options.push(Number(cb.value)); + } + }); + slots.push({ name: name, slot_type: type, is_required: required, options: options }); + }); + hidden.value = JSON.stringify(slots); + } + + addBtn.addEventListener('click', function () { + builder.appendChild(renderSlot(null)); + }); + + form.addEventListener('submit', function () { + serialize(); + }); + + // Rendu initial : slots existants (edition) ou un slot vide (creation). + if (initialSlots.length) { + initialSlots.forEach(function (s) { + builder.appendChild(renderSlot(s)); + }); + } else { + builder.appendChild(renderSlot(null)); + } +})(); diff --git a/src/public/admin/index.php b/src/public/admin/index.php index 7259dac..231e2f4 100644 --- a/src/public/admin/index.php +++ b/src/public/admin/index.php @@ -17,6 +17,7 @@ use App\Controllers\DashboardController; use App\Controllers\HealthController; use App\Controllers\HomeController; use App\Controllers\MeController; +use App\Controllers\MenuController; use App\Controllers\PasswordResetController; use App\Controllers\ProductController; use App\Controllers\ProfileController; @@ -90,6 +91,19 @@ try { $router->add('GET', '/admin/products/{id}/delete', [ProductController::class, 'confirmDelete']); $router->add('POST', '/admin/products/{id}/delete', [ProductController::class, 'destroy']); + // CRUD Menus (menu.read/create/update/delete). Menu compose = burger de base + + // slots (menu_slot / menu_slot_option). PIN equipier + audit sur suppression + // (mlt 8.6) ; create/update sans PIN. {id} = un seul segment, pas de collision + // avec /toggle ni /delete. + $router->add('GET', '/admin/menus', [MenuController::class, 'index']); + $router->add('GET', '/admin/menus/new', [MenuController::class, 'create']); + $router->add('POST', '/admin/menus', [MenuController::class, 'store']); + $router->add('GET', '/admin/menus/{id}/edit', [MenuController::class, 'edit']); + $router->add('POST', '/admin/menus/{id}', [MenuController::class, 'update']); + $router->add('POST', '/admin/menus/{id}/toggle', [MenuController::class, 'toggle']); + $router->add('GET', '/admin/menus/{id}/delete', [MenuController::class, 'confirmDelete']); + $router->add('POST', '/admin/menus/{id}/delete', [MenuController::class, 'destroy']); + $response = $router->dispatch(Request::fromGlobals()); $response->send(); } catch (Throwable $exception) { diff --git a/tests/Integration/MenuRepositoryDbTest.php b/tests/Integration/MenuRepositoryDbTest.php new file mode 100644 index 0000000..5cd098a --- /dev/null +++ b/tests/Integration/MenuRepositoryDbTest.php @@ -0,0 +1,137 @@ + */ + private array $productIds = []; + + protected function setUp(): void + { + if (getenv('WAKDO_DB_TESTS') !== '1') { + self::markTestSkipped('Tests DB desactives (definir WAKDO_DB_TESTS=1 + DB_*).'); + } + + $this->db = new Database(new Config()); + + try { + $this->db->fetch('SELECT 1'); + } catch (Throwable $exception) { + self::markTestSkipped('Base injoignable: ' . $exception->getMessage()); + } + + $this->categoryId = (int) ($this->db->fetch('SELECT id FROM category ORDER BY id LIMIT 1')['id'] ?? 0); + $this->productIds = array_map( + static fn (array $r): int => (int) ($r['id'] ?? 0), + $this->db->fetchAll('SELECT id FROM product ORDER BY id LIMIT 3'), + ); + $this->name = 'it-menu-' . bin2hex(random_bytes(4)); + } + + protected function tearDown(): void + { + if ($this->name !== '') { + // CASCADE menu -> menu_slot -> menu_slot_option. + $this->db->execute('DELETE FROM menu WHERE name = :name', ['name' => $this->name]); + } + } + + public function testCreateFindUpdateSlotsAndDelete(): void + { + self::assertGreaterThan(0, $this->categoryId); + self::assertCount(3, $this->productIds); + [$burger, $optA, $optB] = $this->productIds; + + $repo = new MenuRepository($this->db); + self::assertTrue($repo->categoryExists($this->categoryId)); + self::assertTrue($repo->productExists($burger)); + self::assertFalse($repo->productExists(0)); + + // --- create : menu + 2 slots (drink avec 2 options, side avec 1) --- + $id = $repo->create( + [ + 'category_id' => $this->categoryId, + 'burger_product_id' => $burger, + 'name' => $this->name, + 'price_normal_cents' => 790, + 'price_maxi_cents' => 990, + 'is_available' => 1, + 'display_order' => 50, + ], + [ + ['name' => 'Boisson', 'slot_type' => 'drink', 'is_required' => 1, 'display_order' => 0, 'options' => [$optA, $optB]], + ['name' => 'Accompagnement', 'slot_type' => 'side', 'is_required' => 1, 'display_order' => 1, 'options' => [$optA]], + ], + ); + self::assertGreaterThan(0, $id); + + $found = $repo->find($id); + self::assertNotNull($found); + self::assertSame(790, (int) ($found['price_normal_cents'] ?? 0)); + self::assertSame(990, (int) ($found['price_maxi_cents'] ?? 0)); + + $slots = $repo->slotsWithOptions($id); + self::assertCount(2, $slots); + self::assertSame('drink', $slots[0]['slot_type']); + self::assertEqualsCanonicalizing([$optA, $optB], $slots[0]['option_product_ids']); + self::assertSame('side', $slots[1]['slot_type']); + self::assertSame([$optA], $slots[1]['option_product_ids']); + + // all() porte categorie + burger joints. + $names = array_map(static fn (array $r): string => (string) ($r['name'] ?? ''), $repo->all()); + self::assertContains($this->name, $names); + + self::assertFalse($repo->isReferencedByOrders($id)); + + // --- update : change le prix maxi ET reconfigure en 1 SEUL slot --- + // (verifie le delete-and-reinsert : les 2 anciens slots disparaissent). + $repo->update( + $id, + [ + 'category_id' => $this->categoryId, + 'burger_product_id' => $burger, + 'name' => $this->name, + 'price_normal_cents' => 790, + 'price_maxi_cents' => 1090, + 'is_available' => 0, + 'display_order' => 51, + ], + [ + ['name' => 'Sauce', 'slot_type' => 'sauce', 'is_required' => 0, 'display_order' => 0, 'options' => [$optB]], + ], + ); + + $updated = $repo->find($id); + self::assertNotNull($updated); + self::assertSame(1090, (int) ($updated['price_maxi_cents'] ?? 0)); + self::assertSame(0, (int) ($updated['is_available'] ?? 1)); + + $slotsAfter = $repo->slotsWithOptions($id); + self::assertCount(1, $slotsAfter); // delete-and-reinsert : plus que 1 slot + self::assertSame('sauce', $slotsAfter[0]['slot_type']); + self::assertSame([$optB], $slotsAfter[0]['option_product_ids']); + + // --- delete : menu non reference -> suppression dure OK, slots cascade --- + self::assertSame(1, $repo->delete($id)); + self::assertNull($repo->find($id)); + self::assertSame([], $repo->slotsWithOptions($id)); + } +} diff --git a/tests/Support/FakeDatabase.php b/tests/Support/FakeDatabase.php index c3d0051..cdf0d6e 100644 --- a/tests/Support/FakeDatabase.php +++ b/tests/Support/FakeDatabase.php @@ -134,6 +134,30 @@ final class FakeDatabase implements DatabaseInterface */ public ?array $productRow = null; + /** + * Ligne renvoyee par MenuRepository::find() ; null = introuvable. + * + * @var array|null + */ + public ?array $menuRow = null; + + /** + * Lignes renvoyees par MenuRepository::all(). + * + * @var list> + */ + public array $menusRows = []; + + /** + * Lignes (LEFT JOIN slot/option) renvoyees par MenuRepository::slotsWithOptions(). + * + * @var list> + */ + public array $menuSlotRows = []; + + /** Resultat de MenuRepository::isReferencedByOrders() (true = reference par une commande). */ + public bool $menuReferenced = false; + /** * Ligne renvoyee pour PinVerifier::resolveActingUser (id, role_id, pin_hash) ; * null = email inconnu/inactif. @@ -230,6 +254,14 @@ final class FakeDatabase implements DatabaseInterface return $this->categoryRow; } + if (str_contains($sql, 'FROM menu WHERE id = :id')) { + return $this->menuRow; + } + + if (str_contains($sql, 'FROM order_item WHERE menu_id')) { + return $this->menuReferenced ? ['menu_id' => 1] : null; + } + if (str_contains($sql, 'FROM category WHERE name = :name')) { return $this->categoryNameTaken ? ['id' => 1] : null; } @@ -269,6 +301,14 @@ final class FakeDatabase implements DatabaseInterface return $this->productsRows; } + if (str_contains($sql, 'FROM menu m JOIN category')) { + return $this->menusRows; + } + + if (str_contains($sql, 'FROM menu_slot s')) { + return $this->menuSlotRows; + } + if (str_contains($sql, 'SELECT p.code FROM role_permission')) { if (!$this->roleActive) { return []; diff --git a/tests/Unit/Admin/MenuControllerTest.php b/tests/Unit/Admin/MenuControllerTest.php new file mode 100644 index 0000000..3701850 --- /dev/null +++ b/tests/Unit/Admin/MenuControllerTest.php @@ -0,0 +1,329 @@ +testSession; + } + + protected function db(): DatabaseInterface + { + return $this->fakeDb; + } +} + +final class MenuControllerTest extends TestCase +{ + /** @var list */ + private array $touchedKeys = []; + + private SessionManager $session; + private string $csrf = ''; + + protected function setUp(): void + { + $this->setEnv('SESSION_LIFETIME_IDLE', '14400'); + $this->setEnv('SESSION_LIFETIME_ABSOLUTE', '36000'); + $this->setEnv('STAFF_PIN_MIN_LENGTH', '4'); + $this->setEnv('STAFF_PIN_MAX_LENGTH', '12'); + $this->setEnv('ARGON2_MEMORY_COST', '1024'); + $this->setEnv('ARGON2_TIME_COST', '1'); + $this->setEnv('ARGON2_THREADS', '1'); + + $this->session = new SessionManager(new Config(), true); + $now = time(); + $this->session->set('user_id', 1); + $this->session->set('role_id', 1); + $this->session->set('logged_in_at', $now - 100); + $this->session->set('last_activity', $now - 50); + $this->csrf = Csrf::token($this->session); + } + + protected function tearDown(): void + { + foreach ($this->touchedKeys as $key) { + putenv($key); + } + $this->touchedKeys = []; + } + + private function setEnv(string $key, string $value): void + { + $this->touchedKeys[] = $key; + putenv($key . '=' . $value); + } + + private function permittedDb(): FakeDatabase + { + $db = new FakeDatabase(); + $db->guardUserRow = ['is_active' => 1]; + $db->userDisplayRow = ['first_name' => 'Corentin', 'last_name' => 'J', 'role_label' => 'Administrateur']; + $db->canResult = true; + $db->permissionCodes = ['menu.read', 'menu.create', 'menu.update', 'menu.delete']; + $db->categoryRow = ['id' => 1, 'name' => 'Menus']; // categoryExists -> true + $db->productRow = ['id' => 1, 'name' => 'Big Mac']; // productExists -> true (burger + options) + return $db; + } + + private function get(string $path): Request + { + return new Request('GET', $path, [], [], '', '203.0.113.5'); + } + + /** + * @param array $form + */ + private function post(array $form, string $path): Request + { + return new Request('POST', $path, [], ['content-type' => 'application/x-www-form-urlencoded'], http_build_query($form), '203.0.113.5'); + } + + private function controller(Request $request, FakeDatabase $db): TestMenuController + { + return new TestMenuController($request, new Config(), new Database(new Config()), $this->session, $db); + } + + /** + * @param array $overrides + * @return array + */ + private function validForm(array $overrides = []): array + { + $slots = (string) json_encode([ + ['name' => 'Boisson', 'slot_type' => 'drink', 'is_required' => 1, 'options' => [1]], + ]); + + return array_merge([ + '_csrf' => $this->csrf, + 'category_id' => '1', + 'burger_product_id' => '1', + 'name' => 'Best Of', + 'price_normal_cents' => '790', + 'price_maxi_cents' => '990', + 'display_order' => '1', + 'is_available' => '1', + 'slots_json' => $slots, + ], $overrides); + } + + private function actingPin(FakeDatabase $db): void + { + $db->actingUserRow = ['id' => 9, 'role_id' => 4, 'pin_hash' => (new PasswordHasher(new Config()))->hash('4729')]; + } + + public function testIndexRequiresMenuRead(): void + { + $db = $this->permittedDb(); + $db->canResult = false; + + self::assertSame(403, $this->controller($this->get('/admin/menus'), $db)->index()->status()); + } + + public function testIndexListsMenus(): void + { + $db = $this->permittedDb(); + $db->menusRows = [ + ['id' => 1, 'category_id' => 1, 'burger_product_id' => 2, 'name' => 'Best Of Big Mac', 'price_normal_cents' => 790, 'price_maxi_cents' => 990, 'is_available' => 1, 'display_order' => 0, 'category_name' => 'Menus', 'burger_name' => 'Big Mac'], + ]; + + $response = $this->controller($this->get('/admin/menus'), $db)->index(); + self::assertSame(200, $response->status()); + self::assertStringContainsString('Best Of Big Mac', $response->body()); + self::assertStringContainsString('Nouveau menu', $response->body()); + } + + public function testStoreCreatesMenuWithSlots(): void + { + $db = $this->permittedDb(); + $response = $this->controller($this->post($this->validForm(), '/admin/menus'), $db)->store(); + + self::assertSame(302, $response->status()); + self::assertTrue($db->wrote('INSERT INTO menu')); + self::assertTrue($db->wrote('INSERT INTO menu_slot')); + self::assertTrue($db->wrote('INSERT INTO menu_slot_option')); + self::assertFalse($db->wrote('INSERT INTO audit_log')); // create = pas d'action sensible (mlt 8.4) + self::assertSame('Menu cree.', $this->session->get('_flash')); + } + + public function testStoreRejectsWithoutSlots(): void + { + $db = $this->permittedDb(); + // Precondition mlt 8.4 : >=1 slot avec >=1 option. Ici aucun slot. + $response = $this->controller($this->post($this->validForm(['slots_json' => '[]']), '/admin/menus'), $db)->store(); + + self::assertSame(422, $response->status()); + self::assertFalse($db->wrote('INSERT INTO menu')); + } + + public function testStoreRejectsSlotWithoutOption(): void + { + $db = $this->permittedDb(); + $slots = (string) json_encode([['name' => 'Boisson', 'slot_type' => 'drink', 'is_required' => 1, 'options' => []]]); + $response = $this->controller($this->post($this->validForm(['slots_json' => $slots]), '/admin/menus'), $db)->store(); + + self::assertSame(422, $response->status()); + self::assertFalse($db->wrote('INSERT INTO menu')); + } + + public function testStoreRejectsInvalidCsrf(): void + { + $db = $this->permittedDb(); + $response = $this->controller($this->post($this->validForm(['_csrf' => 'wrong']), '/admin/menus'), $db)->store(); + + self::assertSame(403, $response->status()); + self::assertFalse($db->wrote('INSERT INTO menu')); + } + + public function testUpdateRebuildsSlots(): void + { + $db = $this->permittedDb(); + $db->menuRow = ['id' => 5, 'category_id' => 1, 'burger_product_id' => 2, 'name' => 'Best Of', 'price_normal_cents' => 790, 'price_maxi_cents' => 990, 'is_available' => 1, 'display_order' => 0]; + + $response = $this->controller($this->post($this->validForm(), '/admin/menus/5'), $db)->update(['id' => '5']); + + self::assertSame(302, $response->status()); + self::assertTrue($db->wrote('UPDATE menu SET')); + // delete-and-reinsert des slots (mlt 8.5 RG-2). + self::assertTrue($db->wrote('DELETE FROM menu_slot')); + self::assertTrue($db->wrote('INSERT INTO menu_slot')); + } + + public function testDestroyLockedActorReturns422WithoutDeletingOrAuditing(): void + { + $db = $this->permittedDb(); + $db->menuRow = ['id' => 5, 'name' => 'Best Of']; + $this->actingPin($db); + $db->pinThrottleLockoutUntil = '2099-01-01 00:00:00'; + + $response = $this->controller($this->post(['_csrf' => $this->csrf, 'pin_email' => 'staff@wakdo.local', 'pin' => '4729'], '/admin/menus/5/delete'), $db)->destroy(['id' => '5']); + + self::assertSame(422, $response->status()); + self::assertFalse($db->wrote('DELETE FROM menu')); + self::assertSame([], $db->auditActions()); + } + + public function testDestroyWrongPinRecordsFailureOnSessionActor(): void + { + $db = $this->permittedDb(); + $db->menuRow = ['id' => 5, 'name' => 'Best Of']; + $db->actingUserRow = null; // email/PIN invalide + + $response = $this->controller($this->post(['_csrf' => $this->csrf, 'pin_email' => 'ghost@wakdo.local', 'pin' => '0000'], '/admin/menus/5/delete'), $db)->destroy(['id' => '5']); + + self::assertSame(422, $response->status()); + self::assertSame(['pin.failed'], $db->auditActions()); + self::assertTrue($db->wrote('INSERT INTO pin_throttle')); // RG-T22 increment sur l'agissant + // RG-T08 : pin.failed + increment throttle dans UNE transaction. + self::assertSame(['begin', 'commit'], $db->transactionEvents); + } + + public function testDestroyValidPinDeletesAuditsAndResets(): void + { + $db = $this->permittedDb(); + $db->menuRow = ['id' => 5, 'name' => 'Best Of']; + $this->actingPin($db); + + $response = $this->controller($this->post(['_csrf' => $this->csrf, 'pin_email' => 'staff@wakdo.local', 'pin' => '4729'], '/admin/menus/5/delete'), $db)->destroy(['id' => '5']); + + self::assertSame(302, $response->status()); + self::assertTrue($db->wrote('DELETE FROM menu')); + self::assertSame(['menu.delete'], $db->auditActions()); + // L'audit porte l'acteur RESOLU PAR PIN (id 9), dans la transaction de l'effet. + $audit = $this->findWrite($db, 'INSERT INTO audit_log'); + self::assertNotNull($audit); + self::assertSame(9, $audit['params']['uid'] ?? null); + $this->assertAuditWithinTransaction($db); + // Reset du throttle sur l'acteur de SESSION (id 1). + $reset = $this->findWrite($db, 'UPDATE pin_throttle SET failed_attempts = 0'); + self::assertNotNull($reset); + self::assertSame(1, $reset['params']['uid'] ?? null); + } + + public function testDestroyReferencedByOrderReturns422(): void + { + $db = $this->permittedDb(); + $db->menuRow = ['id' => 5, 'name' => 'Best Of']; + $this->actingPin($db); + $db->failOnExecute = new PDOException('referenced', 23000); // FK order_item.menu_id RESTRICT + + $response = $this->controller($this->post(['_csrf' => $this->csrf, 'pin_email' => 'staff@wakdo.local', 'pin' => '4729'], '/admin/menus/5/delete'), $db)->destroy(['id' => '5']); + + self::assertSame(422, $response->status()); + self::assertStringContainsString('suppression impossible', $response->body()); + } + + public function testToggleFlipsAvailability(): void + { + $db = $this->permittedDb(); + $db->menuRow = ['id' => 5, 'name' => 'Best Of', 'is_available' => 1]; + + $response = $this->controller($this->post(['_csrf' => $this->csrf], '/admin/menus/5/toggle'), $db)->toggle(['id' => '5']); + + self::assertSame(302, $response->status()); + $write = $this->findWrite($db, 'UPDATE menu SET is_available'); + self::assertNotNull($write); + self::assertSame(0, $write['params']['a'] ?? null); // 1 -> 0 + } + + /** + * @return array{sql: string, params: array}|null + */ + private function findWrite(FakeDatabase $db, string $needle): ?array + { + foreach ($db->writes as $write) { + if (str_contains($write['sql'], $needle)) { + return $write; + } + } + + return null; + } + + private function assertAuditWithinTransaction(FakeDatabase $db): void + { + $log = $db->eventLog; + $begin = array_search('begin', $log, true); + $commit = array_search('commit', $log, true); + $auditAt = null; + foreach ($log as $i => $event) { + if (str_contains($event, 'INSERT INTO audit_log')) { + $auditAt = $i; + } + } + + self::assertIsInt($begin); + self::assertIsInt($commit); + self::assertNotNull($auditAt); + self::assertTrue($begin < $auditAt && $auditAt < $commit, 'audit_log doit etre ecrit entre begin et commit'); + } +} -- 2.45.3 From 0666a225622c9c6e1d92e464068c957f924781d4 Mon Sep 17 00:00:00 2001 From: Corentin JOGUET Date: Wed, 17 Jun 2026 10:11:28 +0200 Subject: [PATCH 34/93] fix(admin): conflits HTTP 409 au lieu de 422 (delete FK-bloque + course unicite) (#33) --- docs/api/conventions.md | 2 +- src/app/Catalogue/MenuRepository.php | 4 ++-- src/app/Catalogue/ProductRepository.php | 2 +- src/app/Controllers/CategoryController.php | 10 ++++++---- src/app/Controllers/MenuController.php | 10 +++++----- src/app/Controllers/ProductController.php | 14 +++++++------- src/app/Views/admin/menus/delete.php | 2 +- tests/Unit/Admin/CategoryControllerTest.php | 8 ++++---- tests/Unit/Admin/MenuControllerTest.php | 4 ++-- tests/Unit/Admin/ProductControllerTest.php | 4 ++-- 10 files changed, 31 insertions(+), 29 deletions(-) diff --git a/docs/api/conventions.md b/docs/api/conventions.md index 3599803..395012d 100644 --- a/docs/api/conventions.md +++ b/docs/api/conventions.md @@ -257,7 +257,7 @@ stable). | `NOT_FOUND` | 404 | ressource introuvable | | `METHOD_NOT_ALLOWED` | 405 | methode non autorisee sur ce chemin | | `VALIDATION_ERROR` | 422 | entree invalide (champ, longueur, enum) | -| `CONFLICT` | 409 | conflit d'etat (ex. transition de commande concurrente) | +| `CONFLICT` | 409 | conflit d'etat (ex. transition de commande concurrente) ; suppression dure bloquee par une reference (FK RESTRICT) ; unicite slug/name deja prise (remontee par la base). La validation simple en amont (champ/format/bornes) reste `VALIDATION_ERROR` 422 | | `AUTH_REQUIRED` | 401 | authentification requise (prevu, API admin) | | `FORBIDDEN` | 403 | permission insuffisante, ou jeton CSRF invalide cote formulaire | | `RATE_LIMITED` | 429 | throttling (prevu) | diff --git a/src/app/Catalogue/MenuRepository.php b/src/app/Catalogue/MenuRepository.php index 20f67a2..0ca9854 100644 --- a/src/app/Catalogue/MenuRepository.php +++ b/src/app/Catalogue/MenuRepository.php @@ -17,7 +17,7 @@ use App\Core\DatabaseInterface; * - menu_slot_option.menu_slot_id : CASCADE ; .product_id : RESTRICT. * - order_item.menu_id : RESTRICT -> la suppression dure est bloquee si le menu * est reference par une commande historique (mlt 8.6 RG-1 : le controleur - * traduit la violation en 422 et propose la desactivation). + * traduit la violation en 409 et propose la desactivation). * * create() et update() ecrivent menu + slots + options dans UNE transaction * (RG-T08). update() reconstruit les slots en delete-and-reinsert (mlt 8.5 RG-2). @@ -170,7 +170,7 @@ final class MenuRepository /** * Suppression dure. CASCADE retire menu_slot + menu_slot_option ; * order_item.menu_id (RESTRICT) bloque si une commande historique reference le - * menu (le controleur attrape SQLSTATE 23000 -> 422). + * menu (le controleur attrape SQLSTATE 23000 -> 409). */ public function delete(int $id): int { diff --git a/src/app/Catalogue/ProductRepository.php b/src/app/Catalogue/ProductRepository.php index 3a93f3a..38e7ba8 100644 --- a/src/app/Catalogue/ProductRepository.php +++ b/src/app/Catalogue/ProductRepository.php @@ -14,7 +14,7 @@ use App\Core\DatabaseInterface; * suppression dure : * - RESTRICT (bloquent la suppression) : order_item, menu.burger_product_id, * menu_slot_option, order_item_selection. Le controleur attrape la violation - * (SQLSTATE 23000) -> 422, plutot que de pre-tester chaque reference. + * (SQLSTATE 23000) -> 409 Conflit, plutot que de pre-tester chaque reference. * - CASCADE : product_ingredient (la recette appartient au produit ; la * supprimer avec le produit est voulu). La suppression n'est donc PAS bloquee * par une recette existante. TODO (phase stock/recettes, table aujourd'hui diff --git a/src/app/Controllers/CategoryController.php b/src/app/Controllers/CategoryController.php index 85d64da..60c098e 100644 --- a/src/app/Controllers/CategoryController.php +++ b/src/app/Controllers/CategoryController.php @@ -245,9 +245,11 @@ class CategoryController extends AdminController /** * Traduit une violation de contrainte d'unicite (SQLSTATE 23000) en - * re-affichage 422 du formulaire plutot qu'en 500. Couvre la fenetre de - * concurrence entre le controle nameExists/slugExists et l'ecriture. Tout - * autre code d'erreur est repropage (vrai incident interne). + * re-affichage 409 du formulaire plutot qu'en 500. Conflit remonte par la + * base (slug/name deja pris) = 409 Conflict, aligne sur le contrat d'API + * (SLUG_EXISTS). La pre-verification nameExists/slugExists reste, elle, en + * 422 (validation du formulaire) ; ce catch couvre la fenetre de concurrence + * entre ce controle et l'ecriture. Tout autre code d'erreur est repropage. * * @param array $form */ @@ -256,7 +258,7 @@ class CategoryController extends AdminController // getCode() rend la chaine SQLSTATE pour une vraie PDOException ; le cast // couvre aussi un code entier (23000 = violation de contrainte d'integrite). if ((string) $exception->getCode() === '23000') { - return $this->renderForm($guard, $id, $form, ['slug' => 'Ce libelle ou ce slug existe deja.'], 422); + return $this->renderForm($guard, $id, $form, ['slug' => 'Ce libelle ou ce slug existe deja.'], 409); } throw $exception; diff --git a/src/app/Controllers/MenuController.php b/src/app/Controllers/MenuController.php index 955aa55..cfd2199 100644 --- a/src/app/Controllers/MenuController.php +++ b/src/app/Controllers/MenuController.php @@ -24,7 +24,7 @@ use App\Core\Response; * vat_rate ; la sensibilite fiscale est au niveau composant -> hors RG-T13) ; * - delete (menu.delete) : action sensible -> PIN equipier + audit (RG-T13/T14, * mlt 8.6), suppression dure seulement si non reference par order_item.menu_id - * (FK RESTRICT -> 422 sinon, proposer la desactivation). + * (FK RESTRICT -> 409 sinon, proposer la desactivation). * * La configuration de slots est soumise en un champ cache `slots_json` (le * builder vanilla JS la serialise) : Request::formBody() ne retient que les @@ -236,7 +236,7 @@ class MenuController extends AdminController $name = (string) ($menu['name'] ?? ''); - // FK order_item.menu_id RESTRICT -> PDOException 23000 -> 422 (catch). + // FK order_item.menu_id RESTRICT -> PDOException 23000 -> 409 Conflit (catch). // menu_slot / menu_slot_option sont CASCADE (supprimes avec le menu). try { $this->db()->transaction(function (DatabaseInterface $db) use ($id, $actor, $name): void { @@ -247,7 +247,7 @@ class MenuController extends AdminController }); } catch (PDOException $exception) { if ((string) $exception->getCode() === '23000') { - return $this->renderDelete($guard, $id, $menu, 'Menu reference par des commandes : suppression impossible. Desactivez-le plutot.'); + return $this->renderDelete($guard, $id, $menu, 'Menu reference par des commandes : suppression impossible. Desactivez-le plutot.', 409); } throw $exception; @@ -478,7 +478,7 @@ class MenuController extends AdminController /** * @param array $menu */ - private function renderDelete(GuardResult $guard, int $id, array $menu, ?string $error): Response + private function renderDelete(GuardResult $guard, int $id, array $menu, ?string $error, ?int $status = null): Response { return $this->adminView('admin/menus/delete', [ 'title' => 'Supprimer un menu - Wakdo Admin', @@ -486,7 +486,7 @@ class MenuController extends AdminController 'menuId' => $id, 'name' => (string) ($menu['name'] ?? ''), 'error' => $error, - ], $guard, $error !== null ? 422 : 200); + ], $guard, $status ?? ($error !== null ? 422 : 200)); } private function notFound(GuardResult $guard): Response diff --git a/src/app/Controllers/ProductController.php b/src/app/Controllers/ProductController.php index 6b26e53..062f8ce 100644 --- a/src/app/Controllers/ProductController.php +++ b/src/app/Controllers/ProductController.php @@ -22,7 +22,7 @@ use App\Core\Response; * - update (product.update) : PIN equipier + audit UNIQUEMENT si prix ou TVA * change (mlt 8.2 RG-4) ; sinon mise a jour simple ; * - delete (product.delete) : PIN equipier + audit, suppression dure seulement si - * le produit n'est reference nulle part (FK RESTRICT -> 422 sinon). + * le produit n'est reference nulle part (FK RESTRICT -> 409 sinon). * Le PIN suit le modele "identifiant equipier + PIN" : email + PIN resolus en un * acting_user_id ecrit dans audit_log, dans la meme transaction que l'effet (RG-T08). * @@ -252,8 +252,8 @@ class ProductController extends AdminController $name = (string) ($product['name'] ?? ''); // FK RESTRICT (order_item / menu / menu_slot_option / order_item_selection) - // -> PDOException 23000 -> 422 (catch ci-dessous). product_ingredient est - // CASCADE (recette possedee par le produit) : supprimee avec lui, jamais + // -> PDOException 23000 -> 409 Conflit (catch ci-dessous). product_ingredient + // est CASCADE (recette possedee par le produit) : supprimee avec lui, jamais // bloquante (cf. docblock ProductRepository). try { $this->db()->transaction(function (DatabaseInterface $db) use ($id, $actor, $name): void { @@ -264,7 +264,7 @@ class ProductController extends AdminController }); } catch (PDOException $exception) { if ((string) $exception->getCode() === '23000') { - return $this->renderDelete($guard, $id, $product, 'Produit reference par des commandes ou menus : suppression impossible. Masquez-le plutot.'); + return $this->renderDelete($guard, $id, $product, 'Produit reference par des commandes ou menus : suppression impossible. Masquez-le plutot.', 409); } throw $exception; @@ -272,7 +272,7 @@ class ProductController extends AdminController // PIN valide et suppression effective : reinitialise le compteur de l'acteur // de session (RG-T22, cle = $actorId). Apres le try/catch : non atteint si la - // FK a bloque (422), ce qui est benin (l'acteur n'est pas un attaquant). + // FK a bloque (409), ce qui est benin (l'acteur n'est pas un attaquant). $this->pinThrottle()->reset($actorId); $this->setFlash('Produit supprime.'); @@ -447,7 +447,7 @@ class ProductController extends AdminController /** * @param array $product */ - private function renderDelete(GuardResult $guard, int $id, array $product, ?string $error): Response + private function renderDelete(GuardResult $guard, int $id, array $product, ?string $error, ?int $status = null): Response { return $this->adminView('admin/products/delete', [ 'title' => 'Supprimer un produit - Wakdo Admin', @@ -455,7 +455,7 @@ class ProductController extends AdminController 'productId' => $id, 'name' => (string) ($product['name'] ?? ''), 'error' => $error, - ], $guard, $error !== null ? 422 : 200); + ], $guard, $status ?? ($error !== null ? 422 : 200)); } private function notFound(GuardResult $guard): Response diff --git a/src/app/Views/admin/menus/delete.php b/src/app/Views/admin/menus/delete.php index 24d8dc7..8973af4 100644 --- a/src/app/Views/admin/menus/delete.php +++ b/src/app/Views/admin/menus/delete.php @@ -5,7 +5,7 @@ declare(strict_types=1); /** * Confirmation de suppression d'un menu (action sensible RG-T13/mlt 8.6) : exige * l'email + le PIN de l'equipier. La suppression cascade vers menu_slot / - * menu_slot_option ; bloquee (422) si reference par une commande historique. + * menu_slot_option ; bloquee (409) si reference par une commande historique. * Injecte dans admin/layout.php. * * @var int $menuId diff --git a/tests/Unit/Admin/CategoryControllerTest.php b/tests/Unit/Admin/CategoryControllerTest.php index 6362291..3941090 100644 --- a/tests/Unit/Admin/CategoryControllerTest.php +++ b/tests/Unit/Admin/CategoryControllerTest.php @@ -237,10 +237,10 @@ final class CategoryControllerTest extends TestCase self::assertFalse($this->wroteContaining($db, 'INSERT INTO category')); } - public function testStoreTranslatesUniqueViolationTo422(): void + public function testStoreTranslatesUniqueViolationTo409(): void { - // Fenetre de concurrence : la base leve une violation 23000 a l'insertion ; - // le controleur doit re-afficher le formulaire (422), pas remonter un 500. + // Fenetre de concurrence : la base leve une violation 23000 a l'insertion. + // Conflit remonte par la base -> 409 (re-affiche le formulaire), pas un 500. $db = $this->permittedDb(); $db->failOnExecute = new \PDOException('duplicate', 23000); $request = $this->post( @@ -250,7 +250,7 @@ final class CategoryControllerTest extends TestCase $response = $this->controller($request, $db)->store(); - self::assertSame(422, $response->status()); + self::assertSame(409, $response->status()); self::assertStringContainsString('existe deja', $response->body()); } diff --git a/tests/Unit/Admin/MenuControllerTest.php b/tests/Unit/Admin/MenuControllerTest.php index 3701850..bc18a3e 100644 --- a/tests/Unit/Admin/MenuControllerTest.php +++ b/tests/Unit/Admin/MenuControllerTest.php @@ -269,7 +269,7 @@ final class MenuControllerTest extends TestCase self::assertSame(1, $reset['params']['uid'] ?? null); } - public function testDestroyReferencedByOrderReturns422(): void + public function testDestroyReferencedByOrderReturns409(): void { $db = $this->permittedDb(); $db->menuRow = ['id' => 5, 'name' => 'Best Of']; @@ -278,7 +278,7 @@ final class MenuControllerTest extends TestCase $response = $this->controller($this->post(['_csrf' => $this->csrf, 'pin_email' => 'staff@wakdo.local', 'pin' => '4729'], '/admin/menus/5/delete'), $db)->destroy(['id' => '5']); - self::assertSame(422, $response->status()); + self::assertSame(409, $response->status()); self::assertStringContainsString('suppression impossible', $response->body()); } diff --git a/tests/Unit/Admin/ProductControllerTest.php b/tests/Unit/Admin/ProductControllerTest.php index 1a476eb..317d379 100644 --- a/tests/Unit/Admin/ProductControllerTest.php +++ b/tests/Unit/Admin/ProductControllerTest.php @@ -304,7 +304,7 @@ final class ProductControllerTest extends TestCase $this->assertAuditWithinTransaction($db); } - public function testDestroyReferencedReturns422(): void + public function testDestroyReferencedReturns409(): void { $db = $this->permittedDb(); $db->productRow = ['id' => 5, 'name' => 'Big Mac']; @@ -313,7 +313,7 @@ final class ProductControllerTest extends TestCase $response = $this->controller($this->post(['_csrf' => $this->csrf, 'pin_email' => 'staff@wakdo.local', 'pin' => '4729'], '/admin/products/5/delete'), $db)->destroy(['id' => '5']); - self::assertSame(422, $response->status()); + self::assertSame(409, $response->status()); self::assertStringContainsString('reference', $response->body()); } -- 2.45.3 From 1f4b9478ca0d7c1abf2d2b536bf62552af11c676 Mon Sep 17 00:00:00 2001 From: Corentin JOGUET Date: Wed, 17 Jun 2026 11:11:31 +0200 Subject: [PATCH 35/93] feat(admin): stock ingredients - CRUD, restock, inventaire PIN, mouvements (P3, mlt 8.8 + domaine 9) (#34) --- src/app/Catalogue/IngredientRepository.php | 297 ++++++++ src/app/Controllers/IngredientController.php | 669 ++++++++++++++++++ src/app/Views/admin/ingredients/delete.php | 45 ++ src/app/Views/admin/ingredients/form.php | 88 +++ src/app/Views/admin/ingredients/index.php | 109 +++ src/app/Views/admin/ingredients/inventory.php | 72 ++ src/app/Views/admin/ingredients/movements.php | 79 +++ src/app/Views/admin/ingredients/restock.php | 59 ++ src/app/Views/admin/layout.php | 7 + src/public/admin/index.php | 19 + .../IngredientRepositoryDbTest.php | 258 +++++++ tests/Support/FakeDatabase.php | 59 ++ tests/Unit/Admin/IngredientControllerTest.php | 443 ++++++++++++ .../Catalogue/IngredientRepositoryTest.php | 66 ++ 14 files changed, 2270 insertions(+) create mode 100644 src/app/Catalogue/IngredientRepository.php create mode 100644 src/app/Controllers/IngredientController.php create mode 100644 src/app/Views/admin/ingredients/delete.php create mode 100644 src/app/Views/admin/ingredients/form.php create mode 100644 src/app/Views/admin/ingredients/index.php create mode 100644 src/app/Views/admin/ingredients/inventory.php create mode 100644 src/app/Views/admin/ingredients/movements.php create mode 100644 src/app/Views/admin/ingredients/restock.php create mode 100644 tests/Integration/IngredientRepositoryDbTest.php create mode 100644 tests/Unit/Admin/IngredientControllerTest.php create mode 100644 tests/Unit/Catalogue/IngredientRepositoryTest.php diff --git a/src/app/Catalogue/IngredientRepository.php b/src/app/Catalogue/IngredientRepository.php new file mode 100644 index 0000000..7d9e6b5 --- /dev/null +++ b/src/app/Catalogue/IngredientRepository.php @@ -0,0 +1,297 @@ + 0) = 100 % de + * reference ; stock_pct et la bande (normal/low/critical) sont CALCULES, jamais + * stockes (stockPct/stockBand). stock_quantity est SIGNE : il peut devenir + * negatif quand les ventes depassent le stock compte (survente assumee, remontee + * au manager) ; le systeme ne bloque jamais une commande sur le stock. + * + * Le stock ne bouge JAMAIS par ecriture directe de stock_quantity hors creation : + * - restock(...) : +N packs (mlt 9.1), sans PIN, acteur capture par permission ; + * - inventoryCount(...) : comptage absolu (mlt 9.2), PIN, ecrit une ligne MEME si delta=0. + * Chaque mouvement insere une ligne stock_movement (journal append-only) dans la + * MEME transaction que la mise a jour du stock (RG-T08). L'imputabilite passe par + * stock_movement.user_id, PAS par audit_log (RG-T14 exclut le stock du double-journal). + * + * Topologie FK (db/migrations/0001) : ingredient est reference par product_ingredient + * (RESTRICT) et stock_movement (RESTRICT) -> la suppression dure est bloquee des + * qu'une recette ou un mouvement existe ; le controleur traduit la violation + * (SQLSTATE 23000) en 409 et propose la desactivation (is_active). + */ +final class IngredientRepository +{ + public function __construct(private readonly DatabaseInterface $db) + { + } + + /** + * Liste pour le back-office, enrichie du pourcentage et de la bande calcules. + * + * @return array> + */ + public function all(): array + { + $rows = $this->db->fetchAll( + 'SELECT id, name, unit, stock_quantity, stock_capacity, pack_size, pack_label, ' + . 'low_stock_pct, critical_stock_pct, is_active FROM ingredient ORDER BY name', + ); + + return array_map([self::class, 'withStatus'], $rows); + } + + /** + * @return array|null + */ + public function find(int $id): ?array + { + $row = $this->db->fetch( + 'SELECT id, name, unit, stock_quantity, stock_capacity, pack_size, pack_label, ' + . 'low_stock_pct, critical_stock_pct, is_active FROM ingredient WHERE id = :id', + ['id' => $id], + ); + + return $row === null ? null : self::withStatus($row); + } + + public function nameExists(string $name, int $exceptId = 0): bool + { + return $this->db->fetch( + 'SELECT id FROM ingredient WHERE name = :name AND id <> :id', + ['name' => $name, 'id' => $exceptId], + ) !== null; + } + + /** + * Creation : pose les valeurs initiales, stock_quantity inclus (point de + * depart du stock). Allowlist RG-T16. + * + * @param array{name: string, unit: string, stock_quantity: int, stock_capacity: int, pack_size: int, pack_label: ?string, low_stock_pct: int, critical_stock_pct: int, is_active: int} $data + */ + public function create(array $data): void + { + $this->db->execute( + 'INSERT INTO ingredient (name, unit, stock_quantity, stock_capacity, pack_size, ' + . 'pack_label, low_stock_pct, critical_stock_pct, is_active) ' + . 'VALUES (:name, :unit, :qty, :cap, :pack, :label, :low, :crit, :active)', + [ + 'name' => $data['name'], + 'unit' => $data['unit'], + 'qty' => $data['stock_quantity'], + 'cap' => $data['stock_capacity'], + 'pack' => $data['pack_size'], + 'label' => $data['pack_label'], + 'low' => $data['low_stock_pct'], + 'crit' => $data['critical_stock_pct'], + 'active' => $data['is_active'], + ], + ); + } + + /** + * Mise a jour des attributs de definition. Allowlist RG-T16 : stock_quantity + * et is_active NE sont PAS modifiables ici. Le stock ne bouge que via + * restock/inventoryCount (ledger) ; is_active bascule via setActive + * (soft-delete). Les lier ici ouvrirait une affectation de masse non voulue. + * + * @param array{name: string, unit: string, stock_capacity: int, pack_size: int, pack_label: ?string, low_stock_pct: int, critical_stock_pct: int} $data + */ + public function update(int $id, array $data): void + { + $this->db->execute( + 'UPDATE ingredient SET name = :name, unit = :unit, stock_capacity = :cap, ' + . 'pack_size = :pack, pack_label = :label, low_stock_pct = :low, ' + . 'critical_stock_pct = :crit WHERE id = :id', + [ + 'name' => $data['name'], + 'unit' => $data['unit'], + 'cap' => $data['stock_capacity'], + 'pack' => $data['pack_size'], + 'label' => $data['pack_label'], + 'low' => $data['low_stock_pct'], + 'crit' => $data['critical_stock_pct'], + 'id' => $id, + ], + ); + } + + public function setActive(int $id, bool $active): int + { + return $this->db->execute( + 'UPDATE ingredient SET is_active = :a WHERE id = :id', + ['a' => $active ? 1 : 0, 'id' => $id], + ); + } + + /** + * Suppression dure. Bloquee par FK RESTRICT (product_ingredient / stock_movement) + * des qu'une recette ou un mouvement reference l'ingredient ; le controleur + * attrape SQLSTATE 23000 -> 409 et propose la desactivation. + */ + public function delete(int $id): int + { + return $this->db->execute('DELETE FROM ingredient WHERE id = :id', ['id' => $id]); + } + + /** + * Pre-verification FK-safe : l'ingredient est-il reference par une recette + * (product_ingredient) ou un mouvement de stock (stock_movement) ? Les deux + * FK sont RESTRICT, donc l'un ou l'autre bloque la suppression dure. + */ + public function isReferenced(int $id): bool + { + if ($this->db->fetch('SELECT ingredient_id FROM product_ingredient WHERE ingredient_id = :id LIMIT 1', ['id' => $id]) !== null) { + return true; + } + + return $this->db->fetch('SELECT id FROM stock_movement WHERE ingredient_id = :id LIMIT 1', ['id' => $id]) !== null; + } + + /** + * Reapprovisionnement (mlt 9.1) : +N packs => stock += N * pack_size, et une + * ligne stock_movement(restock) dans la MEME transaction (RG-T08). Sans PIN : + * $userId est l'acteur de session (capture par la permission stock.manage, + * RG-4), pas un acteur resolu par PIN. Les bornes d'entree (packs >= 1, mlt 9.1 + * PRE-3) sont validees par l'appelant (controleur, RG-T18), pas ici. + */ + public function restock(int $id, int $packs, ?int $userId, ?string $note = null): void + { + $this->db->transaction(function (DatabaseInterface $db) use ($id, $packs, $userId, $note): void { + $packSize = (int) ($db->fetch('SELECT pack_size FROM ingredient WHERE id = :id', ['id' => $id])['pack_size'] ?? 0); + $delta = $packs * $packSize; + $db->execute( + 'UPDATE ingredient SET stock_quantity = stock_quantity + :delta WHERE id = :id', + ['delta' => $delta, 'id' => $id], + ); + $this->insertMovement($db, $id, 'restock', $delta, $userId, $note); + }); + } + + /** + * Inventaire (mlt 9.2) : comptage physique absolu => stock_quantity = compte, + * et une ligne stock_movement(inventory_correction, delta = compte - actuel) + * dans la MEME transaction. RG-3 : la ligne est ecrite MEME si delta = 0 (un + * comptage conforme reste une preuve de controle a tracer). $userId est + * l'acteur resolu par le PIN (RG-T13). La borne d'entree (compte >= 0, mlt 9.2 + * PRE-3) est validee par l'appelant (controleur, RG-T18), pas ici. + */ + public function inventoryCount(int $id, int $countedQuantity, ?int $userId, ?string $note = null): void + { + $this->db->transaction(function (DatabaseInterface $db) use ($id, $countedQuantity, $userId, $note): void { + $current = (int) ($db->fetch('SELECT stock_quantity FROM ingredient WHERE id = :id', ['id' => $id])['stock_quantity'] ?? 0); + $delta = $countedQuantity - $current; + $db->execute( + 'UPDATE ingredient SET stock_quantity = :q WHERE id = :id', + ['q' => $countedQuantity, 'id' => $id], + ); + $this->insertMovement($db, $id, 'inventory_correction', $delta, $userId, $note); + }); + } + + /** + * Registre append-only des mouvements d'un ingredient, du plus recent au plus + * ancien, BORNE (mlt 9.3 READ_STOCK RG-3 prescrit LIMIT :n ; stock_movement + * croit a chaque vente, on ne materialise pas tout). La FK order_id reste NULL + * pour restock/inventory (renseignee cote commande en P4). La visibilite de + * user_id (RG-4 : manager/admin seulement) est appliquee par le controleur, pas ici. + * + * @return array> + */ + public function movements(int $id, int $limit = 50): array + { + // La borne est interpolee en entier (cast int + plancher 1) plutot que + // liee en placeholder : avec ATTR_EMULATE_PREPARES=false (Database), un + // ':limit' lie comme chaine fait echouer MariaDB sur LIMIT. Un int n'a + // aucun risque d'injection. + $bounded = max(1, $limit); + + return $this->db->fetchAll( + 'SELECT id, ingredient_id, movement_type, delta, order_id, user_id, note, created_at ' + . 'FROM stock_movement WHERE ingredient_id = :id ORDER BY created_at DESC, id DESC ' + . 'LIMIT ' . $bounded, + ['id' => $id], + ); + } + + private function insertMovement(DatabaseInterface $db, int $ingredientId, string $type, int $delta, ?int $userId, ?string $note): void + { + $db->execute( + 'INSERT INTO stock_movement (ingredient_id, movement_type, delta, order_id, user_id, note) ' + . 'VALUES (:ingredient, :type, :delta, NULL, :user, :note)', + [ + 'ingredient' => $ingredientId, + 'type' => $type, + 'delta' => $delta, + 'user' => $userId, + 'note' => $note, + ], + ); + } + + /** + * Pourcentage de stock = round(quantity / capacity * 100). Calcule, non stocke. + * Garde anti division par zero (stock_capacity porte un CHECK > 0 en base). + */ + public static function stockPct(int $quantity, int $capacity): int + { + if ($capacity <= 0) { + return 0; + } + + return (int) round($quantity * 100 / $capacity); + } + + /** + * Bande a 3 niveaux (mcd 5.3), en arithmetique entiere (pas de flottant) : + * - critical : quantity <= capacity * critical_pct / 100 (rupture auto) + * - low : quantity <= capacity * low_pct / 100 (alerte, encore commandable) + * - normal : au-dessus. + * Un stock negatif (survente) tombe en critical. critical_pct < low_pct est + * garanti par un CHECK de table. + */ + public static function stockBand(int $quantity, int $capacity, int $lowPct, int $critPct): string + { + if ($capacity <= 0) { + return 'critical'; + } + + $scaled = $quantity * 100; + if ($scaled <= $capacity * $critPct) { + return 'critical'; + } + if ($scaled <= $capacity * $lowPct) { + return 'low'; + } + + return 'normal'; + } + + /** + * Enrichit une ligne ingredient des champs calcules stock_pct et stock_band. + * + * @param array $row + * @return array + */ + private static function withStatus(array $row): array + { + $quantity = (int) ($row['stock_quantity'] ?? 0); + $capacity = (int) ($row['stock_capacity'] ?? 0); + $lowPct = (int) ($row['low_stock_pct'] ?? 0); + $critPct = (int) ($row['critical_stock_pct'] ?? 0); + + $row['stock_pct'] = self::stockPct($quantity, $capacity); + $row['stock_band'] = self::stockBand($quantity, $capacity, $lowPct, $critPct); + + return $row; + } +} diff --git a/src/app/Controllers/IngredientController.php b/src/app/Controllers/IngredientController.php new file mode 100644 index 0000000..f8cca52 --- /dev/null +++ b/src/app/Controllers/IngredientController.php @@ -0,0 +1,669 @@ + 409. + * - RESTOCK (9.1) : `stock.manage`, SANS PIN ; PRE-2 ingredient actif, PRE-3 N>=1 ; + * user_id = acteur de SESSION (capture par permission, RG-4). + * - INVENTORY_COUNT (9.2) : `stock.count` + PIN equipier (RG-T13) ; PRE-3 compte>=0 ; + * user_id = acteur resolu par PIN, ecrit dans stock_movement.user_id. PAS d'audit_log + * au succes (RG-T14 : le stock_movement EST la trace). Echec PIN -> pin.failed + + * throttle (RG-T22), comme produit/menu. + * - READ_STOCK (9.3) : `stock.read` ; le user_id des mouvements n'est expose qu'a + * manager/admin (RG-4), detecte via la permission stock.manage. + * + * Le stock ne bouge JAMAIS par le formulaire de definition : creation pose + * stock_quantity=0 (RG-CREATE-ING), update ne lie ni stock_quantity ni is_active + * (RG-T16 ; is_active bascule via toggle, soft-delete). Non `final` : les tests + * sous-classent pour injecter des doubles. + */ +class IngredientController extends AdminController +{ + /** + * @param array $params + */ + public function index(array $params = []): Response + { + $guard = $this->guard('stock.read'); + if ($guard instanceof Response) { + return $guard; + } + + return $this->adminView('admin/ingredients/index', [ + 'title' => 'Stock - Wakdo Admin', + 'activeNav' => 'stock', + 'ingredients' => $this->ingredientRepository()->all(), + 'canManage' => $this->may($guard, 'ingredient.manage'), + 'canRestock' => $this->may($guard, 'stock.manage'), + 'canCount' => $this->may($guard, 'stock.count'), + ], $guard); + } + + /** + * @param array $params + */ + public function create(array $params = []): Response + { + $guard = $this->guard('ingredient.manage'); + if ($guard instanceof Response) { + return $guard; + } + + return $this->renderForm($guard, 0, [], []); + } + + /** + * @param array $params + */ + public function store(array $params = []): Response + { + $guard = $this->guard('ingredient.manage'); + if ($guard instanceof Response) { + return $guard; + } + + $form = $this->request->formBody(); + if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) { + return $this->invalidCsrf(); + } + + [$data, $errors] = $this->validate($form, 0); + if ($errors !== []) { + return $this->renderForm($guard, 0, $form, $errors, 422); + } + + // stock_quantity initial = 0 (RG-CREATE-ING) ; is_active = 1 : valeurs posees + // cote serveur, pas liees au formulaire (RG-T16). Le stock s'etablit ensuite + // via restock/inventaire (chaque mouvement laisse une trace). + try { + $this->ingredientRepository()->create($data + ['stock_quantity' => 0, 'is_active' => 1]); + } catch (PDOException $exception) { + return $this->onWriteConflict($exception, $guard, 0, $form); + } + + $this->setFlash('Ingredient cree.'); + + return $this->redirect('/admin/ingredients'); + } + + /** + * @param array $params + */ + public function edit(array $params): Response + { + $guard = $this->guard('ingredient.manage'); + if ($guard instanceof Response) { + return $guard; + } + + $id = (int) ($params['id'] ?? 0); + $ingredient = $this->ingredientRepository()->find($id); + if ($ingredient === null) { + return $this->notFound($guard); + } + + return $this->renderForm($guard, $id, $ingredient, []); + } + + /** + * @param array $params + */ + public function update(array $params): Response + { + $guard = $this->guard('ingredient.manage'); + if ($guard instanceof Response) { + return $guard; + } + + $form = $this->request->formBody(); + if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) { + return $this->invalidCsrf(); + } + + $id = (int) ($params['id'] ?? 0); + if ($this->ingredientRepository()->find($id) === null) { + return $this->notFound($guard); + } + + [$data, $errors] = $this->validate($form, $id); + if ($errors !== []) { + return $this->renderForm($guard, $id, $form, $errors, 422); + } + + try { + $this->ingredientRepository()->update($id, $data); + } catch (PDOException $exception) { + return $this->onWriteConflict($exception, $guard, $id, $form); + } + + $this->setFlash('Ingredient mis a jour.'); + + return $this->redirect('/admin/ingredients'); + } + + /** + * @param array $params + */ + public function toggle(array $params): Response + { + $guard = $this->guard('ingredient.manage'); + if ($guard instanceof Response) { + return $guard; + } + + $form = $this->request->formBody(); + if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) { + return $this->invalidCsrf(); + } + + $id = (int) ($params['id'] ?? 0); + $ingredient = $this->ingredientRepository()->find($id); + if ($ingredient === null) { + return $this->notFound($guard); + } + + $newActive = (int) ($ingredient['is_active'] ?? 0) !== 1; + $this->ingredientRepository()->setActive($id, $newActive); + $this->setFlash($newActive ? 'Ingredient reactive.' : 'Ingredient desactive.'); + + return $this->redirect('/admin/ingredients'); + } + + /** + * @param array $params + */ + public function confirmDelete(array $params): Response + { + $guard = $this->guard('ingredient.manage'); + if ($guard instanceof Response) { + return $guard; + } + + $id = (int) ($params['id'] ?? 0); + $ingredient = $this->ingredientRepository()->find($id); + if ($ingredient === null) { + return $this->notFound($guard); + } + + return $this->renderDelete($guard, $id, $ingredient, null); + } + + /** + * @param array $params + */ + public function destroy(array $params): Response + { + $guard = $this->guard('ingredient.manage'); + if ($guard instanceof Response) { + return $guard; + } + + $form = $this->request->formBody(); + if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) { + return $this->invalidCsrf(); + } + + $id = (int) ($params['id'] ?? 0); + $ingredient = $this->ingredientRepository()->find($id); + if ($ingredient === null) { + return $this->notFound($guard); + } + + // 8.8 n'est PAS dans l'ensemble PIN (RG-T13) : pas de PIN a la suppression. + // Hard-delete bloquee par FK RESTRICT (product_ingredient / stock_movement) + // -> PDOException 23000 -> 409 Conflit (proposer la desactivation). + try { + $this->ingredientRepository()->delete($id); + } catch (PDOException $exception) { + if ((string) $exception->getCode() === '23000') { + return $this->renderDelete($guard, $id, $ingredient, 'Ingredient reference par une recette ou des mouvements de stock : suppression impossible. Desactivez-le plutot.', 409); + } + + throw $exception; + } + + $this->setFlash('Ingredient supprime.'); + + return $this->redirect('/admin/ingredients'); + } + + /** + * @param array $params + */ + public function restockForm(array $params): Response + { + $guard = $this->guard('stock.manage'); + if ($guard instanceof Response) { + return $guard; + } + + $id = (int) ($params['id'] ?? 0); + $ingredient = $this->ingredientRepository()->find($id); + if ($ingredient === null) { + return $this->notFound($guard); + } + + return $this->renderRestock($guard, $id, $ingredient, [], []); + } + + /** + * @param array $params + */ + public function restock(array $params): Response + { + $guard = $this->guard('stock.manage'); + if ($guard instanceof Response) { + return $guard; + } + + $form = $this->request->formBody(); + if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) { + return $this->invalidCsrf(); + } + + $id = (int) ($params['id'] ?? 0); + $ingredient = $this->ingredientRepository()->find($id); + if ($ingredient === null) { + return $this->notFound($guard); + } + + $errors = []; + + // PRE-2 (9.1) : on ne reapprovisionne qu'un ingredient actif. + if ((int) ($ingredient['is_active'] ?? 0) !== 1) { + $errors['packs'] = 'Ingredient inactif : reactivez-le avant de reapprovisionner.'; + } + + // PRE-3 (9.1) : N >= 1 (borne haute pour eviter un debordement de stock_quantity). + $packsRaw = trim($form['packs'] ?? ''); + $packsValid = ctype_digit($packsRaw) && (int) $packsRaw >= 1 && (int) $packsRaw <= 65535; + if (!$packsValid && !isset($errors['packs'])) { + $errors['packs'] = 'Le nombre de packs doit etre un entier entre 1 et 65535.'; + } + + $note = trim($form['note'] ?? ''); + if (mb_strlen($note) > 255) { + $errors['note'] = 'Note trop longue (255 caracteres max).'; + } + + if ($errors !== []) { + return $this->renderRestock($guard, $id, $ingredient, $form, $errors, 422); + } + + $this->ingredientRepository()->restock($id, (int) $packsRaw, $guard->userId, $note !== '' ? $note : null); + $this->setFlash('Reapprovisionnement enregistre.'); + + return $this->redirect('/admin/ingredients'); + } + + /** + * @param array $params + */ + public function inventoryForm(array $params): Response + { + $guard = $this->guard('stock.count'); + if ($guard instanceof Response) { + return $guard; + } + + $id = (int) ($params['id'] ?? 0); + $ingredient = $this->ingredientRepository()->find($id); + if ($ingredient === null) { + return $this->notFound($guard); + } + + return $this->renderInventory($guard, $id, $ingredient, [], []); + } + + /** + * @param array $params + */ + public function inventory(array $params): Response + { + $guard = $this->guard('stock.count'); + if ($guard instanceof Response) { + return $guard; + } + + $form = $this->request->formBody(); + if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) { + return $this->invalidCsrf(); + } + + $id = (int) ($params['id'] ?? 0); + $ingredient = $this->ingredientRepository()->find($id); + if ($ingredient === null) { + return $this->notFound($guard); + } + + $errors = []; + + // PRE-3 (9.2) : comptage physique non negatif. ctype_digit borne deja >= 0. + $actualRaw = trim($form['actual_quantity'] ?? ''); + $actualValid = ctype_digit($actualRaw) && (int) $actualRaw <= 2147483647; + if (!$actualValid) { + $errors['actual_quantity'] = 'Le comptage doit etre un entier >= 0.'; + } + + $note = trim($form['note'] ?? ''); + if (mb_strlen($note) > 255) { + $errors['note'] = 'Note trop longue (255 caracteres max).'; + } + + if ($errors !== []) { + return $this->renderInventory($guard, $id, $ingredient, $form, $errors, 422); + } + + // RG-T13/RG-4 : correction d'inventaire = action sensible, PIN equipier. + // RG-T22 : verrou du throttle par utilisateur AGISSANT (session), evalue AVANT + // la verification ; sous verrou, leurre de timing et message generique, pas de + // nouvelle ligne pin.failed. + $actorId = $guard->userId ?? 0; + if ($actorId > 0 && $this->pinThrottle()->isLocked($actorId)) { + $this->pinVerifier()->payTimingDecoy($form['pin'] ?? ''); + + return $this->renderInventory($guard, $id, $ingredient, $form, ['pin' => 'Email ou PIN invalide (requis pour l inventaire).'], 422); + } + + $actor = $this->pinVerifier()->resolveActingUser(trim($form['pin_email'] ?? ''), $form['pin'] ?? ''); + if ($actor === null) { + // RG-T08 : trace pin.failed (RG-T14) + increment throttle (RG-T22) dans UNE + // transaction. pin.failed est un evenement securite (aucun stock_movement + // n'est cree), il n'entre donc pas en conflit avec l'exclusion stock de RG-T14. + $email = trim($form['pin_email'] ?? ''); + $this->db()->transaction(function (DatabaseInterface $db) use ($email, $id, $actorId): void { + $this->logFailedPin($db, $email, $id); + $this->pinThrottle()->recordFailureWithin($db, $actorId); + }); + + return $this->renderInventory($guard, $id, $ingredient, $form, ['pin' => 'Email ou PIN invalide (requis pour l inventaire).'], 422); + } + + // Succes : la correction ecrit stock_movement.user_id (acteur resolu par PIN). + // PAS de ligne audit_log (RG-T14 : la trace stock_movement suffit, pas de + // double-journal). inventoryCount ouvre sa propre transaction (UPDATE+INSERT). + $this->ingredientRepository()->inventoryCount($id, (int) $actualRaw, $actor['id'], $note !== '' ? $note : null); + $this->pinThrottle()->reset($actorId); + + $this->setFlash('Inventaire enregistre.'); + + return $this->redirect('/admin/ingredients'); + } + + /** + * @param array $params + */ + public function movements(array $params): Response + { + $guard = $this->guard('stock.read'); + if ($guard instanceof Response) { + return $guard; + } + + $id = (int) ($params['id'] ?? 0); + $ingredient = $this->ingredientRepository()->find($id); + if ($ingredient === null) { + return $this->notFound($guard); + } + + // RG-4 (9.3) : l'identite de l'acteur d'un mouvement n'est exposee qu'a + // manager/admin (detenteurs de stock.manage) ; le personnel de ligne voit + // les deltas sans l'auteur. + $showActor = $this->may($guard, 'stock.manage'); + $movements = $this->ingredientRepository()->movements($id); + + $actorNames = []; + if ($showActor) { + foreach ($movements as $movement) { + $uid = $movement['user_id'] !== null ? (int) $movement['user_id'] : 0; + if ($uid > 0 && !isset($actorNames[$uid])) { + $actorNames[$uid] = $this->userDirectory()->displayInfo($uid)['name']; + } + } + } + + return $this->adminView('admin/ingredients/movements', [ + 'title' => 'Mouvements de stock - Wakdo Admin', + 'activeNav' => 'stock', + 'ingredient' => $ingredient, + 'movements' => $movements, + 'showActor' => $showActor, + 'actorNames' => $actorNames, + ], $guard); + } + + protected function ingredientRepository(): IngredientRepository + { + return new IngredientRepository($this->db()); + } + + protected function pinVerifier(): PinVerifier + { + return new PinVerifier($this->db(), $this->config, $this->passwordHasher()); + } + + protected function pinThrottle(): PinThrottle + { + return new PinThrottle($this->db(), $this->config); + } + + protected function passwordHasher(): PasswordHasher + { + return new PasswordHasher($this->config); + } + + /** + * RG-T03 : la permission est-elle detenue par le role de la session courante ? + * Utilise pour adapter l'affichage (liens d'action, visibilite acteur RG-4) sans + * remplacer la garde par-action (chaque route reste gardee independamment). + */ + private function may(GuardResult $guard, string $permission): bool + { + return $guard->roleId !== null && $this->authorizer()->can($guard->roleId, $permission); + } + + /** + * Validation serveur (RG-T18) + allowlist des champs de definition (RG-T16). + * stock_quantity et is_active ne sont jamais lies ici (poses cote serveur a la + * creation, modifies via restock/inventaire/toggle). Renvoie [donnees, erreurs]. + * + * @param array $form + * @return array{0: array{name: string, unit: string, stock_capacity: int, pack_size: int, pack_label: ?string, low_stock_pct: int, critical_stock_pct: int}, 1: array} + */ + private function validate(array $form, int $exceptId): array + { + $errors = []; + + $name = trim($form['name'] ?? ''); + if ($name === '' || mb_strlen($name) > 120) { + $errors['name'] = 'Le nom est requis (120 caracteres max).'; + } elseif ($this->ingredientRepository()->nameExists($name, $exceptId)) { + $errors['name'] = 'Cet ingredient existe deja.'; + } + + $unit = trim($form['unit'] ?? ''); + if ($unit === '' || mb_strlen($unit) > 40) { + $errors['unit'] = 'L unite est requise (40 caracteres max).'; + } + + $capRaw = trim($form['stock_capacity'] ?? ''); + $capValid = ctype_digit($capRaw) && (int) $capRaw >= 1 && (int) $capRaw <= 2147483647; + if (!$capValid) { + $errors['stock_capacity'] = 'La capacite (reference 100%) doit etre un entier >= 1.'; + } + + $packRaw = trim($form['pack_size'] ?? ''); + $packValid = ctype_digit($packRaw) && (int) $packRaw >= 1 && (int) $packRaw <= 65535; + if (!$packValid) { + $errors['pack_size'] = 'La taille de pack doit etre un entier entre 1 et 65535.'; + } + + $label = trim($form['pack_label'] ?? ''); + if ($label !== '' && mb_strlen($label) > 80) { + $errors['pack_label'] = 'Libelle de pack trop long (80 caracteres max).'; + } + + $lowRaw = trim($form['low_stock_pct'] ?? ''); + $lowValid = ctype_digit($lowRaw) && (int) $lowRaw <= 100; + if (!$lowValid) { + $errors['low_stock_pct'] = 'Le seuil d alerte doit etre un entier entre 0 et 100.'; + } + + $critRaw = trim($form['critical_stock_pct'] ?? ''); + $critValid = ctype_digit($critRaw) && (int) $critRaw <= 100; + if (!$critValid) { + $errors['critical_stock_pct'] = 'Le seuil critique doit etre un entier entre 0 et 100.'; + } + + // RG-CREATE-ING : critical_stock_pct < low_stock_pct (strict). + if ($lowValid && $critValid && (int) $critRaw >= (int) $lowRaw) { + $errors['critical_stock_pct'] = 'Le seuil critique doit etre strictement inferieur au seuil d alerte.'; + } + + $data = [ + 'name' => $name, + 'unit' => $unit, + 'stock_capacity' => $capValid ? (int) $capRaw : 0, + 'pack_size' => $packValid ? (int) $packRaw : 0, + 'pack_label' => $label !== '' ? $label : null, + 'low_stock_pct' => $lowValid ? (int) $lowRaw : 0, + 'critical_stock_pct' => $critValid ? (int) $critRaw : 0, + ]; + + return [$data, $errors]; + } + + /** + * Traduit une violation d'unicite (SQLSTATE 23000, name deja pris) en + * re-affichage 409 du formulaire (coherent avec la convention de conflit du + * back-office). Tout autre code est repropage. + * + * @param array $form + */ + private function onWriteConflict(PDOException $exception, GuardResult $guard, int $id, array $form): Response + { + if ((string) $exception->getCode() === '23000') { + return $this->renderForm($guard, $id, $form, ['name' => 'Cet ingredient existe deja.'], 409); + } + + throw $exception; + } + + private function logFailedPin(DatabaseInterface $db, string $email, int $ingredientId): void + { + $db->execute( + 'INSERT INTO audit_log (actor_user_id, actor_role_id, action_code, entity_type, entity_id, summary) ' + . 'VALUES (:uid, :rid, :code, :etype, :eid, :summary)', + [ + 'uid' => null, + 'rid' => null, + 'code' => 'pin.failed', + 'etype' => 'ingredient', + 'eid' => $ingredientId, + 'summary' => 'Echec PIN inventaire (email tente: ' . $email . ')', + ], + ); + } + + /** + * @param array $values + * @param array $errors + */ + private function renderForm(GuardResult $guard, int $id, array $values, array $errors, int $status = 200): Response + { + return $this->adminView('admin/ingredients/form', [ + 'title' => ($id !== 0 ? 'Modifier' : 'Nouvel') . ' ingredient - Wakdo Admin', + 'activeNav' => 'stock', + 'ingredientId' => $id, + 'values' => [ + 'name' => (string) ($values['name'] ?? ''), + 'unit' => (string) ($values['unit'] ?? ''), + 'stock_capacity' => (string) ($values['stock_capacity'] ?? ''), + 'pack_size' => (string) ($values['pack_size'] ?? '1'), + 'pack_label' => (string) ($values['pack_label'] ?? ''), + 'low_stock_pct' => (string) ($values['low_stock_pct'] ?? '10'), + 'critical_stock_pct' => (string) ($values['critical_stock_pct'] ?? '5'), + ], + 'errors' => $errors, + ], $guard, $status); + } + + /** + * @param array $ingredient + * @param array $values + * @param array $errors + */ + private function renderRestock(GuardResult $guard, int $id, array $ingredient, array $values, array $errors, int $status = 200): Response + { + return $this->adminView('admin/ingredients/restock', [ + 'title' => 'Reapprovisionner - Wakdo Admin', + 'activeNav' => 'stock', + 'ingredientId' => $id, + 'ingredient' => $ingredient, + 'values' => ['packs' => (string) ($values['packs'] ?? ''), 'note' => (string) ($values['note'] ?? '')], + 'errors' => $errors, + ], $guard, $status); + } + + /** + * @param array $ingredient + * @param array $values + * @param array $errors + */ + private function renderInventory(GuardResult $guard, int $id, array $ingredient, array $values, array $errors, int $status = 200): Response + { + return $this->adminView('admin/ingredients/inventory', [ + 'title' => 'Inventaire - Wakdo Admin', + 'activeNav' => 'stock', + 'ingredientId' => $id, + 'ingredient' => $ingredient, + 'values' => ['actual_quantity' => (string) ($values['actual_quantity'] ?? ''), 'note' => (string) ($values['note'] ?? '')], + 'errors' => $errors, + ], $guard, $status); + } + + /** + * @param array $ingredient + */ + private function renderDelete(GuardResult $guard, int $id, array $ingredient, ?string $error, ?int $status = null): Response + { + return $this->adminView('admin/ingredients/delete', [ + 'title' => 'Supprimer un ingredient - Wakdo Admin', + 'activeNav' => 'stock', + 'ingredientId' => $id, + 'name' => (string) ($ingredient['name'] ?? ''), + 'error' => $error, + ], $guard, $status ?? ($error !== null ? 422 : 200)); + } + + private function notFound(GuardResult $guard): Response + { + return $this->adminView('admin/not_found', ['title' => 'Introuvable', 'activeNav' => 'stock'], $guard, 404); + } + + private function redirect(string $location): Response + { + return Response::make('', 302, ['Location' => $location]); + } + + private function invalidCsrf(): Response + { + return Response::make('Requete invalide.', 403, ['Content-Type' => 'text/plain; charset=utf-8']); + } +} diff --git a/src/app/Views/admin/ingredients/delete.php b/src/app/Views/admin/ingredients/delete.php new file mode 100644 index 0000000..30d06bf --- /dev/null +++ b/src/app/Views/admin/ingredients/delete.php @@ -0,0 +1,45 @@ + proposer la + * desactivation. CSRF cache. + * + * @var int $ingredientId + * @var string $name + * @var string|null $error + * @var string $csrfToken + */ + +$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8'); +$id = (int) ($ingredientId ?? 0); +$ingredientName = htmlspecialchars((string) ($name ?? ''), ENT_QUOTES, 'UTF-8'); +$errorMessage = isset($error) && is_string($error) ? $error : null; +?> + + +
+ +

+ + +
+ + +

Un ingredient deja utilise (recette ou mouvement de stock) ne peut pas etre supprime : desactivez-le a la place.

+ +
+ + Annuler +
+
+
diff --git a/src/app/Views/admin/ingredients/form.php b/src/app/Views/admin/ingredients/form.php new file mode 100644 index 0000000..a8cbeba --- /dev/null +++ b/src/app/Views/admin/ingredients/form.php @@ -0,0 +1,88 @@ + $values + * @var array $errors + * @var string $csrfToken + */ + +$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8'); +$id = (int) ($ingredientId ?? 0); +$action = $id !== 0 ? '/admin/ingredients/' . $id : '/admin/ingredients'; + +/** @var array $vals */ +$vals = isset($values) && is_array($values) ? $values : []; +/** @var array $errs */ +$errs = isset($errors) && is_array($errors) ? $errors : []; + +$val = static fn (string $k): string => htmlspecialchars((string) ($vals[$k] ?? ''), ENT_QUOTES, 'UTF-8'); +$err = static fn (string $k): string => isset($errs[$k]) && is_string($errs[$k]) ? $errs[$k] : ''; +?> + + +
+ + +
+ + +

+
+ +
+ + +

+
+ +
+ + +

+
+ +
+ + +

+
+ +
+ + +

+
+ +
+ + +

+
+ +
+ + +

+
+ + +

Le stock initial est a 0 : etablissez-le ensuite via un reapprovisionnement ou un inventaire (chaque mouvement est trace).

+ + +
+ + Annuler +
+
diff --git a/src/app/Views/admin/ingredients/index.php b/src/app/Views/admin/ingredients/index.php new file mode 100644 index 0000000..3204d7c --- /dev/null +++ b/src/app/Views/admin/ingredients/index.php @@ -0,0 +1,109 @@ +> $ingredients + * @var bool $canManage + * @var bool $canRestock + * @var bool $canCount + * @var string $csrfToken + */ + +/** @var array> $rows */ +$rows = isset($ingredients) && is_array($ingredients) ? $ingredients : []; +$esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8'); +$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8'); +$manage = (bool) ($canManage ?? false); +$restock = (bool) ($canRestock ?? false); +$count = (bool) ($canCount ?? false); + +$bandLabel = static fn (string $band): string => match ($band) { + 'critical' => 'pill pill-danger', + 'low' => 'pill pill-warning', + default => 'pill pill-success', +}; +$bandText = static fn (string $band): string => match ($band) { + 'critical' => 'Critique', + 'low' => 'Alerte', + default => 'Normal', +}; +?> + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
IngredientUniteStockNiveauStatut
Aucun ingredient.
+ + / (%) + + + Actif + + Inactif + + + Mouvements + + Reappro + + + Inventaire + + + Modifier +
+ + +
+ Supprimer + +
+
+
diff --git a/src/app/Views/admin/ingredients/inventory.php b/src/app/Views/admin/ingredients/inventory.php new file mode 100644 index 0000000..bdad7ce --- /dev/null +++ b/src/app/Views/admin/ingredients/inventory.php @@ -0,0 +1,72 @@ + $ingredient + * @var array $values + * @var array $errors + * @var string $csrfToken + */ + +$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8'); +$id = (int) ($ingredientId ?? 0); +/** @var array $ing */ +$ing = isset($ingredient) && is_array($ingredient) ? $ingredient : []; +/** @var array $vals */ +$vals = isset($values) && is_array($values) ? $values : []; +/** @var array $errs */ +$errs = isset($errors) && is_array($errors) ? $errors : []; + +$esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8'); +$val = static fn (string $k): string => htmlspecialchars((string) ($vals[$k] ?? ''), ENT_QUOTES, 'UTF-8'); +$err = static fn (string $k): string => isset($errs[$k]) && is_string($errs[$k]) ? $errs[$k] : ''; +?> + + +
+ + +

Saisissez le comptage physique reel. L'ecart avec le theorique est enregistre et impute a l'equipier (action tracee).

+ +
+ + +

+
+ +
+ + +

+
+ +
+ Confirmation par PIN equipier +
+ + +
+
+ + +
+

+
+ +
+ + Annuler +
+
diff --git a/src/app/Views/admin/ingredients/movements.php b/src/app/Views/admin/ingredients/movements.php new file mode 100644 index 0000000..b53a756 --- /dev/null +++ b/src/app/Views/admin/ingredients/movements.php @@ -0,0 +1,79 @@ + $ingredient + * @var array> $movements + * @var bool $showActor + * @var array $actorNames + */ + +/** @var array $ing */ +$ing = isset($ingredient) && is_array($ingredient) ? $ingredient : []; +/** @var array> $rows */ +$rows = isset($movements) && is_array($movements) ? $movements : []; +/** @var array $names */ +$names = isset($actorNames) && is_array($actorNames) ? $actorNames : []; +$withActor = (bool) ($showActor ?? false); + +$esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8'); +$typeText = static fn (string $t): string => match ($t) { + 'restock' => 'Reappro', + 'inventory_correction' => 'Inventaire', + 'sale' => 'Vente', + 'cancellation' => 'Annulation', + default => $t, +}; +$colspan = $withActor ? 5 : 4; +?> + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
DateTypeDeltaNoteActeur
Aucun mouvement.
0 ? '+' . $delta : (string) $delta ?> 0 ? $esc($names[$uid] ?? ('#' . $uid)) : '-' ?>
+
+
diff --git a/src/app/Views/admin/ingredients/restock.php b/src/app/Views/admin/ingredients/restock.php new file mode 100644 index 0000000..e044758 --- /dev/null +++ b/src/app/Views/admin/ingredients/restock.php @@ -0,0 +1,59 @@ + stock += N * pack_size. CSRF cache. + * + * @var int $ingredientId + * @var array $ingredient + * @var array $values + * @var array $errors + * @var string $csrfToken + */ + +$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8'); +$id = (int) ($ingredientId ?? 0); +/** @var array $ing */ +$ing = isset($ingredient) && is_array($ingredient) ? $ingredient : []; +/** @var array $vals */ +$vals = isset($values) && is_array($values) ? $values : []; +/** @var array $errs */ +$errs = isset($errors) && is_array($errors) ? $errors : []; + +$esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8'); +$val = static fn (string $k): string => htmlspecialchars((string) ($vals[$k] ?? ''), ENT_QUOTES, 'UTF-8'); +$err = static fn (string $k): string => isset($errs[$k]) && is_string($errs[$k]) ? $errs[$k] : ''; +$packSize = (int) ($ing['pack_size'] ?? 1); +$packLabel = (string) ($ing['pack_label'] ?? ''); +?> + + +
+ + +

Un pack = unite(s). Le stock augmente de N x taille de pack.

+ +
+ + +

+
+ +
+ + +

+
+ +
+ + Annuler +
+
diff --git a/src/app/Views/admin/layout.php b/src/app/Views/admin/layout.php index a8f174a..8d0036a 100644 --- a/src/app/Views/admin/layout.php +++ b/src/app/Views/admin/layout.php @@ -111,6 +111,13 @@ $navClass = static function (string $code, string $current): string { + + + + add('GET', '/admin/menus/{id}/delete', [MenuController::class, 'confirmDelete']); $router->add('POST', '/admin/menus/{id}/delete', [MenuController::class, 'destroy']); + // Stock / Ingredients (P3, mlt 8.8 + domaine 9). Permissions par operation : + // stock.read (liste/mouvements, tous roles) ; ingredient.manage (CRUD, sans PIN) ; + // stock.manage (reappro, sans PIN) ; stock.count (inventaire, + PIN). Pas d'audit_log + // (RG-T14) : l'attribution passe par stock_movement.user_id. + $router->add('GET', '/admin/ingredients', [IngredientController::class, 'index']); + $router->add('GET', '/admin/ingredients/new', [IngredientController::class, 'create']); + $router->add('POST', '/admin/ingredients', [IngredientController::class, 'store']); + $router->add('GET', '/admin/ingredients/{id}/edit', [IngredientController::class, 'edit']); + $router->add('POST', '/admin/ingredients/{id}', [IngredientController::class, 'update']); + $router->add('POST', '/admin/ingredients/{id}/toggle', [IngredientController::class, 'toggle']); + $router->add('GET', '/admin/ingredients/{id}/delete', [IngredientController::class, 'confirmDelete']); + $router->add('POST', '/admin/ingredients/{id}/delete', [IngredientController::class, 'destroy']); + $router->add('GET', '/admin/ingredients/{id}/restock', [IngredientController::class, 'restockForm']); + $router->add('POST', '/admin/ingredients/{id}/restock', [IngredientController::class, 'restock']); + $router->add('GET', '/admin/ingredients/{id}/inventory', [IngredientController::class, 'inventoryForm']); + $router->add('POST', '/admin/ingredients/{id}/inventory', [IngredientController::class, 'inventory']); + $router->add('GET', '/admin/ingredients/{id}/movements', [IngredientController::class, 'movements']); + $response = $router->dispatch(Request::fromGlobals()); $response->send(); } catch (Throwable $exception) { diff --git a/tests/Integration/IngredientRepositoryDbTest.php b/tests/Integration/IngredientRepositoryDbTest.php new file mode 100644 index 0000000..9eb0e08 --- /dev/null +++ b/tests/Integration/IngredientRepositoryDbTest.php @@ -0,0 +1,258 @@ +db = new Database(new Config()); + + try { + $this->db->fetch('SELECT 1'); + } catch (Throwable $exception) { + self::markTestSkipped('Base injoignable: ' . $exception->getMessage()); + } + + $this->userId = (int) ($this->db->fetch('SELECT id FROM user ORDER BY id LIMIT 1')['id'] ?? 0); + $this->name = 'it-ing-' . bin2hex(random_bytes(4)); + } + + protected function tearDown(): void + { + if ($this->name === '') { + return; + } + $id = (int) ($this->db->fetch('SELECT id FROM ingredient WHERE name = :n', ['n' => $this->name])['id'] ?? 0); + if ($id > 0) { + $this->db->execute('DELETE FROM stock_movement WHERE ingredient_id = :id', ['id' => $id]); + $this->db->execute('DELETE FROM ingredient WHERE id = :id', ['id' => $id]); + } + } + + public function testCreateFindUpdateComputesPctAndBand(): void + { + $repo = new IngredientRepository($this->db); + $id = $this->createIngredient($repo, ['stock_quantity' => 50, 'stock_capacity' => 100]); + + self::assertFalse($repo->nameExists($this->name, $id)); // s'exclut lui-meme + self::assertTrue($repo->nameExists($this->name)); + self::assertFalse($repo->isReferenced($id)); // ni recette ni mouvement + + $found = $repo->find($id); + self::assertNotNull($found); + self::assertSame(50, (int) $found['stock_pct']); + self::assertSame('normal', (string) $found['stock_band']); + + // all() porte aussi les champs calcules. + $names = array_map(static fn (array $r): string => (string) ($r['name'] ?? ''), $repo->all()); + self::assertContains($this->name, $names); + + // update ne touche ni stock_quantity ni is_active (allowlist RG-T16). + $repo->update($id, [ + 'name' => $this->name, + 'unit' => 'sachet', + 'stock_capacity' => 200, + 'pack_size' => 25, + 'pack_label' => 'Sac 25', + 'low_stock_pct' => 20, + 'critical_stock_pct' => 10, + ]); + $updated = $repo->find($id); + self::assertNotNull($updated); + self::assertSame(200, (int) $updated['stock_capacity']); + self::assertSame(50, (int) $updated['stock_quantity']); // inchange + self::assertSame(25, (int) $updated['stock_pct']); // 50/200 + } + + public function testRestockIncrementsStockAndRecordsMovement(): void + { + $repo = new IngredientRepository($this->db); + $id = $this->createIngredient($repo, ['stock_quantity' => 0, 'stock_capacity' => 100, 'pack_size' => 50]); + + $repo->restock($id, 2, $this->userId, 'Livraison A'); + + self::assertSame(100, (int) ($repo->find($id)['stock_quantity'] ?? -1)); + $movements = $repo->movements($id); + self::assertCount(1, $movements); + self::assertSame('restock', (string) $movements[0]['movement_type']); + self::assertSame(100, (int) $movements[0]['delta']); + self::assertSame($this->userId, (int) $movements[0]['user_id']); + self::assertNull($movements[0]['order_id']); + self::assertTrue($repo->isReferenced($id)); // un mouvement reference l'ingredient + } + + public function testInventoryCountRecordsMovementEvenWhenDeltaZero(): void + { + $repo = new IngredientRepository($this->db); + $id = $this->createIngredient($repo, ['stock_quantity' => 100, 'stock_capacity' => 100]); + + // Comptage conforme au theorique : delta = 0, MAIS une ligne est ecrite (RG-3). + $repo->inventoryCount($id, 100, $this->userId, 'Inventaire mensuel'); + $movements = $repo->movements($id); + self::assertCount(1, $movements); + self::assertSame('inventory_correction', (string) $movements[0]['movement_type']); + self::assertSame(0, (int) $movements[0]['delta']); + + // Comptage divergent : delta negatif, stock cale sur le compte. + $repo->inventoryCount($id, 30, $this->userId, null); + self::assertSame(30, (int) ($repo->find($id)['stock_quantity'] ?? -1)); + $movements = $repo->movements($id); + self::assertCount(2, $movements); // plus recent en tete + self::assertSame(-70, (int) $movements[0]['delta']); + } + + public function testReferencedIngredientCannotBeHardDeletedButCanBeDeactivated(): void + { + $repo = new IngredientRepository($this->db); + $id = $this->createIngredient($repo, ['stock_quantity' => 0, 'stock_capacity' => 100, 'pack_size' => 10]); + $repo->restock($id, 1, $this->userId, null); // cree un mouvement -> FK RESTRICT + + $blocked = false; + try { + $repo->delete($id); + } catch (PDOException $exception) { + $blocked = (string) $exception->getCode() === '23000'; + } + self::assertTrue($blocked, 'La suppression dure doit etre bloquee par stock_movement (FK RESTRICT).'); + + // Repli : soft-delete via is_active. + self::assertSame(1, $repo->setActive($id, false)); + self::assertSame(0, (int) ($repo->find($id)['is_active'] ?? -1)); + } + + public function testUnreferencedIngredientCanBeHardDeleted(): void + { + $repo = new IngredientRepository($this->db); + $id = $this->createIngredient($repo, ['stock_quantity' => 5, 'stock_capacity' => 100]); + + self::assertFalse($repo->isReferenced($id)); + self::assertSame(1, $repo->delete($id)); + self::assertNull($repo->find($id)); + } + + public function testRestockIsCumulative(): void + { + $repo = new IngredientRepository($this->db); + // Stock initial > 0 + DEUX restock : tue une mutation 'stock = :delta' (set) + // au lieu de 'stock += :delta', et un test qui partirait de 0. + $id = $this->createIngredient($repo, ['stock_quantity' => 30, 'stock_capacity' => 200, 'pack_size' => 50]); + + $repo->restock($id, 1, $this->userId, null); // 30 -> 80 + $repo->restock($id, 1, $this->userId, null); // 80 -> 130 + + self::assertSame(130, (int) ($repo->find($id)['stock_quantity'] ?? -1)); + $movements = $repo->movements($id); + self::assertCount(2, $movements); + self::assertSame(50, (int) $movements[0]['delta']); + self::assertSame(50, (int) $movements[1]['delta']); + } + + public function testRestockRollsBackWhenMovementInsertFails(): void + { + $repo = new IngredientRepository($this->db); + $id = $this->createIngredient($repo, ['stock_quantity' => 40, 'stock_capacity' => 100, 'pack_size' => 10]); + + // user_id inexistant : l'UPDATE stock passe, l'INSERT stock_movement viole + // la FK user_id -> la transaction (RG-T08) doit TOUT annuler. + $rolledBack = false; + try { + $repo->restock($id, 2, 2147483647, null); + } catch (PDOException $exception) { + $rolledBack = (string) $exception->getCode() === '23000'; + } + self::assertTrue($rolledBack, 'La violation FK user_id doit lever une 23000.'); + self::assertSame(40, (int) ($repo->find($id)['stock_quantity'] ?? -1)); // stock intact (rollback) + self::assertCount(0, $repo->movements($id)); // aucun mouvement laisse + } + + public function testDuplicateNameViolatesUniqueConstraint(): void + { + $repo = new IngredientRepository($this->db); + $this->createIngredient($repo); + + // Meme name : la contrainte DB uk_ingredient_name (independante de l'appel + // applicatif nameExists) doit rejeter le doublon. + $violated = false; + try { + $this->createIngredient($repo); + } catch (PDOException $exception) { + $violated = (string) $exception->getCode() === '23000'; + } + self::assertTrue($violated, 'uk_ingredient_name doit rejeter un doublon (SQLSTATE 23000).'); + } + + public function testMovementsAreBoundedByLimit(): void + { + $repo = new IngredientRepository($this->db); + $id = $this->createIngredient($repo, ['stock_capacity' => 100, 'pack_size' => 1]); + $repo->restock($id, 1, $this->userId, null); + $repo->restock($id, 1, $this->userId, null); + $repo->restock($id, 1, $this->userId, null); + + self::assertCount(3, $repo->movements($id)); // defaut large + self::assertCount(2, $repo->movements($id, 2)); // borne LIMIT (RG-3) sur la vraie base + } + + public function testMovementsOrderByCreatedAtBeforeId(): void + { + $repo = new IngredientRepository($this->db); + $id = $this->createIngredient($repo, ['stock_capacity' => 100, 'pack_size' => 1]); + $repo->restock($id, 1, $this->userId, 'recent-1'); + $repo->restock($id, 1, $this->userId, 'recent-2'); + // Mouvement au created_at le plus ANCIEN mais a l'id le plus ELEVE (insere en dernier) : + // prouve que created_at DESC prime sur le tie-breaker id DESC. + $this->db->execute( + "INSERT INTO stock_movement (ingredient_id, movement_type, delta, created_at) " + . "VALUES (:id, 'inventory_correction', 0, '2000-01-01 00:00:00')", + ['id' => $id], + ); + + $movements = $repo->movements($id); + self::assertCount(3, $movements); + self::assertSame('2000-01-01 00:00:00', (string) $movements[2]['created_at']); // ancien -> dernier + } + + /** + * @param array $overrides + */ + private function createIngredient(IngredientRepository $repo, array $overrides = []): int + { + $repo->create([ + 'name' => $this->name, + 'unit' => 'portion', + 'stock_quantity' => (int) ($overrides['stock_quantity'] ?? 0), + 'stock_capacity' => (int) ($overrides['stock_capacity'] ?? 100), + 'pack_size' => (int) ($overrides['pack_size'] ?? 1), + 'pack_label' => $overrides['pack_label'] ?? null, + 'low_stock_pct' => (int) ($overrides['low_stock_pct'] ?? 10), + 'critical_stock_pct' => (int) ($overrides['critical_stock_pct'] ?? 5), + 'is_active' => 1, + ]); + + return (int) ($this->db->fetch('SELECT id FROM ingredient WHERE name = :n', ['n' => $this->name])['id'] ?? 0); + } +} diff --git a/tests/Support/FakeDatabase.php b/tests/Support/FakeDatabase.php index cdf0d6e..82869fa 100644 --- a/tests/Support/FakeDatabase.php +++ b/tests/Support/FakeDatabase.php @@ -158,6 +158,41 @@ final class FakeDatabase implements DatabaseInterface /** Resultat de MenuRepository::isReferencedByOrders() (true = reference par une commande). */ public bool $menuReferenced = false; + /** + * Ligne renvoyee pour IngredientRepository::find() et les lectures ciblees de + * restock/inventory (pack_size, stock_quantity) ; null = introuvable. + * + * @var array|null + */ + public ?array $ingredientRow = null; + + /** + * Lignes renvoyees par IngredientRepository::all(). + * + * @var list> + */ + public array $ingredientsRows = []; + + /** Resultat de IngredientRepository::nameExists(). */ + public bool $ingredientNameTaken = false; + + /** + * Lignes renvoyees par IngredientRepository::movements(). + * + * @var list> + */ + public array $movementsRows = []; + + /** + * Allowlist optionnelle de codes de permission accordes (RG-T03). Si non nul, + * can() repond par appartenance du :code lie a cette liste (permet de tester la + * differenciation par permission, ex. RG-4 : stock.read sans stock.manage) ; + * sinon on retombe sur le bouton global $canResult. + * + * @var list|null + */ + public ?array $grantedCodes = null; + /** * Ligne renvoyee pour PinVerifier::resolveActingUser (id, role_id, pin_hash) ; * null = email inconnu/inactif. @@ -223,6 +258,12 @@ final class FakeDatabase implements DatabaseInterface } if (str_contains($sql, 'SELECT 1 AS granted FROM role_permission')) { + if ($this->grantedCodes !== null) { + $code = $params['code'] ?? null; + + return (is_string($code) && in_array($code, $this->grantedCodes, true) && $this->roleActive) ? ['granted' => 1] : null; + } + return ($this->canResult && $this->roleActive) ? ['granted' => 1] : null; } @@ -262,6 +303,16 @@ final class FakeDatabase implements DatabaseInterface return $this->menuReferenced ? ['menu_id' => 1] : null; } + // Ingredient : nameExists (avant la route par id, qui ne matche pas + // 'WHERE name'), puis find() + lectures ciblees pack_size/stock_quantity. + if (str_contains($sql, 'FROM ingredient WHERE name = :name')) { + return $this->ingredientNameTaken ? ['id' => 1] : null; + } + + if (str_contains($sql, 'FROM ingredient WHERE id = :id')) { + return $this->ingredientRow; + } + if (str_contains($sql, 'FROM category WHERE name = :name')) { return $this->categoryNameTaken ? ['id' => 1] : null; } @@ -309,6 +360,14 @@ final class FakeDatabase implements DatabaseInterface return $this->menuSlotRows; } + if (str_contains($sql, 'FROM ingredient ORDER BY name')) { + return $this->ingredientsRows; + } + + if (str_contains($sql, 'FROM stock_movement WHERE ingredient_id')) { + return $this->movementsRows; + } + if (str_contains($sql, 'SELECT p.code FROM role_permission')) { if (!$this->roleActive) { return []; diff --git a/tests/Unit/Admin/IngredientControllerTest.php b/tests/Unit/Admin/IngredientControllerTest.php new file mode 100644 index 0000000..121b99b --- /dev/null +++ b/tests/Unit/Admin/IngredientControllerTest.php @@ -0,0 +1,443 @@ +testSession; + } + + protected function db(): DatabaseInterface + { + return $this->fakeDb; + } +} + +final class IngredientControllerTest extends TestCase +{ + /** @var list */ + private array $touchedKeys = []; + + private SessionManager $session; + private string $csrf = ''; + + protected function setUp(): void + { + $this->setEnv('SESSION_LIFETIME_IDLE', '14400'); + $this->setEnv('SESSION_LIFETIME_ABSOLUTE', '36000'); + $this->setEnv('STAFF_PIN_MIN_LENGTH', '4'); + $this->setEnv('STAFF_PIN_MAX_LENGTH', '12'); + $this->setEnv('ARGON2_MEMORY_COST', '1024'); + $this->setEnv('ARGON2_TIME_COST', '1'); + $this->setEnv('ARGON2_THREADS', '1'); + + $this->session = new SessionManager(new Config(), true); + $now = time(); + $this->session->set('user_id', 1); + $this->session->set('role_id', 1); + $this->session->set('logged_in_at', $now - 100); + $this->session->set('last_activity', $now - 50); + $this->csrf = Csrf::token($this->session); + } + + protected function tearDown(): void + { + foreach ($this->touchedKeys as $key) { + putenv($key); + } + $this->touchedKeys = []; + } + + private function setEnv(string $key, string $value): void + { + $this->touchedKeys[] = $key; + putenv($key . '=' . $value); + } + + private function permittedDb(): FakeDatabase + { + $db = new FakeDatabase(); + $db->guardUserRow = ['is_active' => 1]; + $db->userDisplayRow = ['first_name' => 'Sam', 'last_name' => 'K', 'role_label' => 'Manager']; + $db->canResult = true; + $db->permissionCodes = ['stock.read', 'ingredient.manage', 'stock.manage', 'stock.count']; + $db->ingredientRow = $this->ingredient(); + + return $db; + } + + /** + * @param array $overrides + * @return array + */ + private function ingredient(array $overrides = []): array + { + return array_merge([ + 'id' => 5, 'name' => 'Cheddar', 'unit' => 'tranche', + 'stock_quantity' => 40, 'stock_capacity' => 100, 'pack_size' => 10, + 'pack_label' => 'Sachet 10', 'low_stock_pct' => 10, 'critical_stock_pct' => 5, + 'is_active' => 1, + ], $overrides); + } + + /** + * @param array $overrides + * @return array + */ + private function validForm(array $overrides = []): array + { + return array_merge([ + '_csrf' => $this->csrf, + 'name' => 'Cheddar', + 'unit' => 'tranche', + 'stock_capacity' => '100', + 'pack_size' => '10', + 'pack_label' => 'Sachet 10', + 'low_stock_pct' => '10', + 'critical_stock_pct' => '5', + ], $overrides); + } + + private function actingPin(FakeDatabase $db): void + { + $db->actingUserRow = ['id' => 9, 'role_id' => 4, 'pin_hash' => (new PasswordHasher(new Config()))->hash('4729')]; + } + + private function get(string $path): Request + { + return new Request('GET', $path, [], [], '', '203.0.113.5'); + } + + /** + * @param array $form + */ + private function post(array $form, string $path): Request + { + return new Request('POST', $path, [], ['content-type' => 'application/x-www-form-urlencoded'], http_build_query($form), '203.0.113.5'); + } + + private function controller(Request $request, FakeDatabase $db): TestIngredientController + { + return new TestIngredientController($request, new Config(), new Database(new Config()), $this->session, $db); + } + + /** + * @return array|null + */ + private function writeParams(FakeDatabase $db, string $needle): ?array + { + foreach ($db->writes as $write) { + if (str_contains($write['sql'], $needle)) { + return $write['params']; + } + } + + return null; + } + + private function writeSql(FakeDatabase $db, string $needle): string + { + foreach ($db->writes as $write) { + if (str_contains($write['sql'], $needle)) { + return $write['sql']; + } + } + + return ''; + } + + // --- Lecture (READ_STOCK 9.3) --- + + public function testIndexListsStockForStockReader(): void + { + $db = $this->permittedDb(); + $db->ingredientsRows = [$this->ingredient(['stock_quantity' => 8])]; // 8% -> bande alerte + + $response = $this->controller($this->get('/admin/ingredients'), $db)->index(); + + self::assertSame(200, $response->status()); + self::assertStringContainsString('Cheddar', $response->body()); + self::assertStringContainsString('Alerte', $response->body()); + } + + public function testIndexForbiddenWithoutStockRead(): void + { + $db = $this->permittedDb(); + $db->canResult = false; + + self::assertSame(403, $this->controller($this->get('/admin/ingredients'), $db)->index()->status()); + } + + // --- CRUD ingredient (8.8, ingredient.manage, SANS PIN) --- + + public function testStoreCreatesWithZeroStockAndActiveServerSet(): void + { + $db = $this->permittedDb(); + $response = $this->controller($this->post($this->validForm(), '/admin/ingredients'), $db)->store(); + + self::assertSame(302, $response->status()); + $params = $this->writeParams($db, 'INSERT INTO ingredient'); + self::assertNotNull($params); + self::assertSame(0, $params['qty']); // stock_quantity initial = 0 (RG-CREATE-ING) + self::assertSame(1, $params['active']); // is_active pose cote serveur (RG-T16) + } + + public function testStoreRejectsInvalidInput(): void + { + $db = $this->permittedDb(); + $response = $this->controller($this->post($this->validForm(['name' => '', 'stock_capacity' => '0']), '/admin/ingredients'), $db)->store(); + + self::assertSame(422, $response->status()); + self::assertFalse($db->wrote('INSERT INTO ingredient')); + } + + public function testStoreRejectsCriticalNotStrictlyBelowLow(): void + { + $db = $this->permittedDb(); + $response = $this->controller($this->post($this->validForm(['low_stock_pct' => '5', 'critical_stock_pct' => '5']), '/admin/ingredients'), $db)->store(); + + self::assertSame(422, $response->status()); + self::assertStringContainsString('strictement inferieur', $response->body()); + } + + public function testStoreRejectsDuplicateName(): void + { + $db = $this->permittedDb(); + $db->ingredientNameTaken = true; + + $response = $this->controller($this->post($this->validForm(), '/admin/ingredients'), $db)->store(); + + self::assertSame(422, $response->status()); + self::assertFalse($db->wrote('INSERT INTO ingredient')); + } + + public function testStoreTranslatesUniqueRaceTo409(): void + { + $db = $this->permittedDb(); + $db->failOnExecute = new PDOException('duplicate', 23000); + + $response = $this->controller($this->post($this->validForm(), '/admin/ingredients'), $db)->store(); + + self::assertSame(409, $response->status()); + } + + public function testStoreRejectsInvalidCsrf(): void + { + $db = $this->permittedDb(); + $response = $this->controller($this->post($this->validForm(['_csrf' => 'bad']), '/admin/ingredients'), $db)->store(); + + self::assertSame(403, $response->status()); + } + + public function testUpdateDoesNotBindStockOrActive(): void + { + $db = $this->permittedDb(); + $response = $this->controller($this->post($this->validForm(), '/admin/ingredients/5'), $db)->update(['id' => '5']); + + self::assertSame(302, $response->status()); + $sql = $this->writeSql($db, 'UPDATE ingredient'); + self::assertNotSame('', $sql); + self::assertStringNotContainsString('stock_quantity', $sql); // RG-T16 + self::assertStringNotContainsString('is_active', $sql); // RG-T16 (bascule via toggle) + } + + public function testUpdateNotFound(): void + { + $db = $this->permittedDb(); + $db->ingredientRow = null; + + self::assertSame(404, $this->controller($this->post($this->validForm(), '/admin/ingredients/9'), $db)->update(['id' => '9'])->status()); + } + + public function testToggleFlipsActive(): void + { + $db = $this->permittedDb(); // is_active = 1 -> doit basculer a 0 + $response = $this->controller($this->post(['_csrf' => $this->csrf], '/admin/ingredients/5/toggle'), $db)->toggle(['id' => '5']); + + self::assertSame(302, $response->status()); + $params = $this->writeParams($db, 'UPDATE ingredient SET is_active'); + self::assertNotNull($params); + self::assertSame(0, $params['a']); + } + + public function testDestroyUnreferencedDeletesWithoutPin(): void + { + $db = $this->permittedDb(); + // Aucun champ PIN dans le form : 8.8 n'est pas une action sensible. + $response = $this->controller($this->post(['_csrf' => $this->csrf], '/admin/ingredients/5/delete'), $db)->destroy(['id' => '5']); + + self::assertSame(302, $response->status()); + self::assertTrue($db->wrote('DELETE FROM ingredient')); + } + + public function testDestroyReferencedReturns409(): void + { + $db = $this->permittedDb(); + $db->failOnExecute = new PDOException('fk', 23000); // FK RESTRICT (recette / mouvement) + + $response = $this->controller($this->post(['_csrf' => $this->csrf], '/admin/ingredients/5/delete'), $db)->destroy(['id' => '5']); + + self::assertSame(409, $response->status()); + self::assertStringContainsString('reference', $response->body()); + } + + // --- RESTOCK (9.1, stock.manage, SANS PIN) --- + + public function testRestockAddsPacksAndRecordsMovementUnderSessionActor(): void + { + $db = $this->permittedDb(); // pack_size 10 + $response = $this->controller($this->post(['_csrf' => $this->csrf, 'packs' => '2', 'note' => 'Livraison A'], '/admin/ingredients/5/restock'), $db)->restock(['id' => '5']); + + self::assertSame(302, $response->status()); + self::assertSame(['begin', 'commit'], $db->transactionEvents); + self::assertTrue($db->wrote('SET stock_quantity = stock_quantity +')); + $movement = $this->writeParams($db, 'INSERT INTO stock_movement'); + self::assertNotNull($movement); + self::assertSame('restock', $movement['type']); + self::assertSame(20, $movement['delta']); // 2 packs x pack_size 10 + self::assertSame(1, $movement['user']); // acteur de SESSION (RG-4), pas un PIN + self::assertSame([], $db->auditActions()); // pas d'audit_log (RG-T14) + } + + public function testRestockRejectedWhenInactive(): void + { + $db = $this->permittedDb(); + $db->ingredientRow = $this->ingredient(['is_active' => 0]); // PRE-2 + + $response = $this->controller($this->post(['_csrf' => $this->csrf, 'packs' => '2'], '/admin/ingredients/5/restock'), $db)->restock(['id' => '5']); + + self::assertSame(422, $response->status()); + self::assertFalse($db->wrote('stock_movement')); + } + + public function testRestockRejectsPacksBelowOne(): void + { + $db = $this->permittedDb(); + $response = $this->controller($this->post(['_csrf' => $this->csrf, 'packs' => '0'], '/admin/ingredients/5/restock'), $db)->restock(['id' => '5']); + + self::assertSame(422, $response->status()); + self::assertFalse($db->wrote('stock_movement')); + } + + // --- INVENTORY_COUNT (9.2, stock.count + PIN) --- + + public function testInventoryWithValidPinRecordsCorrectionUnderPinActorWithoutAudit(): void + { + $db = $this->permittedDb(); + $this->actingPin($db); // equipier id 9, PIN 4729 + + $response = $this->controller($this->post([ + '_csrf' => $this->csrf, 'actual_quantity' => '30', 'note' => 'mensuel', + 'pin_email' => 'sam@wakdo.local', 'pin' => '4729', + ], '/admin/ingredients/5/inventory'), $db)->inventory(['id' => '5']); + + self::assertSame(302, $response->status()); + $movement = $this->writeParams($db, 'INSERT INTO stock_movement'); + self::assertNotNull($movement); + self::assertSame('inventory_correction', $movement['type']); + self::assertSame(-10, $movement['delta']); // 30 compte - 40 theorique + self::assertSame(9, $movement['user']); // acteur resolu par PIN (RG-4) + self::assertSame([], $db->auditActions()); // RG-T14 : pas de double-journal + } + + public function testInventoryWithBadPinLogsFailedAndChangesNoStock(): void + { + $db = $this->permittedDb(); + $db->actingUserRow = null; // email/PIN non resolu + + $response = $this->controller($this->post([ + '_csrf' => $this->csrf, 'actual_quantity' => '30', + 'pin_email' => 'ghost@wakdo.local', 'pin' => '0000', + ], '/admin/ingredients/5/inventory'), $db)->inventory(['id' => '5']); + + self::assertSame(422, $response->status()); + self::assertSame(['pin.failed'], $db->auditActions()); // trace detective (RG-T22) + self::assertFalse($db->wrote('stock_movement')); // aucun effet sur le stock + } + + public function testInventoryLockedActorReturns422WithoutEffect(): void + { + $db = $this->permittedDb(); + $this->actingPin($db); + $db->pinThrottleLockoutUntil = date('Y-m-d H:i:s', time() + 300); // verrou actif + + $response = $this->controller($this->post([ + '_csrf' => $this->csrf, 'actual_quantity' => '30', + 'pin_email' => 'sam@wakdo.local', 'pin' => '4729', + ], '/admin/ingredients/5/inventory'), $db)->inventory(['id' => '5']); + + self::assertSame(422, $response->status()); + self::assertSame([], $db->auditActions()); // pas de pin.failed sous verrou (RG-T22) + self::assertFalse($db->wrote('stock_movement')); + } + + public function testInventoryRejectsNegativeCount(): void + { + $db = $this->permittedDb(); + $this->actingPin($db); + + $response = $this->controller($this->post([ + '_csrf' => $this->csrf, 'actual_quantity' => '-5', + 'pin_email' => 'sam@wakdo.local', 'pin' => '4729', + ], '/admin/ingredients/5/inventory'), $db)->inventory(['id' => '5']); + + self::assertSame(422, $response->status()); + self::assertFalse($db->wrote('stock_movement')); + } + + // --- Visibilite de l'acteur (RG-4) --- + + public function testMovementsShowActorForManager(): void + { + $db = $this->permittedDb(); + $db->grantedCodes = ['stock.read', 'stock.manage']; // manager + $db->movementsRows = [['id' => 1, 'ingredient_id' => 5, 'movement_type' => 'restock', 'delta' => 20, 'order_id' => null, 'user_id' => 9, 'note' => null, 'created_at' => '2026-06-17 09:00:00']]; + + $response = $this->controller($this->get('/admin/ingredients/5/movements'), $db)->movements(['id' => '5']); + + self::assertSame(200, $response->status()); + self::assertStringContainsString('Acteur', $response->body()); + self::assertStringContainsString('Sam K', $response->body()); // nom resolu + } + + public function testMovementsHideActorForLineStaff(): void + { + $db = $this->permittedDb(); + $db->grantedCodes = ['stock.read']; // ligne : stock.read sans stock.manage + $db->movementsRows = [['id' => 1, 'ingredient_id' => 5, 'movement_type' => 'restock', 'delta' => 20, 'order_id' => null, 'user_id' => 9, 'note' => null, 'created_at' => '2026-06-17 09:00:00']]; + + $response = $this->controller($this->get('/admin/ingredients/5/movements'), $db)->movements(['id' => '5']); + + self::assertSame(200, $response->status()); + self::assertStringNotContainsString('Acteur', $response->body()); // colonne masquee (RG-4) + } +} diff --git a/tests/Unit/Catalogue/IngredientRepositoryTest.php b/tests/Unit/Catalogue/IngredientRepositoryTest.php new file mode 100644 index 0000000..e05e33e --- /dev/null +++ b/tests/Unit/Catalogue/IngredientRepositoryTest.php @@ -0,0 +1,66 @@ + bande critique. + */ +final class IngredientRepositoryTest extends TestCase +{ + public function testStockPctRoundsQuantityOverCapacity(): void + { + self::assertSame(50, IngredientRepository::stockPct(50, 100)); + self::assertSame(0, IngredientRepository::stockPct(0, 100)); + self::assertSame(100, IngredientRepository::stockPct(100, 100)); + self::assertSame(33, IngredientRepository::stockPct(1, 3)); // arrondi + self::assertSame(-10, IngredientRepository::stockPct(-10, 100)); // survente + // Cas ou l'arrondi MONTE : verrouille round() vs troncature/floor. + self::assertSame(67, IngredientRepository::stockPct(2, 3)); // round(66.67) -> 67 + self::assertSame(13, IngredientRepository::stockPct(1, 8)); // round(12.5) -> 13 (half away from zero) + } + + public function testStockPctGuardsAgainstZeroCapacity(): void + { + // stock_capacity porte un CHECK > 0 en base ; garde defensive si une ligne + // aberrante arrive quand meme (pas de division par zero). + self::assertSame(0, IngredientRepository::stockPct(10, 0)); + } + + public function testStockBandNormalAboveLowThreshold(): void + { + self::assertSame('normal', IngredientRepository::stockBand(50, 100, 10, 5)); + self::assertSame('normal', IngredientRepository::stockBand(11, 100, 10, 5)); + } + + public function testStockBandLowAtOrUnderLowThreshold(): void + { + self::assertSame('low', IngredientRepository::stockBand(10, 100, 10, 5)); + self::assertSame('low', IngredientRepository::stockBand(6, 100, 10, 5)); + } + + public function testStockBandCriticalAtOrUnderCriticalThreshold(): void + { + self::assertSame('critical', IngredientRepository::stockBand(5, 100, 10, 5)); + self::assertSame('critical', IngredientRepository::stockBand(0, 100, 10, 5)); + self::assertSame('critical', IngredientRepository::stockBand(-3, 100, 10, 5)); // survente + } + + public function testStockBandStressesIntegerArithmeticAtNonHundredCapacity(): void + { + // capacity != 100 : seuil low = 150*10/100 = 15, seuil critical = 150*5/100 = 7.5. + // L'arithmetique entiere (quantity*100 <= capacity*pct) doit tomber juste sur + // ces frontieres non rondes (sinon une mutation de la formule passerait). + self::assertSame('low', IngredientRepository::stockBand(15, 150, 10, 5)); // 1500 <= 1500 + self::assertSame('normal', IngredientRepository::stockBand(16, 150, 10, 5)); // 1600 > 1500 + self::assertSame('critical', IngredientRepository::stockBand(7, 150, 10, 5)); // 700 <= 750 + self::assertSame('low', IngredientRepository::stockBand(8, 150, 10, 5)); // 800 > 750, <= 1500 + } +} -- 2.45.3 From ed392d4c149e042e5ad1e58ce026d9fb8223c326 Mon Sep 17 00:00:00 2001 From: Corentin JOGUET Date: Wed, 17 Jun 2026 11:29:07 +0200 Subject: [PATCH 36/93] feat(admin): recettes produit - composition product_ingredient + dispo calculee RG-T21 (P3, ferme #27) (#35) --- src/app/Catalogue/ProductRepository.php | 133 ++++++++++- src/app/Controllers/ProductController.php | 176 ++++++++++++++- src/app/Views/admin/products/index.php | 13 +- src/app/Views/admin/products/recipe.php | 88 ++++++++ src/public/admin/assets/js/product-recipe.js | 164 ++++++++++++++ src/public/admin/index.php | 5 + tests/Integration/ProductIngredientDbTest.php | 213 ++++++++++++++++++ tests/Support/FakeDatabase.php | 32 +++ tests/Unit/Admin/ProductControllerTest.php | 162 +++++++++++++ .../Unit/Catalogue/ProductRepositoryTest.php | 73 ++++++ 10 files changed, 1048 insertions(+), 11 deletions(-) create mode 100644 src/app/Views/admin/products/recipe.php create mode 100644 src/public/admin/assets/js/product-recipe.js create mode 100644 tests/Integration/ProductIngredientDbTest.php create mode 100644 tests/Unit/Catalogue/ProductRepositoryTest.php diff --git a/src/app/Catalogue/ProductRepository.php b/src/app/Catalogue/ProductRepository.php index 38e7ba8..3d1d371 100644 --- a/src/app/Catalogue/ProductRepository.php +++ b/src/app/Catalogue/ProductRepository.php @@ -17,9 +17,9 @@ use App\Core\DatabaseInterface; * (SQLSTATE 23000) -> 409 Conflit, plutot que de pre-tester chaque reference. * - CASCADE : product_ingredient (la recette appartient au produit ; la * supprimer avec le produit est voulu). La suppression n'est donc PAS bloquee - * par une recette existante. TODO (phase stock/recettes, table aujourd'hui - * vide) : tracer le nombre de lignes product_ingredient cascade-supprimees - * dans l'audit_log pour ne laisser aucune perte hors-trace. + * par une recette existante. Le nombre de lignes cascade-supprimees est compte + * (compositionCount) et trace dans le resume d'audit par ProductController::destroy + * (dette #27 close) pour ne laisser aucune perte hors-trace. */ final class ProductRepository { @@ -89,6 +89,133 @@ final class ProductRepository return $this->db->execute('DELETE FROM product WHERE id = :id', ['id' => $id]); } + public function ingredientExists(int $id): bool + { + return $this->db->fetch('SELECT id FROM ingredient WHERE id = :id', ['id' => $id]) !== null; + } + + /** + * Composition (recette) d'un produit : lignes product_ingredient enrichies du + * nom + de l'unite de l'ingredient et de ses champs de stock (pour la + * disponibilite calculee RG-T21). Ordonnee par nom d'ingredient. + * + * @return array> + */ + public function composition(int $productId): array + { + return $this->db->fetchAll( + 'SELECT pi.product_id, pi.ingredient_id, pi.quantity_normal, pi.quantity_maxi, ' + . 'pi.is_removable, pi.is_addable, pi.extra_price_cents, ' + . 'i.name AS ingredient_name, i.unit AS ingredient_unit, ' + . 'i.stock_quantity, i.stock_capacity, i.low_stock_pct, i.critical_stock_pct ' + . 'FROM product_ingredient pi JOIN ingredient i ON i.id = pi.ingredient_id ' + . 'WHERE pi.product_id = :id ORDER BY i.name', + ['id' => $productId], + ); + } + + /** + * Nombre de lignes de composition d'un produit. Sert a tracer la cascade #27 : + * combien de product_ingredient seront emportees par la suppression du produit + * (FK product_id CASCADE), pour ne laisser aucune perte hors-trace dans l'audit. + */ + public function compositionCount(int $productId): int + { + return (int) ($this->db->fetch( + 'SELECT COUNT(*) AS n FROM product_ingredient WHERE product_id = :id', + ['id' => $productId], + )['n'] ?? 0); + } + + /** + * Remplace integralement la composition d'un produit (delete-and-reinsert, mlt + * 8.5 RG-2 transpose a la recette) dans UNE transaction (RG-T08) : reposer + * l'ensemble est plus simple et sur qu'une reconciliation en place. La PK + * composite (product_id, ingredient_id) garantit l'unicite par ingredient ; + * l'appelant (controleur) a deja deduplique et valide les bornes (RG-T18). + * + * @param list $lines + */ + public function setComposition(int $productId, array $lines): void + { + $this->db->transaction(function (DatabaseInterface $db) use ($productId, $lines): void { + $db->execute('DELETE FROM product_ingredient WHERE product_id = :id', ['id' => $productId]); + foreach ($lines as $line) { + $db->execute( + 'INSERT INTO product_ingredient (product_id, ingredient_id, quantity_normal, ' + . 'quantity_maxi, is_removable, is_addable, extra_price_cents) ' + . 'VALUES (:product, :ingredient, :qn, :qm, :rem, :add, :extra)', + [ + 'product' => $productId, + 'ingredient' => $line['ingredient_id'], + 'qn' => $line['quantity_normal'], + 'qm' => $line['quantity_maxi'], + 'rem' => $line['is_removable'], + 'add' => $line['is_addable'], + 'extra' => $line['extra_price_cents'], + ], + ); + } + }); + } + + /** + * Ids des produits en RUPTURE AUTOMATIQUE par le stock (RG-T21) : au moins un + * ingredient requis (is_removable=0) au niveau ou sous la bande critique + * (stock_quantity * 100 <= stock_capacity * critical_stock_pct, l'arithmetique + * entiere de IngredientRepository::stockBand). Calcule en UNE requete pour + * eviter le N+1 a l'affichage de la liste. Distinct du retrait manuel + * (is_available=0), que la vue signale separement. + * + * @return list + */ + public function autoUnavailableIds(): array + { + $rows = $this->db->fetchAll( + 'SELECT DISTINCT pi.product_id FROM product_ingredient pi ' + . 'JOIN ingredient i ON i.id = pi.ingredient_id ' + . 'WHERE pi.is_removable = 0 AND i.stock_quantity * 100 <= i.stock_capacity * i.critical_stock_pct', + ); + + return array_map(static fn (array $r): int => (int) ($r['product_id'] ?? 0), $rows); + } + + /** + * Disponibilite produit CALCULEE (RG-T21) : commandable ssi le flag + * is_available vaut 1 ET chaque ingredient NON RETIRABLE (is_removable=0) de la + * composition est au-dessus de la bande critique (stockBand != 'critical'). + * Derivation pure, sans ecriture ni cascade : un ingredient requis tombant en + * critique met le produit en rupture automatique ; un ingredient retirable/ + * optionnel en critique ne bloque pas (seul son supplement devient indispo) ; + * un retrait manuel (is_available=0) prime sur tout. La bande critique est celle + * d'IngredientRepository::stockBand (source unique de la derivation). + * + * @param array> $composition lignes de composition() + */ + public static function isOrderable(bool $flagAvailable, array $composition): bool + { + if (!$flagAvailable) { + return false; + } + + foreach ($composition as $line) { + if ((int) ($line['is_removable'] ?? 1) !== 0) { + continue; // retirable/optionnel : n'entre pas dans la disponibilite du produit + } + $band = IngredientRepository::stockBand( + (int) ($line['stock_quantity'] ?? 0), + (int) ($line['stock_capacity'] ?? 0), + (int) ($line['low_stock_pct'] ?? 0), + (int) ($line['critical_stock_pct'] ?? 0), + ); + if ($band === 'critical') { + return false; + } + } + + return true; + } + /** * Allowlist d'affectation de masse (RG-T16) : seules ces colonnes sont liees. * diff --git a/src/app/Controllers/ProductController.php b/src/app/Controllers/ProductController.php index 062f8ce..766f1fd 100644 --- a/src/app/Controllers/ProductController.php +++ b/src/app/Controllers/ProductController.php @@ -11,6 +11,7 @@ use App\Auth\PasswordHasher; use App\Auth\PinThrottle; use App\Auth\PinVerifier; use App\Catalogue\CategoryRepository; +use App\Catalogue\IngredientRepository; use App\Catalogue\ProductRepository; use App\Core\DatabaseInterface; use App\Core\Response; @@ -41,9 +42,12 @@ class ProductController extends AdminController } return $this->adminView('admin/products/index', [ - 'title' => 'Produits - Wakdo Admin', - 'activeNav' => 'products', - 'products' => $this->productRepository()->all(), + 'title' => 'Produits - Wakdo Admin', + 'activeNav' => 'products', + 'products' => $this->productRepository()->all(), + // Rupture AUTOMATIQUE par le stock (RG-T21), distincte du retrait manuel + // (is_available=0) : la vue signale les deux differemment. + 'autoUnavailable' => $this->productRepository()->autoUnavailableIds(), ], $guard); } @@ -251,15 +255,23 @@ class ProductController extends AdminController $name = (string) ($product['name'] ?? ''); + // Dette #27 : product_ingredient (FK product_id CASCADE) sera emporte par la + // suppression. On compte AVANT (lecture hors transaction) pour tracer le + // nombre de lignes de recette cascade-supprimees dans le resume d'audit : + // aucune perte hors-trace dans le journal append-only. + $cascaded = $this->productRepository()->compositionCount($id); + $summary = 'Suppression produit: ' . $name + . ' (' . $cascaded . ' ligne(s) de recette cascade-supprimee(s))'; + // FK RESTRICT (order_item / menu / menu_slot_option / order_item_selection) // -> PDOException 23000 -> 409 Conflit (catch ci-dessous). product_ingredient // est CASCADE (recette possedee par le produit) : supprimee avec lui, jamais // bloquante (cf. docblock ProductRepository). try { - $this->db()->transaction(function (DatabaseInterface $db) use ($id, $actor, $name): void { + $this->db()->transaction(function (DatabaseInterface $db) use ($id, $actor, $summary): void { $deleted = (new ProductRepository($db))->delete($id); if ($deleted === 1) { - $this->writeAudit($db, 'product.delete', $actor['id'], $actor['role_id'], $id, 'Suppression produit: ' . $name); + $this->writeAudit($db, 'product.delete', $actor['id'], $actor['role_id'], $id, $summary); } }); } catch (PDOException $exception) { @@ -280,11 +292,75 @@ class ProductController extends AdminController return $this->redirect('/admin/products'); } + /** + * Editeur de recette (PR-B, mlt domaine recettes). Compose product_ingredient : + * la commandabilite est gardee par `ingredient.manage` (composition du produit), + * DISTINCTE de product.create/update/delete (CRUD produit). Aucun PIN : editer + * une recette n'est pas une action sensible RG-T13. + * + * @param array $params + */ + public function recipeForm(array $params): Response + { + $guard = $this->guard('ingredient.manage'); + if ($guard instanceof Response) { + return $guard; + } + + $id = (int) ($params['id'] ?? 0); + $product = $this->productRepository()->find($id); + if ($product === null) { + return $this->notFound($guard); + } + + return $this->renderRecipe($guard, $id, $product, []); + } + + /** + * @param array $params + */ + public function saveRecipe(array $params): Response + { + $guard = $this->guard('ingredient.manage'); + if ($guard instanceof Response) { + return $guard; + } + + $form = $this->request->formBody(); + if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) { + return $this->invalidCsrf(); + } + + $id = (int) ($params['id'] ?? 0); + $product = $this->productRepository()->find($id); + if ($product === null) { + return $this->notFound($guard); + } + + $errors = []; + $lines = $this->parseComposition($form['composition_json'] ?? '', $errors); + if ($errors !== []) { + return $this->renderRecipe($guard, $id, $product, $errors, 422); + } + + // Composition vide autorisee : un produit peut n'avoir aucune recette + // definie (setComposition purge alors la table sans rien reinserer). + $this->productRepository()->setComposition($id, $lines); + $this->setFlash('Recette mise a jour.'); + + return $this->redirect('/admin/products'); + } + protected function productRepository(): ProductRepository { return new ProductRepository($this->db()); } + protected function ingredientRepository(): IngredientRepository + { + return new IngredientRepository($this->db()); + } + protected function categoryRepository(): CategoryRepository { return new CategoryRepository($this->db()); @@ -416,6 +492,96 @@ class ProductController extends AdminController ); } + /** + * Decode + valide la composition soumise en JSON (champ cache composition_json), + * RG-T18 (revalidation serveur) + RG-T16 (allowlist). Un ingredient inconnu est + * FILTRE (jamais une erreur bloquante) ; la PK composite impose un ingredient au + * plus une fois (dedup). Les bornes refletent les CHECK de table : quantity_normal + * >= 1, quantity_maxi >= quantity_normal, extra_price_cents >= 0. Composition vide + * = aucune ligne, sans erreur. + * + * @param array $errors + * @return list + */ + private function parseComposition(string $json, array &$errors): array + { + $json = trim($json); + if ($json === '' || $json === '[]') { + return []; + } + + /** @var mixed $decoded */ + $decoded = json_decode($json, true); + if (!is_array($decoded)) { + $errors['composition'] = 'Composition invalide.'; + + return []; + } + + $lines = []; + $seen = []; + foreach ($decoded as $raw) { + if (!is_array($raw)) { + continue; + } + + $ingredientId = is_numeric($raw['ingredient_id'] ?? null) ? (int) $raw['ingredient_id'] : 0; + if ($ingredientId <= 0 || !$this->productRepository()->ingredientExists($ingredientId)) { + continue; // ingredient inconnu : filtre (allowlist), pas une erreur + } + if (isset($seen[$ingredientId])) { + continue; // PK composite (product_id, ingredient_id) : un seul par ingredient + } + + $qn = is_numeric($raw['quantity_normal'] ?? null) ? (int) $raw['quantity_normal'] : 0; + $qm = is_numeric($raw['quantity_maxi'] ?? null) ? (int) $raw['quantity_maxi'] : 0; + $extra = is_numeric($raw['extra_price_cents'] ?? null) ? (int) $raw['extra_price_cents'] : -1; + + if ($qn < 1 || $qn > 65535) { + $errors['composition'] = 'La quantite normale doit etre un entier >= 1.'; + continue; + } + if ($qm < $qn || $qm > 65535) { + $errors['composition'] = 'La quantite maxi doit etre >= la quantite normale.'; + continue; + } + if ($extra < 0 || $extra > 4294967295) { + $errors['composition'] = 'Le supplement (en centimes) doit etre un entier >= 0.'; + continue; + } + + $seen[$ingredientId] = true; + $lines[] = [ + 'ingredient_id' => $ingredientId, + 'quantity_normal' => $qn, + 'quantity_maxi' => $qm, + 'is_removable' => empty($raw['is_removable']) ? 0 : 1, + 'is_addable' => empty($raw['is_addable']) ? 0 : 1, + 'extra_price_cents' => $extra, + ]; + } + + return $lines; + } + + /** + * @param array $product + * @param array $errors + */ + private function renderRecipe(GuardResult $guard, int $id, array $product, array $errors, int $status = 200): Response + { + return $this->adminView('admin/products/recipe', [ + 'title' => 'Recette - ' . (string) ($product['name'] ?? '') . ' - Wakdo Admin', + 'activeNav' => 'products', + 'productId' => $id, + 'productName' => (string) ($product['name'] ?? ''), + 'ingredients' => $this->ingredientRepository()->all(), + 'composition' => $this->productRepository()->composition($id), + 'errors' => $errors, + 'csrfToken' => Csrf::token($this->sessionManager()), + ], $guard, $status); + } + /** * @param array $values * @param array $errors diff --git a/src/app/Views/admin/products/index.php b/src/app/Views/admin/products/index.php index cb57b85..dcfa489 100644 --- a/src/app/Views/admin/products/index.php +++ b/src/app/Views/admin/products/index.php @@ -6,10 +6,13 @@ declare(strict_types=1); * Liste des produits (CRUD admin), injectee dans admin/layout.php. Texte echappe. * * @var array> $products + * @var list $autoUnavailable ids en rupture auto (RG-T21) */ /** @var array> $rows */ $rows = isset($products) && is_array($products) ? $products : []; +/** @var list $autoIds */ +$autoIds = isset($autoUnavailable) && is_array($autoUnavailable) ? array_map('intval', $autoUnavailable) : []; $esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8'); $euros = static fn (int $cents): string => number_format($cents / 100, 2, ',', ' ') . ' EUR'; ?> @@ -44,6 +47,7 @@ $euros = static fn (int $cents): string => number_format($cents / 100, 2, ',', ' @@ -52,14 +56,17 @@ $euros = static fn (int $cents): string => number_format($cents / 100, 2, ',', ' - - Disponible - + Indisponible + + Rupture auto + + Disponible Modifier + Recette Supprimer diff --git a/src/app/Views/admin/products/recipe.php b/src/app/Views/admin/products/recipe.php new file mode 100644 index 0000000..66c1548 --- /dev/null +++ b/src/app/Views/admin/products/recipe.php @@ -0,0 +1,88 @@ +> $ingredients catalogue pour le picker + * @var array> $composition lignes existantes + * @var array $errors + * @var string $csrfToken + */ + +$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8'); +$id = (int) ($productId ?? 0); +$name = htmlspecialchars((string) ($productName ?? ''), ENT_QUOTES, 'UTF-8'); +$action = '/admin/products/' . $id . '/recipe'; + +/** @var array> $ings */ +$ings = isset($ingredients) && is_array($ingredients) ? $ingredients : []; +/** @var array> $comp */ +$comp = isset($composition) && is_array($composition) ? $composition : []; +/** @var array $errs */ +$errs = isset($errors) && is_array($errors) ? $errors : []; +$compError = isset($errs['composition']) && is_string($errs['composition']) ? $errs['composition'] : ''; + +// Donnees pour le builder JS, en attributs data-* (CSP 'self'). htmlspecialchars +// rend le JSON sur-able comme valeur d'attribut. +$slimIngredients = array_map( + static fn (array $i): array => [ + 'id' => (int) ($i['id'] ?? 0), + 'name' => (string) ($i['name'] ?? ''), + 'unit' => (string) ($i['unit'] ?? ''), + ], + $ings, +); +$slimComposition = array_map( + static fn (array $c): array => [ + 'ingredient_id' => (int) ($c['ingredient_id'] ?? 0), + 'quantity_normal' => (int) ($c['quantity_normal'] ?? 1), + 'quantity_maxi' => (int) ($c['quantity_maxi'] ?? 1), + 'is_removable' => (int) ($c['is_removable'] ?? 0), + 'is_addable' => (int) ($c['is_addable'] ?? 0), + 'extra_price_cents' => (int) ($c['extra_price_cents'] ?? 0), + ], + $comp, +); +$attr = static fn (mixed $data): string => htmlspecialchars( + (string) json_encode($data, JSON_UNESCAPED_UNICODE), + ENT_QUOTES, + 'UTF-8', +); +?> + + +
+ + +
+ Ingredients +

Un ingredient NON RETIRABLE en rupture critique met le produit en rupture automatique. Un ingredient retirable/optionnel ne bloque pas le produit.

+

+
+ +
+ + + +
+ + Retour +
+
+ diff --git a/src/public/admin/assets/js/product-recipe.js b/src/public/admin/assets/js/product-recipe.js new file mode 100644 index 0000000..80ff9b5 --- /dev/null +++ b/src/public/admin/assets/js/product-recipe.js @@ -0,0 +1,164 @@ +/* + * product-recipe.js — Builder de composition (recette) du formulaire produit. + * + * CSP 'self' : script externe (pas d'inline). Les donnees (catalogue d'ingredients, + * composition initiale) sont lues depuis les attributs data-* de #recipe-builder. + * A la soumission, l'etat est serialise en JSON dans le champ cache #composition_json + * (Request::formBody cote serveur ne garde que les scalaires). Le serveur revalide + * tout (RG-T18) : bornes, existence de l'ingredient, dedup par PK composite. + * + * Une composition VIDE est valide (un produit peut n'avoir aucune recette definie). + */ +(function () { + 'use strict'; + + var builder = document.getElementById('recipe-builder'); + var form = document.getElementById('recipe-form'); + var hidden = document.getElementById('composition_json'); + var addBtn = document.getElementById('add-ingredient'); + if (!builder || !form || !hidden || !addBtn) { + return; + } + + function parseData(key, fallback) { + try { + var v = JSON.parse(builder.dataset[key] || fallback); + return Array.isArray(v) ? v : JSON.parse(fallback); + } catch (e) { + return JSON.parse(fallback); + } + } + + var ingredients = parseData('ingredients', '[]'); // [{id, name, unit}] + var initial = parseData('composition', '[]'); // [{ingredient_id, quantity_normal, ...}] + + function el(tag, className) { + var e = document.createElement(tag); + if (className) { + e.className = className; + } + return e; + } + + function numberInput(className, value, min) { + var input = el('input', 'form-input ' + className); + input.type = 'number'; + input.min = String(min); + input.value = String(value); + input.style.width = '7rem'; + return input; + } + + // Construit le bloc DOM d'une ligne de composition. `line` peut etre vide (ajout). + function renderLine(line) { + line = line || {}; + + var block = el('fieldset', 'recipe-line form-group'); + block.style.border = '1px solid #ddd'; + block.style.padding = '0.75rem'; + block.style.marginBottom = '0.75rem'; + + // Ingredient (picker) + var ingLabel = el('label'); + ingLabel.appendChild(document.createTextNode('Ingredient ')); + var ingSelect = el('select', 'form-input recipe-ingredient'); + ingredients.forEach(function (i) { + var opt = el('option'); + opt.value = String(i.id); + opt.textContent = String(i.name) + (i.unit ? ' (' + String(i.unit) + ')' : ''); + if (Number(line.ingredient_id) === Number(i.id)) { + opt.selected = true; + } + ingSelect.appendChild(opt); + }); + ingLabel.appendChild(ingSelect); + block.appendChild(ingLabel); + + // Quantites + var qnLabel = el('label'); + qnLabel.appendChild(document.createTextNode(' Qte normale ')); + qnLabel.appendChild(numberInput('recipe-qn', line.quantity_normal != null ? line.quantity_normal : 1, 1)); + block.appendChild(qnLabel); + + var qmLabel = el('label'); + qmLabel.appendChild(document.createTextNode(' Qte maxi ')); + qmLabel.appendChild(numberInput('recipe-qm', line.quantity_maxi != null ? line.quantity_maxi : 1, 1)); + block.appendChild(qmLabel); + + // Supplement (centimes) + var extraLabel = el('label'); + extraLabel.appendChild(document.createTextNode(' Supplement (cts) ')); + extraLabel.appendChild(numberInput('recipe-extra', line.extra_price_cents != null ? line.extra_price_cents : 0, 0)); + block.appendChild(extraLabel); + + // Retirable / Ajoutable + var remLabel = el('label'); + var remInput = el('input', 'recipe-removable'); + remInput.type = 'checkbox'; + if (Number(line.is_removable) === 1) { + remInput.checked = true; + } + remLabel.appendChild(remInput); + remLabel.appendChild(document.createTextNode(' Retirable')); + block.appendChild(remLabel); + + var addLabel = el('label'); + var addInput = el('input', 'recipe-addable'); + addInput.type = 'checkbox'; + if (Number(line.is_addable) === 1) { + addInput.checked = true; + } + addLabel.appendChild(addInput); + addLabel.appendChild(document.createTextNode(' Ajoutable')); + block.appendChild(addLabel); + + // Retirer la ligne + var removeBtn = el('button', 'btn btn-secondary recipe-remove'); + removeBtn.type = 'button'; + removeBtn.textContent = 'Retirer'; + removeBtn.addEventListener('click', function () { + block.parentNode.removeChild(block); + }); + block.appendChild(removeBtn); + + return block; + } + + // Lit l'etat des lignes et le serialise dans #composition_json. + function serialize() { + var lines = []; + var blocks = builder.querySelectorAll('.recipe-line'); + Array.prototype.forEach.call(blocks, function (block) { + var ingredientId = Number(block.querySelector('.recipe-ingredient').value); + if (!ingredientId) { + return; + } + lines.push({ + ingredient_id: ingredientId, + quantity_normal: Number(block.querySelector('.recipe-qn').value), + quantity_maxi: Number(block.querySelector('.recipe-qm').value), + extra_price_cents: Number(block.querySelector('.recipe-extra').value), + is_removable: block.querySelector('.recipe-removable').checked ? 1 : 0, + is_addable: block.querySelector('.recipe-addable').checked ? 1 : 0 + }); + }); + hidden.value = JSON.stringify(lines); + } + + addBtn.addEventListener('click', function () { + if (!ingredients.length) { + return; // aucun ingredient au catalogue : rien a composer + } + builder.appendChild(renderLine(null)); + }); + + form.addEventListener('submit', function () { + serialize(); + }); + + // Rendu initial : lignes existantes (edition). Composition vide -> aucune ligne + // (l'utilisateur ajoute a la demande, ou enregistre une recette vide). + initial.forEach(function (l) { + builder.appendChild(renderLine(l)); + }); +})(); diff --git a/src/public/admin/index.php b/src/public/admin/index.php index 951a074..332a9f3 100644 --- a/src/public/admin/index.php +++ b/src/public/admin/index.php @@ -91,6 +91,11 @@ try { $router->add('POST', '/admin/products/{id}', [ProductController::class, 'update']); $router->add('GET', '/admin/products/{id}/delete', [ProductController::class, 'confirmDelete']); $router->add('POST', '/admin/products/{id}/delete', [ProductController::class, 'destroy']); + // Editeur de recette (composition product_ingredient). Permission ingredient.manage + // (composition), distincte du CRUD produit ; sans PIN. Debloque la dispo calculee + // RG-T21 et ferme la dette #27 (trace cascade a la suppression). + $router->add('GET', '/admin/products/{id}/recipe', [ProductController::class, 'recipeForm']); + $router->add('POST', '/admin/products/{id}/recipe', [ProductController::class, 'saveRecipe']); // CRUD Menus (menu.read/create/update/delete). Menu compose = burger de base + // slots (menu_slot / menu_slot_option). PIN equipier + audit sur suppression diff --git a/tests/Integration/ProductIngredientDbTest.php b/tests/Integration/ProductIngredientDbTest.php new file mode 100644 index 0000000..ed478a2 --- /dev/null +++ b/tests/Integration/ProductIngredientDbTest.php @@ -0,0 +1,213 @@ +db = new Database(new Config()); + + try { + $this->db->fetch('SELECT 1'); + } catch (Throwable $exception) { + self::markTestSkipped('Base injoignable: ' . $exception->getMessage()); + } + + $this->categoryId = (int) ($this->db->fetch('SELECT id FROM category ORDER BY id LIMIT 1')['id'] ?? 0); + $suffix = bin2hex(random_bytes(4)); + $this->product = 'it-prod-' . $suffix; + $this->ingA = 'it-ping-a-' . $suffix; + $this->ingB = 'it-ping-b-' . $suffix; + } + + protected function tearDown(): void + { + if ($this->product === '') { + return; + } + $pid = (int) ($this->db->fetch('SELECT id FROM product WHERE name = :n', ['n' => $this->product])['id'] ?? 0); + if ($pid > 0) { + $this->db->execute('DELETE FROM product WHERE id = :id', ['id' => $pid]); // CASCADE product_ingredient + } + // Ordre FK-safe : retirer les mouvements de stock (FK RESTRICT) avant + // l'ingredient (le test de dispo cree un restock -> stock_movement). + foreach ([$this->ingA, $this->ingB] as $name) { + $iid = (int) ($this->db->fetch('SELECT id FROM ingredient WHERE name = :n', ['n' => $name])['id'] ?? 0); + if ($iid > 0) { + $this->db->execute('DELETE FROM stock_movement WHERE ingredient_id = :id', ['id' => $iid]); + $this->db->execute('DELETE FROM ingredient WHERE id = :id', ['id' => $iid]); + } + } + } + + public function testSetCompositionPersistsAndReplaces(): void + { + $products = new ProductRepository($this->db); + $ingredients = new IngredientRepository($this->db); + $pid = $this->createProduct($products); + $iaId = $this->createIngredient($ingredients, $this->ingA, 50); + $ibId = $this->createIngredient($ingredients, $this->ingB, 50); + + self::assertTrue($products->ingredientExists($iaId)); + self::assertFalse($products->ingredientExists(0)); + + $products->setComposition($pid, [ + $this->line($iaId, ['quantity_normal' => 2, 'quantity_maxi' => 3, 'is_removable' => 1, 'extra_price_cents' => 50]), + ]); + + $composition = $products->composition($pid); + self::assertCount(1, $composition); + self::assertSame($iaId, (int) $composition[0]['ingredient_id']); + self::assertSame($this->ingA, (string) $composition[0]['ingredient_name']); // JOIN ingredient + self::assertSame(2, (int) $composition[0]['quantity_normal']); + self::assertSame(3, (int) $composition[0]['quantity_maxi']); + self::assertSame(1, (int) $composition[0]['is_removable']); + self::assertSame(50, (int) $composition[0]['extra_price_cents']); + self::assertSame(1, $products->compositionCount($pid)); + + // Delete-and-reinsert : la nouvelle composition REMPLACE l'ancienne. + $products->setComposition($pid, [$this->line($ibId)]); + $replaced = $products->composition($pid); + self::assertCount(1, $replaced); + self::assertSame($ibId, (int) $replaced[0]['ingredient_id']); + } + + public function testProductDeleteCascadesComposition(): void + { + $products = new ProductRepository($this->db); + $ingredients = new IngredientRepository($this->db); + $pid = $this->createProduct($products); + $iaId = $this->createIngredient($ingredients, $this->ingA, 50); + $products->setComposition($pid, [$this->line($iaId)]); + + self::assertSame(1, $products->compositionCount($pid)); + self::assertSame(1, $products->delete($pid)); // FK product_id CASCADE + self::assertCount(0, $products->composition($pid)); // recette emportee + + // L'ingredient, lui, survit (la cascade ne remonte pas vers lui). + self::assertNotNull($ingredients->find($iaId)); + } + + public function testIngredientReferencedByCompositionCannotBeHardDeleted(): void + { + $products = new ProductRepository($this->db); + $ingredients = new IngredientRepository($this->db); + $pid = $this->createProduct($products); + $iaId = $this->createIngredient($ingredients, $this->ingA, 50); + $products->setComposition($pid, [$this->line($iaId)]); + + // FK ingredient_id RESTRICT : un ingredient utilise dans une recette ne peut + // pas etre supprime durement (il faut le desactiver). + $blocked = false; + try { + $ingredients->delete($iaId); + } catch (PDOException $exception) { + $blocked = (string) $exception->getCode() === '23000'; + } + self::assertTrue($blocked, 'product_ingredient.ingredient_id (RESTRICT) doit bloquer la suppression.'); + self::assertTrue($ingredients->isReferenced($iaId)); // pre-check FK-safe + } + + public function testAvailabilityIsDerivedFromRequiredIngredientStock(): void + { + $products = new ProductRepository($this->db); + $ingredients = new IngredientRepository($this->db); + $pid = $this->createProduct($products); + // Ingredient requis SOUS la bande critique (3/100 <= 5%). + $critId = $this->createIngredient($ingredients, $this->ingA, 3); + $products->setComposition($pid, [$this->line($critId, ['is_removable' => 0])]); + + $product = $products->find($pid); + self::assertNotNull($product); + $composition = $products->composition($pid); + self::assertFalse(ProductRepository::isOrderable((int) $product['is_available'] === 1, $composition)); + self::assertContains($pid, $products->autoUnavailableIds()); // rupture auto (RG-T21) + + // Reapprovisionnement au-dessus du critique -> redevient commandable de lui-meme. + $ingredients->restock($critId, 50, null, null); // 3 -> 53 (pack_size 1 * 50) + $composition = $products->composition($pid); + self::assertTrue(ProductRepository::isOrderable(true, $composition)); + self::assertNotContains($pid, $products->autoUnavailableIds()); + } + + private function createProduct(ProductRepository $repo): int + { + $repo->create([ + 'category_id' => $this->categoryId, + 'name' => $this->product, + 'description' => null, + 'price_cents' => 590, + 'vat_rate' => 100, + 'image_path' => null, + 'is_available' => 1, + 'display_order' => 99, + ]); + + return (int) ($this->db->fetch('SELECT id FROM product WHERE name = :n', ['n' => $this->product])['id'] ?? 0); + } + + private function createIngredient(IngredientRepository $repo, string $name, int $stock): int + { + $repo->create([ + 'name' => $name, + 'unit' => 'portion', + 'stock_quantity' => $stock, + 'stock_capacity' => 100, + 'pack_size' => 1, + 'pack_label' => null, + 'low_stock_pct' => 10, + 'critical_stock_pct' => 5, + 'is_active' => 1, + ]); + + return (int) ($this->db->fetch('SELECT id FROM ingredient WHERE name = :n', ['n' => $name])['id'] ?? 0); + } + + /** + * @param array $over + * @return array{ingredient_id:int, quantity_normal:int, quantity_maxi:int, is_removable:int, is_addable:int, extra_price_cents:int} + */ + private function line(int $ingredientId, array $over = []): array + { + return [ + 'ingredient_id' => $ingredientId, + 'quantity_normal' => $over['quantity_normal'] ?? 1, + 'quantity_maxi' => $over['quantity_maxi'] ?? 1, + 'is_removable' => $over['is_removable'] ?? 0, + 'is_addable' => $over['is_addable'] ?? 0, + 'extra_price_cents' => $over['extra_price_cents'] ?? 0, + ]; + } +} diff --git a/tests/Support/FakeDatabase.php b/tests/Support/FakeDatabase.php index 82869fa..222bb2b 100644 --- a/tests/Support/FakeDatabase.php +++ b/tests/Support/FakeDatabase.php @@ -183,6 +183,24 @@ final class FakeDatabase implements DatabaseInterface */ public array $movementsRows = []; + /** + * Lignes renvoyees par ProductRepository::composition() (JOIN product_ingredient/ingredient). + * + * @var list> + */ + public array $compositionRows = []; + + /** + * Lignes {product_id} renvoyees par ProductRepository::autoUnavailableIds() + * (produits en rupture automatique par le stock, RG-T21). + * + * @var list> + */ + public array $autoUnavailableRows = []; + + /** Compteur renvoye par ProductRepository::compositionCount() (trace cascade #27). */ + public int $productCompositionCount = 0; + /** * Allowlist optionnelle de codes de permission accordes (RG-T03). Si non nul, * can() repond par appartenance du :code lie a cette liste (permet de tester la @@ -313,6 +331,10 @@ final class FakeDatabase implements DatabaseInterface return $this->ingredientRow; } + if (str_contains($sql, 'COUNT(*) AS n FROM product_ingredient')) { + return ['n' => $this->productCompositionCount]; + } + if (str_contains($sql, 'FROM category WHERE name = :name')) { return $this->categoryNameTaken ? ['id' => 1] : null; } @@ -364,6 +386,16 @@ final class FakeDatabase implements DatabaseInterface return $this->ingredientsRows; } + // Composition d'un produit (recette) vs ensemble des produits en rupture + // auto : meme table jointe, distingues par la clause WHERE. + if (str_contains($sql, 'FROM product_ingredient pi') && str_contains($sql, 'is_removable = 0')) { + return $this->autoUnavailableRows; + } + + if (str_contains($sql, 'FROM product_ingredient pi') && str_contains($sql, 'WHERE pi.product_id')) { + return $this->compositionRows; + } + if (str_contains($sql, 'FROM stock_movement WHERE ingredient_id')) { return $this->movementsRows; } diff --git a/tests/Unit/Admin/ProductControllerTest.php b/tests/Unit/Admin/ProductControllerTest.php index 317d379..120c25f 100644 --- a/tests/Unit/Admin/ProductControllerTest.php +++ b/tests/Unit/Admin/ProductControllerTest.php @@ -398,6 +398,168 @@ final class ProductControllerTest extends TestCase self::assertSame([], $db->auditActions()); } + // --- Editeur de recette (PR-B, product_ingredient, permission ingredient.manage) --- + + public function testRecipeFormRequiresIngredientManage(): void + { + $db = $this->permittedDb(); + $db->productRow = ['id' => 5, 'name' => 'Big Mac']; + $db->canResult = false; // ni ingredient.manage ni rien + + self::assertSame(403, $this->controller($this->get('/admin/products/5/recipe'), $db)->recipeForm(['id' => '5'])->status()); + } + + public function testRecipeFormNotFound(): void + { + $db = $this->permittedDb(); + $db->productRow = null; + + self::assertSame(404, $this->controller($this->get('/admin/products/9/recipe'), $db)->recipeForm(['id' => '9'])->status()); + } + + public function testRecipeFormShowsCompositionAndPicker(): void + { + $db = $this->permittedDb(); + $db->productRow = ['id' => 5, 'name' => 'Big Mac']; + $db->ingredientsRows = [$this->ingredientPick(7, 'Cheddar'), $this->ingredientPick(8, 'Cornichon')]; + $db->compositionRows = [[ + 'product_id' => 5, 'ingredient_id' => 7, 'quantity_normal' => 2, 'quantity_maxi' => 3, + 'is_removable' => 1, 'is_addable' => 0, 'extra_price_cents' => 0, + 'ingredient_name' => 'Cheddar', 'ingredient_unit' => 'tranche', + 'stock_quantity' => 50, 'stock_capacity' => 100, 'low_stock_pct' => 10, 'critical_stock_pct' => 5, + ]]; + + $response = $this->controller($this->get('/admin/products/5/recipe'), $db)->recipeForm(['id' => '5']); + + self::assertSame(200, $response->status()); + self::assertStringContainsString('Big Mac', $response->body()); + self::assertStringContainsString('Cheddar', $response->body()); // picker + composition existante + self::assertStringContainsString('composition_json', $response->body()); + } + + public function testSaveRecipeReplacesCompositionInTransaction(): void + { + $db = $this->permittedDb(); + $db->productRow = ['id' => 5, 'name' => 'Big Mac']; + $db->ingredientRow = ['id' => 7, 'name' => 'Cheddar']; // ingredientExists -> true + $json = (string) json_encode([[ + 'ingredient_id' => 7, 'quantity_normal' => 2, 'quantity_maxi' => 3, + 'is_removable' => 1, 'is_addable' => 0, 'extra_price_cents' => 50, + ]]); + + $response = $this->controller($this->post(['_csrf' => $this->csrf, 'composition_json' => $json], '/admin/products/5/recipe'), $db)->saveRecipe(['id' => '5']); + + self::assertSame(302, $response->status()); + self::assertSame(['begin', 'commit'], $db->transactionEvents); + self::assertTrue($db->wrote('DELETE FROM product_ingredient')); // delete-and-reinsert (RG-2) + $insert = $this->findWrite($db, 'INSERT INTO product_ingredient'); + self::assertNotNull($insert); + self::assertSame(5, $insert['params']['product'] ?? null); + self::assertSame(7, $insert['params']['ingredient'] ?? null); + self::assertSame(2, $insert['params']['qn'] ?? null); + self::assertSame(3, $insert['params']['qm'] ?? null); + self::assertSame(50, $insert['params']['extra'] ?? null); + } + + public function testSaveRecipeEmptyClearsComposition(): void + { + $db = $this->permittedDb(); + $db->productRow = ['id' => 5, 'name' => 'Big Mac']; + + // Composition vide : un produit peut n'avoir aucune recette definie -> on + // purge sans erreur (DELETE seul, aucun INSERT). + $response = $this->controller($this->post(['_csrf' => $this->csrf, 'composition_json' => '[]'], '/admin/products/5/recipe'), $db)->saveRecipe(['id' => '5']); + + self::assertSame(302, $response->status()); + self::assertTrue($db->wrote('DELETE FROM product_ingredient')); + self::assertFalse($db->wrote('INSERT INTO product_ingredient')); + } + + public function testSaveRecipeRejectsMaxiBelowNormal(): void + { + $db = $this->permittedDb(); + $db->productRow = ['id' => 5, 'name' => 'Big Mac']; + $db->ingredientRow = ['id' => 7, 'name' => 'Cheddar']; + $json = (string) json_encode([[ + 'ingredient_id' => 7, 'quantity_normal' => 3, 'quantity_maxi' => 1, // viole quantity_maxi >= quantity_normal + 'is_removable' => 0, 'is_addable' => 0, 'extra_price_cents' => 0, + ]]); + + $response = $this->controller($this->post(['_csrf' => $this->csrf, 'composition_json' => $json], '/admin/products/5/recipe'), $db)->saveRecipe(['id' => '5']); + + self::assertSame(422, $response->status()); + self::assertFalse($db->wrote('product_ingredient')); // aucun ecrit (validation RG-T18) + } + + public function testSaveRecipeDropsUnknownIngredient(): void + { + $db = $this->permittedDb(); + $db->productRow = ['id' => 5, 'name' => 'Big Mac']; + $db->ingredientRow = null; // ingredientExists -> false : ligne ignoree (allowlist) + $json = (string) json_encode([[ + 'ingredient_id' => 999, 'quantity_normal' => 1, 'quantity_maxi' => 1, + 'is_removable' => 0, 'is_addable' => 0, 'extra_price_cents' => 0, + ]]); + + $response = $this->controller($this->post(['_csrf' => $this->csrf, 'composition_json' => $json], '/admin/products/5/recipe'), $db)->saveRecipe(['id' => '5']); + + self::assertSame(302, $response->status()); + self::assertTrue($db->wrote('DELETE FROM product_ingredient')); + self::assertFalse($db->wrote('INSERT INTO product_ingredient')); // l'ingredient inconnu est filtre + } + + public function testSaveRecipeRejectsInvalidCsrf(): void + { + $db = $this->permittedDb(); + $db->productRow = ['id' => 5, 'name' => 'Big Mac']; + + $response = $this->controller($this->post(['_csrf' => 'bad', 'composition_json' => '[]'], '/admin/products/5/recipe'), $db)->saveRecipe(['id' => '5']); + + self::assertSame(403, $response->status()); + self::assertFalse($db->wrote('product_ingredient')); + } + + public function testIndexFlagsStockDrivenRupture(): void + { + $db = $this->permittedDb(); + $db->productsRows = [ + ['id' => 1, 'category_id' => 3, 'name' => 'Big Mac', 'price_cents' => 590, 'vat_rate' => 100, 'is_available' => 1, 'category_name' => 'Burgers'], + ]; + $db->autoUnavailableRows = [['product_id' => 1]]; // un ingredient requis en bande critique (RG-T21) + + $response = $this->controller($this->get('/admin/products'), $db)->index(); + + self::assertSame(200, $response->status()); + self::assertStringContainsString('Rupture auto', $response->body()); // distinct du retrait manuel + } + + public function testDestroyTracesCascadedCompositionCount(): void + { + // Dette #27 : la suppression dure cascade product_ingredient (FK CASCADE) ; + // on trace combien de lignes de recette ont ete emportees, pour ne laisser + // aucune perte hors-trace dans l'audit_log. + $db = $this->permittedDb(); + $db->productRow = ['id' => 5, 'name' => 'Big Mac']; + $db->productCompositionCount = 3; + $this->actingPin($db); + + $response = $this->controller($this->post(['_csrf' => $this->csrf, 'pin_email' => 'staff@wakdo.local', 'pin' => '4729'], '/admin/products/5/delete'), $db)->destroy(['id' => '5']); + + self::assertSame(302, $response->status()); + $audit = $this->firstAudit($db); + self::assertNotNull($audit); + self::assertSame('product.delete', $audit['params']['code'] ?? null); + self::assertStringContainsString('3', (string) ($audit['params']['summary'] ?? '')); // nb de lignes cascade tracees + } + + /** + * @return array + */ + private function ingredientPick(int $id, string $name): array + { + return ['id' => $id, 'name' => $name, 'unit' => 'tranche', 'stock_quantity' => 50, 'stock_capacity' => 100, 'pack_size' => 1, 'pack_label' => null, 'low_stock_pct' => 10, 'critical_stock_pct' => 5, 'is_active' => 1]; + } + /** * @return array{sql: string, params: array}|null */ diff --git a/tests/Unit/Catalogue/ProductRepositoryTest.php b/tests/Unit/Catalogue/ProductRepositoryTest.php new file mode 100644 index 0000000..e309278 --- /dev/null +++ b/tests/Unit/Catalogue/ProductRepositoryTest.php @@ -0,0 +1,73 @@ + $over + * @return array + */ + private function line(array $over = []): array + { + return array_merge([ + 'is_removable' => 0, + 'stock_quantity' => 50, + 'stock_capacity' => 100, + 'low_stock_pct' => 10, + 'critical_stock_pct' => 5, + ], $over); + } + + public function testManualUnavailabilityAlwaysBlocks(): void + { + // is_available=0 : retrait manuel, prime meme sur un stock plein (surcharge forte). + self::assertFalse(ProductRepository::isOrderable(false, [$this->line()])); + } + + public function testAvailableWithoutCompositionIsOrderable(): void + { + self::assertTrue(ProductRepository::isOrderable(true, [])); + } + + public function testRequiredIngredientAtCriticalBlocks(): void + { + // Requis, 5/100 <= 5% -> bande critique -> rupture automatique. + self::assertFalse(ProductRepository::isOrderable(true, [$this->line(['stock_quantity' => 5])])); + } + + public function testRequiredIngredientJustAboveCriticalDoesNotBlock(): void + { + // Requis, 6/100 > 5% -> bande basse (pas critique) -> reste commandable. + self::assertTrue(ProductRepository::isOrderable(true, [$this->line(['stock_quantity' => 6])])); + } + + public function testRemovableIngredientAtCriticalDoesNotBlock(): void + { + // Retirable (is_removable=1) a 0 : seul son supplement saute, le produit reste commandable. + self::assertTrue(ProductRepository::isOrderable(true, [$this->line(['is_removable' => 1, 'stock_quantity' => 0])])); + } + + public function testOneRequiredCriticalAmongManyBlocks(): void + { + self::assertFalse(ProductRepository::isOrderable(true, [ + $this->line(['stock_quantity' => 50]), // requis, ok + $this->line(['is_removable' => 1, 'stock_quantity' => 0]), // retirable critique, ok + $this->line(['stock_quantity' => 5]), // requis critique -> bloque + ])); + } +} -- 2.45.3 From 1ecd78324c2659ddeab3a642d95f840396c4c1ab Mon Sep 17 00:00:00 2001 From: Corentin JOGUET Date: Wed, 17 Jun 2026 12:10:46 +0200 Subject: [PATCH 37/93] feat(borne): modale allergenes generale (14 INCO) sur carte et fiche + harnais tests JS (P3) (#36) --- .forgejo/workflows/ci.yml | 29 +- package-lock.json | 545 ++++++++++++++++++++ package.json | 12 + src/public/borne/assets/css/style.css | 116 +++++ src/public/borne/assets/js/allergens.js | 132 +++++ src/public/borne/assets/js/data.js | 18 + src/public/borne/assets/js/package.json | 4 + src/public/borne/assets/js/page-product.js | 15 +- src/public/borne/assets/js/page-products.js | 18 +- src/public/borne/data/allergens.json | 16 + tests/js/allergens.test.js | 110 ++++ tests/js/package.json | 4 + 12 files changed, 1015 insertions(+), 4 deletions(-) create mode 100755 package-lock.json create mode 100755 package.json create mode 100644 src/public/borne/assets/js/allergens.js create mode 100644 src/public/borne/assets/js/package.json create mode 100644 src/public/borne/data/allergens.json create mode 100644 tests/js/allergens.test.js create mode 100644 tests/js/package.json diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 02ddf7a..95ccd8e 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -142,14 +142,39 @@ jobs: # du correctif : plus aucun skip silencieux des chemins securite. php phpunit.phar -c phpunit.xml --fail-on-skipped + js-tests: + # Tests du front borne (kiosk) : node:test + jsdom, sans navigateur. + # GARDE : ne s'active que si package.json + tests/js/ existent. + runs-on: docker + steps: + - uses: actions/checkout@v4 + - name: Install Node.js 20 + run: | + set -eu + # Node 20 epingle via NodeSource (self-contained, comme les .phar/gitleaks) + # plutot que l'apt bookworm (18.x, limite basse pour jsdom). Reproductible. + apt-get update -qq && apt-get install -y -qq curl ca-certificates >/dev/null + curl -fsSL https://deb.nodesource.com/setup_20.x | bash - >/dev/null + apt-get install -y -qq nodejs >/dev/null + node --version && npm --version + - name: Install deps + run kiosk JS tests + run: | + set -eu + if [ ! -f package.json ] || [ ! -d tests/js ]; then + echo "JS tests skipped: no package.json + tests/js/ yet" + exit 0 + fi + npm ci + npm run test:js + auto-merge: # Fusion automatique OPT-IN : poser le label `auto-merge` sur la PR. - # Ne s'execute que si les 3 checks passent (needs). + # Ne s'execute que si tous les checks requis passent (needs). # IMPORTANT : le filtrage par label se fait DANS le step via l'API, pas dans # `if:` — l'expression contains(github.event.pull_request.labels.*.name, ...) # de Forgejo n'est pas fiable (elle s'evalue a vrai meme sans label, ce qui # fusionnait toute PR verte). La verification shell sur l'API est le vrai gate. - needs: [secret-scan, php-lint, static-tests] + needs: [secret-scan, php-lint, static-tests, js-tests] if: github.event_name == 'pull_request' runs-on: docker steps: diff --git a/package-lock.json b/package-lock.json new file mode 100755 index 0000000..25a3089 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,545 @@ +{ + "name": "wakdo", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "wakdo", + "version": "0.0.0", + "devDependencies": { + "jsdom": "^26.0.0" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nwsapi": { + "version": "2.2.24", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", + "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100755 index 0000000..eec3096 --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "wakdo", + "version": "0.0.0", + "private": true, + "description": "Wakdo - tests front borne (kiosk). Back-office PHP teste via PHPUnit (phpunit.phar). NB: pas de \"type\":\"module\" a la racine -> les .js du depot (hooks .claude, _byan, bin) restent CommonJS. L'ESM est declare localement la ou il s'applique (src/public/borne/assets/js, tests/js).", + "scripts": { + "test:js": "node --test tests/js/" + }, + "devDependencies": { + "jsdom": "^26.0.0" + } +} diff --git a/src/public/borne/assets/css/style.css b/src/public/borne/assets/css/style.css index 5f11afb..3cff763 100644 --- a/src/public/borne/assets/css/style.css +++ b/src/public/borne/assets/css/style.css @@ -1437,6 +1437,122 @@ button { line-height: 1.3; } +/* ============================================================ + * Allergenes — bouton "i" + modale generale (14 INCO) + * Info reglementaire generale (pas un calcul par produit). La modale reutilise + * le pattern overlay du composer ; z-index 220 pour passer au-dessus de lui. + * ============================================================ */ + +/* Le bouton "i" se superpose au coin de l'image de la carte. */ +.product-card__image-wrap { + position: relative; +} + +.allergen-info-btn { + width: 2rem; + height: 2rem; + border-radius: 50%; + border: 2px solid var(--color-brand-dark); + background: var(--color-bg-card); + color: var(--color-brand-dark); + font-weight: var(--font-weight-bold); + font-style: italic; + font-size: var(--font-size-base); + line-height: 1; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + box-shadow: var(--shadow-overlay); +} + +.product-card__image-wrap .allergen-info-btn { + position: absolute; + top: var(--space-2); + right: var(--space-2); + z-index: 2; +} + +.product-detail__info .allergen-info-btn { + margin-top: var(--space-3); +} + +.allergen-modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.55); + z-index: 220; + display: flex; + align-items: center; + justify-content: center; + padding: var(--space-4); + animation: composer-fade-in var(--transition-base) both; +} + +.allergen-modal { + position: relative; + background: var(--color-bg-card); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-overlay); + width: 100%; + max-width: 640px; + max-height: 90vh; + overflow-y: auto; + padding: var(--space-6); + animation: composer-slide-up var(--transition-base) both; +} + +.allergen-modal-close { + position: absolute; + top: var(--space-3); + right: var(--space-3); + width: 2.25rem; + height: 2.25rem; + border-radius: 50%; + border: none; + background: var(--color-bg-page); + color: var(--color-text-primary); + font-size: var(--font-size-md); + cursor: pointer; +} + +.allergen-modal-title { + font-size: var(--font-size-xl); + font-weight: var(--font-weight-bold); + color: var(--color-text-primary); + margin-bottom: var(--space-2); +} + +.allergen-modal-intro { + color: var(--color-text-secondary); + font-size: var(--font-size-sm); + margin-bottom: var(--space-4); +} + +.allergen-modal-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: var(--space-2); +} + +.allergen-modal-list li { + padding: var(--space-2) var(--space-3); + background: var(--color-bg-page); + border-radius: var(--radius-md); +} + +.allergen-name { + font-weight: var(--font-weight-bold); + color: var(--color-text-primary); +} + +.allergen-desc { + color: var(--color-text-secondary); + font-size: var(--font-size-sm); +} + .composer-card__price { font-size: var(--font-size-sm); color: var(--color-text-muted); diff --git a/src/public/borne/assets/js/allergens.js b/src/public/borne/assets/js/allergens.js new file mode 100644 index 0000000..62f1244 --- /dev/null +++ b/src/public/borne/assets/js/allergens.js @@ -0,0 +1,132 @@ +/* + * allergens.js — Modale GENERALE d'information allergenes (front borne). + * + * Information reglementaire (UE INCO 1169/2011) presentee au client : la liste + * des 14 allergenes a declaration obligatoire. C'est une info GENERALE (pas un + * calcul par produit) ; le mapping ingredient_allergen par produit reste differe. + * + * CSP 'self' : aucun script inline, aucun handler inline. Le DOM est construit par + * l'API (createElement/textContent) ; textContent neutralise toute injection. + * Les donnees viennent de data.js (loadAllergens) : liste fixe en P5, /api/allergens + * au swap P4. openAllergenModal prend la liste en parametre pour rester independant + * de la couche de chargement (et testable sans fetch). + */ + +const OVERLAY_CLASS = 'allergen-modal-overlay'; + +/* Reference stable du handler clavier pour pouvoir le retirer a la fermeture. */ +function onKeydown(event) { + if (event.key === 'Escape') { + closeAllergenModal(); + } +} + +/** + * Construit le bouton "i" qui ouvre la modale. `onOpen` est appele au clic ; + * la propagation est stoppee pour ne pas declencher le clic de la carte produit + * (sur la carte, le bouton est superpose a une zone cliquable). + * @param {() => void} onOpen + * @returns {HTMLButtonElement} + */ +export function buildAllergenInfoButton(onOpen) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'allergen-info-btn'; + btn.setAttribute('aria-label', 'Informations allergenes'); + btn.title = 'Informations allergenes'; + btn.textContent = 'i'; + btn.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + if (typeof onOpen === 'function') { + onOpen(); + } + }); + return btn; +} + +/** + * Ouvre la modale generale listant les allergenes fournis. Idempotent : une + * eventuelle modale ouverte est d'abord fermee (pas de doublon empile). + * @param {Array<{id:number, name:string, description?:string}>} allergens + * @returns {HTMLElement} l'overlay cree + */ +export function openAllergenModal(allergens) { + closeAllergenModal(); + + const list = Array.isArray(allergens) ? allergens : []; + + const overlay = document.createElement('div'); + overlay.className = OVERLAY_CLASS; + overlay.setAttribute('role', 'dialog'); + overlay.setAttribute('aria-modal', 'true'); + overlay.setAttribute('aria-label', 'Informations allergenes'); + + const modal = document.createElement('div'); + modal.className = 'allergen-modal'; + + const closeBtn = document.createElement('button'); + closeBtn.type = 'button'; + closeBtn.className = 'allergen-modal-close'; + closeBtn.setAttribute('aria-label', 'Fermer'); + closeBtn.textContent = 'x'; + closeBtn.addEventListener('click', closeAllergenModal); + modal.appendChild(closeBtn); + + const title = document.createElement('h2'); + title.className = 'allergen-modal-title'; + title.textContent = 'Allergenes'; + modal.appendChild(title); + + const intro = document.createElement('p'); + intro.className = 'allergen-modal-intro'; + intro.textContent = 'Les 14 allergenes a declaration obligatoire (reglement UE INCO 1169/2011). Pour toute question, demandez en caisse.'; + modal.appendChild(intro); + + const ul = document.createElement('ul'); + ul.className = 'allergen-modal-list'; + for (const allergen of list) { + const li = document.createElement('li'); + + const name = document.createElement('span'); + name.className = 'allergen-name'; + name.textContent = String(allergen.name ?? ''); + li.appendChild(name); + + if (allergen.description) { + const desc = document.createElement('span'); + desc.className = 'allergen-desc'; + desc.textContent = ' - ' + String(allergen.description); + li.appendChild(desc); + } + + ul.appendChild(li); + } + modal.appendChild(ul); + + overlay.appendChild(modal); + + // Clic sur le fond (hors du panneau) = fermeture ; clic dans le panneau, non. + overlay.addEventListener('click', (event) => { + if (event.target === overlay) { + closeAllergenModal(); + } + }); + + document.addEventListener('keydown', onKeydown); + document.body.appendChild(overlay); + + return overlay; +} + +/** + * Ferme la modale si elle est ouverte et retire le handler clavier. Sans effet + * si aucune modale n'est ouverte (sur appel ou Echap repete). + */ +export function closeAllergenModal() { + const existing = document.querySelector('.' + OVERLAY_CLASS); + if (existing && existing.parentNode) { + existing.parentNode.removeChild(existing); + } + document.removeEventListener('keydown', onKeydown); +} diff --git a/src/public/borne/assets/js/data.js b/src/public/borne/assets/js/data.js index 38f03b1..378d82c 100644 --- a/src/public/borne/assets/js/data.js +++ b/src/public/borne/assets/js/data.js @@ -19,6 +19,9 @@ * ----------------------------------------------------------------------- */ const CATEGORIES_URL = 'data/categories.json'; const PRODUCTS_URL = 'data/produits.json'; +/* Liste fixe des 14 allergenes INCO (info generale, modale borne). TODO(P4): + * remplacer par '/api/allergens'. Le reste du fichier est API-agnostique. */ +const ALLERGENS_URL = 'data/allergens.json'; /** @type {Array|null} — in-memory cache to avoid repeated fetches */ let _categoriesCache = null; @@ -26,6 +29,9 @@ let _categoriesCache = null; /** @type {Object|null} */ let _productsCache = null; +/** @type {Array|null} */ +let _allergensCache = null; + /** * Fetches and caches the categories list. * @returns {Promise} @@ -50,6 +56,18 @@ export async function loadProducts() { return _productsCache; } +/** + * Fetches and caches the 14 INCO allergens (general info modal). + * @returns {Promise} + */ +export async function loadAllergens() { + if (_allergensCache) return _allergensCache; + const res = await fetch(ALLERGENS_URL); + if (!res.ok) throw new Error(`Failed to load allergens: HTTP ${res.status}`); + _allergensCache = await res.json(); + return _allergensCache; +} + /** * Returns the array of products for a given category slug. * Returns [] if the slug is not found. diff --git a/src/public/borne/assets/js/package.json b/src/public/borne/assets/js/package.json new file mode 100644 index 0000000..a6f8a22 --- /dev/null +++ b/src/public/borne/assets/js/package.json @@ -0,0 +1,4 @@ +{ + "//": "Marque les scripts du kiosk comme ESM (import/export) pour Node (tests). Le navigateur les charge via + diff --git a/src/public/admin/assets/css/admin.css b/src/public/admin/assets/css/admin.css index 330b1e8..92d4b55 100644 --- a/src/public/admin/assets/css/admin.css +++ b/src/public/admin/assets/css/admin.css @@ -1358,3 +1358,37 @@ tbody td.mono { .feed-text b { font-weight: 800; } .feed-meta { font-size: 13px; color: var(--color-text-muted); margin-top: 2px; } .feed-time { font-size: 13px; color: var(--color-text-muted); font-weight: 600; white-space: nowrap; } + +/* --- Modal PIN (re-autorisation au moment de l'action sensible) --- */ +.pin-modal-overlay { + position: fixed; + inset: 0; + background: rgba(26, 26, 26, 0.45); + display: none; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 24px; +} +.pin-modal-overlay.open { display: flex; } + +.pin-modal { + background: var(--color-white); + border-radius: var(--radius-card); + border-top: 3px solid var(--color-yellow); + box-shadow: var(--shadow-card-hover); + width: 100%; + max-width: 400px; + padding: 28px; +} +.pin-modal-head { display: flex; align-items: flex-start; gap: 14px; margin-bottom: 20px; } +.pin-modal-ico { + width: 44px; height: 44px; flex-shrink: 0; + border-radius: 13px; + background: var(--color-yellow-soft); + color: var(--color-yellow-ink); + display: flex; align-items: center; justify-content: center; +} +.pin-modal-title { font-size: 18px; font-weight: 800; letter-spacing: -0.3px; } +.pin-modal-sub { font-size: 13px; color: var(--color-text-muted); margin-top: 3px; } +.pin-modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 8px; } diff --git a/src/public/admin/assets/js/pin-modal.js b/src/public/admin/assets/js/pin-modal.js new file mode 100644 index 0000000..9aab54e --- /dev/null +++ b/src/public/admin/assets/js/pin-modal.js @@ -0,0 +1,139 @@ +/** + * pin-modal.js — Re-autorisation par PIN au moment de l'action sensible. + * + * Les formulaires d'action sensible portent un fieldset inline (email equipier + PIN, + * RG-T13). Plutot que ce bloc noye en bas du formulaire, on le masque et on le remplace + * par un MODAL clair qui surgit au clic sur "Enregistrer/Supprimer" : l'equipier confirme + * avec son email + PIN (ou ceux d'un responsable), on reinjecte dans les champs caches, + * puis on soumet. Le contrat serveur ne change pas (il lit toujours pin_email + pin). + * + * CSP 'self' : script externe, aucun handler inline, le DOM du modal est construit ici. + */ +(function () { + 'use strict'; + + function init(doc) { + var emailInput = doc.getElementById('pin_email'); + var pinInput = doc.getElementById('pin'); + // Seuls les formulaires de RE-AUTORISATION ont pin_email (la page set-PIN ne + // l'a pas : on ne l'intercepte donc pas). + if (!emailInput || !pinInput) { + return; + } + var form = pinInput.closest('form'); + if (!form) { + return; + } + + var fieldset = pinInput.closest('fieldset'); + if (fieldset) { + fieldset.hidden = true; + } + + // Email de l'utilisateur connecte (expose sur ) : pre-remplit + // le modal pour le cas courant ou l'on valide sa PROPRE action ; reste modifiable + // pour validation par un responsable. + var prefillEmail = (doc.body && doc.body.getAttribute('data-user-email')) || ''; + + var overlay = buildModal(doc); + doc.body.appendChild(overlay); + + var modalEmail = overlay.querySelector('#pm-email'); + var modalPin = overlay.querySelector('#pm-pin'); + var modalError = overlay.querySelector('[data-pm-error]'); + var confirmed = false; + + form.addEventListener('submit', function (e) { + if (confirmed) { + return; // deja valide via le modal -> soumission reelle + } + e.preventDefault(); + openModal(); + }); + + overlay.querySelector('[data-pm-cancel]').addEventListener('click', closeModal); + overlay.addEventListener('mousedown', function (e) { + if (e.target === overlay) { + closeModal(); + } + }); + doc.addEventListener('keydown', function (e) { + if (e.key === 'Escape' && overlay.classList.contains('open')) { + closeModal(); + } + }); + + overlay.querySelector('[data-pm-form]').addEventListener('submit', function (e) { + e.preventDefault(); + var email = modalEmail.value.trim(); + var pin = modalPin.value; + if (email === '' || pin === '') { + modalError.textContent = 'Email et PIN requis pour confirmer.'; + modalError.hidden = false; + return; + } + emailInput.value = email; + pinInput.value = pin; + confirmed = true; + closeModal(); + form.submit(); + }); + + function openModal() { + modalError.hidden = true; + modalEmail.value = emailInput.value || prefillEmail || ''; + modalPin.value = ''; + overlay.classList.add('open'); + (modalEmail.value === '' ? modalEmail : modalPin).focus(); + } + + function closeModal() { + overlay.classList.remove('open'); + } + } + + function buildModal(doc) { + var overlay = doc.createElement('div'); + overlay.className = 'pin-modal-overlay'; + overlay.setAttribute('role', 'dialog'); + overlay.setAttribute('aria-modal', 'true'); + overlay.setAttribute('aria-label', 'Confirmation par PIN'); + overlay.innerHTML = + '
' + + '
' + + ' ' + + '
' + + '

Action a confirmer

' + + '

Saisissez vos identifiants equipier (ou ceux d\'un responsable).

' + + '
' + + '
' + + '
' + + '
' + + ' ' + + ' ' + + '
' + + '
' + + ' ' + + ' ' + + '
' + + ' ' + + '
' + + ' ' + + ' ' + + '
' + + '
' + + '
'; + return overlay; + } + + if (typeof module !== 'undefined' && module.exports) { + module.exports = { init: init, buildModal: buildModal }; + } + if (typeof document !== 'undefined' && document.addEventListener) { + document.addEventListener('DOMContentLoaded', function () { + init(document); + }); + } +})(); diff --git a/tests/Unit/Auth/UserDirectoryTest.php b/tests/Unit/Auth/UserDirectoryTest.php index be0b993..e270d33 100644 --- a/tests/Unit/Auth/UserDirectoryTest.php +++ b/tests/Unit/Auth/UserDirectoryTest.php @@ -25,11 +25,12 @@ final class UserDirectoryTest extends TestCase $this->db->userDisplayRow = [ 'first_name' => 'Corentin', 'last_name' => 'J', + 'email' => 'corentin@wakdo.local', 'role_label' => 'Administrateur', ]; self::assertSame( - ['name' => 'Corentin J', 'role_label' => 'Administrateur'], + ['name' => 'Corentin J', 'role_label' => 'Administrateur', 'email' => 'corentin@wakdo.local'], (new UserDirectory($this->db))->displayInfo(7), ); } @@ -39,7 +40,7 @@ final class UserDirectoryTest extends TestCase $this->db->userDisplayRow = null; self::assertSame( - ['name' => 'Utilisateur', 'role_label' => ''], + ['name' => 'Utilisateur', 'role_label' => '', 'email' => ''], (new UserDirectory($this->db))->displayInfo(999), ); } diff --git a/tests/js/pin-modal.test.js b/tests/js/pin-modal.test.js new file mode 100644 index 0000000..16917c4 --- /dev/null +++ b/tests/js/pin-modal.test.js @@ -0,0 +1,83 @@ +/* + * Tests du modal de re-autorisation PIN du back-office (node:test + jsdom). + * + * Couvre : masquage du fieldset inline, ouverture du modal a la soumission d'un + * formulaire d'action sensible (pas de soumission reelle), pre-remplissage de + * l'email depuis , et la confirmation qui reinjecte + * email + PIN dans les champs caches puis soumet. DOM simule par jsdom. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { JSDOM } from 'jsdom'; + +// pin-modal.js est du CommonJS (admin = racine CommonJS) ; import par defaut. +import pinModal from '../../src/public/admin/assets/js/pin-modal.js'; + +function setup(email) { + const dom = new JSDOM( + '' + + '
' + + '
' + + ' ' + + ' ' + + '
' + + ' ' + + '
', + ); + return dom; +} + +function fireSubmit(dom, el) { + el.dispatchEvent(new dom.window.Event('submit', { cancelable: true, bubbles: true })); +} + +test('init masque le fieldset inline et insere un modal ferme', () => { + const dom = setup('a@b.c'); + pinModal.init(dom.window.document); + const doc = dom.window.document; + assert.equal(doc.getElementById('pinfs').hidden, true); + assert.ok(doc.querySelector('.pin-modal-overlay')); + assert.equal(doc.querySelector('.pin-modal-overlay.open'), null); +}); + +test('soumettre le formulaire ouvre le modal (sans soumission reelle) et pre-remplit l email', () => { + const dom = setup('manager@wakdo.local'); + const doc = dom.window.document; + pinModal.init(doc); + const form = doc.getElementById('f'); + let submitted = false; + form.submit = () => { submitted = true; }; + + fireSubmit(dom, form); + + assert.equal(doc.querySelector('.pin-modal-overlay').classList.contains('open'), true); + assert.equal(submitted, false); + assert.equal(doc.getElementById('pm-email').value, 'manager@wakdo.local'); +}); + +test('confirmer reinjecte email + PIN et soumet ; refuse si champ vide', () => { + const dom = setup('a@b.c'); + const doc = dom.window.document; + pinModal.init(doc); + const form = doc.getElementById('f'); + let submitted = false; + form.submit = () => { submitted = true; }; + + fireSubmit(dom, form); + const modalForm = doc.querySelector('[data-pm-form]'); + + // PIN vide -> pas de soumission, erreur affichee. + doc.getElementById('pm-pin').value = ''; + fireSubmit(dom, modalForm); + assert.equal(submitted, false); + assert.equal(doc.querySelector('[data-pm-error]').hidden, false); + + // Email + PIN -> reinjection + soumission. + doc.getElementById('pm-email').value = 'valid@wakdo.local'; + doc.getElementById('pm-pin').value = '4729'; + fireSubmit(dom, modalForm); + assert.equal(doc.getElementById('pin_email').value, 'valid@wakdo.local'); + assert.equal(doc.getElementById('pin').value, '4729'); + assert.equal(submitted, true); + assert.equal(doc.querySelector('.pin-modal-overlay').classList.contains('open'), false); +}); -- 2.45.3 From 1697b94b62bc9388bf01ff9dc72df64ee0ee6885 Mon Sep 17 00:00:00 2001 From: Corentin JOGUET Date: Thu, 18 Jun 2026 13:45:46 +0200 Subject: [PATCH 54/93] feat(admin): humanise les libelles restants (Reference/Variation/Auteur) (#54) --- src/app/Controllers/CategoryController.php | 6 +++--- src/app/Views/admin/categories/form.php | 2 +- src/app/Views/admin/categories/index.php | 2 +- src/app/Views/admin/ingredients/movements.php | 4 ++-- tests/Unit/Admin/CategoryControllerTest.php | 4 ++-- tests/Unit/Admin/IngredientControllerTest.php | 4 ++-- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/app/Controllers/CategoryController.php b/src/app/Controllers/CategoryController.php index 60c098e..6394082 100644 --- a/src/app/Controllers/CategoryController.php +++ b/src/app/Controllers/CategoryController.php @@ -197,9 +197,9 @@ class CategoryController extends AdminController } if ($slug === '' || mb_strlen($slug) > 60 || preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $slug) !== 1) { - $errors['slug'] = 'Slug requis : minuscules, chiffres et tirets (60 max).'; + $errors['slug'] = 'Reference requise : minuscules, chiffres et tirets (60 max).'; } elseif ($repo->slugExists($slug, $exceptId)) { - $errors['slug'] = 'Ce slug existe deja.'; + $errors['slug'] = 'Cette reference existe deja.'; } if ($image !== '' && mb_strlen($image) > 255) { @@ -258,7 +258,7 @@ class CategoryController extends AdminController // getCode() rend la chaine SQLSTATE pour une vraie PDOException ; le cast // couvre aussi un code entier (23000 = violation de contrainte d'integrite). if ((string) $exception->getCode() === '23000') { - return $this->renderForm($guard, $id, $form, ['slug' => 'Ce libelle ou ce slug existe deja.'], 409); + return $this->renderForm($guard, $id, $form, ['slug' => 'Ce libelle ou cette reference existe deja.'], 409); } throw $exception; diff --git a/src/app/Views/admin/categories/form.php b/src/app/Views/admin/categories/form.php index 09b938f..3b65a2f 100644 --- a/src/app/Views/admin/categories/form.php +++ b/src/app/Views/admin/categories/form.php @@ -40,7 +40,7 @@ $err = static fn (string $k): string => isset($errs[$k]) && is_string($errs[$k])
- +

diff --git a/src/app/Views/admin/categories/index.php b/src/app/Views/admin/categories/index.php index 0131ddf..1d0de06 100644 --- a/src/app/Views/admin/categories/index.php +++ b/src/app/Views/admin/categories/index.php @@ -32,7 +32,7 @@ $esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES, Libelle - Slug + Reference Ordre Statut diff --git a/src/app/Views/admin/ingredients/movements.php b/src/app/Views/admin/ingredients/movements.php index b53a756..a06884e 100644 --- a/src/app/Views/admin/ingredients/movements.php +++ b/src/app/Views/admin/ingredients/movements.php @@ -49,9 +49,9 @@ $colspan = $withActor ? 5 : 4; Date Type - Delta + Variation Note - Acteur + Auteur diff --git a/tests/Unit/Admin/CategoryControllerTest.php b/tests/Unit/Admin/CategoryControllerTest.php index 3941090..57b3f03 100644 --- a/tests/Unit/Admin/CategoryControllerTest.php +++ b/tests/Unit/Admin/CategoryControllerTest.php @@ -202,7 +202,7 @@ final class CategoryControllerTest extends TestCase self::assertSame(422, $response->status()); self::assertStringContainsString('Le libelle est requis', $response->body()); - self::assertStringContainsString('Slug requis', $response->body()); + self::assertStringContainsString('Reference requise', $response->body()); self::assertFalse($this->wroteContaining($db, 'INSERT INTO category')); } @@ -266,7 +266,7 @@ final class CategoryControllerTest extends TestCase $response = $this->controller($request, $db)->store(); self::assertSame(422, $response->status()); - self::assertStringContainsString('Ce slug existe deja', $response->body()); + self::assertStringContainsString('Cette reference existe deja', $response->body()); self::assertFalse($this->wroteContaining($db, 'INSERT INTO category')); } diff --git a/tests/Unit/Admin/IngredientControllerTest.php b/tests/Unit/Admin/IngredientControllerTest.php index 121b99b..cb7a23c 100644 --- a/tests/Unit/Admin/IngredientControllerTest.php +++ b/tests/Unit/Admin/IngredientControllerTest.php @@ -425,7 +425,7 @@ final class IngredientControllerTest extends TestCase $response = $this->controller($this->get('/admin/ingredients/5/movements'), $db)->movements(['id' => '5']); self::assertSame(200, $response->status()); - self::assertStringContainsString('Acteur', $response->body()); + self::assertStringContainsString('Auteur', $response->body()); self::assertStringContainsString('Sam K', $response->body()); // nom resolu } @@ -438,6 +438,6 @@ final class IngredientControllerTest extends TestCase $response = $this->controller($this->get('/admin/ingredients/5/movements'), $db)->movements(['id' => '5']); self::assertSame(200, $response->status()); - self::assertStringNotContainsString('Acteur', $response->body()); // colonne masquee (RG-4) + self::assertStringNotContainsString('Auteur', $response->body()); // colonne masquee (RG-4) } } -- 2.45.3 From 29a191e50653342360ca6c5c2b99892fee8374c6 Mon Sep 17 00:00:00 2001 From: Corentin JOGUET Date: Thu, 18 Jun 2026 14:09:35 +0200 Subject: [PATCH 55/93] feat(api): P4 chunk 1a - creation de commande + chevalet (#55) --- db/migrations/0003_order_service_tag.sql | 17 ++ src/app/Order/OrderRepository.php | 321 +++++++++++++++++++++ src/app/Order/OrderValidationException.php | 14 + tests/Unit/Order/OrderRepositoryTest.php | 212 ++++++++++++++ 4 files changed, 564 insertions(+) create mode 100644 db/migrations/0003_order_service_tag.sql create mode 100644 src/app/Order/OrderRepository.php create mode 100644 src/app/Order/OrderValidationException.php create mode 100644 tests/Unit/Order/OrderRepositoryTest.php diff --git a/db/migrations/0003_order_service_tag.sql b/db/migrations/0003_order_service_tag.sql new file mode 100644 index 0000000..13c5c98 --- /dev/null +++ b/db/migrations/0003_order_service_tag.sql @@ -0,0 +1,17 @@ +-- db/migrations/0003_order_service_tag.sql +-- ============================================================================= +-- Wakdo - Migration 0003 : service_tag (numero de chevalet) sur customer_order +-- ============================================================================= +-- Purpose : numero de chevalet pour le service EN SALLE (mode dine_in / sur place). +-- Saisi a la borne quand le client choisit "sur place" ; permet au +-- service d'apporter la commande a la bonne table (B4). NULL pour +-- takeaway / drive. Colonne additive nullable (aucune donnee existante +-- a retro-remplir). Le runner applique *.sql dans l'ordre lexicographique +-- via schema_migrations. +-- Target : MariaDB 11.4 LTS, InnoDB, utf8mb4 / utf8mb4_unicode_ci. +-- ============================================================================= + +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +ALTER TABLE customer_order + ADD COLUMN service_tag VARCHAR(20) NULL AFTER service_mode; diff --git a/src/app/Order/OrderRepository.php b/src/app/Order/OrderRepository.php new file mode 100644 index 0000000..6167b42 --- /dev/null +++ b/src/app/Order/OrderRepository.php @@ -0,0 +1,321 @@ +db->fetch( + 'SELECT id, order_number, total_ttc_cents, status FROM customer_order WHERE idempotency_key = :k', + ['k' => $key], + ); + if ($row === null) { + return null; + } + + return [ + 'id' => (int) $row['id'], + 'order_number' => (string) $row['order_number'], + 'total_ttc_cents' => (int) $row['total_ttc_cents'], + 'status' => (string) $row['status'], + ]; + } + + /** + * Cree une commande borne en pending_payment. Idempotent sur idempotency_key. + * + * @param array{idempotency_key?:string, service_mode:string, service_tag?:?string, items:list>} $req + * @return array{id:int, order_number:string, total_ttc_cents:int, status:string} + * @throws OrderValidationException si une reference est invalide / indisponible. + */ + public function createPending(array $req): array + { + $key = trim((string) ($req['idempotency_key'] ?? '')); + $existing = $this->findByIdempotencyKey($key); + if ($existing !== null) { + return $existing; + } + + $serviceMode = (string) ($req['service_mode'] ?? ''); + if (!in_array($serviceMode, ['dine_in', 'takeaway', 'drive'], true)) { + throw new OrderValidationException('INVALID_SERVICE_MODE'); + } + $serviceTag = $serviceMode === 'dine_in' ? trim((string) ($req['service_tag'] ?? '')) : ''; + if ($serviceTag !== '' && mb_strlen($serviceTag) > 20) { + throw new OrderValidationException('INVALID_SERVICE_TAG'); + } + + $items = isset($req['items']) && is_array($req['items']) ? $req['items'] : []; + if ($items === []) { + throw new OrderValidationException('EMPTY_ORDER'); + } + + // Resolution + calcul (lecture seule) AVANT la transaction d'ecriture. + $lines = array_map(fn (array $item): array => $this->resolveLine($item), $items); + + $totalTtc = 0; + $totalHt = 0; + foreach ($lines as $l) { + $totalTtc += $l['unit_ttc'] * $l['quantity']; + $totalHt += $l['unit_ht'] * $l['quantity']; + } + $totalVat = $totalTtc - $totalHt; + if ($totalTtc <= 0) { + throw new OrderValidationException('EMPTY_ORDER'); + } + + $result = ['id' => 0, 'order_number' => '', 'total_ttc_cents' => $totalTtc, 'status' => 'pending_payment']; + + $this->db->transaction(function (DatabaseInterface $db) use ($key, $serviceMode, $serviceTag, $lines, $totalTtc, $totalHt, $totalVat, &$result): void { + $db->execute( + 'INSERT INTO customer_order ' + . '(order_number, idempotency_key, source, service_mode, service_tag, status, ' + . ' total_ht_cents, total_vat_cents, total_ttc_cents) ' + . "VALUES ('', :idem, 'kiosk', :mode, :tag, 'pending_payment', :ht, :vat, :ttc)", + [ + 'idem' => $key !== '' ? $key : null, + 'mode' => $serviceMode, + 'tag' => $serviceTag !== '' ? $serviceTag : null, + 'ht' => $totalHt, + 'vat' => $totalVat, + 'ttc' => $totalTtc, + ], + ); + $orderId = (int) ($db->fetch('SELECT LAST_INSERT_ID() AS id')['id'] ?? 0); + $orderNumber = 'K' . $orderId; + $db->execute( + 'UPDATE customer_order SET order_number = :num WHERE id = :id', + ['num' => $orderNumber, 'id' => $orderId], + ); + + foreach ($lines as $l) { + $db->execute( + 'INSERT INTO order_item ' + . '(order_id, item_type, product_id, menu_id, format, label_snapshot, ' + . ' unit_price_cents_snapshot, vat_rate_snapshot, quantity) ' + . 'VALUES (:oid, :type, :pid, :mid, :fmt, :label, :price, :vat, :qty)', + [ + 'oid' => $orderId, + 'type' => $l['item_type'], + 'pid' => $l['product_id'], + 'mid' => $l['menu_id'], + 'fmt' => $l['format'], + 'label' => $l['label'], + 'price' => $l['unit_ttc'], + 'vat' => $l['vat_rate'], + 'qty' => $l['quantity'], + ], + ); + $itemId = (int) ($db->fetch('SELECT LAST_INSERT_ID() AS id')['id'] ?? 0); + + foreach ($l['selections'] as $sel) { + $db->execute( + 'INSERT INTO order_item_selection (order_item_id, menu_slot_id, product_id, label_snapshot) ' + . 'VALUES (:oiid, :slot, :pid, :label)', + ['oiid' => $itemId, 'slot' => $sel['menu_slot_id'], 'pid' => $sel['product_id'], 'label' => $sel['label']], + ); + } + foreach ($l['modifiers'] as $mod) { + $db->execute( + 'INSERT INTO order_item_modifier (order_item_id, ingredient_id, action, extra_price_cents) ' + . 'VALUES (:oiid, :ing, :act, :extra)', + ['oiid' => $itemId, 'ing' => $mod['ingredient_id'], 'act' => $mod['action'], 'extra' => $mod['extra_price_cents']], + ); + } + } + + $result['id'] = $orderId; + $result['order_number'] = $orderNumber; + }); + + return $result; + } + + /** + * Resout une ligne (produit ou menu) : lit le catalogue, valide, calcule le prix. + * + * @param array $item + * @return array{item_type:string, product_id:?int, menu_id:?int, format:string, label:string, unit_ttc:int, unit_ht:int, vat_rate:int, quantity:int, selections:list, modifiers:list} + */ + private function resolveLine(array $item): array + { + $type = (string) ($item['type'] ?? ''); + $quantity = max(1, (int) ($item['quantity'] ?? 1)); + $format = ($item['format'] ?? 'normal') === 'maxi' ? 'maxi' : 'normal'; + + if ($type === 'product') { + $product = $this->products->find((int) ($item['product_id'] ?? 0)); + if ($product === null || (int) ($product['is_available'] ?? 0) !== 1) { + throw new OrderValidationException('PRODUCT_UNAVAILABLE'); + } + $unitBase = (int) $product['price_cents']; + $vat = (int) $product['vat_rate']; + $modifiers = $this->resolveModifiers($item, (int) $product['id']); + $unitTtc = $unitBase + $this->modifiersExtra($modifiers); + + return $this->line('product', (int) $product['id'], null, 'normal', (string) $product['name'], $unitTtc, $vat, $quantity, [], $modifiers); + } + + if ($type === 'menu') { + $menu = $this->menus->find((int) ($item['menu_id'] ?? 0)); + if ($menu === null || (int) ($menu['is_available'] ?? 0) !== 1) { + throw new OrderValidationException('MENU_UNAVAILABLE'); + } + $burger = $this->products->find((int) $menu['burger_product_id']); + $vat = $burger !== null ? (int) $burger['vat_rate'] : 100; + $unitBase = $format === 'maxi' ? (int) $menu['price_maxi_cents'] : (int) $menu['price_normal_cents']; + $selections = $this->resolveSelections($item, (int) $menu['id']); + $modifiers = $this->resolveModifiers($item, (int) $menu['burger_product_id']); + $unitTtc = $unitBase + $this->modifiersExtra($modifiers); + + return $this->line('menu', null, (int) $menu['id'], $format, (string) $menu['name'], $unitTtc, $vat, $quantity, $selections, $modifiers); + } + + throw new OrderValidationException('INVALID_ITEM_TYPE'); + } + + /** + * @param list $modifiers + */ + private function modifiersExtra(array $modifiers): int + { + $extra = 0; + foreach ($modifiers as $m) { + if ($m['action'] === 'add') { + $extra += $m['extra_price_cents']; + } + } + + return $extra; + } + + /** + * @param array $item + * @return list + */ + private function resolveSelections(array $item, int $menuId): array + { + $slots = $this->menus->slotsWithOptions($menuId); + /** @var array> $optionsBySlot */ + $optionsBySlot = []; + foreach ($slots as $s) { + $optionsBySlot[(int) $s['id']] = array_map('intval', $s['option_product_ids']); + } + + $out = []; + $raw = isset($item['selections']) && is_array($item['selections']) ? $item['selections'] : []; + foreach ($raw as $sel) { + $slotId = (int) ($sel['menu_slot_id'] ?? 0); + $pid = (int) ($sel['product_id'] ?? 0); + if (!isset($optionsBySlot[$slotId]) || !in_array($pid, $optionsBySlot[$slotId], true)) { + throw new OrderValidationException('INVALID_SELECTION'); + } + $product = $this->products->find($pid); + $out[] = ['menu_slot_id' => $slotId, 'product_id' => $pid, 'label' => $product !== null ? (string) $product['name'] : '']; + } + + return $out; + } + + /** + * @param array $item + * @return list + */ + private function resolveModifiers(array $item, int $productId): array + { + $raw = isset($item['modifiers']) && is_array($item['modifiers']) ? $item['modifiers'] : []; + if ($raw === []) { + return []; + } + // Recette du produit support : valide l'ingredient + figes l'extra_price (add). + $recipe = []; + foreach ($this->products->composition($productId) as $ing) { + $recipe[(int) $ing['ingredient_id']] = $ing; + } + + $out = []; + foreach ($raw as $mod) { + $ingId = (int) ($mod['ingredient_id'] ?? 0); + $action = ($mod['action'] ?? '') === 'add' ? 'add' : 'remove'; + if (!isset($recipe[$ingId])) { + throw new OrderValidationException('INVALID_MODIFIER'); + } + $row = $recipe[$ingId]; + if ($action === 'remove' && (int) ($row['is_removable'] ?? 0) !== 1) { + throw new OrderValidationException('INGREDIENT_NOT_REMOVABLE'); + } + if ($action === 'add' && (int) ($row['is_addable'] ?? 0) !== 1) { + throw new OrderValidationException('INGREDIENT_NOT_ADDABLE'); + } + $out[] = [ + 'ingredient_id' => $ingId, + 'action' => $action, + 'extra_price_cents' => $action === 'add' ? (int) ($row['extra_price_cents'] ?? 0) : 0, + ]; + } + + return $out; + } + + /** + * @param list $selections + * @param list $modifiers + * @return array{item_type:string, product_id:?int, menu_id:?int, format:string, label:string, unit_ttc:int, unit_ht:int, vat_rate:int, quantity:int, selections:list, modifiers:list} + */ + private function line(string $type, ?int $productId, ?int $menuId, string $format, string $label, int $unitTtc, int $vat, int $quantity, array $selections, array $modifiers): array + { + $unitHt = (int) round($unitTtc * 1000 / (1000 + $vat)); + + return [ + 'item_type' => $type, + 'product_id' => $productId, + 'menu_id' => $menuId, + 'format' => $format, + 'label' => $label, + 'unit_ttc' => $unitTtc, + 'unit_ht' => $unitHt, + 'vat_rate' => $vat, + 'quantity' => $quantity, + 'selections' => $selections, + 'modifiers' => $modifiers, + ]; + } +} diff --git a/src/app/Order/OrderValidationException.php b/src/app/Order/OrderValidationException.php new file mode 100644 index 0000000..e216893 --- /dev/null +++ b/src/app/Order/OrderValidationException.php @@ -0,0 +1,14 @@ +getMessage()`) sert de + * code d'erreur API ; le controleur le traduit en reponse HTTP 422. + */ +final class OrderValidationException extends \RuntimeException +{ +} diff --git a/tests/Unit/Order/OrderRepositoryTest.php b/tests/Unit/Order/OrderRepositoryTest.php new file mode 100644 index 0000000..253f4ed --- /dev/null +++ b/tests/Unit/Order/OrderRepositoryTest.php @@ -0,0 +1,212 @@ +}> */ + public array $writes = []; + /** @var array> */ + public array $products = []; + /** @var array> */ + public array $menus = []; + /** @var array>> */ + public array $slotRows = []; + /** @var array>> */ + public array $compositions = []; + /** @var array|null */ + public ?array $existingByKey = null; + private int $autoId = 99; + + public function fetch(string $sql, array $params = []): ?array + { + if (str_contains($sql, 'LAST_INSERT_ID')) { + return ['id' => $this->autoId]; + } + if (str_contains($sql, 'FROM customer_order WHERE idempotency_key')) { + return $this->existingByKey; + } + if (str_contains($sql, 'FROM product WHERE id = :id')) { + return $this->products[(int) $params['id']] ?? null; + } + if (str_contains($sql, 'FROM menu WHERE id = :id')) { + return $this->menus[(int) $params['id']] ?? null; + } + + return null; + } + + public function fetchAll(string $sql, array $params = []): array + { + if (str_contains($sql, 'FROM menu_slot s')) { + return $this->slotRows[(int) $params['id']] ?? []; + } + if (str_contains($sql, 'FROM product_ingredient pi')) { + return $this->compositions[(int) $params['id']] ?? []; + } + + return []; + } + + public function execute(string $sql, array $params = []): int + { + if (str_contains($sql, 'INSERT INTO customer_order') || str_contains($sql, 'INSERT INTO order_item ')) { + $this->autoId++; + } + $this->writes[] = ['sql' => $sql, 'params' => $params]; + + return 1; + } + + public function transaction(callable $fn): void + { + $fn($this); + } + + /** @return array */ + public function firstWrite(string $needle): array + { + foreach ($this->writes as $w) { + if (str_contains($w['sql'], $needle)) { + return $w['params']; + } + } + + return []; + } + + public function countWrites(string $needle): int + { + return count(array_filter($this->writes, static fn (array $w): bool => str_contains($w['sql'], $needle))); + } +} + +final class OrderRepositoryTest extends TestCase +{ + private function repo(OrderFakeDb $db): OrderRepository + { + return new OrderRepository($db, new ProductRepository($db), new MenuRepository($db)); + } + + public function testProductOrderComputesLineVatAndKId(): void + { + $db = new OrderFakeDb(); + $db->products[12] = ['id' => 12, 'name' => 'Cheeseburger', 'price_cents' => 890, 'vat_rate' => 100, 'is_available' => 1]; + + $res = $this->repo($db)->createPending([ + 'idempotency_key' => 'abc', + 'service_mode' => 'takeaway', + 'items' => [['type' => 'product', 'product_id' => 12, 'quantity' => 1]], + ]); + + // 890 TTC a 10% -> HT = round(890*1000/1100) = 809, TVA = 81. + $order = $db->firstWrite('INSERT INTO customer_order'); + self::assertSame(890, $order['ttc']); + self::assertSame(809, $order['ht']); + self::assertSame(81, $order['vat']); + self::assertSame('K100', $res['order_number']); + self::assertSame('pending_payment', $res['status']); + self::assertSame(890, $res['total_ttc_cents']); + + $item = $db->firstWrite('INSERT INTO order_item '); + self::assertSame('Cheeseburger', $item['label']); + self::assertSame(890, $item['price']); + self::assertSame(100, $item['vat']); + } + + public function testMenuMaxiUsesBurgerVatAndMaxiPrice(): void + { + $db = new OrderFakeDb(); + $db->menus[5] = ['id' => 5, 'burger_product_id' => 12, 'name' => 'Menu Best Of', 'price_normal_cents' => 990, 'price_maxi_cents' => 1200, 'is_available' => 1]; + $db->products[12] = ['id' => 12, 'name' => 'Burger', 'price_cents' => 600, 'vat_rate' => 100, 'is_available' => 1]; + $db->products[20] = ['id' => 20, 'name' => 'Coca', 'price_cents' => 250, 'vat_rate' => 100, 'is_available' => 1]; + $db->slotRows[5] = [['id' => 7, 'name' => 'Boisson', 'slot_type' => 'drink', 'is_required' => 1, 'display_order' => 0, 'product_id' => 20]]; + + $res = $this->repo($db)->createPending([ + 'service_mode' => 'dine_in', + 'service_tag' => '42', + 'items' => [['type' => 'menu', 'menu_id' => 5, 'quantity' => 1, 'format' => 'maxi', + 'selections' => [['menu_slot_id' => 7, 'product_id' => 20]]]], + ]); + + // 1200 TTC a 10% -> HT = round(1200*1000/1100) = 1091, TVA = 109. + $order = $db->firstWrite('INSERT INTO customer_order'); + self::assertSame(1200, $order['ttc']); + self::assertSame(1091, $order['ht']); + self::assertSame('42', $order['tag']); + $item = $db->firstWrite('INSERT INTO order_item '); + self::assertSame('maxi', $item['fmt']); + self::assertSame(1200, $item['price']); + self::assertSame(1, $db->countWrites('INSERT INTO order_item_selection')); + } + + public function testAddModifierAddsExtraToLine(): void + { + $db = new OrderFakeDb(); + $db->products[12] = ['id' => 12, 'name' => 'Burger', 'price_cents' => 890, 'vat_rate' => 100, 'is_available' => 1]; + $db->compositions[12] = [['ingredient_id' => 3, 'is_removable' => 1, 'is_addable' => 1, 'extra_price_cents' => 50, 'quantity_normal' => 1, 'quantity_maxi' => 1]]; + + $res = $this->repo($db)->createPending([ + 'service_mode' => 'takeaway', + 'items' => [['type' => 'product', 'product_id' => 12, 'quantity' => 1, + 'modifiers' => [['ingredient_id' => 3, 'action' => 'add']]]], + ]); + + self::assertSame(940, $res['total_ttc_cents']); // 890 + 50 + self::assertSame(1, $db->countWrites('INSERT INTO order_item_modifier')); + } + + public function testIdempotentReturnsExistingWithoutInsert(): void + { + $db = new OrderFakeDb(); + $db->existingByKey = ['id' => 7, 'order_number' => 'K7', 'total_ttc_cents' => 500, 'status' => 'pending_payment']; + + $res = $this->repo($db)->createPending([ + 'idempotency_key' => 'dup', + 'service_mode' => 'takeaway', + 'items' => [['type' => 'product', 'product_id' => 12, 'quantity' => 1]], + ]); + + self::assertSame('K7', $res['order_number']); + self::assertSame(0, $db->countWrites('INSERT INTO customer_order')); + } + + public function testRejectsUnknownProduct(): void + { + $db = new OrderFakeDb(); + $this->expectException(OrderValidationException::class); + $this->repo($db)->createPending([ + 'service_mode' => 'takeaway', + 'items' => [['type' => 'product', 'product_id' => 999, 'quantity' => 1]], + ]); + } + + public function testRejectsSelectionOutsideSlotOptions(): void + { + $db = new OrderFakeDb(); + $db->menus[5] = ['id' => 5, 'burger_product_id' => 12, 'name' => 'Menu', 'price_normal_cents' => 990, 'price_maxi_cents' => 1200, 'is_available' => 1]; + $db->products[12] = ['id' => 12, 'name' => 'Burger', 'price_cents' => 600, 'vat_rate' => 100, 'is_available' => 1]; + $db->slotRows[5] = [['id' => 7, 'name' => 'Boisson', 'slot_type' => 'drink', 'is_required' => 1, 'display_order' => 0, 'product_id' => 20]]; + + $this->expectException(OrderValidationException::class); + $this->repo($db)->createPending([ + 'service_mode' => 'takeaway', + 'items' => [['type' => 'menu', 'menu_id' => 5, 'quantity' => 1, 'format' => 'normal', + 'selections' => [['menu_slot_id' => 7, 'product_id' => 999]]]], // 999 hors options + ]); + } +} -- 2.45.3 From a6ac3d6421d7b4f838855202668de8568b1b00ec Mon Sep 17 00:00:00 2001 From: Corentin JOGUET Date: Thu, 18 Jun 2026 14:24:34 +0200 Subject: [PATCH 56/93] fix(admin): logo reel dans la sidebar back-office (#56) --- src/app/Views/admin/layout.php | 4 +--- src/public/admin/assets/css/admin.css | 12 +++--------- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/src/app/Views/admin/layout.php b/src/app/Views/admin/layout.php index 7e69f88..b52d52b 100644 --- a/src/app/Views/admin/layout.php +++ b/src/app/Views/admin/layout.php @@ -84,9 +84,7 @@ $navClass = static function (string $code, string $current): string {
+ + diff --git a/tests/js/nav.test.js b/tests/js/nav.test.js new file mode 100644 index 0000000..eaca4df --- /dev/null +++ b/tests/js/nav.test.js @@ -0,0 +1,53 @@ +/* + * nav.test.js — Garde du mode de consommation borne (helpers PURS de nav.js). + * + * nav.js n'enregistre son listener DOMContentLoaded que derriere `typeof document`, + * donc l'import est sans effet de bord en node pur : on teste needsModeRedirect et + * modeLabel sans jsdom. Couvre la cause du 422 INVALID_SERVICE_MODE : atteindre une + * page de commande sans mode memorise (localStorage vide) doit renvoyer a l'accueil, + * et le badge ne doit jamais afficher un faux "Sur place" quand aucun mode n'est choisi. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { needsModeRedirect, modeLabel } from '../../src/public/borne/assets/js/nav.js'; + +/* --- modeLabel ----------------------------------------------------------- */ + +test('modeLabel: libelle humain ; vide si mode absent ou inconnu (ne ment pas)', () => { + assert.equal(modeLabel('sur-place'), 'Sur place'); + assert.equal(modeLabel('a-emporter'), 'A emporter'); + assert.equal(modeLabel(null), ''); + assert.equal(modeLabel(undefined), ''); + assert.equal(modeLabel('bidon'), ''); +}); + +/* --- needsModeRedirect --------------------------------------------------- */ + +test('needsModeRedirect: page profonde SANS mode valide -> redirige', () => { + assert.equal(needsModeRedirect('/payment.html', null), true); + assert.equal(needsModeRedirect('/products.html', undefined), true); + assert.equal(needsModeRedirect('/cart.html', 'bidon'), true); + assert.equal(needsModeRedirect('/categories.html', ''), true); +}); + +test('needsModeRedirect: mode valide -> pas de redirection', () => { + assert.equal(needsModeRedirect('/payment.html', 'sur-place'), false); + assert.equal(needsModeRedirect('/products.html', 'a-emporter'), false); +}); + +test('needsModeRedirect: ecran d accueil jamais redirige (le mode s y choisit)', () => { + assert.equal(needsModeRedirect('/', null), false); + assert.equal(needsModeRedirect('/index.html', null), false); + assert.equal(needsModeRedirect('/index.html', 'bidon'), false); +}); + +/* --- Cablage de la chaine de persistance (regression) -------------------- */ + +test('categories.html charge nav.js (sinon le mode recu en ?mode= n est jamais persiste)', () => { + // 1re page post-accueil : elle recoit ?mode= depuis index.html et DOIT le persister + // via syncModeFromURL. Sans nav.js ici, le mode n atteint pas localStorage et la + // garde renverrait en boucle un utilisateur legitime vers l accueil (regression revue). + const html = readFileSync(new URL('../../src/public/borne/categories.html', import.meta.url), 'utf8'); + assert.match(html, /assets\/js\/nav\.js/); +}); -- 2.45.3 From 82adc77879f6702705df0fac0de14f2945d41e9c Mon Sep 17 00:00:00 2001 From: Corentin JOGUET Date: Mon, 22 Jun 2026 16:23:39 +0200 Subject: [PATCH 91/93] fix(borne): affiche la Grande en menu Maxi (accompagnement) (#90) --- src/app/Catalogue/ProductRepository.php | 13 +++++- src/app/Controllers/CatalogueController.php | 6 ++- src/public/borne/assets/js/data.js | 3 ++ src/public/borne/assets/js/order-panel.js | 7 ++- src/public/borne/assets/js/page-cart.js | 12 +++-- .../borne/assets/js/page-product-menu.js | 35 +++++++++++--- .../Catalogue/CatalogueControllerTest.php | 29 +++++++++++- tests/js/composer-slots.test.js | 46 +++++++++++++++---- tests/js/data.test.js | 12 ++++- tests/js/order-panel.test.js | 17 +++++-- 10 files changed, 149 insertions(+), 31 deletions(-) diff --git a/src/app/Catalogue/ProductRepository.php b/src/app/Catalogue/ProductRepository.php index 0fabfd0..98a46de 100644 --- a/src/app/Catalogue/ProductRepository.php +++ b/src/app/Catalogue/ProductRepository.php @@ -75,10 +75,16 @@ final class ProductRepository */ public function availableForCatalogue(): array { + // mv.name (LEFT JOIN sur la variante Maxi) : la borne affiche ce nom quand le + // menu est commande en Maxi, sans refaire un aller-retour pour resoudre la + // variante. NULL si le produit n'a pas de variante Maxi. La SUBSTITUTION reelle + // a la commande reste serveur (OrderRepository::resolveSelections) ; ici c'est + // un libelle d'affichage seulement. return $this->db->fetchAll( 'SELECT p.id, p.category_id, p.name, p.description, p.price_cents, p.size_cl, ' - . 'p.image_path, p.display_order ' + . 'p.image_path, p.display_order, mv.name AS maxi_variant_name ' . 'FROM product p JOIN category c ON c.id = p.category_id ' + . 'LEFT JOIN product mv ON mv.id = p.maxi_variant_product_id ' . 'WHERE p.is_available = 1 AND c.is_active = 1 AND p.base_product_id IS NULL ' . 'ORDER BY p.display_order, p.name', ); @@ -158,10 +164,13 @@ final class ProductRepository */ public function findForCatalogue(int $id): ?array { + // Meme projection (et meme LEFT JOIN variante Maxi) que la liste : la borne + // recoit maxi_variant_name aussi par lien direct (NULL si pas de variante). return $this->db->fetch( 'SELECT p.id, p.category_id, p.name, p.description, p.price_cents, ' - . 'p.image_path, p.display_order ' + . 'p.image_path, p.display_order, mv.name AS maxi_variant_name ' . 'FROM product p JOIN category c ON c.id = p.category_id ' + . 'LEFT JOIN product mv ON mv.id = p.maxi_variant_product_id ' . 'WHERE p.id = :id AND p.is_available = 1 AND c.is_active = 1 AND p.base_product_id IS NULL', ['id' => $id], ); diff --git a/src/app/Controllers/CatalogueController.php b/src/app/Controllers/CatalogueController.php index 14e9ac7..c549cf7 100644 --- a/src/app/Controllers/CatalogueController.php +++ b/src/app/Controllers/CatalogueController.php @@ -200,7 +200,7 @@ class CatalogueController extends Controller * variantes ; vide si le produit n'a pas de dimension taille. Chaque entree * devient {product_id, size_cl, price_cents, label} ; le label humain est * derive du volume ("30 cl") -- aucun slug/enum ne fuit a l'ecran. - * @return array{id: int, category_id: int, name: string, description: ?string, price_cents: int, image_path: ?string, display_order: int, sizes: list} + * @return array{id: int, category_id: int, name: string, description: ?string, price_cents: int, image_path: ?string, display_order: int, maxi_variant_name: ?string, sizes: list} */ private function presentProduct(array $row, array $sizes = []): array { @@ -212,6 +212,10 @@ class CatalogueController extends Controller 'price_cents' => (int) ($row['price_cents'] ?? 0), 'image_path' => $this->nullableString($row['image_path'] ?? null), 'display_order' => (int) ($row['display_order'] ?? 0), + // Nom de la variante Maxi de l'accompagnement (ex. "Grande Frite") ; NULL si + // le produit n'a pas de variante. La borne l'affiche en format Maxi pour ne + // pas montrer "Moyenne Frite" sur un menu agrandi. + 'maxi_variant_name' => $this->nullableString($row['maxi_variant_name'] ?? null), 'sizes' => array_map( static function (array $size): array { $cl = (int) ($size['size_cl'] ?? 0); diff --git a/src/public/borne/assets/js/data.js b/src/public/borne/assets/js/data.js index 3145883..741003b 100644 --- a/src/public/borne/assets/js/data.js +++ b/src/public/borne/assets/js/data.js @@ -85,6 +85,9 @@ export function loadProducts() { // borne ne montre un picker que si sizes a plus d'une entree. bySlug[slug].push({ id: p.id, nom: p.name, prix: p.price_cents, image: p.image_path, type: 'produit', + // maxiNom : nom de la variante Maxi (ex. "Grande Frite") quand le produit + // en a une, sinon null. Le composeur de menu l'affiche en format Maxi. + maxiNom: p.maxi_variant_name ?? null, sizes: Array.isArray(p.sizes) ? p.sizes : [], }); } diff --git a/src/public/borne/assets/js/order-panel.js b/src/public/borne/assets/js/order-panel.js index b0726e5..ed57f00 100644 --- a/src/public/borne/assets/js/order-panel.js +++ b/src/public/borne/assets/js/order-panel.js @@ -49,11 +49,14 @@ export function compositionLabels(c) { : ''; out.push(`${c.burger.libelle}${opts}`); } + // libelle fait foi : en Maxi l'accompagnement porte deja sa variante par nom + // ("Grande Frite"). Plus de suffixe " grande" -- il doublait le nom ("Grande Frite + // grande") et mentait pour la boisson (le menu Maxi ne l'agrandit pas). if (c.accompagnement) { - out.push(`${c.accompagnement.libelle}${c.accompagnement.taille === 'G' ? ' grande' : ''}`); + out.push(c.accompagnement.libelle); } if (c.boisson) { - out.push(`${c.boisson.libelle}${c.boisson.taille === 'G' ? ' grande' : ''}`); + out.push(c.boisson.libelle); } if (c.sauce) { out.push(c.sauce.libelle); diff --git a/src/public/borne/assets/js/page-cart.js b/src/public/borne/assets/js/page-cart.js index 1f010d1..505e633 100644 --- a/src/public/borne/assets/js/page-cart.js +++ b/src/public/borne/assets/js/page-cart.js @@ -141,8 +141,9 @@ function renderCart() { /** * Builds the composition breakdown HTML for a menu cart line. - * Renders burger (with personalisation options), accompagnement with taille, - * boisson with taille, sauce, and the supplement summary if applicable. + * Renders burger (with personalisation options), accompagnement, boisson, sauce, + * and the supplement summary if applicable. Le format Maxi se lit dans le libelle de + * l'accompagnement (variante "Grande ...") et la ligne de supplement, pas un suffixe. * * @param {Object} item — cart item with type === 'menu' and composition object * @returns {string} HTML string @@ -160,11 +161,14 @@ function renderCompositionBlock(item) { : ''; parts.push(`${escHtml(c.burger.libelle)}${burgerOpts}`); } + // libelle fait foi : en Maxi l'accompagnement porte deja sa variante par nom + // ("Grande Frite"). Plus de suffixe taille -- il doublait le nom ("Grande Frite + // grande") et "normale"/"grande" mentait pour la boisson (le Maxi ne l'agrandit pas). if (c.accompagnement) { - parts.push(`${escHtml(c.accompagnement.libelle)}${c.accompagnement.taille === 'G' ? ' grande' : ' normale'}`); + parts.push(escHtml(c.accompagnement.libelle)); } if (c.boisson) { - parts.push(`${escHtml(c.boisson.libelle)}${c.boisson.taille === 'G' ? ' grande' : ' normale'}`); + parts.push(escHtml(c.boisson.libelle)); } if (c.sauce) { parts.push(escHtml(c.sauce.libelle)); diff --git a/src/public/borne/assets/js/page-product-menu.js b/src/public/borne/assets/js/page-product-menu.js index 624188f..d395fa0 100644 --- a/src/public/borne/assets/js/page-product-menu.js +++ b/src/public/borne/assets/js/page-product-menu.js @@ -26,6 +26,20 @@ import { refreshCartBadge } from './nav.js'; /* slot_type de l'API -> champ de composition attendu par le rendu panier existant. */ const SLOT_FIELD = { side: 'accompagnement', drink: 'boisson', sauce: 'sauce' }; +/** + * Libelle a afficher pour une option selon le format. En Maxi ('M'), un + * accompagnement a une variante agrandie (maxiNom, ex. "Grande Frite") : c'est ce + * nom que le client doit voir au moment de CHOISIR, pas le "Moyenne Frite" de base. + * Sans maxiNom (ex. les boissons, que le menu Maxi n'agrandit pas) ou en Normal, + * on garde le nom de base. Pur. + * @param {Object} option — produit borne {nom, maxiNom?} + * @param {'N'|'M'} size + * @returns {string} + */ +export function optionLabel(option, size) { + return (size === 'M' && option.maxiNom) ? option.maxiNom : option.nom; +} + /* ------------------------------------------------------------------ */ /* Fonctions PURES (cible des tests, sans DOM ni fetch) */ /* ------------------------------------------------------------------ */ @@ -89,9 +103,13 @@ export function buildMenuCartItem(menu, model, { size, selections }) { if (!chosen) continue; // slot optionnel laisse "sans" const field = SLOT_FIELD[slot.slotType]; if (!field) continue; + // libelle PORTE le nom affiche : en Maxi, l'accompagnement prend sa variante + // ("Grande Frite") ; la boisson n'a pas de maxiNom (le menu Maxi ne l'agrandit + // pas) donc garde son nom de base. Plus de suffixe " grande" cote rendu. + const libelle = (isMaxi && chosen.maxiNom) ? chosen.maxiNom : chosen.nom; composition[field] = field === 'sauce' ? { id: chosen.id, libelle: chosen.nom } - : { id: chosen.id, libelle: chosen.nom, taille }; + : { id: chosen.id, libelle, taille }; } return { @@ -294,18 +312,23 @@ function renderSlotStep(body, footer, modal, state, slot) { Sans ` : ''} - ${slot.options.map(o => ` + ${slot.options.map(o => { + // En Maxi, l'accompagnement s'affiche sous sa variante agrandie + // ("Grande Frite") : le client choisit en connaissance de cause. + const label = optionLabel(o, state.size); + return `
  • - `).join('')} + `; + }).join('')} `; body.querySelectorAll('#slot-grid .composer-card').forEach(btn => { diff --git a/tests/Unit/Catalogue/CatalogueControllerTest.php b/tests/Unit/Catalogue/CatalogueControllerTest.php index 1b7d8c7..954c0df 100644 --- a/tests/Unit/Catalogue/CatalogueControllerTest.php +++ b/tests/Unit/Catalogue/CatalogueControllerTest.php @@ -102,6 +102,8 @@ final class CatalogueControllerTest extends TestCase 'id' => '12', 'category_id' => '3', 'name' => 'Cheeseburger', 'description' => 'Pain, steak, cheddar', 'price_cents' => '890', 'vat_rate' => '100', 'image_path' => 'cheese.png', 'display_order' => '1', + // LEFT JOIN variante Maxi : NULL pour un produit sans variante. + 'maxi_variant_name' => null, ], ]; @@ -113,7 +115,7 @@ final class CatalogueControllerTest extends TestCase $product = $payload['data'][0]; self::assertSame( - ['id', 'category_id', 'name', 'description', 'price_cents', 'image_path', 'display_order', 'sizes'], + ['id', 'category_id', 'name', 'description', 'price_cents', 'image_path', 'display_order', 'maxi_variant_name', 'sizes'], array_keys($product), ); self::assertSame(12, $product['id']); @@ -121,9 +123,31 @@ final class CatalogueControllerTest extends TestCase self::assertSame(890, $product['price_cents']); // chaine -> int self::assertArrayNotHasKey('vat_rate', $product); // fiscal interne, non expose self::assertArrayNotHasKey('is_available', $product); // toujours dispo ici -> non expose + self::assertNull($product['maxi_variant_name']); // pas de variante -> null self::assertSame([], $product['sizes']); // produit mono-taille -> sizes vide } + public function testProductsListExposesMaxiVariantName(): void + { + $db = new FakeCatalogueDatabase(); + // "Moyenne Frite" (accompagnement) a une variante Maxi "Grande Frite" : le + // LEFT JOIN remonte mv.name AS maxi_variant_name, expose tel quel a la borne. + $db->productsRows = [ + [ + 'id' => '23', 'category_id' => '4', 'name' => 'Moyenne Frite', + 'description' => null, 'price_cents' => '250', + 'image_path' => 'frite.png', 'display_order' => '1', + 'maxi_variant_name' => 'Grande Frite', + ], + ]; + + $response = $this->controller($db, '/api/products')->products(); + + self::assertSame(200, $response->status()); + $product = $this->decode($response->body())['data'][0]; + self::assertSame('Grande Frite', $product['maxi_variant_name']); + } + public function testProductsListPresentsSizesArrayForDrinkWithVariants(): void { $db = new FakeCatalogueDatabase(); @@ -205,6 +229,8 @@ final class CatalogueControllerTest extends TestCase 'id' => '12', 'category_id' => '3', 'name' => 'Cheeseburger', 'description' => null, 'price_cents' => '890', 'vat_rate' => '100', 'image_path' => null, 'display_order' => '1', + // Detail d'un accompagnement avec variante Maxi : le nom doit ressortir. + 'maxi_variant_name' => 'Grande Frite', ]; $response = $this->controller($db, '/api/products/12')->product(['id' => '12']); @@ -215,6 +241,7 @@ final class CatalogueControllerTest extends TestCase self::assertSame(12, $product['id']); self::assertSame(890, $product['price_cents']); self::assertNull($product['description']); + self::assertSame('Grande Frite', $product['maxi_variant_name']); // variante exposee self::assertArrayNotHasKey('vat_rate', $product); // L'id a bien ete lie a la lecture, converti en entier (le repo a recu :id = 12). self::assertSame(12, $db->reads[0]['params']['id'] ?? null); diff --git a/tests/js/composer-slots.test.js b/tests/js/composer-slots.test.js index 5f9df64..ad6c3c9 100644 --- a/tests/js/composer-slots.test.js +++ b/tests/js/composer-slots.test.js @@ -9,14 +9,14 @@ import { test, before } from 'node:test'; import assert from 'node:assert/strict'; import { JSDOM } from 'jsdom'; -let buildComposerSteps, buildMenuCartItem, selectionsComplete, composerIsViable; +let buildComposerSteps, buildMenuCartItem, selectionsComplete, composerIsViable, optionLabel; before(async () => { const dom = new JSDOM('', { url: 'https://kiosk.test/product.html' }); global.window = dom.window; global.document = dom.window.document; global.localStorage = dom.window.localStorage; - ({ buildComposerSteps, buildMenuCartItem, selectionsComplete, composerIsViable } = + ({ buildComposerSteps, buildMenuCartItem, selectionsComplete, composerIsViable, optionLabel } = await import('../../src/public/borne/assets/js/page-product-menu.js')); }); @@ -33,12 +33,14 @@ const detail = () => ({ }); const byId = () => ({ - 100: { id: 100, nom: 'Le 280', prix: 0, image: 'b.png', type: 'produit' }, - 22: { id: 22, nom: 'Frites', prix: 0, image: 'f.png', type: 'produit' }, - 23: { id: 23, nom: 'Potatoes', prix: 0, image: 'p.png', type: 'produit' }, - 14: { id: 14, nom: 'Coca', prix: 0, image: 'c.png', type: 'produit' }, - 15: { id: 15, nom: 'Eau', prix: 0, image: 'e.png', type: 'produit' }, - 47: { id: 47, nom: 'Ketchup', prix: 0, image: 'k.png', type: 'produit' }, + 100: { id: 100, nom: 'Le 280', prix: 0, image: 'b.png', type: 'produit', maxiNom: null }, + // Accompagnements : variante Maxi (maxiNom) renseignee -> agrandissable. + 22: { id: 22, nom: 'Moyenne Frite', prix: 0, image: 'f.png', type: 'produit', maxiNom: 'Grande Frite' }, + 23: { id: 23, nom: 'Potatoes', prix: 0, image: 'p.png', type: 'produit', maxiNom: 'Grande Potatoes' }, + // Boissons : pas de variante Maxi (le menu Maxi n'agrandit pas la boisson). + 14: { id: 14, nom: 'Coca', prix: 0, image: 'c.png', type: 'produit', maxiNom: null }, + 15: { id: 15, nom: 'Eau', prix: 0, image: 'e.png', type: 'produit', maxiNom: null }, + 47: { id: 47, nom: 'Ketchup', prix: 0, image: 'k.png', type: 'produit', maxiNom: null }, }); const menu = { id: 1, nom: 'Menu Le 280', image: 'b.png', type: 'menu' }; @@ -70,7 +72,8 @@ test('buildMenuCartItem Normal: prix normal, pas de supplement, taille N, compos assert.equal(item.prix_cents, 880); assert.equal(item.supplement_cents, 0); assert.equal(item.composition.burger.libelle, 'Le 280'); - assert.deepEqual(item.composition.accompagnement, { id: 22, libelle: 'Frites', taille: 'N' }); + // Normal : l'accompagnement garde son nom de base (pas la variante Maxi). + assert.deepEqual(item.composition.accompagnement, { id: 22, libelle: 'Moyenne Frite', taille: 'N' }); assert.deepEqual(item.composition.boisson, { id: 14, libelle: 'Coca', taille: 'N' }); assert.deepEqual(item.composition.sauce, { id: 47, libelle: 'Ketchup' }); }); @@ -84,6 +87,31 @@ test('buildMenuCartItem Maxi: supplement = maxi - normal, taille G sur side/drin assert.equal(item.composition.boisson.taille, 'G'); }); +test('buildMenuCartItem Maxi: l accompagnement prend sa variante (Grande Frite), pas le nom de base', () => { + const m = buildComposerSteps(detail(), byId()); + const item = buildMenuCartItem(menu, m, { size: 'M', selections: { 1: 14, 16: 22, 31: 47 } }); + assert.equal(item.composition.accompagnement.libelle, 'Grande Frite'); // pas "Moyenne Frite" + // Boisson sans maxiNom : garde son nom de base meme en Maxi (le Maxi ne l agrandit pas). + assert.equal(item.composition.boisson.libelle, 'Coca'); +}); + +test('buildMenuCartItem Normal: l accompagnement garde "Moyenne Frite" (pas de variante)', () => { + const m = buildComposerSteps(detail(), byId()); + const item = buildMenuCartItem(menu, m, { size: 'N', selections: { 1: 14, 16: 22, 31: 47 } }); + assert.equal(item.composition.accompagnement.libelle, 'Moyenne Frite'); +}); + +/* --- optionLabel (pur) : libelle affiche au CHOIX selon le format -------- */ + +test('optionLabel: Maxi affiche la variante quand elle existe, sinon le nom de base', () => { + const frite = { nom: 'Moyenne Frite', maxiNom: 'Grande Frite' }; + const coca = { nom: 'Coca', maxiNom: null }; + assert.equal(optionLabel(frite, 'M'), 'Grande Frite'); + assert.equal(optionLabel(frite, 'N'), 'Moyenne Frite'); + assert.equal(optionLabel(coca, 'M'), 'Coca'); // pas de variante -> nom de base + assert.equal(optionLabel(coca, 'N'), 'Coca'); +}); + test('buildMenuCartItem: slot optionnel non choisi -> champ absent de composition', () => { const m = buildComposerSteps(detail(), byId()); const item = buildMenuCartItem(menu, m, { size: 'N', selections: { 1: 14, 16: 22 } }); // pas de sauce diff --git a/tests/js/data.test.js b/tests/js/data.test.js index a50a4fc..325ee55 100644 --- a/tests/js/data.test.js +++ b/tests/js/data.test.js @@ -68,10 +68,20 @@ test('loadProducts groupe les produits par slug a la forme borne (type produit)' const data = await loadProducts(); assert.deepEqual(data.burgers, [ // sizes (R4) : tableau vide par defaut quand l'API n'en renvoie pas. - { id: 10, nom: 'Big Mac', prix: 600, image: 'assets/images/produits/burgers/bigmac.png', type: 'produit', sizes: [] }, + // maxiNom : null par defaut quand l'API n'envoie pas maxi_variant_name. + { id: 10, nom: 'Big Mac', prix: 600, image: 'assets/images/produits/burgers/bigmac.png', type: 'produit', maxiNom: null, sizes: [] }, ]); }); +test('loadProducts reporte maxi_variant_name -> maxiNom (variante Maxi de l accompagnement)', async () => { + const fx = fixtures(); + fx['/api/products'].data[0].maxi_variant_name = 'Grande Frite'; + const { loadProducts } = await freshData(fx); + + const data = await loadProducts(); + assert.equal(data.burgers[0].maxiNom, 'Grande Frite'); +}); + test('loadProducts reporte le tableau sizes du produit (R4) tel quel', async () => { const fx = fixtures(); fx['/api/products'].data[0].sizes = [ diff --git a/tests/js/order-panel.test.js b/tests/js/order-panel.test.js index d6bff38..c60217e 100644 --- a/tests/js/order-panel.test.js +++ b/tests/js/order-panel.test.js @@ -40,8 +40,10 @@ const menu = (over = {}) => ({ supplement_cents: 50, image: 'm.png', composition: { burger: { libelle: 'Big Mac', options: ['sans-oignon', 'avec-fromage'] }, - accompagnement: { libelle: 'Frites', taille: 'G' }, - boisson: { libelle: 'Coca', taille: 'M' }, + // Maxi : l accompagnement porte deja sa variante par NOM (le serveur substitue + // Moyenne -> Grande). Le libelle fait foi, plus de suffixe " grande". + accompagnement: { libelle: 'Grande Frite', taille: 'G' }, + boisson: { libelle: 'Coca', taille: 'G' }, sauce: { libelle: 'Ketchup' }, }, ...over, @@ -63,14 +65,19 @@ test('compositionLabels: undefined -> []', () => { assert.deepEqual(compositionLabels(undefined), []); }); -test('compositionLabels: liste burger(options)/accompagnement(taille)/boisson/sauce', () => { +test('compositionLabels: libelle fait foi, le suffixe " grande" trompeur est supprime', () => { const labels = compositionLabels(menu().composition); assert.deepEqual(labels, [ 'Big Mac (sans oignon, avec fromage)', - 'Frites grande', - 'Coca', + 'Grande Frite', // variante par nom, plus de "Moyenne Frite grande" + 'Coca', // boisson non agrandie : pas de faux " grande" 'Ketchup', ]); + // Garde-fou explicite contre la regression du bug rapporte. + const sideLabel = labels[1]; + assert.equal(sideLabel.includes('Moyenne Frite grande'), false); + assert.equal(sideLabel.endsWith(' grande'), false); + assert.ok(sideLabel.includes('Grande Frite')); }); test('compositionLabels: composants absents ignores sans jeter', () => { -- 2.45.3 From fe5ac06e0448b6805643de08dbad5dfdb5bfc887 Mon Sep 17 00:00:00 2001 From: Corentin JOGUET Date: Tue, 23 Jun 2026 09:56:18 +0200 Subject: [PATCH 92/93] fix(admin): racine du back-office redirige vers /login (#91) --- src/app/Controllers/HomeController.php | 12 ++++---- src/app/Views/home.php | 25 ---------------- tests/Unit/Controllers/HomeControllerTest.php | 30 +++++++++++++++++++ 3 files changed, 35 insertions(+), 32 deletions(-) delete mode 100644 src/app/Views/home.php create mode 100644 tests/Unit/Controllers/HomeControllerTest.php diff --git a/src/app/Controllers/HomeController.php b/src/app/Controllers/HomeController.php index e29cf2b..f647871 100644 --- a/src/app/Controllers/HomeController.php +++ b/src/app/Controllers/HomeController.php @@ -8,10 +8,11 @@ use App\Core\Controller; use App\Core\Response; /** - * Page d'accueil du back-office. GET /. + * Racine du FQDN admin. GET /. * - * Volontairement minimale en P2 : prouve que le rendu de vue MVC traverse - * controleur -> vue -> layout sans dependre de la BDD. + * Le back-office n'expose pas de page d'accueil publique : la racine renvoie + * vers la connexion (RG-T02). Une fois authentifie, /login mene l'equipier + * vers role.default_route. La sonde de sante reste sur GET /api/health. */ final class HomeController extends Controller { @@ -20,9 +21,6 @@ final class HomeController extends Controller */ public function index(array $params = []): Response { - return $this->view('home', [ - 'title' => 'Wakdo back-office', - 'appEnv' => $this->config->appEnv(), - ]); + return Response::make('', 302, ['Location' => '/login']); } } diff --git a/src/app/Views/home.php b/src/app/Views/home.php deleted file mode 100644 index 76e8fab..0000000 --- a/src/app/Views/home.php +++ /dev/null @@ -1,25 +0,0 @@ - -
    -

    Wakdo back-office

    -

    Le squelette back-end (P2) est en ligne.

    -

    - - Coeur MVC from scratch : autoloader PSR-4 manuel, routeur, PDO prepared statements. - Environnement : . - -

    -

    - Sonde de sante : GET /api/health -

    -
    diff --git a/tests/Unit/Controllers/HomeControllerTest.php b/tests/Unit/Controllers/HomeControllerTest.php new file mode 100644 index 0000000..01ece05 --- /dev/null +++ b/tests/Unit/Controllers/HomeControllerTest.php @@ -0,0 +1,30 @@ +index(); + + self::assertSame(302, $response->status()); + self::assertSame('/login', $response->header('Location')); + self::assertSame('', $response->body()); + } +} -- 2.45.3 From 193a15abf79262840f349f91af39ae3c21046caf Mon Sep 17 00:00:00 2001 From: Corentin JOGUET Date: Tue, 23 Jun 2026 10:02:52 +0200 Subject: [PATCH 93/93] fix(front): bouton police adaptee en bas-gauche (evite la collision panier) (#92) --- src/public/borne/assets/css/style.css | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/public/borne/assets/css/style.css b/src/public/borne/assets/css/style.css index f3c9a6e..ba4e07b 100644 --- a/src/public/borne/assets/css/style.css +++ b/src/public/borne/assets/css/style.css @@ -2206,10 +2206,13 @@ html.dys-font { } /* Fixed accessibility control, present on every screen (injected by a11y.js). */ +/* Coin bas-gauche : libre du bouton Retour (haut-gauche), du panier (haut-droite) + et du panneau commande sticky (flanc droit). En portrait, le bas reste en zone + de pouce, donc atteignable. */ .a11y-toggle { position: fixed; - top: var(--space-3); - right: var(--space-3); + bottom: var(--space-3); + left: var(--space-3); z-index: 1000; display: inline-flex; align-items: center; -- 2.45.3