# Prompt Claude Code — Application Le Donut Infolab > Copie tout ce qui suit (entre les deux lignes de séparation) dans Claude Code, > depuis un dossier vide où tu veux que le projet soit créé. > Tu peux aussi le sauver dans un fichier `INSTRUCTIONS.md` et lancer > `claude` dans le dossier, puis dire « lis INSTRUCTIONS.md et exécute ». --- # Mission Tu vas générer une application **PHP 8.3 + MariaDB** complète appelée **Le Donut Infolab** : une plateforme de projets citoyens marseillais, structurée autour de la vision Donut (plancher social / plafond écologique). Le backend expose une **API JSON pure**. Le front-end (React/Vue) est séparé et ne fait pas partie de cette mission — tu produis uniquement l'API et son admin par token. L'application sera déployée sur le serveur auto-hébergé **Le Donut Marseille** (Ubuntu 26.04, Docker, MariaDB 11.4, Caddy en reverse proxy) sous le sous-domaine `infolab.ledonut-marseille.org`. # Contexte serveur cible (à respecter) - **MariaDB existante** : conteneur `donut-mariadb` sur le réseau Docker `donut-net` (IPv4 `172.30.0.0/16` + IPv6 `fd00:d07c::/64`). Port `3306` interne uniquement, pas exposé sur l'hôte. - **Caddy natif** sur l'hôte (pas en conteneur). Les services sont reverse-proxy via des fichiers `caddy.conf` posés dans `/srv/donut/compose//caddy.conf` et importés par `/etc/caddy/Caddyfile`. - **Convention de nommage des conteneurs** : `donut-` (ex: `donut-infolab`). - **Arborescence cible sur le serveur** : - `/srv/donut/compose/infolab/` → `docker-compose.yml`, `Dockerfile`, `caddy.conf` - `/srv/donut/web/infolab/` → code PHP (bind-mount dans le conteneur) - `/srv/donut/secrets/infolab.env` → secrets (mode 640, root:donut-admins) - **Conteneur PHP** : doit tourner en UID 33 (`www-data`) pour cohérence avec les autres services Apache de la stack. - **Pas de port exposé sur l'hôte sauf 127.0.0.1** : on bind sur `127.0.0.1:8003:80` (les ports 8001 et 8002 sont déjà pris par WordPress et Nextcloud). # Choix techniques validés (ne pas remettre en question) 1. **PHP 8.3 pur, procédural/POO simple, router maison**. Pas de Composer si possible, ou Composer **uniquement** pour l'autoload PSR-4 — pas de framework. 2. **API JSON pure** (Content-Type: application/json partout, codes HTTP RESTful). 3. **Pas d'auth utilisateur en V1**. Admin caché par **Bearer token** (`Authorization: Bearer `) requis pour toutes les routes POST / PUT / PATCH / DELETE. Les GET sont publiques. 4. **PDO + requêtes préparées partout**. Jamais de concaténation SQL. 5. **MariaDB 11.4** existante, charset `utf8mb4` / `utf8mb4_unicode_ci`. # Schéma de base de données **Le schéma est déjà rédigé.** Tu vas le créer dans `migrations/001_init_schema.sql` et `migrations/002_seed_categories.sql`. Voici le contenu exact à utiliser : ## `migrations/001_init_schema.sql` ```sql SET NAMES utf8mb4; SET FOREIGN_KEY_CHECKS = 0; -- categories : 6 catégories thématiques de la maquette DROP TABLE IF EXISTS categories; CREATE TABLE categories ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, slug VARCHAR(64) NOT NULL, name VARCHAR(128) NOT NULL, description TEXT NULL, color_hex CHAR(7) NOT NULL DEFAULT '#888888', icon VARCHAR(32) NULL, dimension ENUM('social', 'ecologique', 'transversal') NOT NULL DEFAULT 'transversal', sort_order INT 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), UNIQUE KEY uk_categories_slug (slug) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- members : annuaire des contributeurs (pas une table d'auth) DROP TABLE IF EXISTS members; CREATE TABLE members ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, slug VARCHAR(96) NOT NULL, display_name VARCHAR(128) NOT NULL, initials VARCHAR(8) NULL, kind ENUM('person', 'collective', 'org') NOT NULL DEFAULT 'person', bio TEXT NULL, avatar_url VARCHAR(512) NULL, website_url VARCHAR(512) NULL, contact_email VARCHAR(255) 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_members_slug (slug) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- projects : entité centrale DROP TABLE IF EXISTS projects; CREATE TABLE projects ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, slug VARCHAR(160) NOT NULL, title VARCHAR(200) NOT NULL, category_id BIGINT UNSIGNED NOT NULL, status ENUM('active', 'completed', 'archived') NOT NULL DEFAULT 'active', territory VARCHAR(160) NOT NULL DEFAULT 'Marseille', short_description TEXT NULL, context_description LONGTEXT NULL, objectives LONGTEXT NULL, cover_image_url VARCHAR(512) NULL, external_url VARCHAR(512) NULL, progress_percent TINYINT UNSIGNED NOT NULL DEFAULT 0, started_at DATE NULL, completed_at DATE NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted_at DATETIME NULL, PRIMARY KEY (id), UNIQUE KEY uk_projects_slug (slug), KEY idx_projects_category (category_id), KEY idx_projects_status (status), KEY idx_projects_deleted (deleted_at), CONSTRAINT fk_projects_category FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE RESTRICT, CONSTRAINT chk_projects_progress CHECK (progress_percent BETWEEN 0 AND 100) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- project_members : pivot N..N projet/contributeur avec rôle DROP TABLE IF EXISTS project_members; CREATE TABLE project_members ( project_id BIGINT UNSIGNED NOT NULL, member_id BIGINT UNSIGNED NOT NULL, role ENUM('admin', 'contributor', 'observer') NOT NULL DEFAULT 'contributor', joined_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (project_id, member_id), KEY idx_pm_member (member_id), CONSTRAINT fk_pm_project FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE, CONSTRAINT fk_pm_member FOREIGN KEY (member_id) REFERENCES members(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- roadmap_steps : feuille de route DROP TABLE IF EXISTS roadmap_steps; CREATE TABLE roadmap_steps ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, project_id BIGINT UNSIGNED NOT NULL, title VARCHAR(200) NOT NULL, description TEXT NULL, step_date DATE NULL, is_done BOOLEAN NOT NULL DEFAULT FALSE, sort_order INT 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_roadmap_project (project_id, sort_order), CONSTRAINT fk_roadmap_project FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- discussions : onglet Discussions, arborescent (parent_id pour les réponses) DROP TABLE IF EXISTS discussions; CREATE TABLE discussions ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, project_id BIGINT UNSIGNED NOT NULL, parent_id BIGINT UNSIGNED NULL, author_name VARCHAR(128) NOT NULL, author_member_id BIGINT UNSIGNED NULL, body LONGTEXT NOT NULL, 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_disc_project (project_id, created_at), KEY idx_disc_parent (parent_id), CONSTRAINT fk_disc_project FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE, CONSTRAINT fk_disc_parent FOREIGN KEY (parent_id) REFERENCES discussions(id) ON DELETE CASCADE, CONSTRAINT fk_disc_member FOREIGN KEY (author_member_id) REFERENCES members(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- resources : onglet Ressources (liens, docs, datasets) DROP TABLE IF EXISTS resources; CREATE TABLE resources ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, project_id BIGINT UNSIGNED NOT NULL, title VARCHAR(200) NOT NULL, description TEXT NULL, url VARCHAR(1024) NOT NULL, kind ENUM('link', 'document', 'dataset', 'video', 'other') NOT NULL DEFAULT 'link', sort_order INT 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_res_project (project_id, sort_order), CONSTRAINT fk_res_project FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- activity_log : flux du dashboard ("Vous a rejoint le projet X") DROP TABLE IF EXISTS activity_log; CREATE TABLE activity_log ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, project_id BIGINT UNSIGNED NULL, member_id BIGINT UNSIGNED NULL, actor_label VARCHAR(128) NOT NULL DEFAULT 'Vous', verb ENUM('joined', 'left', 'created_project', 'updated_project', 'completed_step', 'added_step', 'posted_discussion', 'added_resource', 'completed_project') NOT NULL, summary VARCHAR(255) NOT NULL, occurred_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_act_project (project_id, occurred_at), KEY idx_act_occurred (occurred_at), CONSTRAINT fk_act_project FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE, CONSTRAINT fk_act_member FOREIGN KEY (member_id) REFERENCES members(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; SET FOREIGN_KEY_CHECKS = 1; ``` ## `migrations/002_seed_categories.sql` ```sql INSERT INTO categories (slug, name, description, color_hex, icon, dimension, sort_order) VALUES ('data-open-data', 'Data & Open Data', 'Projets autour de la donnée ouverte, de la médiation et de la littératie des données.', '#3B82F6', 'database', 'transversal', 10), ('democratie-citoyennete','Démocratie & Citoyenneté', 'Projets favorisant la participation citoyenne, la transparence et la gouvernance ouverte.', '#EF4444', 'users', 'social', 20), ('habitat-solidarite', 'Habitat & Solidarité', 'Projets autour du logement, de la fracture numérique et de la solidarité.', '#14B8A6', 'home', 'social', 30), ('territoire-urbanisme', 'Territoire & Urbanisme', 'Projets liés à l''aménagement du territoire, la renaturation et la vie locale.', '#F97316', 'map', 'transversal', 40), ('ecologie-biodiversite', 'Écologie & Biodiversité', 'Projets liés aux enjeux écologiques, à la biodiversité et à la nature en ville.', '#22C55E', 'leaf', 'ecologique', 50), ('education-ecoles', 'Éducation & Écoles', 'Projets autour de l''école publique, de la formation et de l''éducation aux données.', '#A855F7', 'graduation-cap','social', 60); ``` # Arborescence à générer ``` . ├── README.md # install + endpoints + exemples curl ├── DEPLOY.md # commandes exactes à lancer sur le serveur Donut ├── .env.example # template du fichier secrets ├── .gitignore ├── docker-compose.yml ├── Dockerfile ├── caddy.conf # vhost infolab.ledonut-marseille.org ├── public/ │ ├── index.php # front controller │ └── .htaccess # fallback si pas Caddy (rewrite vers index.php) ├── src/ │ ├── bootstrap.php # charge env, db, error handler │ ├── Database.php # singleton PDO + helpers (fetchOne, fetchAll, insert, update, delete) │ ├── Router.php # router maison, supporte :param dans le path │ ├── Request.php # parse JSON body, query, headers │ ├── Response.php # json(), error(), noContent() │ ├── Auth.php # checkAdminToken() depuis Authorization: Bearer │ ├── Validator.php # validation des payloads │ ├── Slugger.php # slugifie les titres en respectant l'unicité │ ├── ActivityLogger.php # helper pour écrire dans activity_log │ └── Controllers/ │ ├── HealthController.php # GET /api/health │ ├── CategoriesController.php # CRUD catégories │ ├── MembersController.php # CRUD membres │ ├── ProjectsController.php # CRUD projets + filtres │ ├── ProjectMembersController.php # ajout/retrait équipe d'un projet │ ├── RoadmapController.php # CRUD étapes │ ├── DiscussionsController.php # CRUD messages │ ├── ResourcesController.php # CRUD ressources │ ├── ActivityController.php # GET /api/activity │ └── StatsController.php # GET /api/stats (dashboard) ├── migrations/ │ ├── 001_init_schema.sql │ ├── 002_seed_categories.sql │ └── apply.sh # script bash pour appliquer les .sql via docker exec └── tests/ └── smoke.sh # appelle tous les endpoints clés avec curl ``` # Routes API à implémenter Toutes préfixées par `/api`. Format de réponse JSON systématique. Codes HTTP REST classiques. ## Lectures publiques (pas de token) - `GET /api/health` → `{ "status": "ok", "time": "..." }` - `GET /api/categories` → liste triée par `sort_order` - `GET /api/categories/:slug` → détail + count projets - `GET /api/members` → liste, pagination `?page=1&per_page=20` - `GET /api/members/:slug` → détail + projets auxquels il/elle participe - `GET /api/projects` → liste avec filtres `?category=slug&status=active&q=texte&page=1&per_page=20` - `GET /api/projects/:slug` → détail complet (catégorie, membres avec rôle, roadmap, compteurs discussions/ressources) - `GET /api/projects/:slug/roadmap` → étapes triées par `sort_order` - `GET /api/projects/:slug/members` → équipe avec rôles - `GET /api/projects/:slug/discussions` → messages racines triés par date desc, avec leurs réponses imbriquées - `GET /api/projects/:slug/resources` → ressources triées par `sort_order` - `GET /api/activity` → 20 dernières activités, `?limit=N&project=slug` - `GET /api/stats` → KPIs dashboard : ```json { "projects_active": 5, "projects_completed": 10, "projects_archived": 9, "projects_total": 24, "participants_total": 32, "by_category": [{"slug":"...","name":"...","color_hex":"...","count":5}, ...], "by_dimension": {"social": 10, "ecologique": 5, "transversal": 9} } ``` ## Écritures (token Bearer requis) - `POST /api/categories` (rare, normalement seeded) - `PATCH /api/categories/:slug` - `DELETE /api/categories/:slug` (refus si projets attachés → 409) - `POST /api/members` - `PATCH /api/members/:slug` - `DELETE /api/members/:slug` - `POST /api/projects` → crée + log activity `created_project` - `PATCH /api/projects/:slug` → log `updated_project`, si status passe à `completed` log `completed_project` - `DELETE /api/projects/:slug` → soft delete (set `deleted_at`) - `POST /api/projects/:slug/members` `{ "member_slug": "...", "role": "..." }` → log `joined` - `DELETE /api/projects/:slug/members/:member_slug` → log `left` - `POST /api/projects/:slug/roadmap` → log `added_step` - `PATCH /api/projects/:slug/roadmap/:id` → si passage à `is_done=true` log `completed_step` - `DELETE /api/projects/:slug/roadmap/:id` - `POST /api/projects/:slug/discussions` → log `posted_discussion` - `PATCH /api/projects/:slug/discussions/:id` - `DELETE /api/projects/:slug/discussions/:id` - `POST /api/projects/:slug/resources` → log `added_resource` - `PATCH /api/projects/:slug/resources/:id` - `DELETE /api/projects/:slug/resources/:id` # Règles techniques impératives 1. **Toutes les requêtes SQL** passent par PDO avec paramètres nommés ou positionnels. Jamais de concaténation. 2. **Slugs** : auto-générés depuis le titre si non fournis. Suffixe `-2`, `-3`… en cas de collision. 3. **Validation** systématique des payloads POST/PATCH avant insert/update. Retour 422 avec détail des champs en erreur : ```json { "error": "validation_failed", "fields": { "title": "required", "category_id": "not_found" } } ``` 4. **Erreurs uniformes** : - 400 `{ "error": "bad_request", "message": "..." }` - 401 `{ "error": "unauthorized" }` (token manquant ou invalide sur route protégée) - 404 `{ "error": "not_found" }` - 409 `{ "error": "conflict", "message": "..." }` (ex: slug pris, FK bloquée) - 422 `{ "error": "validation_failed", "fields": {...} }` - 500 `{ "error": "internal_server_error" }` (sans détail en prod) 5. **CORS** : header `Access-Control-Allow-Origin` configurable via env `CORS_ORIGIN` (par défaut `*` pour V1). Gérer `OPTIONS` preflight. 6. **Pas de PHP dans les contrôleurs** : tout passe par `Response::json()` avec un tableau associatif. 7. **Pas de `var_dump`, `print_r`, `die`, `exit`** dans le code livré. 8. **Pas de session PHP** (API stateless). 9. **Logs d'erreurs** : `error_log()` vers `php://stderr` → ainsi visible dans Dozzle (logs Docker centralisés sur ce serveur). 10. **Date/heure en UTC** côté DB. Les clients gèrent le fuseau. 11. **Réponses paginées** : ```json { "data": [...], "meta": { "page": 1, "per_page": 20, "total": 47, "total_pages": 3 } } ``` # Format du fichier `.env.example` ```env # Base de donnees (le conteneur tourne sur donut-net, donc on adresse donut-mariadb par nom) DB_HOST=donut-mariadb DB_PORT=3306 DB_NAME=infolab DB_USER=infolab DB_PASS=CHANGE_ME_STRONG_PASSWORD # Admin caché : token Bearer pour les routes d'écriture # Générer avec : openssl rand -hex 32 ADMIN_TOKEN=CHANGE_ME_64_HEX_CHARS # CORS — mettre l'origine du front en prod (ex: https://infolab.ledonut-marseille.org) CORS_ORIGIN=* # Environnement APP_ENV=production APP_DEBUG=false ``` # `docker-compose.yml` ```yaml services: donut-infolab: build: context: . dockerfile: Dockerfile container_name: donut-infolab restart: unless-stopped networks: - donut-net ports: - "127.0.0.1:8003:80" env_file: - /srv/donut/secrets/infolab.env volumes: - /srv/donut/web/infolab:/var/www/html depends_on: [] networks: donut-net: external: true ``` # `Dockerfile` ```dockerfile FROM php:8.3-apache # Extensions PHP nécessaires RUN apt-get update \ && apt-get install -y --no-install-recommends \ libicu-dev \ && docker-php-ext-install -j$(nproc) pdo_mysql intl opcache \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* # Active mod_rewrite (pour le routing index.php) + headers (CORS) RUN a2enmod rewrite headers # Pointe Apache vers /public ENV APACHE_DOCUMENT_ROOT=/var/www/html/public RUN sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' \ /etc/apache2/sites-available/*.conf \ /etc/apache2/apache2.conf \ /etc/apache2/conf-available/*.conf # Config PHP minimale en prod RUN { \ echo 'opcache.enable=1'; \ echo 'opcache.memory_consumption=128'; \ echo 'opcache.validate_timestamps=0'; \ echo 'display_errors=Off'; \ echo 'log_errors=On'; \ echo 'error_log=/dev/stderr'; \ echo 'expose_php=Off'; \ } > /usr/local/etc/php/conf.d/donut.ini EXPOSE 80 ``` # `caddy.conf` (sera importé par /etc/caddy/Caddyfile sur l'hôte) ```caddy # infolab.ledonut-marseille.org — Le Donut Infolab (API + front) infolab.ledonut-marseille.org { encode gzip zstd # Headers de sécurité de base header { Strict-Transport-Security "max-age=31536000; includeSubDomains" X-Content-Type-Options "nosniff" Referrer-Policy "strict-origin-when-cross-origin" -Server } reverse_proxy 127.0.0.1:8003 { header_up Host {host} header_up X-Real-IP {remote_host} header_up X-Forwarded-For {remote_host} header_up X-Forwarded-Proto {scheme} } } ``` # Script `migrations/apply.sh` Doit, depuis l'hôte serveur : 1. Lire le mot de passe root MariaDB depuis `/srv/donut/secrets/mariadb.env` 2. Créer la base `infolab` si absente 3. Créer/MAJ le user `infolab` avec mot de passe lu depuis `/srv/donut/secrets/infolab.env` (variable `DB_PASS`), droits `ALL` sur `infolab.*` 4. Appliquer chaque `.sql` du dossier (sauf lui-même) dans l'ordre alphabétique, via `docker exec -i donut-mariadb mariadb -uroot -p"$ROOT_PWD"` 5. Afficher un résumé (tables créées, lignes insérées dans `categories`) Le script doit être idempotent (re-run = OK). # `tests/smoke.sh` Script bash qui appelle, dans l'ordre : - `GET /api/health` - `GET /api/categories` (vérifie qu'il y en a bien 6) - `POST /api/members` avec token (crée "Collectif des écoles de Marseille") - `POST /api/projects` avec token (crée "Nos Écoles" — exemple visible dans la maquette) - `POST /api/projects/nos-ecoles/members` avec token (ajoute le membre) - `POST /api/projects/nos-ecoles/roadmap` avec token (ajoute "Création de l'outil 15min") - `GET /api/projects/nos-ecoles` (vérifie structure complète) - `GET /api/stats` - `GET /api/activity` Chaque appel affiche le code HTTP et un extrait de la réponse. Sortie non-zéro si un appel échoue. Le token est lu depuis `$ADMIN_TOKEN` en variable d'env, l'hôte depuis `$INFOLAB_URL` (défaut `http://localhost:8003`). # `DEPLOY.md` Document les commandes **exactes** à lancer en SSH sur `arnaud.donut@82.66.40.209`, dans cet ordre : 1. Copier le projet vers `/srv/donut/compose/infolab/` (sauf le sous-dossier `public/` et `src/` qui vont dans `/srv/donut/web/infolab/`) 2. Créer `/srv/donut/secrets/infolab.env` depuis `.env.example`, générer `ADMIN_TOKEN` et `DB_PASS`, `chmod 640`, `chown root:donut-admins` 3. Créer le user MariaDB + la base via le script `apply.sh` 4. Appliquer les migrations 5. Build + start du conteneur : `docker compose --env-file /srv/donut/secrets/infolab.env up -d --build` 6. Copier `caddy.conf` vers `/srv/donut/compose/infolab/caddy.conf` (s'il n'y est pas déjà), valider la config Caddy, reload 7. Vérifier les DNS (rappel : ajouter un A record `infolab` chez OVH vers `82.66.40.209`) 8. Lancer `tests/smoke.sh` # `README.md` Documente : - Description courte du projet - Pré-requis (Docker, MariaDB sur réseau `donut-net`) - Commande de dev local rapide (avec un MariaDB temporaire en compose override) - Liste des endpoints avec un exemple curl par endpoint majeur - Comment ajouter une nouvelle entité (procédure) - Roadmap V2 : auth réelle (Nextcloud OIDC), uploads d'images, notifications email # Contraintes finales - **Aucun appel à du JavaScript côté front** — c'est une API JSON pure, le front est ailleurs. - **Code commenté en français** (l'équipe est française), mais noms de variables/fonctions/classes en anglais. - **Génère vraiment tous les fichiers** sur disque, ne fais pas que les décrire. - Une fois tout généré, **lance `tests/smoke.sh`** si tu peux mettre en place un MariaDB de dev local pour vérifier que ça tourne, sinon documente clairement comment je dois tester. - À la fin, affiche un résumé : fichiers créés, prochaines étapes côté serveur, points qui restent à valider avec l'utilisateur. Procède étape par étape, en m'expliquant brièvement ce que tu fais à chaque grosse étape (migrations / squelette PHP / contrôleurs / docker / caddy / docs / tests). Pas besoin de me demander confirmation entre chaque, mais arrête-toi si tu rencontres une ambiguïté réelle (pas une question de style). C'est parti.