# 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/<service>/caddy.conf` et importés par `/etc/caddy/Caddyfile`.
- **Convention de nommage des conteneurs** : `donut-<service>` (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 <ADMIN_TOKEN>`) 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.
