# 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-mariadb` opé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`, `openssl` pour 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`) : ```sql 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 ```bash 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.sh` est conçu pour la prod (Docker) ; en local, > on applique les fichiers à la main comme ci-dessus. ### 3. Créer un fichier `.env` (gitignoré) ```bash cp .env.example .env ``` Puis éditer `.env` : ```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 : ```bash curl http://donut-gestprojet.test/api/health # {"status":"ok","time":"2026-05-28T..."} ``` ### 5. Lancer la suite de smoke tests ```bash 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 `) | 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 ```bash 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é 1. Écrire la migration SQL dans `migrations/00X_xxx.sql` (CREATE TABLE). 2. Créer le contrôleur dans `src/Controllers/XxxController.php` (un par entité, méthodes `index/show/store/update/destroy`). 3. Brancher les routes dans `public/index.php` (les GET sont publiques, POST/PATCH/DELETE sont protégés automatiquement par le router). 4. 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)` ou `api_abort(...)`. 5. Ajouter quelques `curl` dans `tests/smoke.sh` pour couvrir. --- ## Choix d'implémentation à connaître - **Sluggification** : `Slugger::slugify` utilise `intl/Transliterator` si dispo (translittération propre des accents), avec fallback bas niveau. Unicité garantie par `Slugger::unique` (suffixe `-2`, `-3`...). - **Activité** : `ActivityLogger::log` est best-effort — un échec d'écriture ne casse pas la requête en cours, juste un `error_log` vers 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 un `COUNT(DISTINCT member_id) FROM project_members`. - **CORS** : `Access-Control-Allow-Origin` configurable via `CORS_ORIGIN`. Par défaut `*` en V1. À restreindre en V2 à l'origine du front. - **Pagination** : `per_page` capé à 100 côté serveur. - **JSON typé** : `PDO::ATTR_STRINGIFY_FETCHES=false` + casts explicites dans les `present()` -> les `int`/`bool` sont 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_url` géré côté serveur (drag & drop vers `/srv/donut/web/infolab/uploads/` avec MIME check + redimensionnement). - **Notifications email** : transactional emails sur `joined` / `posted_discussion` via 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 ```