Merge pull request 'release: dev -> main v0.2.0' (#93) from dev into main
All checks were successful
CI / secret-scan (push) Successful in 19s
CI / php-lint (push) Successful in 29s
CI / static-tests (push) Successful in 1m1s
CI / js-tests (push) Successful in 39s

This commit is contained in:
Corentin JOGUET 2026-06-23 10:09:57 +02:00
commit 3dee190a8c
332 changed files with 39921 additions and 5537 deletions

View file

@ -3,17 +3,13 @@
#
# Usage :
# cp .env.example .env
# Editer .env (gitignore) avec les valeurs reelles.
# docker compose up -d
#
# Audience :
# Destine a l'auteur, au jury et aux contributeurs futurs.
#
# Modele de deploiement :
# Ce projet tourne sur serveur derriere un reverse proxy Traefik. Il n'y a
# pas de binding de ports hote : l'acces se fait uniquement via les FQDN
# configures ci-dessous et routes par Traefik (reseau admin_proxy).
# Les distinctions dev / staging / prod se font par FQDN distincts
# (ex : .dev.acadenice.fr vs .acadenice.fr) et par .env dedie.
# Ce template fonctionne EN LOCAL tel quel (valeurs dev) : la stack est joignable
# sur http://kiosk.localhost:8080 (borne) et http://admin.localhost:8080 (admin).
# Le deploiement derriere un reverse proxy (Traefik) se fait via l'overlay
# docker-compose.prod.yml + les variables du bloc "Deploiement prod" en fin de
# fichier. En prod : changer les mots de passe, APP_DEBUG=false, vrais FQDN.
#
# ===================================================================
@ -24,27 +20,30 @@ APP_ENV=dev # dev | staging | prod
APP_DEBUG=true # true en dev, false en prod
APP_TIMEZONE=Europe/Paris
# URL publique de la borne (Bloc 1), doit pointer vers le FQDN Traefik.
# Placeholder example.com (RFC 2606) - a remplacer par le FQDN reel.
APP_URL_KIOSK=https://kiosk.example.com
# Port hote publie par wakdo-web (acces local). Change si 8080 est pris.
HTTP_PORT=8080
# URL publique du back-office + API (Bloc 2).
# Placeholder example.com (RFC 2606) - a remplacer par le FQDN reel.
APP_URL_ADMIN=https://admin.example.com
# Hostnames des deux vhosts Apache (ServerName). En local : *.localhost resout
# vers 127.0.0.1 nativement. En prod : les vrais FQDN (voir bloc prod en bas).
APP_HOST_KIOSK=kiosk.localhost
APP_HOST_ADMIN=admin.localhost
# URLs publiques (consommees par l'app). En local = les hostnames sur HTTP_PORT.
APP_URL_KIOSK=http://kiosk.localhost:8080
APP_URL_ADMIN=http://admin.localhost:8080
# ===================================================================
# Base de donnees (MariaDB)
# ===================================================================
# Valeurs ci-dessous = PLACEHOLDERS. Remplacer par des mots de passe forts.
# Pas accessible depuis l'exterieur : le service wakdo-db est sur le reseau
# interne uniquement, aucun port exposé a l'hote.
# Valeurs dev ci-dessous : OK en local. EN PROD : mots de passe forts.
# wakdo-db est sur le reseau interne, aucun port expose a l'hote.
DB_HOST=wakdo-db # nom du service docker-compose
DB_PORT=3306
DB_NAME=wakdo
DB_USER=wakdo
DB_PASSWORD=change_me_strong_password
DB_ROOT_PASSWORD=change_me_root_password
DB_PASSWORD=wakdo_dev_password
DB_ROOT_PASSWORD=wakdo_dev_root_password
# ===================================================================
# Sessions
@ -58,13 +57,54 @@ SESSION_NAME=WAKDO_SID # nom du cookie (evite PHPSESSID)
# Securite
# ===================================================================
# Origine autorisee pour les requetes CORS de l'API.
# Doit correspondre exactement a APP_URL_KIOSK (pas de wildcard).
CORS_ALLOWED_ORIGIN=https://kiosk.example.com
# Origine autorisee pour les requetes CORS de l'API. Doit correspondre
# exactement a APP_URL_KIOSK (pas de wildcard).
CORS_ALLOWED_ORIGIN=http://kiosk.localhost:8080
# Algorithme de hashage mot de passe (password_hash PHP).
# argon2id recommande depuis PHP 7.3 pour les nouveaux projets.
PASSWORD_ALGO=argon2id
# Algorithme de hashage : argon2id, FIXE dans le code (App\Auth\PasswordHasher),
# choix security-by-design non configurable. Seuls les COUTS ci-dessous sont reglables.
# Parametres de cout argon2id (password_hash options). Defauts alignes OWASP
# (memoire >= 19 MiB, >= 2 iterations). Servent aussi au hash du PIN equipier.
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 : par compte (user.failed_login_attempts / lockout_until) et par IP
# (table login_throttle). Backoff degressif, pas de lock definitif.
ACCOUNT_LOCKOUT_THRESHOLD=5
ACCOUNT_LOCKOUT_BASE_SECONDS=60
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. Chiffres, bornes min ET max (RG-T18).
STAFF_PIN_MIN_LENGTH=4
STAFF_PIN_MAX_LENGTH=12
# Throttle du PIN d'action sensible (RG-T22) - compteurs SEPARES du login,
# dimension = utilisateur agissant. Bornes plus permissives que le login.
PIN_THROTTLE_THRESHOLD=5
PIN_THROTTLE_BASE_SECONDS=30
PIN_THROTTLE_MAX_SECONDS=300
PIN_THROTTLE_WINDOW_SECONDS=900
# Expiration du token de reinitialisation de mot de passe (secondes).
PASSWORD_RESET_TTL=3600 # 1h
# ===================================================================
# Retention des donnees (RGPD)
# ===================================================================
# Purges executees par le service cron (docker/cron/crontab).
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
# ===================================================================
# Upload images produits
@ -76,35 +116,18 @@ UPLOAD_ALLOWED_MIME=image/jpeg,image/png,image/webp
# ===================================================================
# Cron (fenetre de maintenance 01h30 - 09h30)
# ===================================================================
# Les jobs sont definis dans docker/cron/crontab. Ici uniquement le TZ.
CRON_TIMEZONE=Europe/Paris
# ===================================================================
# Exposition via Traefik
# Deploiement prod (overlay docker-compose.prod.yml) - OPTIONNEL
# ===================================================================
# FQDN consommes par les labels docker-compose.yml pour generer les routes
# Traefik et les certificats TLS (Traefik gere le resolver par defaut).
# Le Traefik de l'hote prend en charge Let's Encrypt automatiquement.
TRAEFIK_DOMAIN_KIOSK=kiosk.example.com
TRAEFIK_DOMAIN_ADMIN=admin.example.com
# ===================================================================
# Reseau Docker externe du reverse proxy
# ===================================================================
# Nom du reseau Docker externe auquel le service web doit se connecter
# pour etre expose par le reverse proxy de l'hote.
# A ignorer pour un usage local. Necessaire UNIQUEMENT derriere un reverse proxy
# Traefik, avec : docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
#
# Adapter selon votre infrastructure. Valeurs courantes :
# traefik_proxy - convention neutre (placeholder)
# traefik_public - convention doc Traefik
# traefik - setups simples
# proxy - autre convention frequente
# En prod, surcharger aussi : APP_ENV=prod, APP_DEBUG=false, mots de passe forts,
# et APP_HOST_*/APP_URL_*/CORS_ALLOWED_ORIGIN avec les vrais FQDN HTTPS.
#
# Le reseau doit exister AVANT 'make init' (cree par votre stack de
# reverse proxy, ou manuellement : docker network create <nom>).
# La cible 'make init' echoue proprement avec un message d'aide si le
# reseau est introuvable.
REVERSE_PROXY_NETWORK=traefik_proxy
# Nom du reseau Docker externe partage avec le Traefik de l'hote (doit exister
# AVANT le up : cree par la stack Traefik, ou `docker network create <nom>`).
REVERSE_PROXY_NETWORK=admin_proxy

172
.forgejo/workflows/ci.yml Normal file
View file

@ -0,0 +1,172 @@
name: CI
# CI Wakdo - Forgejo Actions (runner stark-wakdo, label `docker`).
# Strategie solo dev : PR obligatoire ; l'auto-merge NATIF Forgejo
# (merge_when_checks_succeed, programme a l'ouverture de la PR) fusionne en squash
# des que les checks requis passent. Pas de job de merge dans le workflow.
#
# 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]
types: [opened, synchronize, reopened]
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
# 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)
run: |
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 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
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, avec tests d'integration DB)
run: |
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"
# 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
# --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
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
# Skip le download des browsers Playwright : ce job ne fait que node:test+jsdom.
# (@playwright/test est en devDep pour l'E2E, mais ses browsers ne servent
# qu'a tests/e2e via le conteneur officiel.)
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 npm ci
npm run test:js

View file

@ -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)
<!-- Cocher ce qui s'applique ; voir SECURITY.md et PROJECT_CONTEXT section 19. -->
- [ ] 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
<!-- ex : Bloc 2 Cr 3.b (modelisation), Bloc 1 (accessibilite), Bloc 5 (infra/CI)... -->

53
.githooks/commit-msg Executable file
View file

@ -0,0 +1,53 @@
#!/usr/bin/env bash
#
# Wakdo - hook commit-msg : valide le format Conventional Commits.
#
# Active via scripts/install-hooks.sh (git config core.hooksPath .githooks).
# Recoit en $1 le chemin du fichier contenant le message de commit.
#
# Regle (PROJECT_CONTEXT section 9) :
# <type>(<scope optionnel>): <description min 5 caracteres>
# types : feat|fix|refactor|test|docs|chore|ci|db|perf|style
# scope : minuscules, chiffres, tirets
# interdits : emoji (Mantra IA-23)
#
# Exit codes : 0 = message conforme ; 1 = format invalide ou emoji detecte.
set -euo pipefail
MSG_FILE="${1:?usage: commit-msg <fichier-message>}"
# Premiere ligne non vide (ignore les commentaires git et les lignes vides).
SUBJECT="$(grep -m1 -vE '^\s*(#|$)' "$MSG_FILE" || true)"
if [ -z "$SUBJECT" ]; then
echo "commit-msg: message vide." >&2
exit 1
fi
# Tolerance : commits techniques de git (merge/revert/fixup) non concernes.
case "$SUBJECT" in
"Merge "*|"Revert "*|"fixup! "*|"squash! "*) exit 0 ;;
esac
PATTERN='^(feat|fix|refactor|test|docs|chore|ci|db|perf|style)(\([a-z0-9-]+\))?!?: .{5,}'
if ! printf '%s' "$SUBJECT" | grep -qE "$PATTERN"; then
echo "commit-msg: format invalide." >&2
echo " attendu : <type>(<scope>): <description (>=5 car.)>" >&2
echo " types : feat fix refactor test docs chore ci db perf style" >&2
echo " recu : $SUBJECT" >&2
exit 1
fi
# Refus des emoji (Mantra IA-23). Plage des symboles/pictogrammes courants.
# grep -P (PCRE) est requis pour les classes \x{...} ; il n'est dispo que sur le
# grep GNU (cible Linux/Alpine du projet). Si -P est absent (BSD/macOS), on saute
# ce controle plutot que de bloquer a tort (le format reste verifie ; la CI fait foi).
if printf 'a' | grep -qP 'a' 2>/dev/null; then
if printf '%s' "$SUBJECT" | grep -qP '[\x{1F000}-\x{1FAFF}\x{2600}-\x{27BF}\x{2190}-\x{21FF}\x{2B00}-\x{2BFF}]'; then
echo "commit-msg: emoji detecte dans le sujet (interdit, Mantra IA-23)." >&2
exit 1
fi
fi
exit 0

41
.githooks/pre-commit Executable file
View file

@ -0,0 +1,41 @@
#!/usr/bin/env bash
#
# Wakdo - hook pre-commit : garde-fous locaux avant chaque commit.
#
# Active via scripts/install-hooks.sh (git config core.hooksPath .githooks).
# Defense en profondeur cote dev ; la protection de reference reste la CI Forgejo
# (secret-scan, php-lint, static-tests) et la branch protection serveur.
#
# Controles :
# 1. Refuse un commit direct sur main ou dev (PROJECT_CONTEXT regle 18.5).
# 2. Lint PHP (php -l) sur les fichiers .php indexes, si php est disponible.
#
# Exit codes : 0 = OK ; 1 = commit bloque.
set -euo pipefail
BRANCH="$(git rev-parse --abbrev-ref HEAD)"
if [ "$BRANCH" = "main" ] || [ "$BRANCH" = "dev" ]; then
echo "pre-commit: commit direct sur '$BRANCH' interdit (regle 18.5)." >&2
echo " cree une branche : git checkout -b feat/ma-feature" >&2
exit 1
fi
# Lint PHP des fichiers indexes (added/copied/modified), si l'outil est present.
if command -v php >/dev/null 2>&1; then
FAILED=0
while IFS= read -r file; do
[ -n "$file" ] || continue
[ -f "$file" ] || continue
if ! php -l "$file" >/dev/null 2>&1; then
echo "pre-commit: erreur de syntaxe PHP dans $file" >&2
php -l "$file" >&2 || true
FAILED=1
fi
done < <(git diff --cached --name-only --diff-filter=ACM -- '*.php')
if [ "$FAILED" -ne 0 ]; then
exit 1
fi
fi
exit 0

16
.gitignore vendored
View file

@ -5,6 +5,11 @@
*.pem
*.key
# Compose de production (propre a chaque hote : Traefik/reverse proxy, FQDN).
# Le repo ne ship que docker-compose.yml (standalone, local). Chaque hote derriere
# un proxy maintient son propre docker-compose.prod.yml (hors versionnement).
docker-compose.prod.yml
# === BYAN — plateforme (moteur), masquee ===
# Le code moteur des agents n'est pas part du rendu RNCP.
# La methodologie appliquee (CLAUDE.md + rules + hooks) reste dans .claude/
@ -28,8 +33,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/
@ -67,6 +75,12 @@ node_modules/
npm-debug.log
yarn-error.log
# === Playwright (E2E) ===
playwright-report/
test-results/
/blob-report/
.last-run.json
# === Docker volumes locaux ===
/docker-data/

31
.gitleaks.toml Normal file
View file

@ -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.

218
Makefile
View file

@ -1,218 +0,0 @@
#
# Wakdo - Makefile d'orchestration locale
#
# Conventions :
# - Une cible = une action unitaire. Les cibles composites sont commentees.
# - Chaque cible est documentee par un `## description` pour auto-help.
# - Echec sur erreur (set -e implicite via bash recipes + pipefail).
#
# Documentation :
# make help
#
SHELL := /usr/bin/env bash
.SHELLFLAGS := -eu -o pipefail -c
# === Configuration ===
# Chargement du .env s'il existe (variables Make + export pour docker compose)
ifneq (,$(wildcard .env))
include .env
export
endif
# Prefixe du projet compose (utilise pour nommer les containers)
PROJECT := wakdo
# Nom du fichier compose (override possible : make up COMPOSE_FILE=docker-compose.prod.yml)
COMPOSE_FILE := docker-compose.yml
COMPOSE := docker compose -f $(COMPOSE_FILE) -p $(PROJECT)
# Services docker-compose
SERVICE_WEB := wakdo-web
SERVICE_APP := wakdo-app
SERVICE_DB := wakdo-db
SERVICE_CRON := wakdo-cron
# === Meta ===
.DEFAULT_GOAL := help
.PHONY: help
help: ## Liste toutes les cibles disponibles avec leur description
@echo "Wakdo - cibles Make disponibles :"
@echo ""
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " \033[1m%-22s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) | sort
@echo ""
# === Orchestration principale ===
.PHONY: init
init: ## Build et demarre toute la stack en une commande (Cr RNCP 7.c.4)
@test -f .env || { echo "ERREUR: .env manquant. Executer : cp .env.example .env"; exit 1; }
@$(MAKE) --no-print-directory check-env
@echo "[init] Verification du reseau docker '$(REVERSE_PROXY_NETWORK)'..."
@docker network inspect $(REVERSE_PROXY_NETWORK) >/dev/null 2>&1 || { \
echo "ERREUR: reseau docker '$(REVERSE_PROXY_NETWORK)' introuvable."; \
echo " - Si un Traefik est installe sur l'hote, verifier le nom de son reseau ;"; \
echo " - Adapter REVERSE_PROXY_NETWORK dans .env en consequence ;"; \
echo " - Sinon creer le reseau manuellement :"; \
echo " docker network create $(REVERSE_PROXY_NETWORK)"; \
exit 1; }
@echo "[init] Build des images..."
@$(COMPOSE) build
@echo "[init] Demarrage des services..."
@$(COMPOSE) up -d
@echo "[init] Attente de la base de donnees..."
@$(MAKE) --no-print-directory wait-db
@echo "[init] Execution des migrations..."
@$(MAKE) --no-print-directory migrate
@echo "[init] Stack operationnelle."
@$(COMPOSE) ps
.PHONY: up
up: ## Demarre les services sans rebuild
@$(COMPOSE) up -d
.PHONY: down
down: ## Arrete et supprime les containers (volumes preserves)
@$(COMPOSE) down
.PHONY: stop
stop: ## Arrete les services sans les supprimer
@$(COMPOSE) stop
.PHONY: restart
restart: ## Redemarre tous les services
@$(COMPOSE) restart
.PHONY: build
build: ## Build les images (utilise le cache)
@$(COMPOSE) build
.PHONY: rebuild
rebuild: ## Rebuild complet sans cache puis restart
@$(COMPOSE) build --no-cache
@$(COMPOSE) up -d
# === Observabilite ===
.PHONY: ps
ps: ## Affiche le statut des services
@$(COMPOSE) ps
.PHONY: logs
logs: ## Suit les logs de tous les services (Ctrl+C pour sortir)
@$(COMPOSE) logs -f --tail=100
.PHONY: logs-app
logs-app: ## Suit les logs du service applicatif PHP-FPM
@$(COMPOSE) logs -f --tail=100 $(SERVICE_APP)
.PHONY: logs-web
logs-web: ## Suit les logs du service web Apache
@$(COMPOSE) logs -f --tail=100 $(SERVICE_WEB)
.PHONY: logs-db
logs-db: ## Suit les logs de la base de donnees
@$(COMPOSE) logs -f --tail=100 $(SERVICE_DB)
# === Acces shell ===
.PHONY: shell-app
shell-app: ## Ouvre un shell dans le container applicatif
@$(COMPOSE) exec $(SERVICE_APP) sh
.PHONY: shell-db
shell-db: ## Ouvre le client mariadb dans le container de base de donnees
@$(COMPOSE) exec $(SERVICE_DB) mariadb -u root -p"$${DB_ROOT_PASSWORD}"
.PHONY: shell-cron
shell-cron: ## Ouvre un shell dans le container cron
@$(COMPOSE) exec $(SERVICE_CRON) sh
# === Verification env ===
.PHONY: check-env
check-env: ## Verifie que les variables critiques Wakdo sont definies dans .env
@missing=""; \
for var in DB_PASSWORD DB_ROOT_PASSWORD REVERSE_PROXY_NETWORK TRAEFIK_DOMAIN_KIOSK TRAEFIK_DOMAIN_ADMIN APP_URL_KIOSK APP_URL_ADMIN CORS_ALLOWED_ORIGIN; do \
if [ -z "$${!var:-}" ]; then missing="$$missing $$var"; fi; \
done; \
if [ -n "$$missing" ]; then \
echo "ERREUR: variables manquantes dans .env :$$missing"; \
echo "Conseil : si vous aviez un .env pre-existant (tooling externe),"; \
echo " merger les variables manquantes depuis .env.example au lieu"; \
echo " d'ecraser le fichier."; \
exit 1; \
fi
# === Base de donnees ===
.PHONY: wait-db
wait-db: ## Attend que la base de donnees accepte les connexions (timeout 60s)
@echo "[wait-db] En attente de MariaDB..."
@timeout 60 bash -c 'until $(COMPOSE) exec -T $(SERVICE_DB) healthcheck.sh --connect --innodb_initialized >/dev/null 2>&1; do sleep 2; done' \
|| { echo "ERREUR: MariaDB ne repond pas apres 60s"; $(COMPOSE) logs --tail=50 $(SERVICE_DB); exit 1; }
@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/."
.PHONY: seed
seed: ## Charge les donnees de demo [a venir]
@echo "[seed] Pas encore implemente. Les seeds seront dans db/seeds/."
.PHONY: backup
backup: ## Declenche un dump SQL horodate immediat (via le container cron)
@mkdir -p ./var/backups
@echo "[backup] Execution manuelle de /scripts/backup-db.sh dans wakdo-cron..."
@$(COMPOSE) exec -T $(SERVICE_CRON) /scripts/backup-db.sh
@echo "[backup] Dernier dump :"
@ls -lh ./var/backups/ | tail -n 1
.PHONY: backup-ls
backup-ls: ## Liste les dumps SQL presents dans ./var/backups/
@ls -lh ./var/backups/ 2>/dev/null || echo "[backup-ls] Pas de backups (./var/backups/ vide ou inexistant)."
# === Tests ===
.PHONY: test
test: ## Lance la suite complete de tests PHPUnit [a venir]
@echo "[test] Pas encore implemente. PHPUnit via .phar sera configure en P2."
.PHONY: test-unit
test-unit: ## Lance uniquement les tests unitaires [a venir]
@echo "[test-unit] Pas encore implemente."
.PHONY: test-integration
test-integration: ## Lance uniquement les tests d'integration [a venir]
@echo "[test-integration] Pas encore implemente."
# === Qualite code ===
.PHONY: lint
lint: ## Lance php -l sur tous les fichiers src/ [a venir]
@echo "[lint] Pas encore implemente. PHP syntax check via php -l + outil de style en P2."
# === Nettoyage ===
.PHONY: clean
clean: ## Stop + suppression containers + volumes (DESTRUCTIF, demande confirmation)
@read -p "Supprimer containers ET volumes (les donnees seront perdues) ? [y/N] " ans; \
if [ "$$ans" = "y" ] || [ "$$ans" = "Y" ]; then \
$(COMPOSE) down -v; \
echo "[clean] Stack et volumes supprimes."; \
else \
echo "[clean] Annule."; \
fi
.PHONY: clean-force
clean-force: ## Version non interactive de clean (pour CI uniquement)
@$(COMPOSE) down -v
# === Hooks Git ===
.PHONY: install-hooks
install-hooks: ## Installe les hooks git depuis .githooks/ [a venir]
@echo "[install-hooks] Pas encore implemente. Voir scripts/install-hooks.sh a venir."

165
README.md
View file

@ -20,7 +20,7 @@ Trois canaux de prise de commande :
- `counter` — comptoir (un equipier saisit pour le client au guichet)
- `drive` — drive-thru (equipier saisit via intercom + casque)
Quatre statuts commande : `pending` -> `preparing` -> `ready` -> `delivered` (ou `cancelled`).
Statuts commande (machine a 4 etats) : `pending_payment` -> `paid` -> `delivered`, plus `cancelled` (atteignable depuis `pending_payment` ou `paid`). La saisie du numero tient lieu de paiement : la creation passe atomiquement de `pending_payment` a `paid`. La cuisine voit la file des commandes `paid` en lecture seule ; la remise est un geste unique `paid` -> `delivered`.
Scope metier complet, regles, horaires de service et fenetre de maintenance : voir `docs/PROJECT_CONTEXT.md`.
@ -55,9 +55,9 @@ Realisation avec l'assistance d'outils d'IA generative (Claude Code, BYAN), conf
| Tests | PHPUnit | 11.x (`.phar` autonome, sans Composer) |
| Front | HTML5 + CSS3 + JS ES6+ vanilla | — |
| Conteneurisation | Docker + docker compose v2 | — |
| Orchestration locale | Makefile | — |
| CI/CD | GitHub Actions | — |
| Versioning | Git + GitHub | Conventional Commits |
| Orchestration locale | docker compose v2 (service one-shot `wakdo-migrate`) | — |
| CI/CD | Forgejo Actions | — |
| Versioning | Git + Forgejo (`git.acadenice.com`, miroir GitHub) | Conventional Commits |
Detail et justifications : `docs/PROJECT_CONTEXT.md` section 6.
@ -82,86 +82,50 @@ Detail et justifications : `docs/PROJECT_CONTEXT.md` section 6.
v
wakdo-db (MariaDB 11.4)
wakdo-cron (backup BDD + purge sessions + stats)
wakdo-cron (backup BDD + purge audit-log + purge throttle)
```
Reseaux, volumes, services et decoupage reseau interne / reseau proxy : voir `docs/PROJECT_CONTEXT.md` section 5.
---
## Quickstart
Ce projet tourne **sur serveur derriere un reverse proxy Traefik** : pas de binding de ports hote, pas d'acces `localhost`. L'acces public se fait par FQDN HTTPS (TLS gere automatiquement par Traefik). Les environnements `dev`, `staging` et `prod` se distinguent par des FQDN et des fichiers `.env` separes.
### Prerequis sur l'hote
1. Docker Engine + docker compose v2 (voir ci-dessous)
2. Un reverse proxy Traefik deja en place, avec un reseau Docker externe dedie. Le **nom du reseau** est configurable via la variable `REVERSE_PROXY_NETWORK` du `.env` (defaut : `admin_proxy` — convention de l'auteur). A adapter a votre infrastructure.
3. Les FQDN cibles pointent en DNS vers l'hote
### Sur un hote deja equipe (Docker + Traefik)
## Quickstart (local)
```bash
git clone git@github.com:AcadeNice/wakdo_corentin.git
cd wakdo_corentin
git clone https://git.acadenice.com/AcadeNice/corentin_wakdo.git
cd corentin_wakdo
cp .env.example .env
# Editer .env : DB_PASSWORD, DB_ROOT_PASSWORD, APP_URL_*, TRAEFIK_DOMAIN_*
make init
docker compose up -d
```
> **Attention au `.env` pre-existant.** Si un fichier `.env` existe deja a la racine (tooling externe, autre plateforme installee dans le meme repertoire), **ne pas faire** `cp .env.example .env` — cela ecraserait les variables existantes. Faire un **merge manuel** a la place : ajouter les variables manquantes du template dans le `.env` actuel. Les prefixes de variables de ce projet (`APP_`, `DB_`, `SESSION_`, `CORS_`, `UPLOAD_`, `CRON_`, `TRAEFIK_`, `REVERSE_PROXY_`) sont disjoints de ceux utilises par des outils tiers courants, donc la cohabitation est safe.
Une seule commande lance la stack complete (Cr 7.c.4) : le service one-shot
`wakdo-migrate` applique les migrations puis le seed (idempotents, tables de suivi
`schema_migrations` / `seeds_applied`) avant que l'app ne serve. Ensuite :
Critere RNCP Cr 7.c.4 couvert : une seule commande (`make init`) orchestre build, demarrage, attente BDD, migrations et seed.
- Borne : http://kiosk.localhost:8080
- Admin + API : http://admin.localhost:8080
Services accessibles apres `make init` :
- Borne : la valeur de `TRAEFIK_DOMAIN_KIOSK` dans `.env`
- Admin + API : la valeur de `TRAEFIK_DOMAIN_ADMIN` dans `.env`
`*.localhost` resout vers `127.0.0.1` nativement ; changer le port via `HTTP_PORT`
dans `.env`. Le `.env.example` fonctionne tel quel en local (valeurs dev).
Liste complete des cibles : `make help`.
Docker non installe ? Voir https://docs.docker.com/engine/install/
### Installation Docker sur un hote neuf (Debian / Ubuntu)
### Deploiement prod (derriere un reverse proxy Traefik)
Procedure officielle detaillee : `https://docs.docker.com/engine/install/` (selectionner la distribution). Resume pour Debian stable :
Le repo ne ship que `docker-compose.yml` (standalone). En production derriere un
reverse proxy, chaque hote maintient son **propre `docker-compose.prod.yml`**
(gitignore, hors repo, comme `.env`) : meme stack, mais exposee via Traefik (reseau
externe + labels TLS) au lieu d'un port hote.
```bash
sudo apt update
sudo apt install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/debian/gpg \
-o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
https://download.docker.com/linux/debian \
$(. /etc/os-release && echo $VERSION_CODENAME) stable" \
| sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io \
docker-buildx-plugin docker-compose-plugin
sudo usermod -aG docker $USER
# Fermer et rouvrir la session pour activer le groupe docker
docker compose -f docker-compose.prod.yml up -d
```
### Reseau externe du reverse proxy
Avec un `.env` adapte : `APP_ENV=prod`, `APP_DEBUG=false`, mots de passe forts,
`APP_HOST_*` / `APP_URL_*` / `CORS_ALLOWED_ORIGIN` en vrais FQDN HTTPS, et
`REVERSE_PROXY_NETWORK` = reseau Docker du Traefik de l'hote (doit exister avant le up).
Le `docker-compose.yml` attend un reseau Docker externe deja existant sur l'hote, dont le nom est donne par la variable `REVERSE_PROXY_NETWORK` (defaut : `admin_proxy`).
Si vous avez deja un Traefik en place, ce reseau a generalement ete cree par son propre stack. Adaptez la variable `REVERSE_PROXY_NETWORK` dans votre `.env` au nom utilise par votre proxy. Sinon, creez-le manuellement :
```bash
docker network create mon_reseau_proxy
# puis dans .env :
# REVERSE_PROXY_NETWORK=mon_reseau_proxy
```
Avant le premier `make init`, s'assurer que le reseau existe. Verification rapide :
```bash
docker network inspect "$(grep ^REVERSE_PROXY_NETWORK .env | cut -d= -f2)"
```
Si la commande retourne une erreur, soit adapter `REVERSE_PROXY_NETWORK` au nom du reseau utilise par votre proxy, soit creer le reseau manuellement. La cible `make init` echoue proprement avec un message d'aide si le reseau est introuvable.
*Section mise a jour au fil de l'implementation (migrations reelles, seed, CI/CD deploiement).*
*Deploiement detaille : section Deploiement plus bas et `scripts/deploy.sh`.*
---
@ -170,37 +134,34 @@ Si la commande retourne une erreur, soit adapter `REVERSE_PROXY_NETWORK` au nom
```
.
|-- .claude/ # Methodologie BYAN (visible jury : CLAUDE.md + rules/)
|-- .github/
| `-- workflows/ # CI/CD GitHub Actions [a venir]
|-- .githooks/ # pre-commit + commit-msg [a venir]
|-- .forgejo/workflows/ # CI Forgejo Actions (ci.yml : secret-scan, php-lint, static-tests, js-tests)
|-- .githooks/ # pre-commit (refus main/dev + php -l) + commit-msg (Conventional Commits)
|-- docker/ # Dockerfiles customs par service
| |-- apache/
| |-- php-fpm/
| `-- cron/
| |-- apache/ # httpd + vhosts kiosk / admin
| |-- php-fpm/ # PHP 8.3-fpm + php.ini durci
| `-- cron/ # dcron + scripts (backup, restore, purges)
|-- db/
| |-- migrations/ # DDL MariaDB versionnes [a venir]
| `-- seeds/ # Donnees de demo [a venir]
| |-- init/ # init BDD (scope du user applicatif, moindre privilege)
| |-- migrations/ # DDL MariaDB versionnes (0001_init_schema, 0002_pin_throttle, ...)
| |-- seeds/ # donnees de reference + demo (idempotents)
| `-- *.sh # runners migrate / seed
|-- docs/
| |-- PROJECT_CONTEXT.md # Source de verite projet (scope, stack, RNCP mapping)
| |-- journal/ # Retros par session et par feature (oral RNCP)
| `-- merise/ # MCD, MCT, MLD [a venir]
|-- scripts/ # backup-db, install-hooks, ... [a venir]
|-- src/ # Code applicatif [a venir]
| |-- Core/ # Router, Autoloader, DB
| |-- Controllers/
| |-- Models/
| |-- Views/
| |-- Services/
| |-- public/ # DocumentRoot Apache
| `-- bootstrap.php
| |-- PROJECT_CONTEXT.md # source de verite projet (scope, stack, mapping RNCP)
| |-- ARCHITECTURE.md # vue technique (deploiement, stack, securite)
| |-- merise/ # dictionnaire, MCD, MCT, MLD, MLT (+ diagrammes)
| |-- uml/ # use-cases, sequences, machine a etats
| `-- adr/ api/ domaines/ design/ journal/ _ref/
|-- scripts/ # deploy, install-hooks, forgejo-* (branch-protection, pr-automerge)
|-- src/
| |-- app/ # namespace App\ : Core, Controllers, Auth, Catalogue, Order, Views
| `-- public/ # DocumentRoots Apache : borne/ (kiosk) + admin/ (back-office + API)
|-- tests/
| |-- Unit/ # [a venir]
| `-- Integration/ # [a venir]
|-- .env.example
|-- .dockerignore
|-- .gitignore
|-- Makefile
|-- docker-compose.yml
| |-- Unit/ Integration/ # PHPUnit (.phar autonome, sans Composer ; integration sur vraie MariaDB)
| |-- js/ # node:test + jsdom (front borne)
| |-- e2e/ # Playwright (parcours borne + admin, lance a la main)
| `-- Support/ # doubles de test (Fake* / Spy*)
|-- .env.example .gitleaks.toml phpstan.neon phpunit.xml
|-- docker-compose.yml # standalone local ; prod = docker-compose.prod.yml (gitignore, par hote)
`-- README.md
```
@ -212,22 +173,35 @@ Si la commande retourne une erreur, soit adapter `REVERSE_PROXY_NETWORK` au nom
- **Commits** : Conventional Commits en anglais (`feat`, `fix`, `docs`, `refactor`, `test`, `chore`, `ci`, `db`, `perf`, `style`). Format : `type(scope): description`. Voir `docs/PROJECT_CONTEXT.md` section 9.
- **Branches** : `feat/*`, `fix/*`, `refactor/*`, `docs/*`, `ci/*`, `db/*`, `chore/*`, `test/*` depuis `dev`. Merge vers `dev` par PR squashee. Periodiquement `dev` -> `main` par PR avec tag semver.
- `main` et `dev` sont proteges cote GitHub (PR requise, force push bloque, resolution des conversations requise).
- `main` et `dev` sont proteges cote Forgejo (PR requise, force push bloque, checks requis : secret-scan / php-lint / static-tests).
- Pas d'emoji dans le code, les commits ou les specs techniques (Mantra IA-23).
*Sections detaillees (setup env de dev, lint, tests) : a completer au fil de l'implementation.*
- **Hooks Git** : `scripts/install-hooks.sh` active `pre-commit` (refus de commit direct sur `main`/`dev`, `php -l` des fichiers indexes) et `commit-msg` (format Conventional Commits, refus emoji).
- **Verification locale** : voir la section Tests ci-dessous (PHPUnit + PHPStan via le conteneur applicatif, tests JS via node, E2E Playwright).
---
## Tests
*Section a completer. Strategie globale : PHPUnit via `.phar` autonome (sans Composer), priorite Unit > Integration > E2E, voir `docs/PROJECT_CONTEXT.md` section 6 et mantras Merise Agile.*
Trois niveaux, sans dependance Composer cote PHP (priorite Unit > Integration > E2E).
- **PHP (PHPUnit `.phar`)** — unit + integration sur vraie MariaDB, via le conteneur applicatif :
```bash
docker run --rm -v "$PWD":/app -w /app wakdo-wakdo-app php phpunit.phar -c phpunit.xml
docker run --rm -v "$PWD":/app -w /app wakdo-wakdo-app php -d memory_limit=-1 phpstan.phar analyse
```
- **JS (node:test + jsdom)** — modules du front borne : `npm run test:js`
- **E2E (Playwright)** — parcours borne + admin, lances a la main contre une stack jetable : `tests/e2e/run.sh`
La CI Forgejo execute secret-scan, php-lint, static-tests (PHPStan niveau 6 + PHPUnit avec service MariaDB) et js-tests sur chaque PR.
---
## Deploiement
*Section a completer. Strategie cible : CI GitHub Actions sur PR vers `dev` (lint + PHPUnit), CD automatique sur merge vers `main` via SSH + `make rebuild`, voir `docs/PROJECT_CONTEXT.md` section 7 Bloc 5.*
*CI Forgejo Actions sur PR vers `dev`/`main` (secret-scan gitleaks, php-lint, static-tests PHPStan + PHPUnit, js-tests), avec auto-merge sur CI verte. Deploiement a declenchement humain via `scripts/deploy.sh` (recupere `main` depuis Forgejo puis `docker compose build --pull && up -d` ; les images sont buildees localement depuis les Dockerfiles, le one-shot `wakdo-migrate` applique migrations + seed). L'automatisation visee est pull-based (un job cron cote hote detectant un nouveau `main`), a armer ensuite. Voir `docs/PROJECT_CONTEXT.md` section 7 Bloc 5.*
---
@ -237,7 +211,8 @@ Si la commande retourne une erreur, soit adapter `REVERSE_PROXY_NETWORK` au nom
|---|---|
| `docs/PROJECT_CONTEXT.md` | Source de verite projet (17 sections : scope, stack, architecture, mapping critere RNCP, planning, risques, conventions) |
| `docs/journal/` | Retrospectives par session et par feature (preparation de l'oral RNCP) |
| `docs/merise/` *(a venir)* | Modelisation Merise : dictionnaire, MCD, MCT, MLD |
| `docs/merise/` | Modelisation Merise : dictionnaire, MCD, MCT, MLD, MLT (+ diagrammes) |
| `docs/ARCHITECTURE.md` / `docs/adr/` | Vue technique + decisions d'architecture (ADR) |
| `.claude/CLAUDE.md` | Constitution du projet pour les agents Claude Code |
| `.claude/rules/` | Protocoles appliques : fact-check, merise-agile, elo-trust, hermes-dispatcher, byan-api, byan-agents |

55
SECURITY.md Normal file
View file

@ -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.

38
db/README.md Normal file
View file

@ -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 `bash db/migrate.sh` 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.

25
db/init/10-scope-app-user.sh Executable file
View file

@ -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}'."

66
db/migrate-container.sh Normal file
View file

@ -0,0 +1,66 @@
#!/usr/bin/env bash
#
# Wakdo - runner migrations + seed IN-CONTAINER (service compose one-shot wakdo-migrate).
#
# Applique, dans l'ordre lexicographique et de maniere IDEMPOTENTE :
# 1. db/migrations/*.sql (suivi : table schema_migrations)
# 2. db/seeds/*.sql (suivi : table seeds_applied)
# Relancer ne rejoue que les fichiers en attente (tracking par nom de fichier).
#
# Contrairement a db/migrate.sh (hote, via `docker exec`), ce runner tourne DANS
# un conteneur et se connecte a la base PAR LE RESEAU compose (DB_HOST). Il est
# lance par le service `wakdo-migrate` apres que `wakdo-db` soit healthy ; les
# services applicatifs (app/web) attendent sa COMPLETION (service_completed_successfully).
#
# But : `docker compose up` amene une stack COMPLETE et utilisable (schema + donnees
# de reference, dont l'admin bootstrap) en une seule commande, sans dependance a
# l'hote (Cr 7.c.4) -> remplace `make init`.
#
# Variables injectees par docker-compose : DB_HOST, DB_PORT, DB_NAME, DB_ROOT_PASSWORD.
# Root requis : migrations = DDL, seeds = INSERT de reference.
#
set -euo pipefail
: "${DB_HOST:?DB_HOST manquant}"
: "${DB_NAME:?DB_NAME manquant}"
: "${DB_ROOT_PASSWORD:?DB_ROOT_PASSWORD manquant}"
PORT="${DB_PORT:-3306}"
db() { mariadb -h "$DB_HOST" -P "$PORT" -uroot -p"$DB_ROOT_PASSWORD" "$@"; }
# Applique les *.sql d'un dossier non encore enregistres dans sa table de suivi.
apply_tracked() {
local dir="$1" table="$2"
local f base n applied=0
db "$DB_NAME" -e "CREATE TABLE IF NOT EXISTS ${table} (
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
local files=("$dir"/*.sql)
if [ ${#files[@]} -eq 0 ]; then
echo "[${table}] aucun fichier dans ${dir}"
return 0
fi
for f in "${files[@]}"; do
base="$(basename "$f")"
n="$(db "$DB_NAME" -N -s -e "SELECT COUNT(*) FROM ${table} WHERE filename='${base}';")"
if [ "$n" = "0" ]; then
echo "[${table}] application de ${base} ..."
db "$DB_NAME" < "$f"
db "$DB_NAME" -e "INSERT INTO ${table} (filename) VALUES ('${base}');"
applied=$((applied + 1))
else
echo "[${table}] ${base} deja applique, ignore"
fi
done
echo "[${table}] termine (${applied} nouveau(x))."
}
echo "[migrate] cible ${DB_HOST}:${PORT}/${DB_NAME}"
apply_tracked /db/migrations schema_migrations
apply_tracked /db/seeds seeds_applied
echo "[migrate] stack a jour (schema + donnees de reference)."

70
db/migrate.sh Executable file
View file

@ -0,0 +1,70 @@
#!/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
# (usage manuel / `--status`, identifiants lus dans .env). Au boot de la stack,
# c'est le service `wakdo-migrate` (db/migrate-container.sh, via le reseau) qui
# applique migrations + seed automatiquement.
#
# 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 (docker compose up -d)" >&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))."

View file

@ -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;

View file

@ -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 <THROTTLE_PURGE_AFTER_HOURS> HOUR;

View file

@ -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;

View file

@ -0,0 +1,25 @@
-- db/migrations/0005_ingredient_nutrition.sql
-- =============================================================================
-- Wakdo - Migration 0005 : enrichissement nutritionnel depuis une API EXTERNE
-- =============================================================================
-- Purpose : ajoute a `ingredient` des colonnes nullables pour stocker des donnees
-- nutritionnelles importees depuis une API TIERCE (OpenFoodFacts), a la
-- demande d'un manager/admin (action explicite, pas au runtime borne).
-- Demontre l'exploitation, DANS LE MODELE de donnees, d'informations
-- externes provenant d'une API (Cr 3.a.3). Egress maitrise et opt-in :
-- aucun appel automatique ; la passerelle (App\Catalogue\
-- OpenFoodFactsGateway) est invoquee seulement par IngredientController::enrich.
-- Target : MariaDB 11.4 LTS, InnoDB, utf8mb4 / utf8mb4_unicode_ci.
-- =============================================================================
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE ingredient
ADD COLUMN energy_kcal_100g SMALLINT UNSIGNED NULL AFTER pack_label,
ADD COLUMN nutrition_source VARCHAR(120) NULL AFTER energy_kcal_100g,
ADD COLUMN nutrition_fetched_at DATETIME NULL AFTER nutrition_source;
-- energy_kcal_100g : apport energetique pour 100 g (SMALLINT UNSIGNED suffit ; les
-- valeurs reelles restent < 1000). nutrition_source : provenance ("OpenFoodFacts").
-- nutrition_fetched_at : horodatage de l'import, pour tracer la fraicheur. Toutes
-- nullables : un ingredient non enrichi reste valide (donnee optionnelle).

View file

@ -0,0 +1,32 @@
-- db/migrations/0006_product_maxi_variant.sql
-- =============================================================================
-- Wakdo - Migration 0006 : variante Maxi d'un produit (accompagnement de menu)
-- =============================================================================
-- Purpose : ajoute a `product` une auto-reference nullable vers la variante
-- servie quand un menu est commande au format Maxi. L'accompagnement
-- de menu (slot_type='side') propose la version standard (ex. Moyenne
-- Frite, Potatoes) ; au format Maxi, le serveur substitue la variante
-- Grande (Grande Frite / Grande Potatoes) sans choix supplementaire.
-- Approche data-driven : la regle vit dans la donnee, pas dans le code,
-- et le decrement de stock (consumption()) frappe alors le bon produit.
-- Target : MariaDB 11.4 LTS, InnoDB, utf8mb4 / utf8mb4_unicode_ci.
-- =============================================================================
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE product
ADD COLUMN maxi_variant_product_id INT UNSIGNED NULL AFTER price_cents,
ADD CONSTRAINT fk_product_maxi_variant_product_id FOREIGN KEY (maxi_variant_product_id)
REFERENCES product (id) ON DELETE SET NULL;
-- maxi_variant_product_id : produit servi a la place de celui-ci quand le menu est
-- au format Maxi (ex. Moyenne Frite -> Grande Frite). Place AFTER price_cents :
-- regroupe avec les attributs de commercialisation du produit. Nullable : la
-- plupart des produits n'ont pas de variante Maxi (un produit sans variante reste
-- valide et n'est jamais substitue).
--
-- ON DELETE SET NULL (et non RESTRICT) : si la variante Grande est supprimee du
-- catalogue, le produit de base reste vendable, il perd seulement sa substitution
-- Maxi (degradation gracieuse). RESTRICT bloquerait la suppression d'une Grande
-- referencee, ce qui n'est pas souhaitable : la reference est un confort metier,
-- pas une integrite forte de commande (les commandes figent deja leurs snapshots).

View file

@ -0,0 +1,53 @@
-- db/migrations/0007_product_size_variant.sql
-- =============================================================================
-- Wakdo - Migration 0007 : variante de TAILLE d'un produit (boisson 30/50 cl)
-- =============================================================================
-- Purpose : ajoute a `product` la dimension TAILLE des boissons fontaine (la
-- maquette borne propose 30 cl / 50 cl), modelisee comme des LIGNES
-- produit distinctes (meme approche que Moyenne/Grande Frite). Le
-- domaine commande facture deja par product_id : le flux de commande
-- reste inchange, la borne resout juste la taille choisie en product_id.
--
-- Grouping DEDIE, distinct de maxi_variant_product_id (migration 0006) :
-- ce dernier pilote la substitution Maxi de l'accompagnement de menu
-- (resolveSelections) ; le reutiliser ferait basculer en 50 cl une
-- boisson 30 cl glissee dans un menu Maxi (effet de bord non voulu).
-- Target : MariaDB 11.4 LTS, InnoDB, utf8mb4 / utf8mb4_unicode_ci.
-- =============================================================================
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
-- Idempotence : meme garde information_schema que 0006 (re-jouable sans erreur).
-- On verifie l'absence de la colonne `size_cl` avant l'ALTER ; les deux colonnes
-- sont ajoutees ensemble, l'existence de l'une suffit donc a court-circuiter.
SET @col_exists := (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'product' AND column_name = 'size_cl'
);
SET @ddl := IF(
@col_exists = 0,
'ALTER TABLE product
ADD COLUMN size_cl SMALLINT UNSIGNED NULL AFTER price_cents,
ADD COLUMN base_product_id INT UNSIGNED NULL AFTER size_cl,
ADD CONSTRAINT fk_product_base_product_id FOREIGN KEY (base_product_id)
REFERENCES product (id) ON DELETE CASCADE',
'DO 0'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- size_cl : volume en centilitres. NULL = le produit n'a pas de dimension taille
-- (bouteilles, produits non-boissons). La ligne de base (30) ET la variante (50)
-- portent toutes deux leur volume, pour que le picker affiche un libelle humain.
--
-- base_product_id : auto-reference vers la ligne de base. NULL = produit de base
-- ou autonome (visible dans le catalogue) ; NON NULL = variante de taille du
-- produit reference (masquee de la grille catalogue, atteinte via le picker).
--
-- ON DELETE CASCADE (et non SET NULL comme 0006) : une variante de taille n'a
-- AUCUN sens sans sa base (une "Coca Cola 50cl" orpheline n'est pas commercialisable),
-- alors que la substitution Maxi de 0006 est un confort optionnel survivant a la
-- perte de sa cible. Supprimer la base emporte donc ses variantes de taille. Les
-- commandes passees ne sont pas affectees (elles figent leurs snapshots, RG-T05).

69
db/seed.sh Executable file
View file

@ -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 (docker compose up -d)" >&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))."

View file

@ -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 <resource>.<action>. 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';

195
db/seeds/0002_catalogue.sql Normal file
View file

@ -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/<category>/<file>.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';

View file

@ -0,0 +1,238 @@
-- =============================================================================
-- Wakdo — Seed 0003 : Ingredients + recettes (product_ingredient)
-- =============================================================================
-- Active le decrement de stock (RG-T20) au paiement et la dispo calculee
-- (RG-T21). Catalogue d'ingredients ferme + une recette par produit (53).
-- FK resolus par sous-requete sur le nom (convention seed 0002). INSERT IGNORE
-- => idempotent (UNIQUE name sur ingredient ; PK (product_id,ingredient_id)).
-- Quantites : burger/wrap/salade normal==maxi ; frites/boissons maxi>=normal.
-- Lignes quantity_normal=0 (extras hors base) ecartees : CHECK quantity_normal>0.
-- =============================================================================
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 1. ingredient (catalogue, stock plein)
INSERT IGNORE INTO ingredient (name, unit, stock_quantity, stock_capacity) VALUES
('Pain burger', 'piece', 300, 300),
('Pain sesame', 'piece', 300, 300),
('Pain signature', 'piece', 250, 250),
('Tortilla', 'piece', 200, 200),
('Steak hache', 'piece', 400, 400),
('Filet de poulet pane', 'piece', 300, 300),
('Galette de poisson', 'piece', 150, 150),
('Tranche de bacon', 'piece', 500, 500),
('Nugget de poulet', 'piece', 1500, 1500),
('Jambon', 'tranche', 200, 200),
('Cheddar', 'tranche', 600, 600),
('Fromage de chevre', 'portion', 150, 150),
('Mozzarella', 'portion', 150, 150),
('Emmental', 'tranche', 250, 250),
('Salade', 'portion', 600, 600),
('Tomate', 'rondelle', 400, 400),
('Oignon', 'portion', 600, 600),
('Cornichon', 'rondelle', 800, 800),
('Roquette', 'portion', 200, 200),
('Sauce Big Mac', 'dose', 1000, 1000),
('Sauce ranch', 'dose', 1000, 1000),
('Sauce barbecue', 'dose', 1000, 1000),
('Sauce deluxe', 'dose', 1000, 1000),
('Pomme de terre frite', 'portion', 3000, 3000),
('Galette de pomme de terre', 'portion', 1000, 1000),
('Dose Coca', 'dose', 1500, 1500),
('Dose Coca Zero', 'dose', 1000, 1000),
('Dose Eau', 'dose', 800, 800),
('Dose Fanta', 'dose', 1000, 1000),
('Dose Ice Tea Peche', 'dose', 1000, 1000),
('Dose Ice Tea Citron', 'dose', 800, 800),
('Dose Jus d Orange', 'dose', 400, 400),
('Dose Jus de Pomme', 'dose', 400, 400),
('Gobelet', 'piece', 3000, 3000),
('Brownie', 'piece', 150, 150),
('Cheesecake', 'piece', 150, 150),
('Cookie', 'piece', 150, 150),
('Donut', 'piece', 150, 150),
('Macaron', 'piece', 200, 200),
('Glace McFleury', 'piece', 200, 200),
('Muffin', 'piece', 150, 150),
('Glace sundae', 'piece', 200, 200),
('Topping chocolat', 'dose', 300, 300),
('Dosette Barbecue', 'dosette', 1500, 1500),
('Dosette Moutarde', 'dosette', 1500, 1500),
('Dosette Deluxe', 'dosette', 1500, 1500),
('Dosette Ketchup', 'dosette', 2000, 2000),
('Dosette Chinoise', 'dosette', 1000, 1000),
('Dosette Curry', 'dosette', 1500, 1500),
('Dosette Pommes Frites', 'dosette', 1500, 1500);
-- 2. product_ingredient (recettes ; FK par nom)
INSERT IGNORE INTO product_ingredient (product_id, ingredient_id, quantity_normal, quantity_maxi, is_removable, is_addable, extra_price_cents) VALUES
((SELECT id FROM product WHERE name='Le 280'), (SELECT id FROM ingredient WHERE name='Pain signature'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='Le 280'), (SELECT id FROM ingredient WHERE name='Steak hache'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='Le 280'), (SELECT id FROM ingredient WHERE name='Cheddar'), 2, 2, 1, 1, 60),
((SELECT id FROM product WHERE name='Le 280'), (SELECT id FROM ingredient WHERE name='Oignon'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='Le 280'), (SELECT id FROM ingredient WHERE name='Salade'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='Le 280'), (SELECT id FROM ingredient WHERE name='Sauce barbecue'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='Big Tasty'), (SELECT id FROM ingredient WHERE name='Pain sesame'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='Big Tasty'), (SELECT id FROM ingredient WHERE name='Steak hache'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='Big Tasty'), (SELECT id FROM ingredient WHERE name='Emmental'), 2, 2, 1, 0, 0),
((SELECT id FROM product WHERE name='Big Tasty'), (SELECT id FROM ingredient WHERE name='Salade'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='Big Tasty'), (SELECT id FROM ingredient WHERE name='Tomate'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='Big Tasty'), (SELECT id FROM ingredient WHERE name='Oignon'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='Big Tasty'), (SELECT id FROM ingredient WHERE name='Sauce barbecue'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='Big Tasty Bacon'), (SELECT id FROM ingredient WHERE name='Pain sesame'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='Big Tasty Bacon'), (SELECT id FROM ingredient WHERE name='Steak hache'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='Big Tasty Bacon'), (SELECT id FROM ingredient WHERE name='Tranche de bacon'), 2, 2, 1, 1, 80),
((SELECT id FROM product WHERE name='Big Tasty Bacon'), (SELECT id FROM ingredient WHERE name='Emmental'), 2, 2, 1, 0, 0),
((SELECT id FROM product WHERE name='Big Tasty Bacon'), (SELECT id FROM ingredient WHERE name='Salade'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='Big Tasty Bacon'), (SELECT id FROM ingredient WHERE name='Tomate'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='Big Tasty Bacon'), (SELECT id FROM ingredient WHERE name='Oignon'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='Big Tasty Bacon'), (SELECT id FROM ingredient WHERE name='Sauce barbecue'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='Big Mac'), (SELECT id FROM ingredient WHERE name='Pain sesame'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='Big Mac'), (SELECT id FROM ingredient WHERE name='Steak hache'), 2, 2, 0, 0, 0),
((SELECT id FROM product WHERE name='Big Mac'), (SELECT id FROM ingredient WHERE name='Cheddar'), 1, 1, 1, 1, 60),
((SELECT id FROM product WHERE name='Big Mac'), (SELECT id FROM ingredient WHERE name='Salade'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='Big Mac'), (SELECT id FROM ingredient WHERE name='Oignon'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='Big Mac'), (SELECT id FROM ingredient WHERE name='Cornichon'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='Big Mac'), (SELECT id FROM ingredient WHERE name='Sauce Big Mac'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='CBO'), (SELECT id FROM ingredient WHERE name='Pain signature'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='CBO'), (SELECT id FROM ingredient WHERE name='Filet de poulet pane'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='CBO'), (SELECT id FROM ingredient WHERE name='Tranche de bacon'), 2, 2, 1, 1, 80),
((SELECT id FROM product WHERE name='CBO'), (SELECT id FROM ingredient WHERE name='Emmental'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='CBO'), (SELECT id FROM ingredient WHERE name='Oignon'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='CBO'), (SELECT id FROM ingredient WHERE name='Salade'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='CBO'), (SELECT id FROM ingredient WHERE name='Sauce barbecue'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='MC Chicken'), (SELECT id FROM ingredient WHERE name='Pain sesame'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='MC Chicken'), (SELECT id FROM ingredient WHERE name='Filet de poulet pane'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='MC Chicken'), (SELECT id FROM ingredient WHERE name='Salade'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='MC Chicken'), (SELECT id FROM ingredient WHERE name='Sauce ranch'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='MC Crispy'), (SELECT id FROM ingredient WHERE name='Pain signature'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='MC Crispy'), (SELECT id FROM ingredient WHERE name='Filet de poulet pane'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='MC Crispy'), (SELECT id FROM ingredient WHERE name='Salade'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='MC Crispy'), (SELECT id FROM ingredient WHERE name='Tomate'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='MC Crispy'), (SELECT id FROM ingredient WHERE name='Sauce deluxe'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='MC Fish'), (SELECT id FROM ingredient WHERE name='Pain burger'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='MC Fish'), (SELECT id FROM ingredient WHERE name='Galette de poisson'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='MC Fish'), (SELECT id FROM ingredient WHERE name='Cheddar'), 1, 1, 1, 1, 60),
((SELECT id FROM product WHERE name='MC Fish'), (SELECT id FROM ingredient WHERE name='Sauce deluxe'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='Royal Bacon'), (SELECT id FROM ingredient WHERE name='Pain sesame'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='Royal Bacon'), (SELECT id FROM ingredient WHERE name='Steak hache'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='Royal Bacon'), (SELECT id FROM ingredient WHERE name='Tranche de bacon'), 2, 2, 1, 1, 80),
((SELECT id FROM product WHERE name='Royal Bacon'), (SELECT id FROM ingredient WHERE name='Cheddar'), 1, 1, 1, 1, 60),
((SELECT id FROM product WHERE name='Royal Bacon'), (SELECT id FROM ingredient WHERE name='Salade'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='Royal Bacon'), (SELECT id FROM ingredient WHERE name='Oignon'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='Royal Bacon'), (SELECT id FROM ingredient WHERE name='Sauce barbecue'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='Royal Cheese'), (SELECT id FROM ingredient WHERE name='Pain sesame'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='Royal Cheese'), (SELECT id FROM ingredient WHERE name='Steak hache'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='Royal Cheese'), (SELECT id FROM ingredient WHERE name='Cheddar'), 2, 2, 1, 1, 60),
((SELECT id FROM product WHERE name='Royal Cheese'), (SELECT id FROM ingredient WHERE name='Oignon'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='Royal Cheese'), (SELECT id FROM ingredient WHERE name='Cornichon'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='Royal Cheese'), (SELECT id FROM ingredient WHERE name='Sauce barbecue'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='Royal Deluxe'), (SELECT id FROM ingredient WHERE name='Pain sesame'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='Royal Deluxe'), (SELECT id FROM ingredient WHERE name='Steak hache'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='Royal Deluxe'), (SELECT id FROM ingredient WHERE name='Cheddar'), 1, 1, 1, 1, 60),
((SELECT id FROM product WHERE name='Royal Deluxe'), (SELECT id FROM ingredient WHERE name='Salade'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='Royal Deluxe'), (SELECT id FROM ingredient WHERE name='Tomate'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='Royal Deluxe'), (SELECT id FROM ingredient WHERE name='Cornichon'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='Royal Deluxe'), (SELECT id FROM ingredient WHERE name='Sauce deluxe'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='Signature BBQ Beef 2 viandes'), (SELECT id FROM ingredient WHERE name='Pain signature'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='Signature BBQ Beef 2 viandes'), (SELECT id FROM ingredient WHERE name='Steak hache'), 2, 2, 0, 0, 0),
((SELECT id FROM product WHERE name='Signature BBQ Beef 2 viandes'), (SELECT id FROM ingredient WHERE name='Cheddar'), 2, 2, 1, 1, 60),
((SELECT id FROM product WHERE name='Signature BBQ Beef 2 viandes'), (SELECT id FROM ingredient WHERE name='Tranche de bacon'), 2, 2, 1, 1, 80),
((SELECT id FROM product WHERE name='Signature BBQ Beef 2 viandes'), (SELECT id FROM ingredient WHERE name='Oignon'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='Signature BBQ Beef 2 viandes'), (SELECT id FROM ingredient WHERE name='Salade'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='Signature BBQ Beef 2 viandes'), (SELECT id FROM ingredient WHERE name='Sauce barbecue'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='Signature Beef BBQ'), (SELECT id FROM ingredient WHERE name='Pain signature'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='Signature Beef BBQ'), (SELECT id FROM ingredient WHERE name='Steak hache'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='Signature Beef BBQ'), (SELECT id FROM ingredient WHERE name='Cheddar'), 1, 1, 1, 1, 60),
((SELECT id FROM product WHERE name='Signature Beef BBQ'), (SELECT id FROM ingredient WHERE name='Oignon'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='Signature Beef BBQ'), (SELECT id FROM ingredient WHERE name='Salade'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='Signature Beef BBQ'), (SELECT id FROM ingredient WHERE name='Sauce barbecue'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='Coca Cola'), (SELECT id FROM ingredient WHERE name='Gobelet'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='Coca Cola'), (SELECT id FROM ingredient WHERE name='Dose Coca'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='Coca Sans Sucres'), (SELECT id FROM ingredient WHERE name='Gobelet'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='Coca Sans Sucres'), (SELECT id FROM ingredient WHERE name='Dose Coca Zero'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='Eau'), (SELECT id FROM ingredient WHERE name='Dose Eau'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='Fanta Orange'), (SELECT id FROM ingredient WHERE name='Gobelet'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='Fanta Orange'), (SELECT id FROM ingredient WHERE name='Dose Fanta'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='Ice Tea Peche'), (SELECT id FROM ingredient WHERE name='Gobelet'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='Ice Tea Peche'), (SELECT id FROM ingredient WHERE name='Dose Ice Tea Peche'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='Ice Tea Citron'), (SELECT id FROM ingredient WHERE name='Gobelet'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='Ice Tea Citron'), (SELECT id FROM ingredient WHERE name='Dose Ice Tea Citron'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='Jus d''Orange'), (SELECT id FROM ingredient WHERE name='Gobelet'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='Jus d''Orange'), (SELECT id FROM ingredient WHERE name='Dose Jus d Orange'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='Jus de Pommes Bio'), (SELECT id FROM ingredient WHERE name='Gobelet'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='Jus de Pommes Bio'), (SELECT id FROM ingredient WHERE name='Dose Jus de Pomme'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='Petite Frite'), (SELECT id FROM ingredient WHERE name='Pomme de terre frite'), 1, 2, 0, 0, 0),
((SELECT id FROM product WHERE name='Moyenne Frite'), (SELECT id FROM ingredient WHERE name='Pomme de terre frite'), 2, 3, 0, 0, 0),
((SELECT id FROM product WHERE name='Grande Frite'), (SELECT id FROM ingredient WHERE name='Pomme de terre frite'), 3, 4, 0, 0, 0),
((SELECT id FROM product WHERE name='Potatoes'), (SELECT id FROM ingredient WHERE name='Galette de pomme de terre'), 2, 3, 0, 0, 0),
((SELECT id FROM product WHERE name='Grande Potatoes'), (SELECT id FROM ingredient WHERE name='Galette de pomme de terre'), 3, 4, 0, 0, 0),
((SELECT id FROM product WHERE name='Cheeseburger'), (SELECT id FROM ingredient WHERE name='Pain burger'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='Cheeseburger'), (SELECT id FROM ingredient WHERE name='Steak hache'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='Cheeseburger'), (SELECT id FROM ingredient WHERE name='Cheddar'), 1, 1, 1, 1, 60),
((SELECT id FROM product WHERE name='Cheeseburger'), (SELECT id FROM ingredient WHERE name='Oignon'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='Cheeseburger'), (SELECT id FROM ingredient WHERE name='Cornichon'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='Cheeseburger'), (SELECT id FROM ingredient WHERE name='Sauce Big Mac'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='Croc MCdo'), (SELECT id FROM ingredient WHERE name='Pain burger'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='Croc MCdo'), (SELECT id FROM ingredient WHERE name='Jambon'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='Croc MCdo'), (SELECT id FROM ingredient WHERE name='Emmental'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='Nuggets x4'), (SELECT id FROM ingredient WHERE name='Nugget de poulet'), 4, 4, 0, 0, 0),
((SELECT id FROM product WHERE name='Nuggets x20'), (SELECT id FROM ingredient WHERE name='Nugget de poulet'), 20, 20, 0, 0, 0),
((SELECT id FROM product WHERE name='MC Wrap Chevre'), (SELECT id FROM ingredient WHERE name='Tortilla'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='MC Wrap Chevre'), (SELECT id FROM ingredient WHERE name='Filet de poulet pane'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='MC Wrap Chevre'), (SELECT id FROM ingredient WHERE name='Fromage de chevre'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='MC Wrap Chevre'), (SELECT id FROM ingredient WHERE name='Salade'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='MC Wrap Chevre'), (SELECT id FROM ingredient WHERE name='Tomate'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='MC Wrap Chevre'), (SELECT id FROM ingredient WHERE name='Sauce ranch'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='MC Wrap Poulet Bacon'), (SELECT id FROM ingredient WHERE name='Tortilla'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='MC Wrap Poulet Bacon'), (SELECT id FROM ingredient WHERE name='Filet de poulet pane'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='MC Wrap Poulet Bacon'), (SELECT id FROM ingredient WHERE name='Tranche de bacon'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='MC Wrap Poulet Bacon'), (SELECT id FROM ingredient WHERE name='Cheddar'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='MC Wrap Poulet Bacon'), (SELECT id FROM ingredient WHERE name='Salade'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='MC Wrap Poulet Bacon'), (SELECT id FROM ingredient WHERE name='Tomate'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='MC Wrap Poulet Bacon'), (SELECT id FROM ingredient WHERE name='Sauce barbecue'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='Ptit Wrap Chevre'), (SELECT id FROM ingredient WHERE name='Tortilla'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='Ptit Wrap Chevre'), (SELECT id FROM ingredient WHERE name='Filet de poulet pane'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='Ptit Wrap Chevre'), (SELECT id FROM ingredient WHERE name='Fromage de chevre'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='Ptit Wrap Chevre'), (SELECT id FROM ingredient WHERE name='Salade'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='Ptit Wrap Chevre'), (SELECT id FROM ingredient WHERE name='Sauce ranch'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='Ptit Wrap Ranch'), (SELECT id FROM ingredient WHERE name='Tortilla'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='Ptit Wrap Ranch'), (SELECT id FROM ingredient WHERE name='Filet de poulet pane'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='Ptit Wrap Ranch'), (SELECT id FROM ingredient WHERE name='Cheddar'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='Ptit Wrap Ranch'), (SELECT id FROM ingredient WHERE name='Salade'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='Ptit Wrap Ranch'), (SELECT id FROM ingredient WHERE name='Oignon'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='Ptit Wrap Ranch'), (SELECT id FROM ingredient WHERE name='Sauce ranch'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='Petite Salade'), (SELECT id FROM ingredient WHERE name='Salade'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='Petite Salade'), (SELECT id FROM ingredient WHERE name='Tomate'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='Petite Salade'), (SELECT id FROM ingredient WHERE name='Roquette'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='Cesar Classic'), (SELECT id FROM ingredient WHERE name='Salade'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='Cesar Classic'), (SELECT id FROM ingredient WHERE name='Filet de poulet pane'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='Cesar Classic'), (SELECT id FROM ingredient WHERE name='Emmental'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='Cesar Classic'), (SELECT id FROM ingredient WHERE name='Tomate'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='Cesar Classic'), (SELECT id FROM ingredient WHERE name='Sauce ranch'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='Cesar Classic'), (SELECT id FROM ingredient WHERE name='Tranche de bacon'), 1, 1, 1, 1, 80),
((SELECT id FROM product WHERE name='Italienne Mozza'), (SELECT id FROM ingredient WHERE name='Salade'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='Italienne Mozza'), (SELECT id FROM ingredient WHERE name='Mozzarella'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='Italienne Mozza'), (SELECT id FROM ingredient WHERE name='Tomate'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='Italienne Mozza'), (SELECT id FROM ingredient WHERE name='Roquette'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='Italienne Mozza'), (SELECT id FROM ingredient WHERE name='Sauce deluxe'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='Brownie'), (SELECT id FROM ingredient WHERE name='Brownie'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='Cheesecake chocolat M&M''S'), (SELECT id FROM ingredient WHERE name='Cheesecake'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='Cheesecake chocolat M&M''S'), (SELECT id FROM ingredient WHERE name='Topping chocolat'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='Cheesecake Fraise'), (SELECT id FROM ingredient WHERE name='Cheesecake'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='Cookie'), (SELECT id FROM ingredient WHERE name='Cookie'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='Donut'), (SELECT id FROM ingredient WHERE name='Donut'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='Macarons'), (SELECT id FROM ingredient WHERE name='Macaron'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='MC Fleury'), (SELECT id FROM ingredient WHERE name='Glace McFleury'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='MC Fleury'), (SELECT id FROM ingredient WHERE name='Topping chocolat'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='Muffin'), (SELECT id FROM ingredient WHERE name='Muffin'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='Sunday'), (SELECT id FROM ingredient WHERE name='Glace sundae'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='Sunday'), (SELECT id FROM ingredient WHERE name='Topping chocolat'), 1, 1, 1, 0, 0),
((SELECT id FROM product WHERE name='Classic Barbecue'), (SELECT id FROM ingredient WHERE name='Dosette Barbecue'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='Classic Moutarde'), (SELECT id FROM ingredient WHERE name='Dosette Moutarde'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='Creamy Deluxe'), (SELECT id FROM ingredient WHERE name='Dosette Deluxe'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='Ketchup'), (SELECT id FROM ingredient WHERE name='Dosette Ketchup'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='Chinoise'), (SELECT id FROM ingredient WHERE name='Dosette Chinoise'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='Curry'), (SELECT id FROM ingredient WHERE name='Dosette Curry'), 1, 1, 0, 0, 0),
((SELECT id FROM product WHERE name='Pommes Frites'), (SELECT id FROM ingredient WHERE name='Dosette Pommes Frites'), 1, 1, 0, 0, 0);

View file

@ -0,0 +1,61 @@
-- =============================================================================
-- Wakdo — Seed 0004 : accompagnement de menu = variante Maxi automatique
-- =============================================================================
-- Purpose : cabler la regle metier "accompagnement Maxi" sur les donnees seedees
-- par 0002_catalogue.sql, sans toucher au code :
-- 1. lier chaque accompagnement standard a sa variante Grande
-- (Moyenne Frite -> Grande Frite, Potatoes -> Grande Potatoes) ;
-- 2. restreindre les options du slot 'side' des menus aux deux seuls
-- choix conformes a la maquette (ecran 4) : Moyenne Frite + Potatoes.
-- Phase : P4 — depend du schema 0006 (product.maxi_variant_product_id) et du
-- catalogue 0002 (produits frites + menu_slot 'side').
--
-- Etat initial (0002_catalogue.sql, section 5) : le slot 'side' recoit TOUS les
-- produits de la categorie 'frites', soit les 5 : Petite Frite, Moyenne Frite,
-- Grande Frite, Potatoes, Grande Potatoes. Ce seed retire Petite Frite, Grande
-- Frite et Grande Potatoes des options de menu (elles restent a la carte dans la
-- categorie frites) : le DELETE n'est donc PAS un no-op sur une base 0002.
--
-- Conventions:
-- - Aucun id en dur : toutes les references sont resolues par sous-requete sur
-- le nom du produit / le type de slot (memes noms que 0002_catalogue.sql).
-- - IDEMPOTENT : UPDATE convergent (repositionne la meme valeur) et DELETE par
-- appartenance (re-supprimer des options deja absentes ne fait rien) ; rejouer
-- ce seed laisse la base dans le meme etat.
-- =============================================================================
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
-- -----------------------------------------------------------------------------
-- 1. Lier chaque accompagnement standard a sa variante Grande.
-- Le SELECT cible la table `product`, que l'UPDATE modifie aussi : MariaDB/
-- MySQL interdit de lire et d'ecrire la meme table dans une seule requete
-- sans niveau de derivation. La sous-requete est donc enveloppee dans une
-- table derivee (SELECT ... FROM (SELECT ...) x) qui materialise l'id avant
-- l'UPDATE, contournant l'erreur "can't specify target table for update".
-- -----------------------------------------------------------------------------
UPDATE product
SET maxi_variant_product_id = (
SELECT id FROM (SELECT id FROM product WHERE name = 'Grande Frite') x
)
WHERE name = 'Moyenne Frite';
UPDATE product
SET maxi_variant_product_id = (
SELECT id FROM (SELECT id FROM product WHERE name = 'Grande Potatoes') x
)
WHERE name = 'Potatoes';
-- -----------------------------------------------------------------------------
-- 2. Restreindre les options du slot 'side' des menus aux deux choix de la
-- maquette. On supprime des slots 'side' toute option qui n'est ni Moyenne
-- Frite ni Potatoes (Petite Frite, Grande Frite, Grande Potatoes). Les autres
-- slots (drink, sauce) et les produits a la carte ne sont pas touches.
-- Idempotent : sur une base deja restreinte, ces lignes n'existent plus, le
-- DELETE affecte 0 ligne.
-- -----------------------------------------------------------------------------
DELETE FROM menu_slot_option
WHERE menu_slot_id IN (SELECT id FROM menu_slot WHERE slot_type = 'side')
AND product_id IN (
SELECT id FROM product WHERE name IN ('Petite Frite', 'Grande Frite', 'Grande Potatoes')
);

View file

@ -0,0 +1,86 @@
-- =============================================================================
-- Wakdo — Seed 0005 : tailles a la carte des boissons fontaine (30 / 50 cl)
-- =============================================================================
-- Purpose : cabler la dimension TAILLE (schema 0007) sur les boissons fontaine
-- seedees par 0002_catalogue.sql, sans toucher au code :
-- 1. la ligne existante de chaque soda devient la BASE 30 cl ;
-- 2. une ligne VARIANTE 50 cl est inseree par soda (base_product_id ->
-- la base, prix = base + 50c par defaut, +50 cl) ;
-- 3. la recette (product_ingredient) de la base est dupliquee sur la
-- variante, pour que le decrement de stock (consumption) frappe
-- aussi la 50 cl.
--
-- Perimetre : seules les boissons fontaine ont deux tailles (Coca Cola, Coca Sans
-- Sucres, Fanta Orange, Ice Tea Peche, Ice Tea Citron). Les boissons en bouteille
-- (Eau, Jus d'Orange, Jus de Pommes Bio) restent mono-taille (size_cl laisse NULL,
-- aucune variante).
--
-- Phase : R4 — depend du schema 0007 (product.size_cl + base_product_id) et du
-- catalogue 0002 (lignes boissons) ; la duplication de recette depend de
-- 0003 (product_ingredient des sodas).
--
-- Conventions:
-- - Aucun id en dur : toutes les references sont resolues par sous-requete sur
-- le nom du produit (memes noms que 0002_catalogue.sql).
-- - IDEMPOTENT : UPDATE convergent (repositionne la meme valeur) ; INSERT gardes
-- par WHERE NOT EXISTS (re-jouer n'insere pas de doublon). La sous-requete qui
-- lit `product` dans un INSERT INTO product est enveloppee en table derivee
-- pour contourner l'erreur MariaDB 1093 (technique de 0004_menu_side_maxi.sql).
-- =============================================================================
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
-- -----------------------------------------------------------------------------
-- 1. Marquer chaque soda fontaine comme BASE 30 cl. UPDATE convergent (rejouer
-- repose 30) -> idempotent. Le nom de base reste propre ("Coca Cola") : la
-- tuile catalogue garde le nom court, le picker affiche "30 cl" / "50 cl".
-- -----------------------------------------------------------------------------
UPDATE product
SET size_cl = 30
WHERE base_product_id IS NULL
AND name IN ('Coca Cola', 'Coca Sans Sucres', 'Fanta Orange', 'Ice Tea Peche', 'Ice Tea Citron');
-- -----------------------------------------------------------------------------
-- 2. Inserer la VARIANTE 50 cl de chaque soda. category_id / vat_rate / image
-- copies de la base ; price = base + 50c (defaut sensible, a confirmer) ;
-- base_product_id -> id de la base ; size_cl = 50 ; is_available = 1.
-- L'INSERT lit ET ecrit `product` : la sous-requete est enveloppee en table
-- derivee (b) pour contourner l'erreur 1093. WHERE NOT EXISTS garde le doublon
-- a la re-execution (une variante 50 cl de cette base existe deja -> 0 ligne).
-- -----------------------------------------------------------------------------
INSERT INTO product (category_id, name, price_cents, size_cl, base_product_id, vat_rate, image_path, is_available, display_order)
SELECT b.category_id, b.name_50, b.price_cents + 50, 50, b.id, b.vat_rate, b.image_path, 1, b.display_order
FROM (
SELECT id, category_id, CONCAT(name, ' 50cl') AS name_50, price_cents, vat_rate, image_path, display_order
FROM product
WHERE base_product_id IS NULL
AND name IN ('Coca Cola', 'Coca Sans Sucres', 'Fanta Orange', 'Ice Tea Peche', 'Ice Tea Citron')
) b
WHERE NOT EXISTS (
SELECT 1 FROM (SELECT base_product_id FROM product WHERE base_product_id IS NOT NULL) v
WHERE v.base_product_id = b.id
);
-- -----------------------------------------------------------------------------
-- 3. Dupliquer la recette de chaque base 30 cl sur sa variante 50 cl, pour que
-- le decrement de stock frappe aussi la 50 cl. Memes ingredients / quantites
-- que la base (simplification assumee : R4 vise le flux de commande, pas une
-- consommation volumetrique exacte). Une base sans recette (ex. theorique) ne
-- produit aucune ligne pour sa variante.
-- PK composite (product_id, ingredient_id) : WHERE NOT EXISTS garde la
-- re-execution (les lignes de la variante existent deja -> 0 ligne inseree).
-- -----------------------------------------------------------------------------
INSERT INTO product_ingredient (product_id, ingredient_id, quantity_normal, quantity_maxi, is_removable, is_addable, extra_price_cents)
SELECT v.id, src.ingredient_id, src.quantity_normal, src.quantity_maxi, src.is_removable, src.is_addable, src.extra_price_cents
FROM product v
JOIN (
SELECT pi.product_id AS base_id, pi.ingredient_id, pi.quantity_normal, pi.quantity_maxi,
pi.is_removable, pi.is_addable, pi.extra_price_cents
FROM product_ingredient pi
) src ON src.base_id = v.base_product_id
WHERE v.base_product_id IS NOT NULL
AND v.size_cl = 50
AND NOT EXISTS (
SELECT 1 FROM (SELECT product_id, ingredient_id FROM product_ingredient) e
WHERE e.product_id = v.id AND e.ingredient_id = src.ingredient_id
);

View file

@ -1,88 +1,19 @@
#
# Wakdo - orchestration des 4 services de la stack (Bloc 5 DevOps, Cr 7.c.3 / 7.c.4).
#
# Services :
# wakdo-web : Apache httpd, reverse proxy FastCGI -> wakdo-app, expose 80 via Traefik
# wakdo-app : PHP-FPM 8.3, execute le code PHP back-office + API
# wakdo-db : MariaDB 11.4, persistance des donnees metier
# wakdo-cron : Alpine + dcron, backups nocturnes et taches planifiees
#
# Reseaux :
# default (nom = wakdo_internal) : bridge interne pour l'inter-service,
# non expose a l'hote. app/db/cron ne sont PAS joignables publiquement.
# <REVERSE_PROXY_NETWORK> : reseau externe (ex. traefik_proxy) partage
# avec le Traefik de l'hote. Seul wakdo-web y est attache.
#
# Volumes :
# wakdo_db_data : named volume pour /var/lib/mysql (persistance BDD)
# wakdo_uploads : named volume pour les uploads produits back-office
# ./var/backups : bind-mount lecture-ecriture pour les dumps SQL
#
# Variables d'env consommees depuis .env (chargees par make via `include .env`
# et transmises ici par docker compose qui fait l'expansion automatique).
#
# Persistance : un `make down` preserve les named volumes. Seul `make clean`
# (interactif) ou `docker compose down -v` supprime les donnees.
#
name: wakdo
networks:
# Reseau interne : isolement des services applicatifs.
# wakdo-app, wakdo-db et wakdo-cron n'y sont accessibles que via les autres
# conteneurs de la stack. Aucun port hote expose.
wakdo_internal:
driver: bridge
internal: false
# internal: false (par defaut) car wakdo-app et wakdo-cron ont besoin
# de sortir sur internet (pour telecharger des packages au build et pour
# de potentiels appels API externes futurs). L'isolation vient du fait
# qu'aucun port hote n'est binde ici.
#
# Subnet explicite (RFC 1918) : l'auto-allocateur Docker du daemon hote
# est sature (15 /16 + 15 /20 deja alloues par d'autres stacks), il ne
# peut plus creer de reseau bridge sans subnet explicite. 192.168.148.0/24
# est dans le gap libre 192.168.144-159 (256 IP, largement suffisant pour
# 4 services), aucune collision avec les /24 acquagest voisins (150/154/
# 155/157). Choix defendable : right-sizing + isolation des fluctuations
# d'allocation auto sur cet hote mutualise.
ipam:
driver: default
config:
- subnet: 192.168.148.0/24
# Reseau du reverse proxy (Traefik) pre-existant sur l'hote.
# Son nom est configurable via REVERSE_PROXY_NETWORK dans .env pour
# supporter differents setups (traefik_proxy, traefik_public, proxy, ...).
reverse_proxy:
name: ${REVERSE_PROXY_NETWORK}
external: true
volumes:
wakdo_db_data:
# Named volume MariaDB. Permissions gerees par Docker (UID mysql=999
# dans le conteneur), zero souci cote hote. Survit a `docker compose down`.
# Pour remise a zero : `make clean` (interactif, confirme) ou
# `docker compose down -v` (destructif direct).
wakdo_uploads:
# Images produits uploadees par les equipiers depuis le back-office.
# Named volume pour les memes raisons que wakdo_db_data : permissions
# propres (www-data UID 82 en Alpine) et pas de pollution du repo git
# par des binaires.
services:
# =======================================================================
# wakdo-db : MariaDB 11.4 LTS
# =======================================================================
wakdo-db:
image: mariadb:11.4
container_name: wakdo-db
restart: unless-stopped
# Variables d'env d'initialisation (ne s'appliquent qu'au premier demarrage
# sur volume vide - voir docs/notes/docker-volumes-vs-bind-mounts.md).
environment:
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
MARIADB_DATABASE: ${DB_NAME}
@ -90,18 +21,11 @@ services:
MARIADB_PASSWORD: ${DB_PASSWORD}
MARIADB_AUTO_UPGRADE: "1"
TZ: ${APP_TIMEZONE:-Europe/Paris}
volumes:
- wakdo_db_data:/var/lib/mysql
- ./db/init:/docker-entrypoint-initdb.d:ro
networks:
- wakdo_internal
# Pas de ports exposes a l'hote : seuls wakdo-app et wakdo-cron peuvent
# joindre wakdo-db:3306 via le reseau interne.
# Healthcheck officiel fourni par l'image mariadb : le script bundled
# healthcheck.sh teste la connexion et l'init innodb.
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
@ -109,16 +33,30 @@ services:
retries: 6
start_period: 30s
# =======================================================================
# wakdo-app : PHP-FPM 8.3 (execute le code back-office + API)
# =======================================================================
wakdo-migrate:
image: mariadb:11.4
container_name: wakdo-migrate
restart: "no"
environment:
DB_HOST: ${DB_HOST}
DB_PORT: ${DB_PORT}
DB_NAME: ${DB_NAME}
DB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
volumes:
- ./db:/db:ro
networks:
- wakdo_internal
depends_on:
wakdo-db:
condition: service_healthy
entrypoint: ["bash", "/db/migrate-container.sh"]
wakdo-app:
build:
context: ./docker/php-fpm
dockerfile: Dockerfile
container_name: wakdo-app
restart: unless-stopped
environment:
APP_ENV: ${APP_ENV}
APP_DEBUG: ${APP_DEBUG}
@ -134,131 +72,78 @@ services:
SESSION_LIFETIME_ABSOLUTE: ${SESSION_LIFETIME_ABSOLUTE}
SESSION_NAME: ${SESSION_NAME}
CORS_ALLOWED_ORIGIN: ${CORS_ALLOWED_ORIGIN}
PASSWORD_ALGO: ${PASSWORD_ALGO}
ARGON2_MEMORY_COST: ${ARGON2_MEMORY_COST}
ARGON2_TIME_COST: ${ARGON2_TIME_COST}
ARGON2_THREADS: ${ARGON2_THREADS}
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}
STAFF_PIN_MIN_LENGTH: ${STAFF_PIN_MIN_LENGTH}
STAFF_PIN_MAX_LENGTH: ${STAFF_PIN_MAX_LENGTH}
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}
PASSWORD_RESET_TTL: ${PASSWORD_RESET_TTL}
UPLOAD_MAX_SIZE_MB: ${UPLOAD_MAX_SIZE_MB}
UPLOAD_ALLOWED_MIME: ${UPLOAD_ALLOWED_MIME}
volumes:
# Bind-mount du code source pour le hot-reload en dev.
# En prod, cette ligne est remplacee par un COPY dans l'image
# via docker-compose.prod.yml (override a venir en P7).
- ./src:/var/www/html
# Named volume pour les uploads, plus specifique que le bind-mount
# parent : les uploads ne vont pas dans ./src, ils restent dans le
# volume Docker et survivent aux `make down`.
- wakdo_uploads:/var/www/html/public/uploads
networks:
- wakdo_internal
depends_on:
wakdo-migrate:
condition: service_completed_successfully
wakdo-db:
condition: service_healthy
# Healthcheck defini dans le Dockerfile (php -r exit 0).
# =======================================================================
# wakdo-web : Apache httpd (reverse FastCGI vers wakdo-app, expose via Traefik)
# =======================================================================
wakdo-web:
build:
context: ./docker/apache
dockerfile: Dockerfile
container_name: wakdo-web
restart: unless-stopped
environment:
# Noms de domaine injectes dans les vhosts (ServerName ${TRAEFIK_DOMAIN_*}).
TRAEFIK_DOMAIN_KIOSK: ${TRAEFIK_DOMAIN_KIOSK}
TRAEFIK_DOMAIN_ADMIN: ${TRAEFIK_DOMAIN_ADMIN}
APP_HOST_KIOSK: ${APP_HOST_KIOSK}
APP_HOST_ADMIN: ${APP_HOST_ADMIN}
ports:
- "${HTTP_PORT:-8080}:80"
volumes:
# Meme bind-mount que wakdo-app : Apache sert les fichiers statiques
# (HTML, CSS, JS, images) depuis ce dossier. PHP ne passe pas par
# ce chemin ici, il passe par le proxy FastCGI vers wakdo-app.
- ./src:/var/www/html
- wakdo_uploads:/var/www/html/public/uploads
networks:
- wakdo_internal
- reverse_proxy
depends_on:
wakdo-migrate:
condition: service_completed_successfully
wakdo-app:
condition: service_started
wakdo-db:
condition: service_healthy
# === Labels Traefik : deux routers (kiosk + admin) sur le meme conteneur ===
# Le Traefik de l'hote decouvre ces labels automatiquement (provider docker).
# On ne configure PAS le certresolver ici : le Traefik hote le gere via
# sa propre config (acme.json, resolver par defaut).
labels:
- "traefik.enable=true"
- "traefik.docker.network=${REVERSE_PROXY_NETWORK}"
# --- Router kiosk (borne client) ---
- "traefik.http.routers.wakdo-kiosk.rule=Host(`${TRAEFIK_DOMAIN_KIOSK}`)"
- "traefik.http.routers.wakdo-kiosk.entrypoints=websecure"
- "traefik.http.routers.wakdo-kiosk.tls=true"
- "traefik.http.routers.wakdo-kiosk.tls.certresolver=letsencrypt"
- "traefik.http.routers.wakdo-kiosk.service=wakdo-kiosk-svc"
- "traefik.http.services.wakdo-kiosk-svc.loadbalancer.server.port=80"
# --- Router admin (back-office + API) ---
- "traefik.http.routers.wakdo-admin.rule=Host(`${TRAEFIK_DOMAIN_ADMIN}`)"
- "traefik.http.routers.wakdo-admin.entrypoints=websecure"
- "traefik.http.routers.wakdo-admin.tls=true"
- "traefik.http.routers.wakdo-admin.tls.certresolver=letsencrypt"
- "traefik.http.routers.wakdo-admin.service=wakdo-admin-svc"
- "traefik.http.services.wakdo-admin-svc.loadbalancer.server.port=80"
# --- Middleware : redirection HTTP -> HTTPS ---
# Applique aux 2 hosts via un router "catch-all" sur entrypoints=web.
- "traefik.http.routers.wakdo-kiosk-http.rule=Host(`${TRAEFIK_DOMAIN_KIOSK}`)"
- "traefik.http.routers.wakdo-kiosk-http.entrypoints=web"
- "traefik.http.routers.wakdo-kiosk-http.middlewares=wakdo-to-https"
- "traefik.http.routers.wakdo-admin-http.rule=Host(`${TRAEFIK_DOMAIN_ADMIN}`)"
- "traefik.http.routers.wakdo-admin-http.entrypoints=web"
- "traefik.http.routers.wakdo-admin-http.middlewares=wakdo-to-https"
- "traefik.http.middlewares.wakdo-to-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.wakdo-to-https.redirectscheme.permanent=true"
# =======================================================================
# wakdo-cron : taches planifiees (backup, purge, agregations)
# =======================================================================
wakdo-cron:
build:
context: ./docker/cron
dockerfile: Dockerfile
container_name: wakdo-cron
restart: unless-stopped
# init: true -> Docker injecte tini comme PID 1. dcron exige un init
# parent pour pouvoir setpgid() sur ses jobs (sinon "Operation not
# permitted" en boucle car un PID 1 sans init ne peut pas changer les
# groupes de processus). Cf. busybox-utils issue tracker.
init: true
environment:
# Credentials BDD pour mysqldump (lecture seule via USER applicatif,
# PAS le root password). Le user applicatif doit avoir SELECT +
# LOCK TABLES + SHOW VIEW sur la BDD (migrations P2).
DB_HOST: ${DB_HOST}
DB_PORT: ${DB_PORT}
DB_NAME: ${DB_NAME}
DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD}
AUDIT_LOG_RETENTION_DAYS: ${AUDIT_LOG_RETENTION_DAYS:-365}
THROTTLE_PURGE_AFTER_HOURS: ${THROTTLE_PURGE_AFTER_HOURS:-24}
TZ: ${CRON_TIMEZONE:-Europe/Paris}
volumes:
# Bind-mount vers l'hote pour les dumps : inspectables par ls, scp-able
# hors docker. Le dossier ./var/backups est gitignore.
- ./var/backups:/backups
networks:
- wakdo_internal
depends_on:
wakdo-db:
condition: service_healthy

View file

@ -2,8 +2,8 @@
# Wakdo - vhosts applicatifs
#
# Un seul conteneur Apache derriere Traefik sert **les deux** FQDN :
# - TRAEFIK_DOMAIN_KIOSK -> /var/www/html/public/borne (borne client, Bloc 1)
# - TRAEFIK_DOMAIN_ADMIN -> /var/www/html/public/admin (back-office + API, Bloc 2)
# - APP_HOST_KIOSK -> /var/www/html/public/borne (borne client, Bloc 1)
# - APP_HOST_ADMIN -> /var/www/html/public/admin (back-office + API, Bloc 2)
#
# Comme Traefik termine TLS en amont et communique en HTTP clair avec Apache
# sur le reseau docker, les vhosts ecoutent sur :80 et font confiance aux
@ -39,8 +39,8 @@
# === Borne client (Bloc 1 - front vanilla HTML/CSS/JS) ===
<VirtualHost *:80>
# Hostname injecte par la var d'env TRAEFIK_DOMAIN_KIOSK au runtime.
ServerName ${TRAEFIK_DOMAIN_KIOSK}
# Hostname injecte par la var d'env APP_HOST_KIOSK au runtime.
ServerName ${APP_HOST_KIOSK}
DocumentRoot "/var/www/html/public/borne"
@ -55,7 +55,10 @@
# SPA-like fallback : toute URL non-fichier -> index.html
# (pour permettre de bookmarker un chemin profond dans la borne).
# Exclusion /api/ : ces requetes sont relayees a l'API (cf. <Location /api>
# plus bas) et ne doivent JAMAIS retomber sur index.html.
RewriteEngine On
RewriteCond %{REQUEST_URI} !^/api/
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.html [L]
@ -66,6 +69,28 @@
Require all denied
</Directory>
# === API en MEME origine (P4 - passerelle same-origin) ===
# La borne consomme l'API publique (/api/*) sur SA PROPRE origine : ce vhost
# relaie ces requetes au front controller admin via PHP-FPM. data.js garde
# donc ses URLs relatives (/api/categories...) -> aucune requete cross-origin
# cote borne -> CORS inutile pour ce parcours (le middleware reste en place
# cote API comme defense en profondeur). SEUL /api est relaye : le back-office
# (/login, /admin/*) n'est PAS joignable depuis l'origine borne.
#
# Le chemin apres host:port dans l'URL fcgi EST le SCRIPT_FILENAME envoye a
# FPM : on le force sur le front controller admin (un .php REEL). Sans ca, FPM
# recevrait un chemin sous le docroot borne sans extension .php et rejetterait
# (security.limit_extensions par defaut = .php -> reponse "Access denied").
# ProxyPassMatch intercepte des la phase translate -> le faux chemin
# /.../borne/api/... n'est jamais calcule. REQUEST_URI (=/api/categories) et la
# query string sont preserves -> le Router (qui lit REQUEST_URI) route correctement.
ProxyPassMatch "^/api(/.*)?$" "fcgi://wakdo-app:9000/var/www/html/public/admin/index.php"
# mod_proxy_fcgi derive un SCRIPT_FILENAME corrompu (prefixe proxy: + chemin
# original colle apres index.php) -> FPM rejette (extension != .php). On force
# la valeur sur le front controller admin (un .php REEL) ; REQUEST_URI reste
# intact, donc le Router route correctement.
ProxyFCGISetEnvIf "true" SCRIPT_FILENAME "/var/www/html/public/admin/index.php"
# Compression text/html, css, js, json (Cr 1.e.8 temps de chargement).
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE text/html text/css text/javascript \
@ -89,7 +114,7 @@
# === Back-office + API REST (Bloc 2 - PHP from scratch + MVC) ===
<VirtualHost *:80>
ServerName ${TRAEFIK_DOMAIN_ADMIN}
ServerName ${APP_HOST_ADMIN}
DocumentRoot "/var/www/html/public/admin"
@ -128,7 +153,7 @@
</DirectoryMatch>
# CORS : l'API admin sous /api/* doit accepter les requetes venant
# de la borne kiosk (TRAEFIK_DOMAIN_KIOSK). Wildcard interdit.
# de la borne kiosk (APP_HOST_KIOSK). Wildcard interdit.
# La vraie valeur vient de CORS_ALLOWED_ORIGIN dans .env, lue cote PHP.
# Ici on pose juste les headers de prealable OPTIONS.
<Location /api>

View file

@ -16,6 +16,12 @@
# 03h00 : dump BDD complet, compresse et rotate (garde 14 derniers).
0 3 * * * /scripts/backup-db.sh 2>&1
# 04h15 : purge de retention du journal d'audit (mlt.md 13.4, AUDIT_LOG_RETENTION_DAYS).
15 4 * * * /scripts/purge-audit-log.sh 2>&1
# 04h45 : purge des compteurs de throttle sans verrou actif (mlt.md 13.5, THROTTLE_PURGE_AFTER_HOURS).
45 4 * * * /scripts/purge-throttle.sh 2>&1
# Toutes les 15 min pendant la fenetre de maintenance : purge des sessions
# PHP expirees cote BDD (pas les sessions systeme qui sont en /tmp du conteneur
# wakdo-app, donc ephemeres par nature). A activer quand la table sessions

View file

@ -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

View file

@ -0,0 +1,34 @@
#!/usr/bin/env bash
#
# Wakdo - purge de retention du journal d'audit (mlt.md 13.4).
#
# Supprime les lignes audit_log plus anciennes que AUDIT_LOG_RETENTION_DAYS
# (interet legitime / tracabilite fiscale, configurable). L'imputabilite recente
# est preservee. C'est l'unique exception documentee a l'append-only de audit_log
# (RG-T14) : une purge de retention planifiee, jamais une mutation applicative.
#
# Variables d'env (injectees par docker-compose depuis .env) :
# DB_HOST DB_PORT DB_NAME DB_USER DB_PASSWORD
# AUDIT_LOG_RETENTION_DAYS (defaut 365)
#
# Exit codes : 0 OK | 1 env manquant/invalide | 2 requete SQL echouee
set -euo pipefail
log() { echo "[purge-audit-log $(date -Iseconds)] $*" >&2; }
for var in DB_HOST DB_PORT DB_NAME DB_USER DB_PASSWORD; do
if [ -z "${!var:-}" ]; then log "ERROR: variable $var vide ou non definie"; exit 1; fi
done
DAYS="${AUDIT_LOG_RETENTION_DAYS:-365}"
case "$DAYS" in
''|*[!0-9]*) log "ERROR: AUDIT_LOG_RETENTION_DAYS non entier ('$DAYS')"; exit 1 ;;
esac
if ! n="$(mariadb --host="$DB_HOST" --port="$DB_PORT" --user="$DB_USER" --password="$DB_PASSWORD" \
--default-character-set=utf8mb4 -N -B "$DB_NAME" \
-e "DELETE FROM audit_log WHERE created_at < NOW() - INTERVAL ${DAYS} DAY; SELECT ROW_COUNT();")"; then
log "ERROR: purge audit_log a echoue"
exit 2
fi
log "audit_log: ${n} ligne(s) purgee(s) (> ${DAYS} jours)"

View file

@ -0,0 +1,40 @@
#!/usr/bin/env bash
#
# Wakdo - purge des compteurs de throttle sans verrou actif (mlt.md 13.5).
#
# Borne la croissance de login_throttle (per-IP, RG-8) et pin_throttle
# (per-acteur, RG-T22) : supprime les lignes dont le verrou n'est plus actif
# ET dont la derniere tentative est plus ancienne que THROTTLE_PURGE_AFTER_HOURS.
# Les lignes servant encore un verrou actif sont conservees.
#
# Variables d'env (injectees par docker-compose depuis .env) :
# DB_HOST DB_PORT DB_NAME DB_USER DB_PASSWORD
# THROTTLE_PURGE_AFTER_HOURS (defaut 24)
#
# Exit codes : 0 OK | 1 env manquant/invalide | 2 requete SQL echouee
set -euo pipefail
log() { echo "[purge-throttle $(date -Iseconds)] $*" >&2; }
for var in DB_HOST DB_PORT DB_NAME DB_USER DB_PASSWORD; do
if [ -z "${!var:-}" ]; then log "ERROR: variable $var vide ou non definie"; exit 1; fi
done
HOURS="${THROTTLE_PURGE_AFTER_HOURS:-24}"
case "$HOURS" in
''|*[!0-9]*) log "ERROR: THROTTLE_PURGE_AFTER_HOURS non entier ('$HOURS')"; exit 1 ;;
esac
db() {
mariadb --host="$DB_HOST" --port="$DB_PORT" --user="$DB_USER" --password="$DB_PASSWORD" \
--default-character-set=utf8mb4 -N -B "$DB_NAME" -e "$1"
}
# login_throttle et pin_throttle partagent le meme predicat (mlt.md 13.5).
for table in login_throttle pin_throttle; do
if ! n="$(db "DELETE FROM ${table} WHERE (lockout_until IS NULL OR lockout_until < NOW()) AND last_attempt_at < NOW() - INTERVAL ${HOURS} HOUR; SELECT ROW_COUNT();")"; then
log "ERROR: purge ${table} a echoue"
exit 2
fi
log "${table}: ${n} ligne(s) purgee(s) (sans verrou actif, > ${HOURS}h)"
done

View file

@ -0,0 +1,82 @@
#!/usr/bin/env bash
#
# Wakdo - restauration de la BDD depuis un dump produit par backup-db.sh.
#
# Operation MANUELLE (pas un job cron) : restaurer ecrase les donnees courantes.
# A lancer dans le conteneur disposant du client mysql et du reseau de la BDD, p.ex.
# docker compose run --rm -v "$PWD/var/backups:/backups" wakdo-cron \
# /scripts/restore-db.sh /backups/wakdo_YYYYMMDD_HHMMSS.sql.gz --force
#
# Variables d'env lues (memes que backup-db.sh) :
# DB_HOST DB_PORT DB_NAME DB_USER DB_PASSWORD
# Note : un dump complet contient des DROP/CREATE TABLE ; le compte utilise doit
# donc avoir les privileges DDL. Le user applicatif (DML seul) ne suffit pas :
# fournir un compte privilegie via DB_USER/DB_PASSWORD pour la restauration.
#
# Usage :
# restore-db.sh <fichier.sql.gz|fichier.sql> [--force]
# Sans --force, le script demande une confirmation interactive.
#
# Exit codes :
# 0 - restauration OK
# 1 - variables env manquantes / mauvais usage / fichier absent
# 2 - restauration mysql a echoue
# 3 - confirmation refusee
set -euo pipefail
DUMP_FILE="${1:-}"
FORCE="${2:-}"
log() {
echo "[restore-db $(date -Iseconds)] $*" >&2
}
if [ -z "$DUMP_FILE" ]; then
log "usage: restore-db.sh <fichier.sql.gz|fichier.sql> [--force]"
exit 1
fi
if [ ! -f "$DUMP_FILE" ]; then
log "ERROR: fichier de dump introuvable : $DUMP_FILE"
exit 1
fi
for var in DB_HOST DB_PORT DB_NAME DB_USER DB_PASSWORD; do
if [ -z "${!var:-}" ]; then
log "ERROR: variable $var vide ou non definie"
exit 1
fi
done
# Garde-fou : la restauration ecrase la base cible. Confirmation requise sauf --force.
if [ "$FORCE" != "--force" ]; then
printf 'Restaurer %s dans la base "%s" sur %s:%s ? Les donnees actuelles seront ecrasees. [oui/NON] ' \
"$DUMP_FILE" "$DB_NAME" "$DB_HOST" "$DB_PORT" >&2
read -r answer
if [ "$answer" != "oui" ]; then
log "restauration annulee."
exit 3
fi
fi
# Decompression a la volee si le dump est gzippe.
reader=(cat "$DUMP_FILE")
case "$DUMP_FILE" in
*.gz) reader=(gzip -dc "$DUMP_FILE") ;;
esac
log "restauration de ${DB_NAME} depuis ${DUMP_FILE}"
if ! "${reader[@]}" | mysql \
--host="${DB_HOST}" \
--port="${DB_PORT}" \
--user="${DB_USER}" \
--password="${DB_PASSWORD}" \
--default-character-set=utf8mb4 \
"${DB_NAME}"; then
log "ERROR: la restauration mysql a echoue"
exit 2
fi
log "restauration OK : ${DB_NAME}"
exit 0

View file

@ -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]

270
docs/ARCHITECTURE.md Normal file
View file

@ -0,0 +1,270 @@
# Architecture — Wakdo
Vue d'ensemble technique du projet (borne de commande fast-food, certification RNCP 37805).
Point d'entree pour comprendre la stack, le decoupage et les choix de conception.
- Scope metier, planning, mapping RNCP : `docs/PROJECT_CONTEXT.md`.
- Modelisation detaillee (entites, operations, regles) : `docs/merise/` (dictionary, mcd, mct, mlt).
- Decisions tracees : `docs/adr/` et `docs/journal/`.
**Auteur : BYAN** (formalisation ; arbitrage et validation par l'auteur du projet).
---
## 1. Vue d'ensemble
Wakdo simule une borne de commande tactile de restauration rapide, avec back-office
d'administration, workflow cuisine et API REST interne. Deux surfaces applicatives :
- **Borne (kiosk)** — front statique (HTML/CSS/JS vanilla ES6) servi par Apache,
consommant des donnees (JSON statique en P5, API DB-backed au swap P4).
- **Back-office + API** — application PHP rendue serveur (MVC maison) + endpoints
`/api/*`, derriere authentification et RBAC.
Trois canaux de commande (`source`) : `kiosk`, `counter`, `drive`. Le cycle de vie
d'une commande et la machine a etats sont decrits dans `docs/merise/` (domaine
commande = phase **P4**, schema en base mais workflow applicatif a venir).
---
## 2. Stack technique
| Couche | Techno | Note |
|---|---|---|
| Langage back | PHP 8.3 | from scratch, sans framework |
| Autoloader | PSR-4 manuel (`spl_autoload_register`) | namespace `App\` -> `src/app/` |
| Base de donnees | MariaDB 11.4 | PDO, requetes preparees uniquement |
| Serveur web | Apache httpd 2.4 (Alpine) | reverse FastCGI -> PHP-FPM |
| Serveur app | PHP-FPM 8.3 (Alpine) | execute le code back-office + API |
| Front borne | HTML5 + CSS3 + JS ES6 (modules) | vanilla, sans build |
| Conteneurisation | Docker + docker compose v2 | `docker compose up` = stack complete |
| Tests PHP | PHPUnit 11 (`.phar`, sans Composer) | unit + integration DB |
| Tests front | node:test + jsdom | harnais kiosk (`tests/js/`) |
| Analyse statique | PHPStan niveau 6 (`.phar`) | |
| CI/CD | Forgejo Actions | secret-scan, lint, tests ; merge natif sur CI verte |
| Versioning | Git + Forgejo (`git.acadenice.com`, miroir GitHub) | Conventional Commits |
Justifications (composer-less, from-scratch, etc.) : `docs/PROJECT_CONTEXT.md` section 6.
---
## 3. Topologie de deploiement
Cinq services Docker. Deux modes, par fichier compose :
- **`docker-compose.yml`** (versionne) — standalone : tourne en local sans configuration.
`wakdo-web` publie un port hote (`${HTTP_PORT:-8080}`), reseau interne seul.
- **`docker-compose.prod.yml`** (gitignore, propre a chaque hote) — meme stack exposee
via un reverse proxy Traefik (reseau externe + labels TLS), sans port hote.
```
[ docker compose up -d ]
|
wakdo-db (MariaDB 11.4, healthcheck)
| service_healthy
v
wakdo-migrate (one-shot : migrations + seed idempotents, puis sort)
| service_completed_successfully
+---------------+----------------+
v v
wakdo-app (PHP-FPM 8.3) wakdo-web (Apache)
^ FastCGI :9000 <-----------/ publie ${HTTP_PORT}:80 (mode local)
| ou labels Traefik (mode prod)
|
wakdo-db <-- PDO
wakdo-cron (dcron) : backup BDD + purges retention (RGPD)
```
- **Reseau** : `wakdo_internal` (bridge) isole les services ; aucun port hote en mode
prod (acces par le proxy). En mode local, seul `wakdo-web` publie un port.
- **Volumes** : `wakdo_db_data` (persistance MariaDB), `wakdo_uploads` (images produits) ;
bind-mount `./var/backups` pour les dumps.
- **`wakdo-cron`** utilise `init: true` (tini comme PID 1 : dcron a besoin d'un init
parent pour `setpgid` sur ses jobs).
- Choix d'un **subnet RFC 1918 explicite** sur `wakdo_internal` cote prod : l'hote
mutualise a un allocateur Docker sature ; le subnet evite l'echec d'allocation auto.
Detail reseaux/volumes : `docs/PROJECT_CONTEXT.md` section 5.
---
## 4. Demarrage : une commande (Cr 7.c.4)
`docker compose up -d` amene une stack complete et utilisable :
1. `wakdo-db` demarre, devient *healthy* (script `healthcheck.sh` de l'image).
2. `wakdo-migrate` (service one-shot) applique, par le reseau et de maniere
**idempotente** :
- `db/migrations/*.sql` — suivi dans la table `schema_migrations` ;
- `db/seeds/*.sql` — suivi dans la table `seeds_applied`.
Relancer ne rejoue que les fichiers en attente. Le runner : `db/migrate-container.sh`.
3. `wakdo-app` et `wakdo-web` attendent la **completion** de `wakdo-migrate`
(`depends_on: service_completed_successfully`) avant de servir.
Le schema (DDL) et les donnees de reference (roles, permissions, catalogue, admin
bootstrap) sont donc en place sans etape manuelle. `db/migrate.sh` (hote, via
`docker exec`) reste disponible pour l'usage manuel / `--status`.
> Migration de mecanisme : sur une base **deja seedee** avant l'introduction du suivi
> (`seeds_applied` absente), back-filler la table avant le premier `up` (sinon re-seed
> -> conflits d'unicite). Volume vierge : aucun souci. Cf.
> `docs/journal/2026-06-17--makefile-to-compose-migrate.md`.
---
## 5. Structure du code
Namespace `App\` -> `src/app/` (PSR-4 manuel). Front controller du vhost admin :
`src/public/admin/index.php` (Apache reecrit tout vers ce fichier ; le routeur voit
le `REQUEST_URI` intact).
```
src/app/
Core/ Autoloader, Config, Database (PDO), Request, Response, Router
Auth/ AuthService, SessionManager, SessionGuard, Authorizer, PinVerifier,
PinThrottle, ThrottlePolicy, PasswordHasher, Csrf, PasswordResetService,
UserRepository, RoleRepository, UserDirectory, Mailer/LogMailer
Catalogue/ Category / Product / Menu / Ingredient / Stats Repository
Controllers/ Admin (base), Authenticated (base), Auth, PasswordReset, Profile, Me,
Dashboard, Stats, Category, Product, Menu, Ingredient, User, Role,
Health, Home
Views/ admin/* (pages back-office rendues serveur), auth/* (login/reset)
src/public/
admin/ front controller + assets (CSS/JS) du back-office
borne/ front kiosk statique (index, categories, products, product, cart,
payment, confirmation) + assets JS modules + data JSON
```
Conventions transverses : controleurs non-`final` (seam de test : sous-classe injectant
des doubles via `db()` / `sessionManager()`) ; repository sur `DatabaseInterface` ;
chaque mutation passe par CSRF + validation serveur + allowlist (voir section 7).
---
## 6. Flux d'une requete back-office
```
Navigateur --(HTTPS via Traefik | HTTP local)--> wakdo-web (Apache)
| vhost par ServerName (APP_HOST_KIOSK -> public/borne, APP_HOST_ADMIN -> public/admin)
| PHP -> FastCGI :9000
v
wakdo-app (PHP-FPM) : src/public/admin/index.php
| Router (methode + chemin) -> [Controller, action]
v
Controller (extends AdminController)
| guard(permission) -> SessionGuard (RG-6/RG-T02 : session valide ?)
| + Authorizer::can(role, permission) (RG-T03, recharge DB)
| (mutation) Csrf::validate + validation serveur (RG-T18) + allowlist (RG-T16)
| (action sensible) PinVerifier + throttle, audit_log dans la meme transaction
v
Repository -> PDO (prepared) -> MariaDB
|
v
Vue rendue dans admin/layout (sorties echappees, RG-T15) | ou JSON pour /api/*
```
La borne (kiosk) est servie en statique par Apache ; ses pages consomment les donnees
via `fetch` (JSON statique en P5 ; bascule sur `/api/*` DB-backed au swap P4).
---
## 7. Securite (security-by-design)
Couche transverse, regles `RG-T*` definies dans `docs/merise/mlt.md`. Synthese :
- **Authentification** : mot de passe hache **argon2id** (cout configurable, defauts
OWASP) ; sessions PHP avec regeneration d'ID au login, idle 4h + absolu 10h ; cookie
nomme `WAKDO_SID`.
- **RBAC** : `Authorizer::can(role_id, permission_code)` teste une **permission** (pas
un nom de role), rechargee depuis la base a chaque verification. 5 roles seedes, 23
permissions figees, matrice `role_permission` editable (back-office, voir domaine 10).
- **PIN d'action sensible (RG-T13)** : les operations sensibles (annulation, prix/TVA,
suppressions, inventaire, gestion utilisateur, RBAC, effacement PII) exigent une
re-autorisation par PIN equipier (argon2id). L'`acting_user_id` resolu par le PIN est
ecrit dans `audit_log` (RG-T14) dans la **meme transaction** que l'effet (RG-T08). Les
operations de stock tracent via `stock_movement.user_id` (pas de double-journal).
- **Throttling** (backoff degressif, pas de verrou definitif) :
- login par compte (`user.failed_login_attempts` / `lockout_until`) + par IP
(`login_throttle`, RG-8/9) ;
- PIN d'action sensible (`pin_throttle`, RG-T22) — compteur **separe** du login, par
utilisateur agissant.
- **Entrees / sorties** : validation serveur bornee (RG-T18) ; allowlist d'affectation
de masse (RG-T16, empeche d'injecter `role_id`/`price_cents`/`is_active`...) ; toutes
les sorties HTML echappees (RG-T15) ; front borne CSP-safe (pas de script inline cote
code projet).
- **Conventions HTTP** : conflit d'etat (unicite, FK RESTRICT) -> **409** ; validation
qui echoue -> **422** ; CSRF/permission -> **403**.
- **RGPD** : anonymisation (mlt 10.5) qui conserve la ligne (tombstone) pour preserver
les FK et la trace d'audit, en vidant la PII ; purges de retention par `wakdo-cron`
(audit_log, throttle, sessions, commandes).
- **Isolation** : pas de port hote en mode prod (acces par le proxy) ; user applicatif
MariaDB en moindre privilege (DDL reserve au runner migrate root ; cf.
`db/init/10-scope-app-user.sh`).
Threat model STRIDE + classification des donnees : `docs/PROJECT_CONTEXT.md` section 19.
---
## 8. Modele de donnees
22 tables (DDL `db/migrations/`), regroupees par domaine :
- **Catalogue** : `category`, `product`, `menu`, `menu_slot`, `menu_slot_option`,
`ingredient`, `product_ingredient`, `allergen`, `ingredient_allergen`, `stock_movement`.
- **RBAC / comptes** : `user`, `role`, `permission`, `role_permission`,
`role_visible_source`.
- **Commande (P4, schema pret)** : `customer_order`, `order_item`,
`order_item_selection`, `order_item_modifier`.
- **Transverses** : `audit_log` (journal immuable), `login_throttle`, `pin_throttle`.
Quelques derivations **calculees, non stockees** :
- **Stock en pourcentage** (mcd 5.3) : `stock_pct = round(stock_quantity / stock_capacity
* 100)` ; 3 bandes (normal / alerte / critique) selon `low_stock_pct` /
`critical_stock_pct`. `stock_quantity` est signe (survente assumee).
- **Disponibilite produit (RG-T21)** : un produit est commandable si `is_available = 1`
ET chaque ingredient non retirable de sa composition est au-dessus de la bande
critique. Pas de cascade ni de colonne stockee.
- **`service_day`** : journee de service (coupure a 10:00) pour les agregations stats,
expression SQL non materialisee.
MCD / MLD / dictionnaire : `docs/merise/`.
---
## 9. Tests & qualite
- **PHPUnit** (`.phar`, sans Composer) : tests *unit* (controleurs via double
`FakeDatabase`, logique pure) + *integration* contre une vraie MariaDB (auto-skip si
`WAKDO_DB_TESTS != 1`). Lancement :
`docker run --rm -v "$PWD":/app -w /app wakdo-wakdo-app php phpunit.phar -c phpunit.xml`.
- **Front borne** : `node --test` + jsdom (`tests/js/`).
- **PHPStan niveau 6** (`.phar`).
- **CI Forgejo Actions** (`.forgejo/workflows/ci.yml`) : `secret-scan` (gitleaks),
`php-lint`, `static-tests` (PHPStan + PHPUnit avec service MariaDB ephemere migre +
seede), `js-tests` (Node 20). Fusion par auto-merge NATIF Forgejo (squash,
`merge_when_checks_succeed`) des que les checks requis sont verts — pas de job de merge.
- **Branch protection** : `dev` et `main` proteges (PR requise, force-push bloque,
checks requis).
Pyramide visee : Unit > Integration > E2E. Les tests E2E navigateur (Playwright) sont
une initiative a venir.
---
## 10. Methodologie & tracabilite
Projet developpe avec l'appui de **BYAN** (agents IA custom, Merise Agile + 64 mantras)
et d'outils d'IA generative, conformement a l'autorisation du centre de formation.
- Decisions d'architecture, scope et design : prises par l'auteur.
- Code, tests, doc : co-rediges et valides par l'auteur avant commit.
- **Pas de trailer `Co-Authored-By`** sur les commits : la transparence vit dans le
README et `docs/PROJECT_CONTEXT.md` section 17, pas dans les metadonnees git.
- Tracabilite : `docs/journal/` (retros par session et par feature).
---
*Document vivant — mis a jour au fil de l'implementation. Source de verite scope/RNCP :
`docs/PROJECT_CONTEXT.md`.*

144
docs/DEVELOPER.md Normal file
View file

@ -0,0 +1,144 @@
# Guide developpeur — Wakdo
Comment lancer, tester et contribuer. Pour l'architecture (stack, services, modele,
securite), voir `docs/ARCHITECTURE.md`. Pour le scope et le mapping RNCP,
`docs/PROJECT_CONTEXT.md`.
---
## 1. Prerequis
- Docker Engine + docker compose v2 (https://docs.docker.com/engine/install/).
- Node 20+ (uniquement pour les tests front borne ; pas requis pour faire tourner l'app).
Aucune installation de PHP / Composer / PHPUnit sur l'hote : tout passe par les
conteneurs et des `.phar` autonomes.
---
## 2. Lancer en local
```bash
cp .env.example .env
docker compose up -d
```
- Borne : http://kiosk.localhost:8080
- Admin + API : http://admin.localhost:8080
`*.localhost` resout vers `127.0.0.1`. Changer le port via `HTTP_PORT` dans `.env`.
Le `.env.example` fonctionne tel quel en local (valeurs dev). Au boot, le service
`wakdo-migrate` applique migrations + seed (admin bootstrap inclus) avant que l'app
ne serve.
Commandes utiles :
```bash
docker compose ps # etat des services
docker compose logs -f wakdo-app # logs PHP-FPM
docker compose down # arret (volumes preserves)
docker compose down -v # arret + suppression des donnees
```
Deploiement derriere un reverse proxy : voir le `README.md` (section prod) +
`docker-compose.prod.yml` (gitignore, propre a l'hote).
---
## 3. Base de donnees : migrations & seed
- `db/migrations/*.sql` (DDL) et `db/seeds/*.sql` (donnees de reference) sont appliques
de maniere **idempotente** par `wakdo-migrate` (suivi `schema_migrations` /
`seeds_applied`). Relancer `docker compose up` ne rejoue que les fichiers en attente.
- **Ajouter une migration** : creer `db/migrations/000N_description.sql` (ordre
lexicographique). Appliquee au prochain `docker compose up`, ou a la main :
```bash
bash db/migrate.sh # applique les migrations en attente (hote)
bash db/migrate.sh --status # liste l'etat sans rien appliquer
```
- Idem pour un seed : `db/seeds/000N_description.sql`.
---
## 4. Tests & analyse statique
Les tests PHP tournent dans l'image applicative (PHPUnit `.phar`). La stack doit etre
demarree pour les tests d'integration (ils ciblent le service `wakdo-db`).
```bash
# Tests unitaires (sans base)
docker run --rm -v "$PWD":/app -w /app wakdo-wakdo-app \
php phpunit.phar -c phpunit.xml --testsuite unit
# Tests d'integration (vraie MariaDB ; auto-skip si WAKDO_DB_TESTS != 1)
docker run --rm --network wakdo_wakdo_internal --env-file .env -e WAKDO_DB_TESTS=1 \
-v "$PWD":/app -w /app wakdo-wakdo-app php phpunit.phar -c phpunit.xml
# Analyse statique PHPStan niveau 6
docker run --rm -v "$PWD":/app -w /app wakdo-wakdo-app \
php -d memory_limit=512M phpstan.phar analyse -c phpstan.neon --no-progress
```
Tests front borne (Node + jsdom) :
```bash
npm install # une fois (devDependency jsdom)
npm run test:js # node --test tests/js/
```
> Le nom de reseau `wakdo_wakdo_internal` et l'image `wakdo-wakdo-app` derivent du
> nom de projet compose (`name: wakdo`). Les `.phar` (phpunit, phpstan) sont
> gitignores ; les retelecharger si absents (voir `docs/journal/`).
---
## 5. Conventions de code
- **PSR-4 manuel** : namespace `App\` -> `src/app/`. Pas de framework.
- **Controleurs** : non-`final` (seam de test ; les tests sous-classent et injectent
des doubles via `db()` / `sessionManager()`). Heritent de `AdminController`
(back-office) ou `AuthenticatedController`.
- **Acces donnees** : un repository par entite, dependant de `DatabaseInterface`
(PDO en prod, `FakeDatabase` en test). Requetes preparees uniquement.
- **Mutations** : CSRF (`Csrf::validate`) + validation serveur bornee (RG-T18) +
allowlist de colonnes (RG-T16). Sorties HTML echappees (RG-T15).
- **Actions sensibles** : PIN equipier (`PinVerifier`) + `audit_log` dans la meme
transaction ; throttle PIN (`PinThrottle`). Voir `docs/ARCHITECTURE.md` section 7.
- **Statuts HTTP** : conflit -> 409 ; validation -> 422 ; CSRF/permission -> 403.
- **Pas d'emoji** dans le code, les commits, les specs (Mantra IA-23).
Detail par entite : `docs/merise/` et `docs/domaines/` (a venir).
---
## 6. Git & CI
- **Conventional Commits** (anglais) : `type(scope): description` — types `feat`, `fix`,
`docs`, `refactor`, `test`, `chore`, `ci`, `db`, `perf`, `style`.
- **Branches** depuis `dev` : `feat/*`, `fix/*`, `docs/*`, `chore/*`, `ci/*`, `db/*`,
`refactor/*`, `test/*`. Merge vers `dev` par **PR squashee**. Periodiquement
`dev -> main` avec tag semver.
- **Auto-merge** : l'ouverture de la PR programme la fusion squash automatique des que
les checks requis passent (auto-merge NATIF Forgejo `merge_when_checks_succeed`, sans
label ni job CI). Script : `scripts/forgejo-pr-automerge.sh`.
- **Pas de trailer `Co-Authored-By`** : la transparence sur l'usage de l'IA vit dans le
`README.md` et `docs/PROJECT_CONTEXT.md` section 17.
---
## 7. Ou trouver quoi
| Besoin | Emplacement |
|---|---|
| Architecture, stack, securite, modele | `docs/ARCHITECTURE.md` |
| Scope metier, planning, mapping RNCP | `docs/PROJECT_CONTEXT.md` |
| Modelisation Merise (dictionnaire, MCD/MCT/MLT, regles RG-T*) | `docs/merise/` |
| Decisions d'architecture (le pourquoi) | `docs/adr/` |
| Retros par session / feature | `docs/journal/` |
| Methodologie agents | `.claude/CLAUDE.md` + `.claude/rules/` |
---
*Document vivant — mis a jour au fil de l'implementation.*

View file

@ -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
@ -106,13 +113,13 @@ Client Borne (Bloc 1) API (Bloc 2) BDD
**Un seul codebase, deux FQDN d'exposition publique.** Le front Bloc 1 et le back Bloc 2 coexistent dans la meme arborescence. Une bascule mode JSON-seuls (Bloc 1 isole) vs mode API-connecte doit rester possible via configuration.
**Pourquoi pas strategie A (deux rendus isoles)** : le Bloc 5 DevOps impose une conteneurisation **unique** qui lance la stack complete avec `make init` en une commande (Cr 7.c.4). Deux codebases isolees seraient incoherentes avec cette exigence.
**Pourquoi pas strategie A (deux rendus isoles)** : le Bloc 5 DevOps impose une conteneurisation **unique** qui lance la stack complete avec `docker compose up` en une commande (Cr 7.c.4). Deux codebases isolees seraient incoherentes avec cette exigence.
### Compatibilite evaluation par bloc
- **Jury Bloc 1** : voit le front seul ; le front peut tomber en fallback sur JSON statiques fournis (`src/public/borne/data/*.json`) si l'API est indisponible.
- **Jury Bloc 2** : voit le back-office + teste l'API via curl/Postman de maniere autonome, sans dependre du front.
- **Jury Bloc 5** : lance `make init` ou `docker compose up`, verifie la CI/CD, les crons, l'archi, les scripts.
- **Jury Bloc 5** : lance `docker compose up` ou `docker compose up`, verifie la CI/CD, les crons, l'archi, les scripts.
---
@ -135,7 +142,7 @@ Client Borne (Bloc 1) API (Bloc 2) BDD
│ (admin_proxy network) │
└──────────┬────────────────────────────┬─────────────────────────┘
│ │
wakdo.acadenice.fr wakdo-admin.acadenice.fr
corentin-wakdo.stark.a3n.fr corentin-wakdo-admin.stark.a3n.fr
│ │
▼ ▼
┌──────────────────────────────────────────┐
@ -186,10 +193,10 @@ Reseaux :
| Reverse proxy | Traefik (deja en place) | existant | `admin_proxy` network |
| 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) |
| Hooks Git | pre-commit + commit-msg | versionnes dans `.githooks/` | Conventional Commits |
| Orchestration locale | docker compose (service wakdo-migrate) | — | Cr 7.b (script) + Cr 7.c.4 (une commande) |
| 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 (refus main/dev + php -l) + commit-msg (format Conventional Commits) | versionnes dans `.githooks/`, actives via `scripts/install-hooks.sh` | 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 ; 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`)
- Historique commandes par statut
- Stats de base (commandes du jour, CA jour, produits top)
@ -254,16 +262,18 @@ Reseaux :
**IN scope :**
- Dockerfile custom PHP-FPM avec extensions
- `docker-compose.yml` orchestrant les 4 services (web, app, db, cron)
- `Makefile` avec cible `make init` qui lance tout en une commande (Cr 7.c.4)
- Scripts Bash d'automatisation (backup, deploy, migrate)
- **Cron tab** avec au moins 3 jobs planifies dans la fenetre de maintenance (01h30-09h30) :
- `docker-compose.yml` orchestrant 5 services : 4 longs (web, app, db, cron) + 1 one-shot (`wakdo-migrate`)
- `docker compose up` lance toute la stack (service one-shot `wakdo-migrate` : migrations + seed idempotents) en une commande (Cr 7.c.4)
- Scripts Bash d'automatisation (backup, restore, deploy, migrate)
- **Cron tab** avec 3 jobs actifs planifies dans la fenetre de maintenance (01h30-09h30) :
- `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
- `15 4 * * *` — purge du journal d'audit au-dela de la fenetre de retention (~12 mois)
- `45 4 * * *` — purge des compteurs de throttle expires
- Differes (templates commentes dans `docker/cron/crontab`, a activer plus tard) : purge des sessions expirees, agregation des stats sur le jour de service
- **CI Forgejo Actions** (act_runner auto-heberge) : lint PHP + PHPStan + PHPUnit + secret-scan (gitleaks) + js-tests sur PR -> dev
- **CD : deploiement scripte a declenchement humain** (`scripts/deploy.sh` : recupere `main` depuis Forgejo puis `docker compose build --pull && up -d` -- les images wakdo sont buildees localement depuis les Dockerfiles, pas de registre). Choix solo dev sur un environnement de prod unique. L'automatisation visee est **pull-based** : un job cron cote hote qui detecte un nouveau `main` et lance `deploy.sh` (a armer ensuite, reutilise le meme script)
- `.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/`)
@ -294,21 +304,21 @@ Reseaux :
| Cr 2.a.1-5 | JS ES6+ + DOM + animations | Modules ES6, classes, async/await, pas de jQuery |
| Cr 2.b.1-3 | Validation formulaires | Validation client temps reel (regex) + validation serveur |
| Cr 2.c.1-4 | Ajax async | `fetch()` avec gestion erreurs, pas d'exposition donnees sensibles |
| Cr 2.d.1-3 | Librairies externes | **Non applicable** (zero dep JS) — argumenter "developpement sans lib externe" |
| Cr 2.d.1-3 | Librairies externes | Choix de stack assume : **zero lib JS** (vanilla). Cr 2.d.1-3 restent du tronc commun evaluable -> a argumenter a l'oral ; ce n'est pas une dispense du referentiel |
### Bloc 2
| Critere | Libelle court | Feature Wakdo couvrant |
|---|---|---|
| Cr 3.a.1-4 | Analyse + modele donnees | Dictionnaire + MCD + cardinalites |
| Cr 3.a.3 | Exploiter donnees externes d'API | API interne consommee par le front (auto-consommation) |
| Cr 3.a.2-4 | Analyse + modele donnees (3 criteres ; le referentiel ne contient PAS de Cr 3.a.1) | Dictionnaire + MCD + cardinalites |
| Cr 3.a.3 | Exploiter donnees externes d'API | Enrichissement nutritionnel depuis **OpenFoodFacts** (API tierce) importe DANS le modele (`ingredient.energy_kcal_100g`), a la demande admin (opt-in, sans egress runtime) ; + auto-consommation de l'API interne par la borne |
| Cr 3.b.1-3 | Construction BDD | MCD → MLD → DDL MariaDB, FK + typage coherent |
| Cr 3.c.1-3 | Requetes SQL optimisees | PDO prepared, index sur FK, LIMIT/tri explicites |
| Cr 3.d.1-4 | RGPD | hash mdp, droit acces/modif/suppr, info utilisation donnees |
| Cr 4.a.1-4 | Conceptualisation | Schema fonctionnel des vues + interactions |
| Cr 4.b.1-6 | Syntaxe + indentation + erreurs | PSR-12 style, try/catch cibles, logs |
| Cr 4.c.1-3 | POO + heritage + namespaces | `BaseModel` -> `Product`, `BaseController` -> `AdminController`, PSR-4 |
| Cr 4.d.1-3 | MVC | `src/Models/`, `src/Views/`, `src/Controllers/`, separation stricte |
| Cr 4.c.1-3 | POO + heritage + namespaces | heritage de controleurs (`Controller` -> `AuthenticatedController` -> `AdminController` -> ...), couche modele en Repository pattern, autoloader PSR-4 manuel |
| Cr 4.d.1-3 | MVC | `src/app/Controllers/`, `src/app/Views/`, couche modele = Repositories (`*Repository`) + `Core/Database` ; separation stricte (pas de dossier `Models/` : Repository pattern) |
| Cr 4.e.1-3 | Securite | PDO prepared (anti-SQLi), sessions regeneration, role-based middleware |
| Cr 4.f.2 | Maitrise outil collaboratif (artefact) | Commits Conventional, branches `feat/*`, PR descriptions, squash merge, hooks Git |
| Cr 4.f.1, 4.f.3, 4.f.4 | Soft skills (evalues a l'oral) | Partage de savoir-faire (4.f.1), auto-evaluation avant PR (4.f.3), compte-rendu de la participation individuelle (4.f.4) — demontres pendant la soutenance |
@ -319,16 +329,16 @@ Reseaux :
| Critere | Libelle court | Feature Wakdo couvrant |
|---|---|---|
| Cr 7.a.1-3 | Analyse infra + securite | Audit code + proposition automatisation documentee |
| Cr 7.b.1 | Langage de script | Bash (deploy, backup) + Makefile |
| Cr 7.b.2 | Automatisation fiabilisee | Makefile avec exit codes, retries, logs |
| Cr 7.b.1 | Langage de script | Bash (db/*.sh migrate/seed, scripts/forgejo-*.sh, entrypoints, backup) |
| Cr 7.b.2 | Automatisation fiabilisee | Scripts Bash (set -euo pipefail, exit codes, logs) + service compose wakdo-migrate idempotent |
| Cr 7.b.3 | **Cron tab** | `wakdo-cron` service avec crontab : backup BDD, purge sessions, stats |
| Cr 7.c.1 | VM operationnelle | Serveur existant Acadenice |
| Cr 7.c.2 | OS conteneur installe | Docker Engine |
| 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.c.3 | App conteneurisee complete | 5 services : 4 longs (web, app, db, cron) + 1 one-shot (wakdo-migrate) |
| Cr 7.c.4 | **Une ligne de commande** | `docker compose up` 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 | CI complete sur PR ; deploiement scripte a declenchement humain (`scripts/deploy.sh`). Auto-CD sur merge main non arme (choix solo dev, a argumenter) |
---
@ -344,13 +354,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`)
@ -430,11 +440,12 @@ Les branches `main` et `dev` sont **protegees** cote GitHub. Pas de commit direc
| 8 | 2 FQDN | Separation claire borne publique / admin+API interne, defensible jury |
| 9 | API sous `/api` sur le FQDN admin | Simplicite d'exploitation, CORS explicite gere |
| 10 | Service cron dedie | Cr 7.b.3 explicite + realiste prod |
| 11 | Makefile avec `make init` | Cr 7.c.4 + demonstration DevOps |
| 11 | Orchestration `docker compose up` (service wakdo-migrate) | 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 +453,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**.
---
@ -480,8 +491,8 @@ Buffer : ~20 h pour imprevus. Cible effective : ~240 h sur 20 semaines = **12 h/
**Bloc 5 :**
- `docker-compose.yml` commente
- Dockerfiles customs commentes
- `Makefile` avec `make help`
- `.github/workflows/` avec CI + CD
- Orchestration via `docker compose` (service one-shot `wakdo-migrate` : migrate + seed)
- `.forgejo/workflows/` avec CI (PHPUnit + PHPStan + secret-scan) + CD
- Crontab documente
- Script de backup/restore teste
- Architecture serveur decrite (`docs/architecture/deployment.md`)
@ -490,7 +501,7 @@ Buffer : ~20 h pour imprevus. Cible effective : ~240 h sur 20 semaines = **12 h/
- **README.md** synthetique (quick start + liens docs)
- **Presentation** (slides ou live) argumentant les choix
- **Demo** live : borne + back-office + API (Postman/curl) + `make init`
- **Demo** live : borne + back-office + API (Postman/curl) + `docker compose up`
- **Capacite modification en direct** (Cr 4.a.1) : code structure pour permettre modifs sans casser
---
@ -501,7 +512,7 @@ Buffer : ~20 h pour imprevus. Cible effective : ~240 h sur 20 semaines = **12 h/
|---|---|---|---|
| Sous-estimation temps front (accessibilite RGAA stricte) | Haute | Moyen | 60 h budgetees + tests W3C/axe-core pendant le dev, pas a la fin |
| Complexite MCT (statuts commande) mal modelisee | Moyenne | Fort | Valider MCT avec un pair ou prof avant d'implementer Bloc 2 |
| Dockerfile PHP extensions manquantes decouvert tard | Moyenne | Faible | Tester `make up` + un vrai appel BDD des P0 |
| Dockerfile PHP extensions manquantes decouvert tard | Moyenne | Faible | Tester `docker compose up -d` + un vrai appel BDD des P0 |
| Conflit reseau Docker `wakdo_internal` existant | Faible | Faible | Verifie au setup, fallback nom `wakdo_backend` |
| CORS mal configure bloque la borne | Moyenne | Moyen | Test immediat apres setup 2 FQDN |
| Performance borne sur ecran tactile reel | Faible | Fort | Optimiser images + lazy loading + tests sur device tactile si possible |
@ -604,7 +615,7 @@ L'auteur peut recourir ponctuellement a d'autres outils IA (completion IDE, assi
- **Choix du scope fonctionnel** : defini par l'auteur a partir du brief RNCP. L'IA n'ajoute ni ne retire de fonctionnalite sans instruction explicite.
- **Modelisation Merise** (MCD, MCT, MLD) : formalisation produite par l'IA a partir du dictionnaire de donnees et des user stories ; arbitrage, validation et corrections par l'auteur. Chaque cardinalite, chaque relation et chaque transition de statut est validee par l'auteur avant integration. Le livrable final reflete ses decisions.
- **Validation des livrables** : reservee au jury. L'IA n'emet pas de jugement final sur la conformite RNCP.
- **Deploiements** : declenchement humain uniquement, y compris sur `make init` local. Aucune action sur environnement serveur sans instruction explicite.
- **Deploiements** : declenchement humain uniquement, y compris sur `docker compose up` local. Aucune action sur environnement serveur sans instruction explicite.
- **Commit en son nom** : aucun trailer `Co-Authored-By: Claude...` n'est appose sur les commits. Voir section 17.7.
- **Decisions de securite critiques** : tous les choix de type hash mdp, CORS, RBAC, politique sessions sont valides par l'auteur meme si l'IA en propose la mise en oeuvre.
@ -670,9 +681,143 @@ Ces regles tiennent lieu de garde-fous pendant toute la duree du projet. Les enf
6. **Zero requete SQL sans prepared statement** (anti-SQLi)
7. **Zero hash mdp en clair** (bcrypt ou argon2)
8. **Zero CORS `*`** (origine explicite uniquement)
9. **Zero deployment manuel** en condition normale (CI/CD)
9. **Deploiement scripte et trace** (`scripts/deploy.sh`), declenche par l'exploitant ; pas de modification manuelle ad hoc en prod
10. **Zero feature hors scope** sans mise a jour de ce document
---
*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-<id>@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.*

104
docs/TESTING.md Normal file
View file

@ -0,0 +1,104 @@
# Strategie de test — Wakdo
> Comment le projet est teste, comment lancer chaque niveau, ce qui tourne en CI,
> comment mesurer la couverture, et pourquoi les tests E2E ne tournent pas en CI.
> Priorite : Unit > Integration > E2E. Cote PHP, aucune dependance Composer
> (PHPUnit en `.phar` autonome).
---
## 1. Niveaux de test
| Niveau | Outil | Perimetre | Ou |
|---|---|---|---|
| Unitaire PHP | PHPUnit (`.phar`) | logique (Auth, RBAC, PIN, throttle, calcul commande, controleurs via doubles) | CI + local |
| Integration PHP | PHPUnit + vraie MariaDB | requetes SQL preparees, contraintes, RBAC `is_active`, audit, FK | CI + local |
| Analyse statique | PHPStan niveau 6 | typage, erreurs potentielles sur `src/` + `tests/` | CI + local |
| Unitaire JS | `node:test` + jsdom | modules du front borne (panier, composeur, checkout, allergenes, a11y, validation) | CI + local |
| E2E | Playwright | parcours borne + admin de bout en bout | **local / manuel** (voir section 5) |
---
## 2. Lancer les tests PHP (sans Composer)
Le binaire `php` n'est pas requis sur l'hote : on passe par l'image applicative.
```bash
# Unitaire + integration (la vraie base est utilisee si WAKDO_DB_TESTS=1) :
docker run --rm -v "$PWD":/app -w /app wakdo-wakdo-app php phpunit.phar -c phpunit.xml
# Integration DB explicite (vraie MariaDB du reseau interne) :
docker run --rm --network wakdo_wakdo_internal --env-file .env -e WAKDO_DB_TESTS=1 \
-v "$PWD":/app -w /app wakdo-wakdo-app php phpunit.phar -c phpunit.xml
# Analyse statique :
docker run --rm -v "$PWD":/app -w /app wakdo-wakdo-app \
php -d memory_limit=-1 phpstan.phar analyse --no-progress
```
Les `*.phar` (phpunit 11.5.2, phpstan 1.12.27) sont gitignores ; les retelecharger si absents.
Les tests d'integration s'auto-skippent hors `WAKDO_DB_TESTS=1` ; la CI force `--fail-on-skipped`
pour qu'aucun test de securite (throttle, RBAC, audit, FK) ne soit silencieusement saute.
---
## 3. Lancer les tests JS
```bash
npm run test:js # node --test tests/js/ (jsdom en devDependency)
```
---
## 4. Couverture de code
Le pilote de couverture (pcov ou Xdebug) n'est **pas** embarque dans l'image de
production (surcout au runtime, sans interet en prod). La couverture se mesure en
dev/CI, avec un PHP equipe d'un pilote de couverture :
```bash
# avec pcov ou xdebug actif dans le PHP utilise :
php -d pcov.enabled=1 phpunit.phar -c phpunit.xml --coverage-text --coverage-html var/coverage
```
`phpunit.xml` declare deja la source a mesurer (`<source><include><directory>src`).
A ce stade, la couverture est produite **a la demande** : aucun seuil n'est impose
en CI (pas de gate de pourcentage). L'ajout d'un pilote de couverture a l'etape CI
`static-tests` et d'un seuil minimal est une evolution identifiee (decision exploitant :
elle suppose un PHP de CI equipe de pcov).
---
## 5. E2E (Playwright) — execution manuelle, hors CI
Les parcours E2E (borne : accueil -> commande -> chevalet -> confirmation ; admin :
login -> dashboard -> logout) se lancent **a la main**, contre une stack jetable :
```bash
tests/e2e/run.sh
```
Le script monte une stack isolee (`docker-compose.yml` + `tests/e2e/docker-compose.e2e.yml`),
attend migrate + healthcheck, puis lance Playwright dans le conteneur officiel.
**Pourquoi pas en CI ?** Decision assumee : le runner Forgejo de production execute les
jobs sans acces au socket Docker (pas de docker-in-docker), et les jobs sont repartis sur
plusieurs runners. Monter une stack Docker complete + Playwright dans ce contexte n'est pas
fiable. Les E2E restent donc un filet **manuel** (lance avant une livraison sensible), tandis
que la CI couvre l'unitaire, l'integration DB, l'analyse statique, le lint et le scan de secrets.
A l'oral, c'est la position a defendre : E2E reels et reproductibles, mais declenches a la main.
---
## 6. Ce que la CI execute (Forgejo Actions, sur PR)
`.forgejo/workflows/ci.yml`, sur `pull_request` vers `dev`/`main` :
| Job | Verifie |
|---|---|
| `secret-scan` | gitleaks (aucun secret dans le diff/historique) |
| `php-lint` | `php -l` sur tous les fichiers `.php` |
| `static-tests` | PHPStan niveau 6 + PHPUnit (unit + integration sur service MariaDB, `--fail-on-skipped`) |
| `js-tests` | `node --test tests/js/` (jsdom) |
L'auto-merge ne se declenche que lorsque ces checks requis sont verts (branch protection).

View file

@ -0,0 +1,23 @@
# ADR-0001 — PHP from scratch, sans framework ni Composer
- Statut : Accepte
- Date : 2026-04-23
## Contexte
Certification RNCP (Titre Developpeur Web, option DevOps). L'objectif pedagogique est
de demontrer la maitrise des fondamentaux (routage, PDO, sessions, securite) plutot que
la configuration d'un framework. Options : Symfony/Laravel ; micro-framework (Slim) ;
from scratch.
## Decision
Application PHP 8.3 ecrite **from scratch** : routeur, autoloader PSR-4 manuel
(`spl_autoload_register`), couche `Database` sur PDO, le tout **sans Composer**. Les
outils de dev (PHPUnit, PHPStan) sont utilises via leurs **`.phar` autonomes**.
## Consequences
- (+) Chaque mecanisme (routage, auth, RBAC, requetes preparees) est explicite et
defendable a l'oral ; pas de magie de framework.
- (+) Surface de dependances minimale (moins de supply-chain a auditer).
- (-) Du code d'infrastructure a ecrire et tester soi-meme (Core, Auth).
- CI sans Composer : les `.phar` (phpunit, phpstan) sont epingles/telecharges.
Voir `docs/PROJECT_CONTEXT.md` section 6.

View file

@ -0,0 +1,22 @@
# ADR-0002 — Back-office en MVC rendu serveur (pas de SPA)
- Statut : Accepte
- Date : 2026-06-15
## Contexte
Le back-office (login, CRUD catalogue, stock, users, RBAC, stats) doit etre construit.
Options : SPA JS consommant une API JSON ; pages rendues serveur (MVC PHP) ; hybride.
La borne client, elle, est deja un front statique distinct (Bloc 1).
## Decision
Le back-office est en **MVC rendu serveur** : formulaires POST + redirections, vues PHP
injectees dans un layout commun. L'API REST (`/api/*`) reste interne, consommee par la
borne. Login = vue PHP, pas un endpoint JSON.
## Consequences
- (+) CSRF, sessions, garde de permission et echappement de sortie se branchent
naturellement sur chaque page ; demontre le MVC sans build front.
- (+) Pas de duplication d'etat client/serveur pour l'admin.
- (-) Interactions riches (matrice RBAC, editeur recette) gerees en JS vanilla cible,
CSP-safe (champs caches / cases scalaires), sans framework front.
- Controleurs non-`final` (seam de test) ; vues sous `src/app/Views/admin`.

View file

@ -0,0 +1,25 @@
# ADR-0003 — Stock en pourcentage + disponibilite produit calculee (RG-T21)
- Statut : Accepte
- Date : 2026-06-12
## Contexte
Modeliser le stock des ingredients et la commandabilite des produits. Un stock en
quantites absolues seules rend les seuils d'alerte arbitraires d'un ingredient a
l'autre ; et marquer la disponibilite produit "en dur" exige une cascade a maintenir
a chaque mouvement de stock.
## Decision
Stock ancre sur une **`stock_capacity`** (reference 100%, `CHECK > 0`) ; `stock_pct` et
les 3 bandes (normal / alerte / critique) sont **calcules**, pas stockes. La
**disponibilite produit (RG-T21)** est derivee : commandable si `is_available = 1` ET
chaque ingredient non retirable est au-dessus de la bande critique. Aucune colonne
stockee, aucune cascade.
## Consequences
- (+) Seuils homogenes (en %) ; un reappro au-dessus du critique rend le produit
commandable de lui-meme, sans ecriture.
- (+) `stock_quantity` signe (survente assumee, remontee manager) : le systeme ne bloque
pas une commande sur une lecture de stock.
- (-) Le calcul de dispo se fait a la lecture (jointure composition) ; borne par requete.
- Source unique de la derivation : `IngredientRepository::stockBand`. Voir `docs/merise/`.

View file

@ -0,0 +1,25 @@
# ADR-0004 — PIN d'action sensible (equipier) + audit dans la meme transaction
- Statut : Accepte
- Date : 2026-06-15
## Contexte
Les postes back-office sont partages (session ouverte au comptoir). Pour les operations
sensibles (annulation, changement prix/TVA, suppressions, inventaire, gestion
utilisateur, RBAC, effacement PII), il faut imputer l'acte a une personne, pas a la
session partagee.
## Decision
Modele **identifiant equipier + PIN** : l'operation sensible exige email + PIN, verifies
contre `user.pin_hash` (argon2id). Le `user_id` ainsi resolu est l'**acteur** ecrit dans
`audit_log` (RG-T14), dans la **meme transaction** que l'effet (RG-T08). Le set sensible
est defini par RG-T13. Les operations de stock tracent via `stock_movement.user_id`
(pas de double-journal).
## Consequences
- (+) Imputabilite reelle sur poste partage ; trace immuable et atomique (pas d'effet
sans audit, ni l'inverse).
- (+) Le PIN n'identifie pas la session : un manager peut autoriser sur le poste d'un
autre sans relog.
- (-) Surface d'attaque PIN (4 chiffres) -> necessite un throttle dedie (voir ADR-0005).
- Brique : `App\Auth\PinVerifier`. Regle : `docs/merise/mlt.md` RG-T13/RG-T14.

View file

@ -0,0 +1,23 @@
# ADR-0005 — Throttle du PIN separe des compteurs de connexion (RG-T22)
- Statut : Accepte
- Date : 2026-06-15
## Contexte
Le PIN d'action sensible (ADR-0004) est court (4 chiffres) : il faut limiter le
brute-force. Question : reutiliser les compteurs de login (`user.lockout_until` /
`login_throttle`) ou un compteur dedie ? Et sur quelle dimension compter ?
## Decision
Table **`pin_throttle`** dediee, **separee** des compteurs de connexion. La dimension
est l'**utilisateur agissant** (la session authentifiee qui soumet le PIN), pas l'email
cible (contournable par rotation) ni l'IP (collateral sur poste partage). Backoff
degressif, bornes propres plus permissives que le login. Verrou evalue AVANT la
verification ; sous verrou actif, pas de nouvelle ligne `pin.failed` (anti-amplification).
## Consequences
- (+) Spammer le PIN d'une victime ne verrouille pas sa CONNEXION (pas d'escalade DoS
sur une surface plus sensible).
- (+) Detection : un pic de `pin.failed` reste alertable.
- (-) Un compteur de plus a purger (cron, comme `login_throttle`).
- Brique : `App\Auth\PinThrottle`. Regle : RG-T22. Cf. ADR-0004.

View file

@ -0,0 +1,25 @@
# ADR-0006 — HTTP 409 (conflit) vs 422 (validation)
- Statut : Accepte
- Date : 2026-06-17
## Contexte
Les controleurs renvoyaient 422 a la fois pour une validation qui echoue ET pour un
conflit d'etat (unicite, suppression bloquee par FK RESTRICT). Le contrat documente
(`byan-api.md`) attendait 409 pour les conflits. Derive a corriger.
## Decision
Convention harmonisee sur tous les controleurs :
- **422** : requete bien formee mais **semantiquement invalide** (validation serveur,
RG-T18) ;
- **409** : **conflit d'etat** (violation d'unicite SQLSTATE 23000, hard-delete bloque
par FK RESTRICT) ;
- **403** : CSRF invalide ou permission manquante ; **404** : ressource introuvable.
## Consequences
- (+) Statuts semantiquement justes (RFC 9110), testables, coherents entre Category /
Product / Menu / Ingredient / User / Role.
- (+) Aligne le code sur le contrat d'API documente.
- (-) Pages rendues serveur : un 200-avec-erreurs "marcherait" visuellement, mais le
statut correct est verrouille par les tests (un oubli = test rouge).
- Remediation : PR #33 (Category/Product/Menu) ; les controleurs suivants naissent en 409.

View file

@ -0,0 +1,24 @@
# ADR-0007 — Effacement RGPD par anonymisation (tombstone), pas DELETE
- Statut : Accepte
- Date : 2026-06-17
## Contexte
Le droit a l'effacement (RGPD, Cr 3.d) s'applique aux comptes back-office. Un `DELETE`
dur casserait l'integrite referentielle (FK entrantes depuis `stock_movement.user_id`,
`customer_order.acting_user_id`, `audit_log.actor_user_id`) et effacerait la trace
d'imputabilite des actes passes.
## Decision
**Anonymisation** (mlt 10.5), pas suppression : en une transaction, vider la PII de la
ligne `user` (email -> `anon-<id>@wakdo.invalid` RFC 2606, prenom/nom vides, hash vide,
PIN/reset NULL), poser `anonymized_at`, `is_active = 0`. La ligne **persiste** comme
tombstone. Idempotent (clause `anonymized_at IS NULL`). Trace : `audit_log`
`user.erase_pii`.
## Consequences
- (+) FK preservees ; les actes passes restent imputables a un principal anonymise
(qui-en-tant-qu-id), sans PII.
- (+) Email unique conserve, non identifiant.
- (-) La ligne reste en base (tombstone) : a documenter dans le registre de traitement.
- Garde-fou : interdit d'anonymiser le dernier admin actif / soi-meme (anti-lockout).

View file

@ -0,0 +1,26 @@
# ADR-0008 — Du Makefile a `docker compose up` (service wakdo-migrate)
- Statut : Accepte
- Date : 2026-06-17
## Contexte
Le critere Cr 7.c.4 demande de lancer la stack complete en une commande. C'etait
`make init`. Mais le Makefile portait surtout des cibles mortes/trompeuses
(`test`/`lint` annoncaient "pas implemente" alors que les tests tournent) ; sa seule
cible porteuse, `init`, existait parce que `docker compose up` seul n'applique pas les
migrations. Le critere parle d'un **resultat**, pas de `make`.
## Decision
Migration + seed deplaces **dans la stack** : un service one-shot **`wakdo-migrate`**
(image mariadb, `db/migrate-container.sh` par le reseau) applique
`db/migrations/*.sql` (suivi `schema_migrations`) puis `db/seeds/*.sql` (suivi
`seeds_applied`), idempotents. `wakdo-app`/`wakdo-web` `depends_on:
service_completed_successfully`. **Makefile supprime.** `docker compose up` devient
l'unique commande.
## Consequences
- (+) Commande universelle, sans dependance a l'outil `make` sur l'hote.
- (+) Comportement = doc (l'ancien `make init` ne seedait meme pas).
- (-) Migrations/seed evalues a chaque `up` (cout negligeable, suivi -> re-run sans effet).
- (-) Base **deja seedee** avant le suivi : back-filler `seeds_applied` avant le 1er up.
- `db/migrate.sh` (hote) conserve pour l'usage manuel. Detail : journal 2026-06-17.

View file

@ -0,0 +1,29 @@
# ADR-0009 — docker-compose.yml standalone + docker-compose.prod.yml gitignore
- Statut : Accepte
- Date : 2026-06-17
## Contexte
Le `docker-compose.yml` versionne supposait un reverse proxy Traefik (reseau externe,
labels, aucun port hote) : `docker compose up` echouait pour quiconque sans Traefik
(jury, contributeur, tests E2E). Options envisagees : overlay `-f` (base + prod fusionnes
via `!reset`) ; un seul fichier parametre ; deux fichiers complets independants.
## Decision
Deux fichiers **complets et independants** (pas d'overlay) :
- **`docker-compose.yml`** (versionne) : standalone, `wakdo-web` publie
`${HTTP_PORT:-8080}:80`, reseau interne seul, sans Traefik. `docker compose up` tourne
partout, facon app open-source self-hostable.
- **`docker-compose.prod.yml`** : **gitignore**, propre a chaque hote derriere un proxy
(meme stack + reseau externe + labels Traefik, sans port). `docker compose -f
docker-compose.prod.yml up -d`.
Renommage `TRAEFIK_DOMAIN_*` -> `APP_HOST_*` (ce sont des `ServerName` de vhosts, pas du
Traefik). `.env.example` local-first.
## Consequences
- (+) `docker compose up` marche en local sans configuration ; le repo ne porte aucune
hypothese d'infra.
- (+) Le critere Cr 7.c.4 tient avec un fichier que tout le monde peut lancer.
- (-) Duplication entre les deux fichiers (assumee : clarte > DRY pour l'infra).
- (-) Le serveur maintient son propre fichier prod (comme `.env`).

View file

@ -0,0 +1,28 @@
# ADR-0010 — Cookie de session Secure conditionnel au HTTPS
- Statut : Accepte
- Date : 2026-06-17
## Contexte
Le cookie de session du back-office etait pose avec `secure => true` en dur
(security-by-design). Or un cookie `Secure` n'est emis/renvoye par le navigateur que
sur HTTPS : en HTTP (dev, stack standalone locale, E2E sans TLS) la session ne tenait
pas d'une requete a l'autre, donc le login admin echouait ("Session expiree" au POST,
le jeton CSRF ne pouvant matcher une session perdue). Revele par le parcours E2E admin.
En prod le souci n'apparait pas : Traefik termine le TLS.
## Decision
`secure` devient **conditionnel au schema** : vrai si la requete est HTTPS, faux sinon.
Detection (`SessionManager::cookieSecure()`) : `X-Forwarded-Proto: https` (pose par
Traefik en prod) en priorite, sinon la variable serveur `HTTPS`, sinon le port 443.
Applique aux deux points (pose du cookie + expiration au logout).
## Consequences
- (+) Le back-office est utilisable en **HTTP local** (dev, standalone, E2E) ; prod
**inchange** (derriere Traefik -> `X-Forwarded-Proto=https` -> `Secure` reste pose).
- (+) Comportement standard (les frameworks derivent `Secure` du schema).
- Confiance en `X-Forwarded-Proto` : sure ici car l'app n'est joignable que par le
reverse proxy sur le reseau interne (aucun acces client direct).
- (-) Un deploiement en **HTTP nu** (sans proxy TLS) n'aurait pas `Secure` — mais servir
l'authentification en HTTP nu est de toute facon a proscrire (independant de ce flag).
- `httponly` et `SameSite=Strict` restent inconditionnels. Revele par [E2E admin](../domaines/auth.md).

38
docs/adr/README.md Normal file
View file

@ -0,0 +1,38 @@
# Registre des decisions d'architecture (ADR)
Une fiche courte par decision structurante : **contexte**, **decision**, **consequences**.
Format inspire des Architecture Decision Records (M. Nygard). Les ADR sont immuables :
une decision revisee donne une nouvelle fiche qui *supersede* l'ancienne (statut mis a jour).
**Auteur : BYAN** (formalisation ; arbitrage et validation par l'auteur).
| # | Decision | Statut |
|---|---|---|
| [0001](0001-php-from-scratch-sans-composer.md) | PHP from scratch, sans framework ni Composer | Accepte |
| [0002](0002-back-office-mvc-rendu-serveur.md) | Back-office en MVC rendu serveur (pas de SPA) | Accepte |
| [0003](0003-stock-pourcentage-dispo-calculee.md) | Stock en pourcentage + disponibilite produit calculee (RG-T21) | Accepte |
| [0004](0004-pin-action-sensible-audit.md) | PIN d'action sensible (equipier) + audit dans la meme transaction | Accepte |
| [0005](0005-throttle-pin-separe-du-login.md) | Throttle du PIN separe des compteurs de connexion (RG-T22) | Accepte |
| [0006](0006-http-409-conflit-422-validation.md) | HTTP 409 (conflit) vs 422 (validation) | Accepte |
| [0007](0007-rgpd-anonymisation-tombstone.md) | Effacement RGPD par anonymisation (tombstone), pas DELETE | Accepte |
| [0008](0008-makefile-vers-compose-migrate.md) | Du Makefile a `docker compose up` (service wakdo-migrate) | Accepte |
| [0009](0009-compose-standalone-et-prod-gitignore.md) | docker-compose.yml standalone + docker-compose.prod.yml gitignore | Accepte |
| [0010](0010-cookie-secure-conditionnel-https.md) | Cookie de session Secure conditionnel au HTTPS | Accepte |
## Modele de fiche
```
# ADR-NNNN — Titre
- Statut : Propose | Accepte | Supersede par ADR-XXXX
- Date : AAAA-MM-JJ
## Contexte
Le probleme, les contraintes, les options envisagees.
## Decision
Le choix retenu, en une ou deux phrases nettes.
## Consequences
Ce que ca implique (positif et negatif), et les regles/fichiers concernes.
```

343
docs/api/conventions.md Normal file
View file

@ -0,0 +1,343 @@
# 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 (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
-> 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) |
| 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)
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 | livre |
| GET | `/api/products` | (lecture publique) | READ_CATALOGUE | livre |
| GET | `/api/products/{id}` | (lecture publique) | READ_CATALOGUE | livre |
| GET | `/api/menus` | (lecture publique) | READ_CATALOGUE | livre |
| GET | `/api/menus/{id}` | (lecture publique) | READ_CATALOGUE | livre (slots de composition) |
| GET | `/api/allergens` | (lecture publique) | READ_CATALOGUE | livre (14 allergenes INCO) |
| POST | `/api/orders` | (kiosk public) | CREATE_ORDER (mlt 3.3) | livre (idempotency_key, RG-T19) |
| POST | `/api/orders/{number}/pay` | (kiosk public) | (encaissement) | livre (paid + decrement stock RG-T20) |
| GET | `/api/orders/{number}` | (lecture publique) | (suivi statut) | livre (champs non sensibles : numero, statut, total) |
### 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 | vue back-office detaillee (differe) ; le suivi public minimal est livre en 5.2 |
| 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) ; 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) |
| `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
La borne consomme `/api/*` en **meme origine** : le vhost kiosk (`docker/apache/vhost.conf`)
relaie `/api/*` au front controller admin via PHP-FPM (`ProxyPassMatch` + `ProxyFCGISetEnvIf`
qui force `SCRIPT_FILENAME` sur `public/admin/index.php`). `data.js` garde donc des URLs
relatives et le navigateur n'emet pas de requete cross-origin pour ce parcours.
Le middleware `App\Core\Cors` reste en place comme defense en profondeur : il lit
`CORS_ALLOWED_ORIGIN` (valeur exacte, sans joker, = `APP_URL_KIOSK`) et autorise un eventuel
consommateur cross-origin de l'API. Il n'est pas sur le chemin de la borne.
---
## 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` |

View file

@ -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 "<REGISTRATION_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).

View file

@ -0,0 +1,124 @@
# Schema fonctionnel — Wakdo
> Conceptualisation de l'application (Cr 4.a.1 a 4.a.4) : enchainement des vues en
> fonction des actions et interactions utilisateur, pour les deux interfaces
> (borne kiosk Bloc 1, back-office Bloc 2). Complete les diagrammes UML
> (`docs/uml/use-cases.md`, `sequence-passer-commande.md`, `state-commande.md`) et
> le modele Merise (`docs/merise/`).
---
## 1. Vue d'ensemble
Deux interfaces, deux parcours, un meme catalogue en base :
- **Borne (kiosk)** — publique, anonyme, tactile. Le client compose une commande et
la valide ; la borne consomme l'API de lecture (catalogue) et l'API de commande
(creation + encaissement) en `fetch` Ajax.
- **Back-office** — interne, authentifie (sessions + RBAC par permission), pages
rendues serveur (MVC). Chaque action sensible repasse par un PIN equipier.
Les transitions ci-dessous decrivent quelle vue mene a quelle vue, sous quelle
action, et quel appel API ou garde de securite intervient.
---
## 2. Parcours borne (Bloc 1)
```mermaid
flowchart TD
A["Accueil (index.html)\nchoix sur place / a emporter"] -->|clic mode| B["Categories (categories.html)"]
B -->|clic categorie| C["Produits (products.html?category)\npanneau commande persistant"]
C -->|produit simple| D["Modale options (product-options.js)\ntaille / quantite"]
C -->|menu| E["Composeur menu (page-product-menu.js)\nslots GET /api/menus/{id}"]
D -->|ajouter| C
E -->|ajouter| C
C -->|voir panier| F["Panier (cart.html)\nmodifier quantite / retirer"]
F -->|valider| G["Paiement (payment.html)\nsaisie numero chevalet si sur place"]
G -->|enregistrer| H["Confirmation (confirmation.html)\nnumero + montant"]
H -->|nouvelle commande| A
C -. "GET /api/categories,/products,/menus (data.js)" .-> API[(API kiosk)]
G -. "POST /api/orders puis /pay (checkout.js)" .-> API
H -. "suivi optionnel GET /api/orders/{number}" .-> API
```
**Transitions detaillees :**
| Vue | Action | Vue suivante | API / etat |
|---|---|---|---|
| Accueil | Choisir « sur place » / « a emporter » | Categories | mode memorise (state.js / nav.js) |
| Categories | Choisir une categorie | Produits | `GET /api/categories` (chargement) |
| Produits | Cliquer un produit simple | Modale options | `GET /api/products` |
| Produits | Cliquer un menu | Composeur de menu | `GET /api/menus/{id}` (slots) |
| Modale / Composeur | Ajouter au panier | Produits (panneau mis a jour) | panier en `localStorage` |
| Produits | Voir le panier | Panier | — |
| Panier | Valider | Paiement | — |
| Paiement | Saisir le numero (chevalet, si sur place) puis enregistrer | Confirmation | `POST /api/orders` puis `POST /api/orders/{number}/pay` (idempotent) |
| Confirmation | Nouvelle commande | Accueil | panier vide |
**Transverse borne :** bascule de police adaptee aux dyslexiques (bouton `a11y.js`,
present sur chaque vue, RGAA Cr 1.c.2) ; navigation clavier + focus-trap dans les
modales ; panneau commande persistant (aside) sur Produits.
---
## 3. Parcours back-office (Bloc 2)
```mermaid
flowchart TD
L["Connexion (/login)"] -->|identifiants valides| R{"role.default_route\n(seed)"}
L -->|oubli mdp| RP["/forgot_password -> /reset_password"]
R -->|admin| DB["Tableau de bord (/admin/dashboard)"]
R -->|manager| ST["Statistiques (/admin/stats)"]
DB --> NAV["Navigation laterale\n(conditionnee aux permissions)"]
ST --> NAV
NAV --> CAT["Categories / Produits / Menus\n(+ editeur de recette)"]
NAV --> STK["Stock / Ingredients\n(reappro, inventaire, mouvements)"]
NAV --> USR["Utilisateurs / Roles (RBAC)"]
NAV --> ORD["Commandes (liste, lecture seule)"]
NAV --> PRO["Profil : PIN + mention RGPD (/admin/privacy)"]
CAT -.->|action sensible : prix/TVA, suppression| PIN["PIN equipier + audit_log\n(meme transaction)"]
STK -.->|inventaire| PIN
USR -.->|mutation compte / matrice RBAC / effacement| PIN
```
**Gardes et regles :**
| Etape | Garde | Regle Merise |
|---|---|---|
| Acces a toute page `/admin/*` | `SessionGuard::check()` : session valide (idle 4h, absolu 10h, compte actif) | RG-6 / RG-T02 |
| Acces a une fonction | `Authorizer::can(role_id, permission)` : teste une permission, pas un nom de role | RG-T03 |
| Action sensible (annulation, prix/TVA, suppression, gestion compte/RBAC, inventaire, effacement PII) | PIN equipier verifie + ecriture `audit_log` dans la meme transaction | RG-T13 / RG-T14 |
| Echec de PIN | trace `pin.failed` + throttle degressif | RG-T22 |
**Landing par role** (seed `role.default_route`) : admin -> `/admin/dashboard`,
manager -> `/admin/stats`. Les autres roles (kitchen, counter, drive) sont definis
en base ; leurs ecrans operationnels (file cuisine, saisie comptoir/drive) sont
suivis comme evolution (voir le backlog de finition).
---
## 4. Points de contact API
| Interface | Appelle | Sens |
|---|---|---|
| Borne | `GET /api/categories`, `/products`, `/products/{id}`, `/menus`, `/menus/{id}`, `/allergens` | lecture catalogue (anonyme) |
| Borne | `POST /api/orders`, `POST /api/orders/{number}/pay`, `GET /api/orders/{number}` | commande + suivi (anonyme, idempotent) |
| Back-office | pages rendues serveur sous `/admin/*` + `GET /api/me` | session + RBAC |
CORS : la borne et le back-office partagent l'origine via une passerelle `/api/*`
(meme origine) ; le middleware CORS reste en defense (origine exacte, sans joker).
---
## 5. References croisees
- Cas d'usage et acteurs : `docs/uml/use-cases.md`
- Sequence de commande : `docs/uml/sequence-passer-commande.md`
- Machine a etats de la commande : `docs/uml/state-commande.md`
- Sequence securite (annulation PIN-gated) : `docs/uml/security-sequence.md`
- Modele de donnees : `docs/merise/{dictionary,mcd,mld,mlt}.md`
- Contrat API : `docs/api/conventions.md`

View file

@ -3,6 +3,8 @@
## Fichiers
- `maquette-borne.pdf` : maquette ecrans complete fournie avec le brief ecole
- `screens/` : les 10 ecrans de la maquette exportes en PNG (un par ecran)
- `maquette-vs-build.md` : decomposition ecran par ecran + tracabilite maquette vs kiosk construit (ecarts structurants)
## Source en ligne

View file

@ -0,0 +1,121 @@
# Maquette borne vs kiosk construit — decomposition et tracabilite
> Auteur : BYAN. Note de tracabilite maquette -> code (appui oral RNCP Bloc 1 :
> "comment etes-vous passe de la maquette au code ?").
> Source : `docs/design/maquette-borne.pdf` (export Figma de l'ecole, 10 ecrans,
> format 1440x1024). Ecrans exportes un a un dans `docs/design/screens/`.
## 1. Lecture d'ensemble
La maquette decrit un **parcours de type McDonald's** (Big Mac, Best Of, McCafe,
arches M, Coca) : c'est la base de reference a rebrander en Wakdo.
Point central : les **10 "ecrans" ne sont pas 10 pages**. Ce sont en realite
~4 ecrans de base plus un systeme de modales qui s'ouvrent par-dessus l'ecran de
commande :
```
Accueil
-> Ecran de commande UNIQUE
(bandeau categories en haut + grille produits + panneau commande persistant a droite)
sur lequel s'ouvrent les modales de composition (taille menu, accompagnement, boisson, format/quantite)
-> Chevalet (sur place : saisie du numero)
-> Remerciement
```
Le kiosk construit, lui, eclate cet ecran unique en **pages distinctes** et n'a
pas de panneau de commande persistant. C'est l'origine du sentiment "ca ne
correspond pas".
## 2. Decomposition ecran par ecran
### Ecran 1 — Accueil
![Accueil](screens/01-accueil.png)
- "Bonjour," + "Souhaitez-vous consommer votre menu sur place ou preferez-vous l'emporter ?"
- Deux grandes cartes : **Sur Place** (icone table) / **A Emporter** (icone sac).
- Fond : arches + Big Mac + Coca.
### Ecran 2 — Ecran de commande (pivot)
![Ecran de commande menus](screens/02-commande-menus.png)
- **Bandeau categories horizontal** (Menus actif, Sandwiches, Wraps, Frites, Boissons Froides, Encas, Desserts...) avec fleches rouges ◀ ▶ pour faire defiler.
- Titre de section "Nos menus" + sous-titre + **grille de produits** (image, nom, prix).
- **Panneau de commande persistant a droite** : numero de commande (72), "Sur place : 326", lignes de commande avec options en puces et icone corbeille, "TOTAL (ttc) 36,50 EUR", boutons "Abandon" / "Payer", logo W en haut.
- Cet ecran est le coeur de la maquette : tout le reste (sauf accueil/chevalet/remerciement) se joue ici ou en modale par-dessus.
### Ecran 3 — Modale "Une grosse faim ?" (composition menu, etape 1)
![Modale taille menu](screens/03-modale-taille-menu.png)
- Choix de la taille : **Menu Maxi Best Of** / **Menu Best Of**.
- Bouton "Etape Suivante". Premiere etape d'un assistant en modale.
### Ecran 4 — Modale "Choisissez votre accompagnement" (etape 2)
![Modale accompagnement](screens/04-modale-accompagnement.png)
- **Frites** / **Potatoes**.
- Boutons "Retour" + "Etape Suivante".
### Ecran 5 — Modale "Choisissez votre boisson" (etape 3)
![Modale boisson](screens/05-modale-boisson.png)
- Carrousel de boissons (Eau, Coca, Coca Zero, Jus de pomme BIO, The...) avec ◀ ▶.
- Bouton "Ajouter le menu a ma commande" (fin de l'assistant).
### Ecran 6 — Ecran de commande, categorie Boissons Froides (a la carte)
![Commande boissons](screens/06-commande-boissons.png)
- Meme ecran pivot, categorie "Boissons Froides" active.
- Grille de 8 boissons avec prix unitaires (Eau 1 EUR, Coca 1.90 EUR, Fanta 1.90 EUR, Jus de pomme BIO 2.30 EUR...).
### Ecran 7 — Selection d'un produit (etat)
![Boisson selectionnee](screens/07-boissons-selection.png)
- Meme grille, "Coca Cola" entoure en jaune : etat visuel de selection.
### Ecran 8 — Modale "Une petite soif ?" (option produit a la carte)
![Modale format quantite](screens/08-modale-format-quantite.png)
- Taille **30Cl / 50Cl** (+0.50 EUR pour le 50Cl).
- **Stepper de quantite** (- 1 +).
- Boutons "Annuler" / "Ajouter a ma commande".
### Ecran 9 — Chevalet (sur place)
![Chevalet](screens/09-chevalet.png)
- "Pour etre servis a table," + "Recuperez un chevalet et indiquez ici le numero inscrit dessus".
- Grands chiffres `2 6 1` + bouton "Enregistrer le numero".
### Ecran 10 — Remerciement
![Remerciement](screens/10-remerciement.png)
- "Toute l'equipe vous remercie, Et vous souhaite un bon appetit dans nos restaurants, A bientot !"
- Bouton "Nouvelle commande".
## 3. Maquette -> kiosk construit (mapping)
| Maquette | Kiosk construit | Verdict |
|----------|-----------------|---------|
| 1. Accueil sur place / a emporter | `index.html` | conforme |
| 2 + 6. Ecran de commande unique (bandeau + grille + **panneau persistant**) | eclate en `categories.html` -> `products.html` -> `cart.html` | divergence structurante : multi-pages, et **pas de panneau de commande persistant** |
| (pas de page categories separee) | `categories.html` plein ecran "Que souhaitez-vous commander ?" | ecran **ajoute** (la maquette met les categories en bandeau) |
| 3-5. Composeur menu = **assistant modal en etapes** | `page-product-menu.js` = composition **libre** | divergence (le refactor "consommer les slots /api/menus" est deja en file P4) |
| 8. Modale d'option produit (taille + quantite) | `product.html` (page) | divergence : page au lieu de modale |
| 9. Ecran **chevalet** dedie (saisie numero) | numero gere par l'API (chunk 1a), affiche en confirmation | manquant cote ecran |
| (aucun ecran de paiement) | `payment.html` "Carte bancaire / Especes" | ecran **ajoute** par le build |
| 10. Remerciement | `confirmation.html` | conforme |
## 4. Ecarts structurants (le fond du sujet)
1. **Paradigme inverse.** Maquette = **mono-ecran** (un plan de commande avec
categories en bandeau et un panneau recapitulatif persistant a droite, modales
par-dessus). Build = **multi-pages** classiques (categories -> produits ->
produit -> panier). C'est l'ecart structurant principal.
2. **Panneau de commande lateral absent.** La piece centrale de la maquette
(numero de commande, lignes editables avec corbeille, TOTAL ttc, Abandon /
Payer, visible en permanence) n'est pas presente dans le build.
3. **Composition de menu.** Maquette = assistant modal en etapes ; build =
composition libre cote client (`page-product-menu.js`).
## 5. Rebrand McDonald's -> Wakdo
Le visuel de la maquette est du McDonald's litteral (Big Mac, Best Of, McCafe,
arches, "Tous a l'eau by M"). Le rebrand vers Wakdo (logo W, catalogue propre)
est attendu et legitime : le branding McDo n'est pas livrable. Le sujet de cette
note n'est donc pas le rebrand mais la **structure** des ecrans.
## 6. Suite
Re-alignement du kiosk sur la maquette (panneau persistant + bandeau categories +
composeur en modale) = chantier UI conduit via un cycle FD dedie. Backlog des
divergences = section 3 ci-dessus.

Binary file not shown.

After

Width:  |  Height:  |  Size: 825 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 313 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 910 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 916 KiB

18
docs/domaines/README.md Normal file
View file

@ -0,0 +1,18 @@
# Documentation par domaine
Une fiche par domaine fonctionnel livre : **perimetre**, **ce qui est livre** (code +
routes), **regles metier** (RG-T* de `docs/merise/mlt.md`), **decisions** (renvoi
`docs/adr/`), **tables**. Vue d'ensemble : `docs/ARCHITECTURE.md`.
**Auteur : BYAN** (formalisation ; arbitrage et validation par l'auteur).
| Domaine | Fiche | Statut |
|---|---|---|
| Authentification & sessions | [auth.md](auth.md) | Livre (P2) |
| Catalogue (categories, produits, menus) | [catalogue.md](catalogue.md) | Livre (P3) |
| Stock & recettes (ingredients) | [stock-recettes.md](stock-recettes.md) | Livre (P3) |
| Comptes utilisateurs | [users.md](users.md) | Livre (P3) |
| RBAC (roles & permissions) | [rbac.md](rbac.md) | Livre (P3) |
| Statistiques | [stats.md](stats.md) | Livre (P3, KPIs vente differes P4) |
| Borne (kiosk) | [borne.md](borne.md) | Front P5 (API au swap P4) |
| Commande | — | P4 (schema pret, workflow a venir) |

29
docs/domaines/auth.md Normal file
View file

@ -0,0 +1,29 @@
# Domaine — Authentification & sessions
## Perimetre
Connexion back-office, deconnexion, reinitialisation de mot de passe, garde de session,
PIN d'action sensible. Pas d'auth cote borne (front public).
## Ce qui est livre
- `App\Auth\AuthService` (login 12.1 / logout 12.2), `PasswordResetService` (12.3).
- `SessionManager` (seul a toucher `$_SESSION`/cookie, mode test memoire), `SessionGuard`
(RG-6/RG-T02 : idle 4h, absolu 10h, `is_active`), `Csrf` (jeton synchroniseur).
- `PasswordHasher` (argon2id + leurre de timing), `PinVerifier`, `PinThrottle`,
`ThrottlePolicy` (backoff degressif).
- Controleurs `AuthController`, `PasswordResetController`, `ProfileController` (set-PIN
self-service), `MeController` (`/api/me`).
## Regles metier
- RG-6 / RG-T02 : session valide (idle + absolu + compte actif) sinon 302 `/login`.
- RG-8 / RG-9 : throttle login par compte + par IP (`login_throttle`), backoff degressif.
- RG-T13 : PIN d'action sensible (voir [users](users.md), [rbac](rbac.md), stock).
- Anti-enumeration : reponses neutres (reset, login) ; leurre de timing argon2id.
## Decisions
[ADR-0001](../adr/0001-php-from-scratch-sans-composer.md) (from scratch),
[ADR-0004](../adr/0004-pin-action-sensible-audit.md) (PIN),
[ADR-0005](../adr/0005-throttle-pin-separe-du-login.md) (throttle PIN).
## Tables
`user`, `login_throttle`, `pin_throttle`, `audit_log` (login + pin.failed). Detail :
`docs/merise/mlt.md` section 12 + 22.

31
docs/domaines/borne.md Normal file
View file

@ -0,0 +1,31 @@
# Domaine — Borne (kiosk)
## Perimetre
Front client tactile (Bloc 1) : parcours welcome -> categories -> produit -> panier ->
confirmation. HTML/CSS/JS vanilla, servi en statique par Apache.
## Ce qui est livre
- Pages : `index`, `categories`, `products`, `product`, `cart`, `payment`,
`confirmation` (`src/public/borne/`).
- JS modules ES6 (`assets/js/`) : `data.js` (chargement, point de swap P4), `state.js`
(panier), `page-*.js`, `nav.js`, et `allergens.js` (modale generale 14 INCO sur carte
et fiche).
- Donnees : JSON statiques (`data/`) en P5 ; basculent sur `/api/*` DB-backed au swap P4.
## Regles metier / conventions
- Allergenes : info **generale** (les 14 INCO, reglement UE 1169/2011), pas un calcul
par produit (mapping `ingredient_allergen` differe).
- CSP-safe pour le code projet : pas de script inline ajoute (donnees via `data-*`,
`addEventListener`). Source allergenes = liste fixe `data/allergens.json`, se branchera
sur `/api/allergens` au swap P4.
## Tests
Harnais front `node:test` + jsdom (`tests/js/allergens.test.js`) : 14 INCO, bouton "i",
ouverture/fermeture (bouton/overlay/Echap), idempotence. Job CI `js-tests` (Node 20).
## Decisions
Swap point P5 -> API au P4 (cf. `data.js` + journaux). Modele = app self-hostable
([ADR-0009](../adr/0009-compose-standalone-et-prod-gitignore.md)).
## Tables (au swap P4)
`category`, `product`, `menu` + `allergen` (lecture). Aujourd'hui : JSON statiques.

View file

@ -0,0 +1,29 @@
# Domaine — Catalogue (categories, produits, menus)
## Perimetre
CRUD des categories, produits et menus composes (borne de base + slots). Base du
catalogue consomme par la borne.
## Ce qui est livre
- Repositories : `CategoryRepository`, `ProductRepository`, `MenuRepository`.
- Controleurs : `CategoryController` (`category.manage`), `ProductController`
(`product.read/create/update/delete`), `MenuController` (`menu.read/create/update/delete`).
- Menus composes : burger de base + `menu_slot` / `menu_slot_option`, editeur slots en
JS vanilla CSP-safe (champ cache `slots_json`), reecriture delete-and-reinsert en tx.
## Regles metier
- RG-T16 (allowlist colonnes), RG-T18 (validation serveur bornee : prix > 0, TVA dans
{55,100}, etc.), RG-T15 (sorties echappees).
- Produit : PIN equipier + audit UNIQUEMENT si prix ou TVA change (mlt 8.2 RG-4) ;
suppression = PIN + audit (8.3). Menu : suppression = PIN + audit (8.6).
- Pas de suppression dure si reference (FK RESTRICT depuis order_item / menu / selection)
-> 409, alternative = desactivation (`is_available`).
## Decisions
[ADR-0002](../adr/0002-back-office-mvc-rendu-serveur.md) (MVC serveur),
[ADR-0006](../adr/0006-http-409-conflit-422-validation.md) (409/422),
[ADR-0004](../adr/0004-pin-action-sensible-audit.md) (PIN + audit).
## Tables
`category`, `product`, `menu`, `menu_slot`, `menu_slot_option`. Detail :
`docs/merise/mlt.md` section 8.

30
docs/domaines/rbac.md Normal file
View file

@ -0,0 +1,30 @@
# Domaine — RBAC (roles & permissions)
## Perimetre
Gestion des roles et de la matrice role/permission (mlt 10.4 MANAGE_RBAC), permission
`role.manage`. Catalogue de permissions fige au seed (lecture seule).
## Ce qui est livre
- `RoleRepository` (App\Auth) : roles (CRUD, code immuable), permissions (lecture),
matrice (`permissionIdsFor`/`permissionCodesFor`, `setPermissions` tx +
`replacePermissions` raw), `role_visible_source` (`setVisibleSources` / raw).
- `RoleController` (`role.manage`) : index, create/store (role custom RG-4), edit/update
(champs role + matrice + sources visibles en UNE transaction). Vues `admin/roles/{index,form}`.
- Matrice soumise en champs **scalaires** (`perm_<id>`, `source_<enum>`) : `Request::formBody`
ne garde que les scalaires (pas de `name[]`, pas de JS).
## Regles metier
- RG-6 (mlt 10.4) : PIN equipier + `audit_log` (`role.manage`) dans une transaction ;
`details` JSON = **diff** des codes de permission (ajoutes/retires), calcule avant la
reecriture delete-and-reinsert.
- `Authorizer::can` recharge les permissions a chaque verification (effet immediat).
- Garde-fous anti-lockout : le role `admin` conserve `role.manage` ET reste actif ;
`code` immuable apres creation ; `order_source` borne a l'ENUM ; code dupli -> 409.
## Decisions
[ADR-0004](../adr/0004-pin-action-sensible-audit.md) (PIN + audit),
[ADR-0006](../adr/0006-http-409-conflit-422-validation.md) (409).
## Tables
`role`, `permission`, `role_permission`, `role_visible_source`, `audit_log`. Detail :
`docs/merise/mlt.md` section 10.4.

26
docs/domaines/stats.md Normal file
View file

@ -0,0 +1,26 @@
# Domaine — Statistiques
## Perimetre
Tableau de bord de pilotage (mlt domaine 11), permission `stats.read`. Landing par
defaut du role manager.
## Ce qui est livre
- `StatsRepository` : `counts()` (compteurs catalogue : produits/menus/categories/
ingredients, total + actifs/disponibles), `stockHealth()` (repartition des ingredients
actifs par bande RG-T21 + liste d'alerte triee du plus critique).
- `StatsController` (`stats.read`) -> `/admin/stats` + vue `admin/stats/index` (cartes
KPI + table d'alerte stock) + lien nav "Pilotage".
## Regles metier / perimetre
- KPIs sur les **donnees disponibles** en P3 : sante catalogue + stock. **Ferme le 404**
du landing manager (`role.default_route = /admin/stats`).
- KPIs de **vente** (CA, volumes, `service_day`) = **P4** : ils dependent du domaine
commande (encore en schema seul).
- Sante stock = reutilise `IngredientRepository::stockBand` (source unique RG-T21).
## Decisions
[ADR-0003](../adr/0003-stock-pourcentage-dispo-calculee.md) (bandes RG-T21).
## Tables
Lecture seule : `product`, `menu`, `category`, `ingredient` (compteurs + bandes).
KPIs vente (P4) : `customer_order`, `order_item`. Detail : `docs/merise/mlt.md` section 11.

View file

@ -0,0 +1,31 @@
# Domaine — Stock & recettes (ingredients)
## Perimetre
Gestion des ingredients, du stock (reappro + inventaire), des mouvements de stock, et de
la composition des produits (recettes). Sous-tend la disponibilite produit calculee.
## Ce qui est livre
- `IngredientRepository` : CRUD, stock %/bande calcules, `restock` (tx), `inventoryCount`
(tx, ecrit une ligne meme a delta=0, RG-3), `movements` (borne), `isReferenced`.
- `IngredientController` : CRUD (`ingredient.manage`, sans PIN), RESTOCK (`stock.manage`,
sans PIN), INVENTORY_COUNT (`stock.count` + PIN), mouvements (`stock.read`).
- `ProductRepository` : composition (`product_ingredient`), `setComposition`
(delete-and-reinsert tx), `isOrderable` (RG-T21), `autoUnavailableIds`.
- Editeur de recette (`ProductController::recipeForm/saveRecipe`, `ingredient.manage`).
## Regles metier
- RG-T13 : INVENTORY_COUNT seule action sensible du stock (PIN equipier) ; succes ->
`stock_movement.user_id`, **sans** `audit_log` (RG-T14 : le mouvement EST la trace).
RESTOCK et CRUD ingredient ne sont PAS sensibles.
- RG-T22 : echec PIN inventaire -> `pin.failed` + throttle dans une transaction.
- RG-T21 : disponibilite produit calculee (cf. [ADR-0003](../adr/0003-stock-pourcentage-dispo-calculee.md)).
- FK : `product_ingredient`/`stock_movement` RESTRICT sur l'ingredient (hard-delete -> 409) ;
`product_ingredient.product_id` CASCADE (trace du nombre de lignes a la suppression, dette #27).
## Decisions
[ADR-0003](../adr/0003-stock-pourcentage-dispo-calculee.md) (stock % + RG-T21),
[ADR-0004](../adr/0004-pin-action-sensible-audit.md) / RG-T14 (attribution sans double-journal).
## Tables
`ingredient`, `product_ingredient`, `stock_movement`, `allergen`, `ingredient_allergen`
(mapping differe). Detail : `docs/merise/mlt.md` sections 8.8 + 9.

31
docs/domaines/users.md Normal file
View file

@ -0,0 +1,31 @@
# Domaine — Comptes utilisateurs
## Perimetre
Gestion des comptes back-office (mlt domaine 10.1-10.3 + 10.5) : creation, edition,
desactivation, reinitialisation de PIN, effacement RGPD.
## Ce qui est livre
- `UserRepository` (App\Auth) : all (JOIN role) / find / emailExists / activeRoleExists /
create / update (allowlist) / setPasswordHash / clearPin / deactivate / anonymise /
activeAdminCount / isAdmin.
- `UserController` : index (`user.read`), create/store (`user.create`), edit/update
(`user.update`), deactivate (`user.deactivate`), reset-pin, erase-PII. Vues
`admin/users/{index,form,confirm}`.
## Regles metier
- RG-T13/14 : **toutes** les mutations sont sensibles -> PIN equipier + `audit_log`
(`user.create/update/deactivate/erase_pii`) dans la meme transaction ; `details` JSON =
noms de champs / role (pas de PII). Throttle RG-T22.
- RG-T16 : allowlist (email/prenom/nom/role_id/is_active) ; `is_active` pose serveur a
la creation. Unicite email -> 409.
- Self-protection : pas d'auto-desactivation (403 SELF_DEACTIVATION) ; on ne retire pas
le statut du **dernier admin actif** (update/deactivate/erase) ; effacement deja fait -> 409.
## Decisions
[ADR-0004](../adr/0004-pin-action-sensible-audit.md) (PIN + audit),
[ADR-0007](../adr/0007-rgpd-anonymisation-tombstone.md) (anonymisation RGPD),
[ADR-0006](../adr/0006-http-409-conflit-422-validation.md) (409/422).
## Tables
`user` (+ `anonymized_at` pour RGPD), `audit_log`, `role` (FK). Detail :
`docs/merise/mlt.md` section 10.1-10.5.

View file

@ -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).

View file

@ -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.

View file

@ -0,0 +1,88 @@
# 2026-06-17 — Du Makefile a `docker compose up` (service wakdo-migrate)
**Auteur : BYAN.** Remplacement de l'orchestration locale par Makefile par un service
compose one-shot. Objectif : que `docker compose up` amene a lui seul une stack
complete et utilisable, et retirer un Makefile devenu en partie trompeur.
## Pourquoi ce changement
### Le declencheur : le Makefile mentait en partie
Un audit du Makefile (24 cibles) a montre trois categories :
- **Cibles mortes / trompeuses** : `test`, `test-unit`, `test-integration`, `lint`
affichaient *« Pas encore implemente … en P2 »* alors que les tests EXISTENT et
tournent (PHPUnit via `.phar`, 263 tests unit + 301/916 en integration ; PHPStan
L6 ; tests JS node:test+jsdom). `install-hooks` referencait un `.githooks/` et un
`scripts/install-hooks.sh` absents. Une cible qui annonce un faux est pire qu'une
cible absente.
- **Wrappers fins** : `up/down/logs/shell/...` = une ligne au-dessus de
`docker compose`, valeur surtout de decouvrabilite (`make help`).
- **Une seule cible reellement porteuse** : `init` (build -> up -> wait-db ->
migrate), citee comme la preuve du critere RNCP **Cr 7.c.4** (*« lancer la stack
complete avec une seule ligne de commande »*).
### Le point cle : Cr 7.c.4 parle d'un RESULTAT, pas de `make`
Le critere exige *une commande -> stack complete*. Il ne mentionne pas `make` ;
`make init` n'etait qu'un choix d'implementation. Or `docker compose up` seul ne
suffisait pas : il demarre les conteneurs mais **n'applique pas les migrations**
(base vide -> stack non « complete »). C'etait l'unique raison d'etre de `make init`.
En deplacant migration + seed DANS la stack (un service one-shot qui tourne au
boot), c'est `docker compose up` LUI-MEME qui amene la stack complete. Avantages :
- **Commande universelle** : `docker compose up`, sans dependance a l'outil `make`
sur l'hote (un correcteur n'a pas a installer/connaitre `make`).
- **Comportement = documentation** : l'ancien `make init` ne faisait meme PAS le
seed (il s'arretait a `migrate`), alors que le README annoncait « migrate + seed ».
Le nouveau chemin seed pour de vrai, donc la stack est *loginnable* (admin present)
en une commande.
- **Plus idiomatique** : faire porter l'init par la couche d'orchestration (compose)
plutot que par un outil hote externe.
## Ce qui a ete fait
- **`db/migrate-container.sh`** : runner in-container. Applique `db/migrations/*.sql`
(suivi `schema_migrations`) PUIS `db/seeds/*.sql` (suivi `seeds_applied`), de
maniere idempotente, en se connectant a la base par le reseau compose (DB_HOST).
Distinct de `db/migrate.sh` (hote, via `docker exec`), conserve pour l'usage manuel
(`--status`) et la CI.
- **Service `wakdo-migrate`** (image mariadb, `restart: "no"`) : `depends_on`
`wakdo-db: service_healthy`, lance le runner puis sort. `wakdo-app` et `wakdo-web`
gagnent `depends_on wakdo-migrate: service_completed_successfully` -> ils ne servent
qu'une fois le schema + le seed en place.
- **Makefile supprime.** Les commandes equivalentes en clair :
`docker compose up -d` (= ex-`make init`/`up`), `docker compose down` (`make down`),
`docker compose down -v` (`make clean`), `docker compose build --no-cache && up -d`
(`make rebuild`), `docker compose logs -f` (`make logs`),
`docker compose exec wakdo-db mariadb -uroot -p"$DB_ROOT_PASSWORD"` (`make shell-db`).
Tests : `docker run --rm -v "$PWD":/app -w /app wakdo-wakdo-app php phpunit.phar -c phpunit.xml`
(cf. README / SESSION_RESUME).
## Verification
Base MariaDB ephemere et vierge (pour ne pas toucher la dev) :
- Run #1 : 2 migrations + 2 seeds appliques.
- Run #2 : 0 nouveau (idempotent, tout ignore).
- Donnees : 5 roles, 23 permissions, admin `admin@wakdo.local` present ;
`schema_migrations`=2, `seeds_applied`=2.
`docker compose -p wakdo config` valide. La CI n'utilise pas `make` (0 appel) :
elle garde sa propre boucle migrate -> non impactee.
## Mapping Cr 7.c.4 (apres ce changement)
*« Le fichier de configuration permet de lancer la stack applicative complete avec
une seule ligne de commande »* -> **`docker compose up`** : `docker-compose.yml`
decrit la stack, le service `wakdo-migrate` applique schema + donnees, app/web
attendent sa completion. Une commande, aucune dependance hote.
## Note de deploiement (environnements deja seedes)
Sur une base existante deja migree ET seedee AVANT l'introduction du suivi
(`seeds_applied` absente), le premier `docker compose up` avec le nouveau service
tenterait de rejouer les seeds (INSERT non idempotents) -> conflits d'unicite. Pour
ces environnements : back-fill une fois la table de suivi
(`CREATE TABLE seeds_applied(...)` + INSERT des noms de fichiers seed deja appliques,
idem `schema_migrations` si besoin) AVANT le premier up. Les deploiements sur volume
VIERGE ne sont pas concernes (le service applique tout proprement, comme verifie).
## Compromis assumes
- migrations + seeds evalues a CHAQUE `up` : cout negligeable (le suivi rend les
re-runs sans effet).
- `wakdo-migrate` se connecte en root (DDL + INSERT de reference), comme `migrate.sh`.

View file

@ -0,0 +1,55 @@
# 2026-06-17 — Session : infra compose, documentation, E2E Playwright
**Auteur : BYAN.** Retrospective de session. Suite a l'achevement du back-office P3
(Stats/Users/RBAC), cette session a porte sur l'infra de demarrage, un jeu de
documentation pour la Forge, et l'amorce des tests E2E.
## Contexte de depart
P3 back-office complet et merge (#37 Stats, #38 Users, #39 RBAC). `dev` propre.
## Ce qui a ete livre (PR mergees sur dev)
| PR | Objet |
|----|-------|
| #40 | Makefile -> `docker compose up` : service one-shot `wakdo-migrate` (migrate + seed idempotents, suivi `schema_migrations`/`seeds_applied`). Makefile supprime. |
| #41 | `docker-compose.yml` **standalone portable** (port hote, sans Traefik) ; prod = `docker-compose.prod.yml` **gitignore** par hote. Renommage `TRAEFIK_DOMAIN_*` -> `APP_HOST_*`. `.env.example` local-first. |
| #42 | Doc socle : `ARCHITECTURE.md` (10 sections) + `DEVELOPER.md`. |
| #43 | Registre `docs/adr/` : 9 ADR (puis 10, cf. #46). |
| #44 | Doc par domaine `docs/domaines/` : 7 fiches (auth, catalogue, stock-recettes, users, rbac, stats, borne). |
| #45 | E2E Playwright **etape 1** : parcours borne (welcome -> confirmation). |
| #46 | E2E Playwright **etape 2** : parcours admin (garde -> login -> dashboard -> logout) + fix securite (cf. ADR-0010). |
## Decisions notables
- **`docker compose up` comme commande unique** (Cr 7.c.4) sans `make` ni dependance
hote, via le service `wakdo-migrate`. Cf. ADR-0008, journal makefile-to-compose.
- **Deux fichiers compose pleins et independants** (pas d'overlay `-f`) : un standalone
versionne pour tous, un prod gitignore par hote. Choix de simplicite assume sur
demande du user (clarte > DRY pour l'infra). Cf. ADR-0009.
- **Playwright en conteneur officiel, contre une stack jetable isolee** (`run.sh`,
projet `-p wakdoe2e`, override container_name, joint par `--add-host`) : aucune
dependance browser sur l'hote, ne touche aucune stack existante. Hostnames de test
en `.test` (Chromium force `*.localhost` vers 127.0.0.1, RFC 6761).
## Ce que l'E2E a fait remonter (sa valeur)
1. **a11y** : le bouton "Valider ma commande" (`<a>`) gardait `aria-disabled="true"`
(`.disabled` est un no-op sur un `<a>`) -> annonce desactive panier rempli. Corrige.
2. **securite/usage** : cookie de session `secure=true` en dur -> session intenable en
HTTP, donc admin inconnectable en local. Rendu **conditionnel au HTTPS** (prod
inchange). Cf. ADR-0010.
## Verifications
PHPStan L6 OK ; 263 tests unit ; 7 tests JS ; 2 parcours E2E verts (borne + admin) ;
smoke-test standalone (stack jetable, migrate + seed + vhosts) ; CI Forgejo verte sur
chaque PR (auto-merge sur label). `.env` LOCAL migre vers `APP_HOST_*`.
## Reste a faire (file d'attente)
- **Deploiement serveur (Thanos)** : migrer le `.env` serveur (`APP_HOST_*`), placer son
`docker-compose.prod.yml`, back-fill `seeds_applied` avant le 1er up.
- **E2E etape 3** : job CI Forgejo (stack jetable + Playwright conteneur) ; verifier que
le runner peut lancer Docker.
- **Front** : page de **login** a retravailler (signalee comme "moche" ; pas le dashboard).
- **Doc** : enrichissements (diagrammes), doc commande quand P4 sort.
- **P4** : domaine commande (KPIs vente, nav orders, swap borne -> API DB-backed).
## Reprise
`docs/SESSION_RESUME.md` tient l'etat detaille et les commandes de reprise.

View file

@ -0,0 +1,80 @@
# 2026-06-18 — Session : page login, refonte UI admin, humanisation, P4 commande
**Auteur : BYAN.** Retrospective de session. Apres la session infra/doc/E2E du 2026-06-17,
cette session a porte sur le front (pages auth), une refonte du back-office pour des
equipiers non-techniques (UX + UI), l'humanisation des libelles, et l'amorce du domaine
P4 commande (creation + encaissement cote API).
## Contexte de depart
Back-office P3 complet et merge. `dev` propre. Pistes ouvertes : E2E-CI, page login
signalee comme "moche", domaine P4 commande.
## Ce qui a ete livre (PR mergees sur dev)
| PR | Objet |
|----|-------|
| #48 | Relooking des pages auth (login / forgot / reset) : `.login-card`, logo reel, `.form-input`, `.btn`. La page login servie via `layout.php` (passe sur `admin.css`). |
| #49 | Design system back-office (direction A+C, lot 1) : shell en grille `sidebar/topbar/content`, tokens (jaune Wakdo doux, ombres, rayons), `.sidebar-item`, `.tile`, `.alert`. |
| #50 | Dashboard donnees reelles (lot 2) : tuiles KPI alimentees par `StatsRepository` (produits dispo, categories, menus, stock critique). |
| #51 | Fix borne : logo d'en-tete centre (etait a droite). |
| #52 | Modal de re-autorisation PIN : le PIN d'action sensible devient un modal au clic (email pre-rempli), au lieu d'un fieldset inline. CSP-safe (`pin-modal.js`). |
| #54 | Humanise les libelles restants : Slug -> Reference, Delta -> Variation, Acteur -> Auteur (vues + messages de validation + tests). |
| #55 | P4 chunk 1a : creation de commande (`OrderRepository::createPending`, RG-5 etapes 1-4, calcul RG-4, numero K+id, idempotence) + migration `service_tag` (chevalet, B4). |
| #56 | Fix : logo reel dans la sidebar (un "W" dessine a la main avait ete introduit ; remplace par `logo.png` + mot-symbole, comme la page login). |
| #57 | P4 chunk 1b : encaissement (`OrderController` `POST /api/orders` + `/{number}/pay`, `OrderRepository::pay`, transition gardee -> paid + decrement de stock atomique RG-T20, idempotence). |
| #58 | CI : retire le job `auto-merge` redondant (bruit HTTP 405). En cours de merge a la redaction. |
## Decisions notables
- **E2E-CI abandonne.** Le log reel du runner a montre `Cannot connect to the Docker
daemon` : le runner de prod (`forgejo_forgejo_internal`) n'expose pas le socket Docker
aux jobs, et les jobs se repartissent sur plusieurs runners. L'E2E reste manuel via
`tests/e2e/run.sh`. Le`s emulations locales contre le mauvais runner tournaient a blanc.
- **UI pour equipiers non-techniques.** Zero jargon dev dans les ecrans ; le PIN (action
sensible) est un modal au moment de l'action, pas un champ inline ; direction visuelle
"Mix A+C" (neutre + accent jaune parcimonieux).
- **P4 commande, regles tranchees** :
- `order_number` = `K` + id auto-increment (plus simple que `K-AAAA-MM-JJ-NNN`, pas de
compteur jour) ;
- TVA portee par le produit (`product.vat_rate`), independante du mode de service
(la distinction sur place / a emporter est surtout fiscale mais la TVA reste celle
du produit) ; TVA d'une ligne menu = `vat_rate` du burger du menu ;
- flux en **deux etapes** (creation `pending_payment` puis paiement -> `paid` +
decrement), divergence assumee du flux mono-transaction de la spec, alignee sur un
parcours borne reel (ecran paiement).
- **Modele de menu borne** : B1 burger impose, B2 Normal/Maxi, B3 salades-en-menus,
B4 numero de chevalet quand "sur place".
- **Auto-merge** : bascule definitive sur l'auto-merge NATIF Forgejo
(`merge_when_checks_succeed`) ; le job CI `auto-merge` (par label) est retire (#58).
## Ce que la session a fait remonter (dette / pistes)
1. **PR #53 (ecran Roles humanise) restee ouverte et en conflit avec `dev`.** CI verte sur
sa tete, mais `mergeable: false` (5 commits de retard, 2 d'avance) : les vues Roles et
`admin.css` ont bouge avec le design system (#49) et les relabels (#54). A rebaser sur
`dev` et resoudre avant merge. Dette principale a resorber.
2. **CORS PHP manquant.** Le vhost admin delegue les headers CORS a "un middleware PHP" qui
n'existe pas. Sans bloquer le chunk 1b (endpoints + tests cote serveur), cela bloquera
la borne des qu'elle appellera `/api` cross-origin. Prerequis de la fondation borne.
3. **`pay()` / decrement de stock inerte** tant que les recettes (`product_ingredient`) ne
sont pas seedees : la transition `paid` s'applique, mais aucun `stock_movement` n'est
produit faute de composition. La logique s'active des le seed des recettes.
4. **Logo admin** : un "W" dessine a la main avait remplace le vrai logo ; corrige (#56).
## Verifications
PHPStan L6 0 erreur ; PHPUnit 284 tests unit (chunk 1b : +7 tests `pay`, +6 tests
controleur) ; php -l propre ; CI Forgejo verte par PR (merge natif squash sur les checks
requis : secret-scan, php-lint, static-tests).
## Reste a faire (file d'attente)
- **Rebaser + merger PR #53** (ecran Roles humanise) — conflit avec `dev`.
- **Middleware CORS PHP** sur `/api` (prerequis borne cross-origin).
- **Seed des recettes** (`product_ingredient`) -> active le decrement de `pay()`.
- **Fondation borne** : read API (`GET /api/categories|products|menus`) pour consommer le
vrai modele -> B1 (burger impose) + B2 (Normal/Maxi).
- **B3** salades-en-menus (Cesar Classic / Italienne Mozza en menus) ; **B4** etape
chevalet a la borne.
- **Optionnel** : prix en euros vs centimes ; flux d'activite du dashboard (audit).
## Reprise
`dev` porte tout le livre de la session sauf #53 (ouverte) et #58 (en cours de merge).
Domaine commande : `src/app/Order/` (OrderRepository create + pay), routes anonymes
`/api/orders` dans `src/public/admin/index.php`.

View file

@ -31,6 +31,8 @@ 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` |
| 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.*

View file

@ -1,67 +0,0 @@
<mxfile host="app.diagrams.net" agent="claude-code-byan" type="device">
<diagram name="MCD - Catalogue" id="mcd-catalogue">
<mxGraphModel dx="1400" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1169" pageHeight="826" math="0" shadow="0">
<root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
<mxCell id="categorie" value="&lt;b&gt;CATEGORIE&lt;/b&gt;&lt;hr&gt;id : INT (PK)&lt;br&gt;libelle : VARCHAR (UNIQUE)&lt;br&gt;slug : VARCHAR (UNIQUE)&lt;br&gt;image_path : VARCHAR&lt;br&gt;ordre : SMALLINT&lt;br&gt;est_actif : BOOLEAN" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;align=left;verticalAlign=top;fontSize=12;spacingLeft=8;spacingTop=4" vertex="1" parent="1">
<mxGeometry x="440" y="40" width="280" height="160" as="geometry" />
</mxCell>
<mxCell id="produit" value="&lt;b&gt;PRODUIT&lt;/b&gt;&lt;hr&gt;id : INT (PK)&lt;br&gt;categorie_id : INT (FK)&lt;br&gt;libelle : VARCHAR&lt;br&gt;description : TEXT&lt;br&gt;prix_ttc_cents : INT&lt;br&gt;image_path : VARCHAR&lt;br&gt;est_disponible : BOOLEAN&lt;br&gt;ordre : SMALLINT" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;align=left;verticalAlign=top;fontSize=12;spacingLeft=8;spacingTop=4" vertex="1" parent="1">
<mxGeometry x="60" y="320" width="280" height="200" as="geometry" />
</mxCell>
<mxCell id="menu" value="&lt;b&gt;MENU&lt;/b&gt;&lt;hr&gt;id : INT (PK)&lt;br&gt;categorie_id : INT (FK)&lt;br&gt;libelle : VARCHAR&lt;br&gt;description : TEXT&lt;br&gt;prix_ttc_cents : INT&lt;br&gt;image_path : VARCHAR&lt;br&gt;est_disponible : BOOLEAN&lt;br&gt;ordre : SMALLINT" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;align=left;verticalAlign=top;fontSize=12;spacingLeft=8;spacingTop=4" vertex="1" parent="1">
<mxGeometry x="820" y="320" width="280" height="200" as="geometry" />
</mxCell>
<mxCell id="menu_produit" value="&lt;b&gt;MENU_PRODUIT&lt;/b&gt; &lt;i&gt;(associative)&lt;/i&gt;&lt;hr&gt;menu_id : INT (PK, FK)&lt;br&gt;produit_id : INT (PK, FK)&lt;br&gt;role : ENUM&lt;br&gt;position : SMALLINT" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;align=left;verticalAlign=top;fontSize=12;spacingLeft=8;spacingTop=4;dashed=1" vertex="1" parent="1">
<mxGeometry x="440" y="640" width="280" height="140" as="geometry" />
</mxCell>
<mxCell id="e_cat_prod" value="regroupe" style="endArrow=none;html=1;fontSize=12;fontStyle=2;edgeStyle=orthogonalEdgeStyle;rounded=0" edge="1" parent="1" source="categorie" target="produit">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_cat_prod_a" value="(0,N)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=11;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e_cat_prod">
<mxGeometry x="-0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e_cat_prod_b" value="(1,1)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=11;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e_cat_prod">
<mxGeometry x="0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e_cat_menu" value="regroupe" style="endArrow=none;html=1;fontSize=12;fontStyle=2;edgeStyle=orthogonalEdgeStyle;rounded=0" edge="1" parent="1" source="categorie" target="menu">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_cat_menu_a" value="(0,N)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=11;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e_cat_menu">
<mxGeometry x="-0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e_cat_menu_b" value="(1,1)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=11;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e_cat_menu">
<mxGeometry x="0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e_prod_mp" value="fait_partie_de" style="endArrow=none;html=1;fontSize=12;fontStyle=2;edgeStyle=orthogonalEdgeStyle;rounded=0" edge="1" parent="1" source="produit" target="menu_produit">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_prod_mp_a" value="(0,N)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=11;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e_prod_mp">
<mxGeometry x="-0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e_prod_mp_b" value="(1,1)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=11;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e_prod_mp">
<mxGeometry x="0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e_menu_mp" value="compose" style="endArrow=none;html=1;fontSize=12;fontStyle=2;edgeStyle=orthogonalEdgeStyle;rounded=0" edge="1" parent="1" source="menu" target="menu_produit">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_menu_mp_a" value="(1,N)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=11;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e_menu_mp">
<mxGeometry x="-0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e_menu_mp_b" value="(1,1)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=11;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e_menu_mp">
<mxGeometry x="0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>

View file

@ -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"

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 119 KiB

View file

@ -1,93 +0,0 @@
<mxfile host="app.diagrams.net" agent="claude-code-byan" type="device">
<diagram name="MCD - Commande" id="mcd-commande">
<mxGraphModel dx="1400" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1169" pageHeight="826" math="0" shadow="0">
<root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
<mxCell id="commande" value="&lt;b&gt;COMMANDE&lt;/b&gt;&lt;hr&gt;id : INT (PK)&lt;br&gt;numero : VARCHAR (UNIQUE)&lt;br&gt;source : ENUM (kiosk|comptoir|drive)&lt;br&gt;mode_consommation : ENUM (sur_place|a_emporter|drive)&lt;br&gt;statut : ENUM&lt;br&gt;total_ht_cents : INT&lt;br&gt;total_tva_cents : INT&lt;br&gt;total_ttc_cents : INT&lt;br&gt;tva_taux_pourmille : SMALLINT&lt;br&gt;paye_a : DATETIME" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;align=left;verticalAlign=top;fontSize=12;spacingLeft=8;spacingTop=4" vertex="1" parent="1">
<mxGeometry x="440" y="40" width="320" height="240" as="geometry" />
</mxCell>
<mxCell id="user_stub" value="&lt;b&gt;USER&lt;/b&gt;&lt;hr&gt;id : INT (PK)&lt;br&gt;&lt;i&gt;(detail dans RBAC)&lt;/i&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;align=left;verticalAlign=top;fontSize=12;spacingLeft=8;spacingTop=4" vertex="1" parent="1">
<mxGeometry x="880" y="40" width="240" height="80" as="geometry" />
</mxCell>
<mxCell id="commande_event" value="&lt;b&gt;COMMANDE_EVENT&lt;/b&gt;&lt;hr&gt;id : INT (PK)&lt;br&gt;commande_id : INT (FK)&lt;br&gt;event_type : ENUM&lt;br&gt;from_statut : ENUM (NULL)&lt;br&gt;to_statut : ENUM&lt;br&gt;user_id : INT (FK, NULL)&lt;br&gt;payload : JSON (NULL)&lt;br&gt;created_at : DATETIME" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;align=left;verticalAlign=top;fontSize=12;spacingLeft=8;spacingTop=4" vertex="1" parent="1">
<mxGeometry x="840" y="360" width="280" height="200" as="geometry" />
</mxCell>
<mxCell id="ligne_commande" value="&lt;b&gt;LIGNE_COMMANDE&lt;/b&gt;&lt;hr&gt;id : INT (PK)&lt;br&gt;commande_id : INT (FK)&lt;br&gt;type_item : ENUM (produit|menu)&lt;br&gt;produit_id : INT (FK, NULL)&lt;br&gt;menu_id : INT (FK, NULL)&lt;br&gt;libelle_snapshot : VARCHAR&lt;br&gt;prix_unitaire_ttc_cents_snapshot : INT&lt;br&gt;quantite : SMALLINT" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;align=left;verticalAlign=top;fontSize=12;spacingLeft=8;spacingTop=4" vertex="1" parent="1">
<mxGeometry x="440" y="360" width="280" height="220" as="geometry" />
</mxCell>
<mxCell id="produit" value="&lt;b&gt;PRODUIT&lt;/b&gt;&lt;hr&gt;id : INT (PK)&lt;br&gt;&lt;i&gt;(detail dans Catalogue)&lt;/i&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;align=left;verticalAlign=top;fontSize=12;spacingLeft=8;spacingTop=4" vertex="1" parent="1">
<mxGeometry x="80" y="700" width="240" height="80" as="geometry" />
</mxCell>
<mxCell id="menu" value="&lt;b&gt;MENU&lt;/b&gt;&lt;hr&gt;id : INT (PK)&lt;br&gt;&lt;i&gt;(detail dans Catalogue)&lt;/i&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;align=left;verticalAlign=top;fontSize=12;spacingLeft=8;spacingTop=4" vertex="1" parent="1">
<mxGeometry x="840" y="700" width="240" height="80" as="geometry" />
</mxCell>
<mxCell id="e_cmd_lc" value="contient" style="endArrow=none;html=1;fontSize=12;fontStyle=2;edgeStyle=orthogonalEdgeStyle;rounded=0" edge="1" parent="1" source="commande" target="ligne_commande">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_cmd_lc_a" value="(1,N)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=11;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e_cmd_lc">
<mxGeometry x="-0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e_cmd_lc_b" value="(1,1)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=11;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e_cmd_lc">
<mxGeometry x="0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e_lc_prod" value="refere_si_type_produit" style="endArrow=none;html=1;fontSize=12;fontStyle=2;edgeStyle=orthogonalEdgeStyle;rounded=0;dashed=1" edge="1" parent="1" source="ligne_commande" target="produit">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_lc_prod_a" value="(0,1)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=11;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e_lc_prod">
<mxGeometry x="-0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e_lc_prod_b" value="(0,N)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=11;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e_lc_prod">
<mxGeometry x="0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e_lc_menu" value="refere_si_type_menu" style="endArrow=none;html=1;fontSize=12;fontStyle=2;edgeStyle=orthogonalEdgeStyle;rounded=0;dashed=1" edge="1" parent="1" source="ligne_commande" target="menu">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_lc_menu_a" value="(0,1)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=11;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e_lc_menu">
<mxGeometry x="-0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e_lc_menu_b" value="(0,N)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=11;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e_lc_menu">
<mxGeometry x="0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="note_poly" value="&lt;b&gt;Polymorphisme&lt;/b&gt;&lt;br&gt;Exactement UNE des deux references est non-nulle.&lt;br&gt;Discriminateur : type_item &amp;isin; {produit, menu}.&lt;br&gt;Contrainte CHECK SQL au MLD." style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;align=left;verticalAlign=top;fontSize=11;spacingLeft=8;spacingTop=4;dashed=1" vertex="1" parent="1">
<mxGeometry x="80" y="360" width="280" height="100" as="geometry" />
</mxCell>
<mxCell id="e_cmd_evt" value="journalise" style="endArrow=none;html=1;fontSize=12;fontStyle=2;edgeStyle=orthogonalEdgeStyle;rounded=0" edge="1" parent="1" source="commande" target="commande_event">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_cmd_evt_a" value="(1,N)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=11;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e_cmd_evt">
<mxGeometry x="-0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e_cmd_evt_b" value="(1,1)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=11;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e_cmd_evt">
<mxGeometry x="0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e_user_evt" value="declenche" style="endArrow=none;html=1;fontSize=12;fontStyle=2;edgeStyle=orthogonalEdgeStyle;rounded=0;dashed=1" edge="1" parent="1" source="user_stub" target="commande_event">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_user_evt_a" value="(0,N)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=11;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e_user_evt">
<mxGeometry x="-0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e_user_evt_b" value="(0,1)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=11;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e_user_evt">
<mxGeometry x="0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="note_audit" value="&lt;b&gt;Journal d'audit (event sourcing)&lt;/b&gt;&lt;br&gt;Append-only : aucun UPDATE / DELETE applicatif.&lt;br&gt;user_id NULL si auto-validation kiosk.&lt;br&gt;ON DELETE CASCADE cote commande_id.&lt;br&gt;ON DELETE SET NULL cote user_id." style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;align=left;verticalAlign=top;fontSize=11;spacingLeft=8;spacingTop=4;dashed=1" vertex="1" parent="1">
<mxGeometry x="840" y="580" width="280" height="100" as="geometry" />
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 704 KiB

View file

@ -1,182 +0,0 @@
<mxfile host="app.diagrams.net" agent="claude-code-byan" type="device">
<diagram name="MCD - Global" id="mcd-global">
<mxGraphModel dx="1800" dy="1100" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1654" pageHeight="1169" math="0" shadow="0">
<root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
<mxCell id="categorie" value="&lt;b&gt;CATEGORIE&lt;/b&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=13;align=center;verticalAlign=middle" vertex="1" parent="1">
<mxGeometry x="600" y="40" width="200" height="50" as="geometry" />
</mxCell>
<mxCell id="produit" value="&lt;b&gt;PRODUIT&lt;/b&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=13;align=center;verticalAlign=middle" vertex="1" parent="1">
<mxGeometry x="240" y="220" width="200" height="50" as="geometry" />
</mxCell>
<mxCell id="menu_produit" value="&lt;b&gt;MENU_PRODUIT&lt;/b&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=13;align=center;verticalAlign=middle;dashed=1" vertex="1" parent="1">
<mxGeometry x="600" y="220" width="200" height="50" as="geometry" />
</mxCell>
<mxCell id="menu" value="&lt;b&gt;MENU&lt;/b&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=13;align=center;verticalAlign=middle" vertex="1" parent="1">
<mxGeometry x="960" y="220" width="200" height="50" as="geometry" />
</mxCell>
<mxCell id="ligne_commande" value="&lt;b&gt;LIGNE_COMMANDE&lt;/b&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=13;align=center;verticalAlign=middle" vertex="1" parent="1">
<mxGeometry x="600" y="400" width="200" height="50" as="geometry" />
</mxCell>
<mxCell id="commande" value="&lt;b&gt;COMMANDE&lt;/b&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=13;align=center;verticalAlign=middle" vertex="1" parent="1">
<mxGeometry x="600" y="540" width="200" height="50" as="geometry" />
</mxCell>
<mxCell id="commande_event" value="&lt;b&gt;COMMANDE_EVENT&lt;/b&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=13;align=center;verticalAlign=middle" vertex="1" parent="1">
<mxGeometry x="960" y="540" width="200" height="50" as="geometry" />
</mxCell>
<mxCell id="user" value="&lt;b&gt;USER&lt;/b&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=13;align=center;verticalAlign=middle" vertex="1" parent="1">
<mxGeometry x="120" y="780" width="200" height="50" as="geometry" />
</mxCell>
<mxCell id="role" value="&lt;b&gt;ROLE&lt;/b&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=13;align=center;verticalAlign=middle" vertex="1" parent="1">
<mxGeometry x="440" y="780" width="200" height="50" as="geometry" />
</mxCell>
<mxCell id="role_permission" value="&lt;b&gt;ROLE_PERMISSION&lt;/b&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=13;align=center;verticalAlign=middle;dashed=1" vertex="1" parent="1">
<mxGeometry x="760" y="780" width="200" height="50" as="geometry" />
</mxCell>
<mxCell id="permission" value="&lt;b&gt;PERMISSION&lt;/b&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=13;align=center;verticalAlign=middle" vertex="1" parent="1">
<mxGeometry x="1080" y="780" width="200" height="50" as="geometry" />
</mxCell>
<mxCell id="e1" value="regroupe" style="endArrow=none;html=1;fontSize=11;fontStyle=2;edgeStyle=orthogonalEdgeStyle;rounded=0" edge="1" parent="1" source="categorie" target="produit">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e1a" value="(0,N)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e1">
<mxGeometry x="-0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e1b" value="(1,1)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e1">
<mxGeometry x="0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e2" value="regroupe" style="endArrow=none;html=1;fontSize=11;fontStyle=2;edgeStyle=orthogonalEdgeStyle;rounded=0" edge="1" parent="1" source="categorie" target="menu">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e2a" value="(0,N)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e2">
<mxGeometry x="-0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e2b" value="(1,1)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e2">
<mxGeometry x="0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e3" value="fait_partie_de" style="endArrow=none;html=1;fontSize=11;fontStyle=2;edgeStyle=orthogonalEdgeStyle;rounded=0" edge="1" parent="1" source="produit" target="menu_produit">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e3a" value="(0,N)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e3">
<mxGeometry x="-0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e3b" value="(1,1)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e3">
<mxGeometry x="0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e4" value="compose" style="endArrow=none;html=1;fontSize=11;fontStyle=2;edgeStyle=orthogonalEdgeStyle;rounded=0" edge="1" parent="1" source="menu" target="menu_produit">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e4a" value="(1,N)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e4">
<mxGeometry x="-0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e4b" value="(1,1)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e4">
<mxGeometry x="0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e5" value="contient" style="endArrow=none;html=1;fontSize=11;fontStyle=2;edgeStyle=orthogonalEdgeStyle;rounded=0" edge="1" parent="1" source="commande" target="ligne_commande">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e5a" value="(1,N)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e5">
<mxGeometry x="-0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e5b" value="(1,1)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e5">
<mxGeometry x="0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e6" value="refere_si_type_produit" style="endArrow=none;html=1;fontSize=11;fontStyle=2;edgeStyle=orthogonalEdgeStyle;rounded=0;dashed=1" edge="1" parent="1" source="ligne_commande" target="produit">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="120" y="425" />
<mxPoint x="120" y="245" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="e6a" value="(0,1)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e6">
<mxGeometry x="-0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e6b" value="(0,N)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e6">
<mxGeometry x="0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e7" value="refere_si_type_menu" style="endArrow=none;html=1;fontSize=11;fontStyle=2;edgeStyle=orthogonalEdgeStyle;rounded=0;dashed=1" edge="1" parent="1" source="ligne_commande" target="menu">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="1280" y="425" />
<mxPoint x="1280" y="245" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="e7a" value="(0,1)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e7">
<mxGeometry x="-0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e7b" value="(0,N)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e7">
<mxGeometry x="0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e8" value="a_pour_role" style="endArrow=none;html=1;fontSize=11;fontStyle=2;edgeStyle=orthogonalEdgeStyle;rounded=0" edge="1" parent="1" source="user" target="role">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e8a" value="(1,1)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e8">
<mxGeometry x="-0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e8b" value="(0,N)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e8">
<mxGeometry x="0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e9" value="possede" style="endArrow=none;html=1;fontSize=11;fontStyle=2;edgeStyle=orthogonalEdgeStyle;rounded=0" edge="1" parent="1" source="role" target="role_permission">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e9a" value="(0,N)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e9">
<mxGeometry x="-0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e9b" value="(1,1)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e9">
<mxGeometry x="0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e10" value="assignee_a" style="endArrow=none;html=1;fontSize=11;fontStyle=2;edgeStyle=orthogonalEdgeStyle;rounded=0" edge="1" parent="1" source="permission" target="role_permission">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e10a" value="(0,N)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e10">
<mxGeometry x="-0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e10b" value="(1,1)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e10">
<mxGeometry x="0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e11" value="journalise" style="endArrow=none;html=1;fontSize=11;fontStyle=2;edgeStyle=orthogonalEdgeStyle;rounded=0" edge="1" parent="1" source="commande" target="commande_event">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e11a" value="(1,N)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e11">
<mxGeometry x="-0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e11b" value="(1,1)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e11">
<mxGeometry x="0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e12" value="declenche" style="endArrow=none;html=1;fontSize=11;fontStyle=2;edgeStyle=orthogonalEdgeStyle;rounded=0;dashed=1" edge="1" parent="1" source="user" target="commande_event">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="1280" y="805" />
<mxPoint x="1280" y="565" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="e12a" value="(0,N)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e12">
<mxGeometry x="-0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e12b" value="(0,1)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e12">
<mxGeometry x="0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 363 KiB

View file

@ -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"

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 141 KiB

View file

@ -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"

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 156 KiB

View file

@ -1,57 +0,0 @@
<mxfile host="app.diagrams.net" agent="claude-code-byan" type="device">
<diagram name="MCD - RBAC" id="mcd-rbac">
<mxGraphModel dx="1400" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1169" pageHeight="826" math="0" shadow="0">
<root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
<mxCell id="user" value="&lt;b&gt;USER&lt;/b&gt;&lt;hr&gt;id : INT (PK)&lt;br&gt;email : VARCHAR (UNIQUE, RFC 5321)&lt;br&gt;password_hash : VARCHAR (argon2id)&lt;br&gt;nom : VARCHAR&lt;br&gt;prenom : VARCHAR&lt;br&gt;role_id : INT (FK)&lt;br&gt;est_actif : BOOLEAN&lt;br&gt;last_login_at : DATETIME" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;align=left;verticalAlign=top;fontSize=12;spacingLeft=8;spacingTop=4" vertex="1" parent="1">
<mxGeometry x="60" y="80" width="280" height="200" as="geometry" />
</mxCell>
<mxCell id="role" value="&lt;b&gt;ROLE&lt;/b&gt;&lt;hr&gt;id : INT (PK)&lt;br&gt;code : VARCHAR (UNIQUE)&lt;br&gt;libelle : VARCHAR&lt;br&gt;description : TEXT&lt;br&gt;est_actif : BOOLEAN" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;align=left;verticalAlign=top;fontSize=12;spacingLeft=8;spacingTop=4" vertex="1" parent="1">
<mxGeometry x="440" y="80" width="280" height="160" as="geometry" />
</mxCell>
<mxCell id="permission" value="&lt;b&gt;PERMISSION&lt;/b&gt;&lt;hr&gt;id : INT (PK)&lt;br&gt;code : VARCHAR (UNIQUE, resource.action)&lt;br&gt;libelle : VARCHAR&lt;br&gt;description : TEXT" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;align=left;verticalAlign=top;fontSize=12;spacingLeft=8;spacingTop=4" vertex="1" parent="1">
<mxGeometry x="820" y="80" width="280" height="160" as="geometry" />
</mxCell>
<mxCell id="role_permission" value="&lt;b&gt;ROLE_PERMISSION&lt;/b&gt; &lt;i&gt;(associative)&lt;/i&gt;&lt;hr&gt;role_id : INT (PK, FK)&lt;br&gt;permission_id : INT (PK, FK)" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;align=left;verticalAlign=top;fontSize=12;spacingLeft=8;spacingTop=4;dashed=1" vertex="1" parent="1">
<mxGeometry x="630" y="440" width="300" height="120" as="geometry" />
</mxCell>
<mxCell id="e_user_role" value="a_pour_role" style="endArrow=none;html=1;fontSize=12;fontStyle=2;edgeStyle=orthogonalEdgeStyle;rounded=0" edge="1" parent="1" source="user" target="role">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_user_role_a" value="(1,1)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=11;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e_user_role">
<mxGeometry x="-0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e_user_role_b" value="(0,N)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=11;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e_user_role">
<mxGeometry x="0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e_role_rp" value="possede" style="endArrow=none;html=1;fontSize=12;fontStyle=2;edgeStyle=orthogonalEdgeStyle;rounded=0" edge="1" parent="1" source="role" target="role_permission">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_role_rp_a" value="(0,N)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=11;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e_role_rp">
<mxGeometry x="-0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e_role_rp_b" value="(1,1)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=11;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e_role_rp">
<mxGeometry x="0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e_perm_rp" value="assignee_a" style="endArrow=none;html=1;fontSize=12;fontStyle=2;edgeStyle=orthogonalEdgeStyle;rounded=0" edge="1" parent="1" source="permission" target="role_permission">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_perm_rp_a" value="(0,N)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=11;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e_perm_rp">
<mxGeometry x="-0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="e_perm_rp_b" value="(1,1)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=11;fontStyle=1;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="e_perm_rp">
<mxGeometry x="0.85" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>

View file

@ -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"

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 337 KiB

After

Width:  |  Height:  |  Size: 151 KiB

View file

@ -1,59 +0,0 @@
<mxfile host="app.diagrams.net" agent="claude-code-byan" type="device">
<diagram name="MLD - Catalogue" id="mld-catalogue">
<mxGraphModel dx="1400" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1169" pageHeight="826" math="0" shadow="0">
<root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
<mxCell id="t_categorie" value="&lt;b&gt;categorie&lt;/b&gt;&lt;hr&gt;&lt;u&gt;PK id : INT UNSIGNED AUTO_INCREMENT&lt;/u&gt;&lt;br&gt;UK libelle : VARCHAR(80)&lt;br&gt;UK slug : VARCHAR(60)&lt;br&gt;image_path : VARCHAR(255) NULL&lt;br&gt;ordre : SMALLINT UNSIGNED DEFAULT 0&lt;br&gt;est_actif : TINYINT(1) DEFAULT 1&lt;br&gt;created_at : DATETIME&lt;br&gt;updated_at : DATETIME" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;align=left;verticalAlign=top;fontSize=11;spacingLeft=8;spacingTop=4" vertex="1" parent="1">
<mxGeometry x="440" y="40" width="300" height="180" as="geometry" />
</mxCell>
<mxCell id="t_produit" value="&lt;b&gt;produit&lt;/b&gt;&lt;hr&gt;&lt;u&gt;PK id : INT UNSIGNED AUTO_INCREMENT&lt;/u&gt;&lt;br&gt;FK categorie_id : INT UNSIGNED&lt;br&gt;libelle : VARCHAR(120)&lt;br&gt;description : TEXT NULL&lt;br&gt;prix_ttc_cents : INT UNSIGNED (CHECK &gt; 0)&lt;br&gt;image_path : VARCHAR(255) NULL&lt;br&gt;est_disponible : TINYINT(1) DEFAULT 1&lt;br&gt;ordre : SMALLINT UNSIGNED DEFAULT 0&lt;br&gt;created_at : DATETIME&lt;br&gt;updated_at : DATETIME" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;align=left;verticalAlign=top;fontSize=11;spacingLeft=8;spacingTop=4" vertex="1" parent="1">
<mxGeometry x="40" y="280" width="320" height="220" as="geometry" />
</mxCell>
<mxCell id="t_menu" value="&lt;b&gt;menu&lt;/b&gt;&lt;hr&gt;&lt;u&gt;PK id : INT UNSIGNED AUTO_INCREMENT&lt;/u&gt;&lt;br&gt;FK categorie_id : INT UNSIGNED&lt;br&gt;libelle : VARCHAR(120)&lt;br&gt;description : TEXT NULL&lt;br&gt;prix_ttc_cents : INT UNSIGNED (CHECK &gt; 0)&lt;br&gt;image_path : VARCHAR(255) NULL&lt;br&gt;est_disponible : TINYINT(1) DEFAULT 1&lt;br&gt;ordre : SMALLINT UNSIGNED DEFAULT 0&lt;br&gt;created_at : DATETIME&lt;br&gt;updated_at : DATETIME" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;align=left;verticalAlign=top;fontSize=11;spacingLeft=8;spacingTop=4" vertex="1" parent="1">
<mxGeometry x="820" y="280" width="320" height="220" as="geometry" />
</mxCell>
<mxCell id="t_menu_produit" value="&lt;b&gt;menu_produit&lt;/b&gt; (jointure)&lt;hr&gt;&lt;u&gt;PK FK menu_id : INT UNSIGNED&lt;/u&gt;&lt;br&gt;&lt;u&gt;PK FK produit_id : INT UNSIGNED&lt;/u&gt;&lt;br&gt;role : ENUM(burger,accompagnement,boisson,sauce,dessert)&lt;br&gt;position : SMALLINT UNSIGNED DEFAULT 0" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;align=left;verticalAlign=top;fontSize=11;spacingLeft=8;spacingTop=4;dashed=1" vertex="1" parent="1">
<mxGeometry x="400" y="560" width="380" height="130" as="geometry" />
</mxCell>
<mxCell id="fk_prod_cat" style="endArrow=open;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle" edge="1" parent="1" source="t_produit" target="t_categorie">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="fk_prod_cat_lbl" value="FK ON DELETE RESTRICT" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="fk_prod_cat">
<mxGeometry relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="fk_menu_cat" style="endArrow=open;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle" edge="1" parent="1" source="t_menu" target="t_categorie">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="fk_menu_cat_lbl" value="FK ON DELETE RESTRICT" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="fk_menu_cat">
<mxGeometry relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="fk_mp_menu" style="endArrow=open;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle" edge="1" parent="1" source="t_menu_produit" target="t_menu">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="fk_mp_menu_lbl" value="FK ON DELETE CASCADE" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="fk_mp_menu">
<mxGeometry relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="fk_mp_prod" style="endArrow=open;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle" edge="1" parent="1" source="t_menu_produit" target="t_produit">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="fk_mp_prod_lbl" value="FK ON DELETE RESTRICT" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="fk_mp_prod">
<mxGeometry relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="legende" value="&lt;b&gt;Legende&lt;/b&gt;&lt;br&gt;&lt;u&gt;PK&lt;/u&gt; : cle primaire&lt;br&gt;FK : cle etrangere (fleche -&gt; table referencee)&lt;br&gt;UK : contrainte unique&lt;br&gt;Bleu = table principale&lt;br&gt;Jaune pointille = table de jointure" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;align=left;verticalAlign=top;fontSize=10;spacingLeft=8;spacingTop=4;dashed=1" vertex="1" parent="1">
<mxGeometry x="40" y="40" width="280" height="130" as="geometry" />
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>

View file

@ -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)"

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 107 KiB

View file

@ -1,78 +0,0 @@
<mxfile host="app.diagrams.net" agent="claude-code-byan" type="device">
<diagram name="MLD - Commande" id="mld-commande">
<mxGraphModel dx="1400" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1654" pageHeight="1169" math="0" shadow="0">
<root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
<mxCell id="t_commande" value="&lt;b&gt;commande&lt;/b&gt;&lt;hr&gt;&lt;u&gt;PK id : INT UNSIGNED AUTO_INCREMENT&lt;/u&gt;&lt;br&gt;UK numero : VARCHAR(20)&lt;br&gt;source : ENUM(kiosk,comptoir,drive)&lt;br&gt;mode_consommation : ENUM(sur_place,a_emporter,drive)&lt;br&gt;statut : ENUM DEFAULT pending_payment&lt;br&gt;total_ht_cents : INT UNSIGNED&lt;br&gt;total_tva_cents : INT UNSIGNED&lt;br&gt;total_ttc_cents : INT UNSIGNED&lt;br&gt;tva_taux_pourmille : SMALLINT UNSIGNED&lt;br&gt;paye_a : DATETIME NULL&lt;br&gt;created_at : DATETIME&lt;br&gt;updated_at : DATETIME&lt;hr&gt;CHECK (source != drive OR mode = drive)&lt;br&gt;CHECK (ttc = ht + tva)" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;align=left;verticalAlign=top;fontSize=11;spacingLeft=8;spacingTop=4" vertex="1" parent="1">
<mxGeometry x="500" y="40" width="380" height="290" as="geometry" />
</mxCell>
<mxCell id="t_ligne_commande" value="&lt;b&gt;ligne_commande&lt;/b&gt;&lt;hr&gt;&lt;u&gt;PK id : INT UNSIGNED AUTO_INCREMENT&lt;/u&gt;&lt;br&gt;FK commande_id : INT UNSIGNED&lt;br&gt;type_item : ENUM(produit,menu)&lt;br&gt;FK produit_id : INT UNSIGNED NULL&lt;br&gt;FK menu_id : INT UNSIGNED NULL&lt;br&gt;libelle_snapshot : VARCHAR(120)&lt;br&gt;prix_unitaire_ttc_cents_snapshot : INT UNSIGNED&lt;br&gt;quantite : SMALLINT UNSIGNED DEFAULT 1&lt;br&gt;created_at : DATETIME&lt;hr&gt;CHECK polymorphisme exclusif" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;align=left;verticalAlign=top;fontSize=11;spacingLeft=8;spacingTop=4" vertex="1" parent="1">
<mxGeometry x="40" y="400" width="380" height="220" as="geometry" />
</mxCell>
<mxCell id="t_commande_event" value="&lt;b&gt;commande_event&lt;/b&gt; (append-only)&lt;hr&gt;&lt;u&gt;PK id : INT UNSIGNED AUTO_INCREMENT&lt;/u&gt;&lt;br&gt;FK commande_id : INT UNSIGNED&lt;br&gt;event_type : ENUM(CREATED,PAID,...)&lt;br&gt;from_statut : ENUM NULL&lt;br&gt;to_statut : ENUM&lt;br&gt;FK user_id : INT UNSIGNED NULL&lt;br&gt;payload : JSON NULL&lt;br&gt;created_at : DATETIME" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;align=left;verticalAlign=top;fontSize=11;spacingLeft=8;spacingTop=4" vertex="1" parent="1">
<mxGeometry x="960" y="400" width="380" height="200" as="geometry" />
</mxCell>
<mxCell id="t_produit_stub" value="&lt;b&gt;produit&lt;/b&gt; &lt;i&gt;(cf. Catalogue)&lt;/i&gt;&lt;hr&gt;&lt;u&gt;PK id&lt;/u&gt;&lt;br&gt;..." style="rounded=0;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;align=left;verticalAlign=top;fontSize=11;spacingLeft=8;spacingTop=4" vertex="1" parent="1">
<mxGeometry x="40" y="680" width="200" height="80" as="geometry" />
</mxCell>
<mxCell id="t_menu_stub" value="&lt;b&gt;menu&lt;/b&gt; &lt;i&gt;(cf. Catalogue)&lt;/i&gt;&lt;hr&gt;&lt;u&gt;PK id&lt;/u&gt;&lt;br&gt;..." style="rounded=0;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;align=left;verticalAlign=top;fontSize=11;spacingLeft=8;spacingTop=4" vertex="1" parent="1">
<mxGeometry x="280" y="680" width="200" height="80" as="geometry" />
</mxCell>
<mxCell id="t_user_stub" value="&lt;b&gt;user&lt;/b&gt; &lt;i&gt;(cf. RBAC)&lt;/i&gt;&lt;hr&gt;&lt;u&gt;PK id&lt;/u&gt;&lt;br&gt;..." style="rounded=0;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;align=left;verticalAlign=top;fontSize=11;spacingLeft=8;spacingTop=4" vertex="1" parent="1">
<mxGeometry x="1140" y="680" width="200" height="80" as="geometry" />
</mxCell>
<mxCell id="fk_lc_cmd" style="endArrow=open;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle" edge="1" parent="1" source="t_ligne_commande" target="t_commande">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="fk_lc_cmd_lbl" value="FK ON DELETE CASCADE" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="fk_lc_cmd">
<mxGeometry relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="fk_lc_prod" style="endArrow=open;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1" edge="1" parent="1" source="t_ligne_commande" target="t_produit_stub">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="fk_lc_prod_lbl" value="FK NULL ON DELETE RESTRICT" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="fk_lc_prod">
<mxGeometry relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="fk_lc_menu" style="endArrow=open;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1" edge="1" parent="1" source="t_ligne_commande" target="t_menu_stub">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="fk_lc_menu_lbl" value="FK NULL ON DELETE RESTRICT" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="fk_lc_menu">
<mxGeometry relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="fk_evt_cmd" style="endArrow=open;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle" edge="1" parent="1" source="t_commande_event" target="t_commande">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="fk_evt_cmd_lbl" value="FK ON DELETE CASCADE" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="fk_evt_cmd">
<mxGeometry relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="fk_evt_user" style="endArrow=open;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1" edge="1" parent="1" source="t_commande_event" target="t_user_stub">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="fk_evt_user_lbl" value="FK NULL ON DELETE SET NULL" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;fillColor=#ffffff;strokeColor=none" vertex="1" connectable="0" parent="fk_evt_user">
<mxGeometry relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
</mxCell>
<mxCell id="note_audit" value="&lt;b&gt;Journal d'audit (event sourcing)&lt;/b&gt;&lt;br&gt;Append-only : aucun UPDATE / DELETE applicatif.&lt;br&gt;3 IDX : (commande_id, created_at), (user_id, created_at), (event_type, created_at).&lt;br&gt;Pattern d'ecriture : transaction qui modifie commande.statut insere aussi une ligne d'event." style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;align=left;verticalAlign=top;fontSize=10;spacingLeft=8;spacingTop=4;dashed=1" vertex="1" parent="1">
<mxGeometry x="960" y="620" width="380" height="80" as="geometry" />
</mxCell>
<mxCell id="legende" value="&lt;b&gt;Legende&lt;/b&gt;&lt;br&gt;&lt;u&gt;PK&lt;/u&gt; : cle primaire&lt;br&gt;FK : cle etrangere (fleche -&gt; table referencee)&lt;br&gt;UK : contrainte unique&lt;br&gt;Bleu = table principale&lt;br&gt;Vert = journal d'audit&lt;br&gt;Violet = stub d'un autre sous-domaine&lt;br&gt;Pointille = FK nullable" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;align=left;verticalAlign=top;fontSize=10;spacingLeft=8;spacingTop=4;dashed=1" vertex="1" parent="1">
<mxGeometry x="40" y="40" width="280" height="150" as="geometry" />
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>

View file

@ -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)"

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 135 KiB

View file

@ -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)"

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 167 KiB

Some files were not shown because too many files have changed in this diff Show more