Beaucoup de dimensions Raworth dépassent l'arc disponible à R=88 :
« Revenu & travail », « Liens sociaux », « Égalité de genre »,
« Équité sociale », « Voix politique », « Paix & justice ». Posées en
une seule ligne, le texte débordait ou était illisible.
Solution : permettre 2 lignes par libellé, chaque ligne ayant son propre
arc à un rayon décalé. Du coup ces 6 dimensions sont séparées en 2 mots
empilés radialement. Les 6 autres (Alimentation, Santé, Éducation, Eau,
Énergie, Logement) restent sur une seule ligne.
Refactor :
- Type `Label = readonly string[]` (1..N lignes)
- PLANETARY_BOUNDARIES et SOCIAL_FOUNDATIONS unifiés au format tableau
de tableaux (planétaires restent toutes à 1 ligne, sociales mixtes)
- arcLabelPaths() gère le multi-lignes : une ArcLine par ligne, avec
décalage radial de ±lineSpacing/2
- lineSpacing = 9u (légèrement > font-size 8 pour éviter le chevauchement)
- Inversion du décalage radial selon la moitié du donut pour que la
ligne "primaire" reste toujours du côté lecteur (haut de l'écran) :
top : ligne 0 = R+offset (extérieur)
bot : ligne 0 = R-offset (intérieur)
- Aplatissement via flatMap pour rendre comme une flat-list dans le
SVG (pas de `<template>` imbriqué)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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
⚠️ Important sous Windows : passer
--default-character-set=utf8mb4au clientmysql. Le client Windows utilise par défautcp850comme charset de session, ce qui mangle les bytes UTF-8 du fichier seed avant insertion (DémocratiedevientD├®mocratieen base). Le flag force le client à passer les bytes intacts.
mysql --default-character-set=utf8mb4 -uinfolab -p'dev-pass-change-me' infolab \
< migrations/001_init_schema.sql
mysql --default-character-set=utf8mb4 -uinfolab -p'dev-pass-change-me' infolab \
< migrations/002_seed_categories.sql
# Vérifier (les bytes de "é" doivent être C3 A9, pas E2 94 9C C2 AE) :
mysql -uinfolab -p'dev-pass-change-me' -Nse \
"SELECT HEX(name) FROM categories WHERE slug='democratie-citoyennete'" infolab
# Attendu : 44C3A96D6F6372617469652026204369746F79656E6E6574C3A9
Le script
migrations/apply.shest conçu pour la prod (Docker) ; le clientmariadbdu conteneur utilise déjà utf8mb4 par défaut, donc pas de souci là-bas.
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"
Frontend (SPA Vue 3)
Le SPA vit dans /frontend/ — Vue 3 + Vite + TypeScript + Tailwind v4 + Vue Router.
Il consomme l'API JSON et propose les pages : Tableau de bord, Vision Donut (viz
SVG custom), Projets, Catégories, Fiche projet, Nouveau projet, Archives, Connexion admin.
Dev local
# Backend PHP : Laragon sert déjà /api/* sur http://donut-gestprojet.test
cd frontend
npm install
npm run dev # http://localhost:5173 (Vite proxy /api vers Laragon)
Build de prod
cd frontend
npm run build # vue-tsc + vite build -> frontend/dist/
Le SPA est servi par Caddy en statique depuis frontend/dist/, l'API par le
conteneur PHP. Détails dans DEPLOY.md.
Architecture
- Aucune lib UI — composants maison + Tailwind v4 (utilities classes).
- Pas de Pinia : la source de vérité est l'API. État local via
ref/reactive. - HTTP :
fetchnatif + petit wrappersrc/api/client.ts(typage + erreurs). - Auth admin : token Bearer stocké dans
localStorage, header injecté sur tous les writes. Validé "best-effort" au login en pingant POST /api/members avec body vide. - Routing : Vue Router 4 en mode
history, code-split par page (lazy import). - Donut viz : SVG natif, géométrie polaire calculée à la main dans
DonutPage.vue.
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