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>
This commit is contained in:
2026-05-28 19:53:50 +02:00
parent 6679fa8b30
commit 3a32e93ab1
32 changed files with 3994 additions and 0 deletions
+506
View File
@@ -0,0 +1,506 @@
# 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.