Files
Arnaud 3a32e93ab1 feat: initial API skeleton — routes, controllers, migrations, deploy docs
Implements the JSON API for Le Donut Infolab per INSTRUCTIONS.md.

Schema (migrations/001-002):
  - 8 tables: categories, members, projects, project_members, roadmap_steps,
    discussions, resources, activity_log
  - 6 categories seeded with colors / dimensions (social, ecologique, transversal)
  - FK contraintes + soft delete sur projects, CHECK sur progress_percent

Bare PHP framework (src/):
  - Front controller public/index.php + router :params + auth flag par route
  - PSR-4 autoload sans Composer (bootstrap.php)
  - Database PDO singleton + helpers fetchOne/All/insert/update/delete
  - Validator fluent -> 422 {error: validation_failed, fields: {...}}
  - Bearer token admin (hash_equals) sur POST/PATCH/DELETE
  - Slugger avec unicité (suffixe -2, -3...) via intl Transliterator
  - ActivityLogger best-effort sur activity_log
  - Erreurs uniformes : 400/401/404/405/409/422/500 + CORS configurable

10 contrôleurs (Health, Categories, Members, Projects, ProjectMembers,
Roadmap, Discussions, Resources, Activity, Stats). Transitions de statut
loggées (created_project, completed_step, joined, etc.).

Cible prod :
  - Dockerfile (php:8.3-apache + pdo_mysql + intl + opcache)
  - docker-compose.yml sur réseau donut-net, bind 127.0.0.1:8003
  - caddy.conf pour infolab.ledonut-marseille.org
  - migrations/apply.sh idempotent (table schema_migrations)
  - DEPLOY.md avec commandes SSH exactes

Tests :
  - tests/smoke.sh exerce 10 endpoints clés, body envoyé via stdin
    (--data-binary @-) pour gérer l UTF-8 sous Git Bash Windows.

Vérifié localement sous Laragon Apache + MySQL 8.4 : 10/10 OK + cas
négatifs (401, 404, 405, 409, 422) conformes au contrat.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:55:28 +02:00

25 KiB

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

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

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 :
    {
      "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 :
    { "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 :
    {
      "data": [...],
      "meta": { "page": 1, "per_page": 20, "total": 47, "total_pages": 3 }
    }
    

Format du fichier .env.example

# 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

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

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)

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