# 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

> ⚠️ **Important sous Windows** : passer `--default-character-set=utf8mb4` au client
> `mysql`. Le client Windows utilise par défaut `cp850` comme charset de session, ce
> qui mangle les bytes UTF-8 du fichier seed avant insertion (`Démocratie` devient
> `D├®mocratie` en base). Le flag force le client à passer les bytes intacts.

```bash
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.sh` est conçu pour la prod (Docker) ; le client
> `mariadb` du conteneur utilise déjà utf8mb4 par défaut, donc pas de souci là-bas.

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

```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"
```

---

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

```bash
# 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

```bash
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** : `fetch` natif + petit wrapper `src/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é

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
```
