3a32e93ab1
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>
12 KiB
12 KiB
Le Donut Infolab — API
API JSON pour Le Donut Infolab, plateforme de projets citoyens marseillais structurée autour de la vision Donut (plancher social / plafond écologique).
- Stack : PHP 8.3 + MariaDB 11.4 (compatible MySQL 8.x pour le dev local).
- Routing : router maison, pas de framework, autoload PSR-4 sans Composer.
- Auth : Bearer token unique (
ADMIN_TOKEN) sur toutes les écritures. Lectures publiques. - Front : séparé (React/Vue), pas dans ce repo.
Sous-domaine cible en prod :
https://infolab.ledonut-marseille.org
Pré-requis
Prod (serveur Donut Marseille)
- Docker (la stack y tourne déjà :
donut-mariadb,donut-nextcloud, etc.) - Caddy natif côté hôte
- Réseau Docker externe
donut-net(déjà créé) - Conteneur
donut-mariadbopérationnel et accessible sur ce réseau
Dev local (Laragon, Windows)
- PHP 8.3+ (Laragon embarque PHP 8.3.30)
- MariaDB ou MySQL 8.x (Laragon embarque MySQL 8.4)
- Apache (Laragon) avec
mod_rewrite(activé par défaut) bash,curl,opensslpour les scripts (Git Bash sous Windows)
Démarrage rapide en dev local (Laragon)
1. Créer la base de données et l'utilisateur applicatif
Dans une console Laragon (ou mysql -uroot) :
CREATE DATABASE infolab CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'infolab'@'localhost' IDENTIFIED BY 'dev-pass-change-me';
GRANT ALL PRIVILEGES ON infolab.* TO 'infolab'@'localhost';
FLUSH PRIVILEGES;
2. Appliquer les migrations
mysql -uinfolab -p'dev-pass-change-me' infolab < migrations/001_init_schema.sql
mysql -uinfolab -p'dev-pass-change-me' infolab < migrations/002_seed_categories.sql
Le script
migrations/apply.shest conçu pour la prod (Docker) ; en local, on applique les fichiers à la main comme ci-dessus.
3. Créer un fichier .env (gitignoré)
cp .env.example .env
Puis éditer .env :
DB_HOST=127.0.0.1
DB_PORT=3306
DB_NAME=infolab
DB_USER=infolab
DB_PASS=dev-pass-change-me
ADMIN_TOKEN=$(openssl rand -hex 32) # ← remplacer par la valeur générée
CORS_ORIGIN=*
APP_ENV=development
APP_DEBUG=true
4. Lancer Laragon
Laragon détecte automatiquement le dossier public/ du projet et expose
l'URL http://donut-gestprojet.test (vhost auto).
Vérifier :
curl http://donut-gestprojet.test/api/health
# {"status":"ok","time":"2026-05-28T..."}
5. Lancer la suite de smoke tests
ADMIN_TOKEN="$(grep ^ADMIN_TOKEN= .env | cut -d= -f2-)" \
INFOLAB_URL=http://donut-gestprojet.test \
./tests/smoke.sh
Endpoints
Tous préfixés par /api. Format de réponse : application/json; charset=utf-8.
Conventions
- Listes paginées :
{ "data": [...], "meta": { page, per_page, total, total_pages } } - Listes non paginées :
{ "data": [...] } - Détail d'une ressource : objet JSON directement
- Erreurs uniformes :
{ "error": "code", "message"?: "...", "fields"?: {...} }
Codes HTTP
| Code | Quand | Body |
|---|---|---|
| 200 | Lecture ou update OK | l'objet |
| 201 | Création OK | l'objet créé |
| 204 | Suppression OK | (vide) |
| 400 | JSON invalide ou param connu mais mal formé | {error:"bad_request",message:...} |
| 401 | Token absent / faux sur une route protégée | {error:"unauthorized"} |
| 404 | Ressource inexistante | {error:"not_found"} |
| 405 | Bonne URL, mauvaise méthode | {error:"method_not_allowed",allowed:[...]} |
| 409 | Conflit (slug pris, FK bloquée, déjà membre) | {error:"conflict",message:...} |
| 422 | Payload invalide | {error:"validation_failed",fields:{...}} |
| 500 | Bug imprévu — voir Dozzle | {error:"internal_server_error"} |
Lectures publiques (pas de token)
| Méthode + Path | Description |
|---|---|
GET /api/health |
Sonde de vie + ping BDD |
GET /api/categories |
6 catégories triées par sort_order |
GET /api/categories/:slug |
Détail + projects_count |
GET /api/members?page=1&per_page=20 |
Annuaire paginé |
GET /api/members/:slug |
Détail + projets auxquels le membre participe |
GET /api/projects?category=&status=&q=&page=&per_page= |
Liste paginée |
GET /api/projects/:slug |
Détail complet (catégorie, équipe, roadmap, compteurs) |
GET /api/projects/:slug/roadmap |
Étapes triées par sort_order |
GET /api/projects/:slug/members |
Équipe avec rôle |
GET /api/projects/:slug/discussions |
Messages racines + réponses imbriquées |
GET /api/projects/:slug/resources |
Ressources triées par sort_order |
GET /api/activity?limit=20&project=slug |
Flux d'activité |
GET /api/stats |
KPIs du dashboard |
Écritures protégées (Authorization: Bearer <ADMIN_TOKEN>)
| Méthode + Path | Notes |
|---|---|
POST /api/categories |
Validation : name requis |
PATCH /api/categories/:slug |
Partial update |
DELETE /api/categories/:slug |
409 si projets attachés |
POST /api/members |
Validation : display_name requis ; initiales auto-générées |
PATCH /api/members/:slug |
Partial update |
DELETE /api/members/:slug |
Cascade : retrait des projets, discussions/activity nullifiées |
POST /api/projects |
Log created_project |
PATCH /api/projects/:slug |
Log updated_project (+ completed_project si transition status=completed) |
DELETE /api/projects/:slug |
Soft delete (set deleted_at) |
POST /api/projects/:slug/members |
{member_slug, role} ; log joined ; 409 si déjà membre |
DELETE /api/projects/:slug/members/:member_slug |
Log left |
POST /api/projects/:slug/roadmap |
Log added_step |
PATCH /api/projects/:slug/roadmap/:id |
Log completed_step si is_done passe à true |
DELETE /api/projects/:slug/roadmap/:id |
|
POST /api/projects/:slug/discussions |
{author_name, body, parent_id?, author_member_id?} ; log posted_discussion |
PATCH /api/projects/:slug/discussions/:id |
|
DELETE /api/projects/:slug/discussions/:id |
Cascade des réponses |
POST /api/projects/:slug/resources |
{title, url, kind?} ; log added_resource |
PATCH /api/projects/:slug/resources/:id |
|
DELETE /api/projects/:slug/resources/:id |
Exemples curl
URL=http://donut-gestprojet.test
TOKEN=xxxx-votre-token
# Lecture publique
curl -s "$URL/api/categories" | head
curl -s "$URL/api/projects?category=education-ecoles&status=active&q=ecole"
curl -s "$URL/api/stats"
# Création (token requis)
curl -s -X POST "$URL/api/members" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"display_name":"Jean Dupont","kind":"person","contact_email":"jean@example.org"}'
curl -s -X POST "$URL/api/projects" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"title":"Cartographie des îlots de chaleur","category_id":5,"status":"active","progress_percent":10}'
# Patch
curl -s -X PATCH "$URL/api/projects/cartographie-des-ilots-de-chaleur" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"status":"completed","progress_percent":100}'
# Suppression (soft)
curl -s -X DELETE "$URL/api/projects/cartographie-des-ilots-de-chaleur" \
-H "Authorization: Bearer $TOKEN"
Ajouter une nouvelle entité
- Écrire la migration SQL dans
migrations/00X_xxx.sql(CREATE TABLE). - Créer le contrôleur dans
src/Controllers/XxxController.php(un par entité, méthodesindex/show/store/update/destroy). - Brancher les routes dans
public/index.php(les GET sont publiques, POST/PATCH/DELETE sont protégés automatiquement par le router). - Suivre les patterns existants :
- Tous les accès SQL via
Database::run/fetchAll/...(prepared statements). - Validation via
new Validator($body); finir par$v->check(). - Réponses via
Response::json(...)/Response::noContent(). - Erreurs métier via
throw new \ApiException($status, $payload)ouapi_abort(...).
- Tous les accès SQL via
- Ajouter quelques
curldanstests/smoke.shpour couvrir.
Choix d'implémentation à connaître
- Sluggification :
Slugger::slugifyutiliseintl/Transliteratorsi dispo (translittération propre des accents), avec fallback bas niveau. Unicité garantie parSlugger::unique(suffixe-2,-3...). - Activité :
ActivityLogger::logest best-effort — un échec d'écriture ne casse pas la requête en cours, juste unerror_logvers stderr. - Soft delete : seuls les projets ont
deleted_at. Les autres entités sont supprimées en dur, avec cascades définies au schéma. - Stats :
participants_total= nombre total de membres dans l'annuaire (interprétation choisie : "annuaire des contributeurs" = participants potentiels). Si on veut "participants actifs sur un projet", il faudrait unCOUNT(DISTINCT member_id) FROM project_members. - CORS :
Access-Control-Allow-Originconfigurable viaCORS_ORIGIN. Par défaut*en V1. À restreindre en V2 à l'origine du front. - Pagination :
per_pagecapé à 100 côté serveur. - JSON typé :
PDO::ATTR_STRINGIFY_FETCHES=false+ casts explicites dans lespresent()-> lesint/boolsont propres dans la réponse, pas des strings.
Roadmap V2
- Auth réelle : OIDC via Nextcloud (
nextcloud.ledonut-marseille.org), fin du Bearer admin partagé. Rôles utilisateur, modération par projet. - Uploads d'images :
cover_image_urlgéré côté serveur (drag & drop vers/srv/donut/web/infolab/uploads/avec MIME check + redimensionnement). - Notifications email : transactional emails sur
joined/posted_discussionvia Postfix sortant (relais SMTP existant sur le serveur). - Webhooks : notifier le bot Mattermost du Donut sur création/clôture de projet.
- Snapshots de l'
activity_log: compaction mensuelle pour éviter l'inflation indéfinie de la table.
Arborescence
.
├── README.md # ce fichier
├── DEPLOY.md # déploiement serveur Donut (étape par étape)
├── .env.example # template secrets
├── .gitignore # cf. fichier (couvre .env, vendor, etc.)
├── docker-compose.yml # cible prod : conteneur donut-infolab sur donut-net
├── Dockerfile # php:8.3-apache + pdo_mysql + intl + opcache
├── caddy.conf # vhost infolab.ledonut-marseille.org
├── public/
│ ├── index.php # front controller (toutes les routes)
│ └── .htaccess # rewrite vers index.php (Apache/Laragon)
├── src/
│ ├── bootstrap.php # env, autoload PSR-4, ApiException, handlers
│ ├── Database.php # singleton PDO + helpers fetchOne/All/insert/...
│ ├── Router.php # routing :params + flag auth par route
│ ├── Request.php # parse JSON body, headers, query
│ ├── Response.php # json/error/noContent + CORS
│ ├── Auth.php # checkAdminToken (Bearer)
│ ├── Validator.php # règles fluentes -> 422 fields:{...}
│ ├── Slugger.php # slugify + unicité
│ ├── ActivityLogger.php # insert activity_log (best-effort)
│ └── Controllers/
│ ├── HealthController.php
│ ├── CategoriesController.php
│ ├── MembersController.php
│ ├── ProjectsController.php
│ ├── ProjectMembersController.php
│ ├── RoadmapController.php
│ ├── DiscussionsController.php
│ ├── ResourcesController.php
│ ├── ActivityController.php
│ └── StatsController.php
├── migrations/
│ ├── 001_init_schema.sql
│ ├── 002_seed_categories.sql
│ └── apply.sh # idempotent, suivi via schema_migrations
└── tests/
└── smoke.sh # appelle les endpoints clés, exit non-zéro si KO