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:
@@ -0,0 +1,17 @@
|
||||
# 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
|
||||
@@ -0,0 +1,214 @@
|
||||
# DEPLOY — Le Donut Infolab
|
||||
|
||||
Procédure exacte de déploiement sur le serveur **Donut Marseille**.
|
||||
|
||||
- Hôte : `arnaud.donut@82.66.40.209`
|
||||
- OS : Ubuntu 26.04
|
||||
- Sous-domaine : `infolab.ledonut-marseille.org`
|
||||
- Conteneur PHP : `donut-infolab` (UID `33` = www-data côté Apache)
|
||||
- BDD : conteneur existant `donut-mariadb`, réseau Docker `donut-net`
|
||||
- Reverse proxy : Caddy natif sur l'hôte
|
||||
|
||||
> ⚠️ Pré-requis DNS : créer chez OVH un record `A infolab.ledonut-marseille.org → 82.66.40.209`
|
||||
> **avant** le reload Caddy (sinon échec ACME challenge).
|
||||
|
||||
---
|
||||
|
||||
## 0. Pré-requis vérifiés une fois
|
||||
|
||||
```bash
|
||||
ssh arnaud.donut@82.66.40.209
|
||||
|
||||
# Le réseau donut-net existe
|
||||
docker network ls | grep donut-net
|
||||
# Le mariadb tourne et est joignable depuis donut-net
|
||||
docker ps | grep donut-mariadb
|
||||
# /srv/donut/secrets/mariadb.env est en place et lisible par root
|
||||
sudo ls -l /srv/donut/secrets/mariadb.env
|
||||
# Le group donut-admins existe
|
||||
getent group donut-admins
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Copier le projet vers le serveur
|
||||
|
||||
Depuis ta machine de dev (à la racine du repo) :
|
||||
|
||||
```bash
|
||||
# Code app -> /srv/donut/web/infolab/ (bind-mount dans le conteneur, UID 33)
|
||||
rsync -avz --delete \
|
||||
--exclude '.git' --exclude '.idea' --exclude '.env' --exclude 'tests' \
|
||||
--exclude 'docker-compose.yml' --exclude 'Dockerfile' --exclude 'caddy.conf' \
|
||||
--exclude 'migrations' --exclude 'DEPLOY.md' --exclude 'INSTRUCTIONS.md' \
|
||||
./ arnaud.donut@82.66.40.209:/tmp/infolab-app/
|
||||
|
||||
# Fichiers d'orchestration -> /srv/donut/compose/infolab/
|
||||
rsync -avz \
|
||||
docker-compose.yml Dockerfile caddy.conf README.md DEPLOY.md .env.example \
|
||||
migrations \
|
||||
arnaud.donut@82.66.40.209:/tmp/infolab-compose/
|
||||
```
|
||||
|
||||
Puis sur le serveur :
|
||||
|
||||
```bash
|
||||
ssh arnaud.donut@82.66.40.209
|
||||
|
||||
# Code app (UID 33 = www-data du conteneur Apache)
|
||||
sudo mkdir -p /srv/donut/web/infolab
|
||||
sudo rsync -a --delete /tmp/infolab-app/ /srv/donut/web/infolab/
|
||||
sudo chown -R 33:33 /srv/donut/web/infolab
|
||||
sudo find /srv/donut/web/infolab -type d -exec chmod 755 {} \;
|
||||
sudo find /srv/donut/web/infolab -type f -exec chmod 644 {} \;
|
||||
|
||||
# Orchestration (root:donut-admins, lisible par les admins)
|
||||
sudo mkdir -p /srv/donut/compose/infolab
|
||||
sudo rsync -a /tmp/infolab-compose/ /srv/donut/compose/infolab/
|
||||
sudo chown -R root:donut-admins /srv/donut/compose/infolab
|
||||
sudo chmod 750 /srv/donut/compose/infolab
|
||||
sudo chmod 640 /srv/donut/compose/infolab/*
|
||||
|
||||
# Cleanup tmp
|
||||
rm -rf /tmp/infolab-app /tmp/infolab-compose
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Créer les secrets `/srv/donut/secrets/infolab.env`
|
||||
|
||||
```bash
|
||||
ssh arnaud.donut@82.66.40.209
|
||||
|
||||
DB_PASS="$(openssl rand -hex 24)"
|
||||
ADMIN_TOKEN="$(openssl rand -hex 32)"
|
||||
|
||||
sudo tee /srv/donut/secrets/infolab.env > /dev/null <<EOF
|
||||
DB_HOST=donut-mariadb
|
||||
DB_PORT=3306
|
||||
DB_NAME=infolab
|
||||
DB_USER=infolab
|
||||
DB_PASS=$DB_PASS
|
||||
|
||||
ADMIN_TOKEN=$ADMIN_TOKEN
|
||||
|
||||
CORS_ORIGIN=https://infolab.ledonut-marseille.org
|
||||
APP_ENV=production
|
||||
APP_DEBUG=false
|
||||
EOF
|
||||
|
||||
sudo chown root:donut-admins /srv/donut/secrets/infolab.env
|
||||
sudo chmod 640 /srv/donut/secrets/infolab.env
|
||||
|
||||
# Note les 2 secrets quelque part de sûr (gestionnaire de mdp) :
|
||||
sudo cat /srv/donut/secrets/infolab.env | grep -E '^(ADMIN_TOKEN|DB_PASS)='
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Créer la BDD + le user MariaDB + appliquer les migrations
|
||||
|
||||
```bash
|
||||
# Le script apply.sh fait tout : crée la base, le user, applique les .sql
|
||||
# Idempotent : peut être ré-exécuté sans danger (table schema_migrations).
|
||||
sudo /srv/donut/compose/infolab/migrations/apply.sh
|
||||
```
|
||||
|
||||
Tu dois voir :
|
||||
- `apply 001_init_schema.sql`
|
||||
- `apply 002_seed_categories.sql`
|
||||
- résumé avec `categories_count = 6`
|
||||
|
||||
---
|
||||
|
||||
## 4. Build + start du conteneur
|
||||
|
||||
```bash
|
||||
cd /srv/donut/compose/infolab
|
||||
sudo docker compose --env-file /srv/donut/secrets/infolab.env up -d --build
|
||||
|
||||
# Vérifie qu'il tourne et écoute en local sur 127.0.0.1:8003
|
||||
docker ps | grep donut-infolab
|
||||
ss -ltn | grep 8003
|
||||
|
||||
# Sonde de vie via le port mappé en local
|
||||
curl -s http://127.0.0.1:8003/api/health
|
||||
# {"status":"ok","time":"..."}
|
||||
```
|
||||
|
||||
Si la sonde renvoie `degraded`, vérifier les logs :
|
||||
```bash
|
||||
docker logs --tail 100 donut-infolab
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Vhost Caddy
|
||||
|
||||
Le fichier `caddy.conf` a été copié à `/srv/donut/compose/infolab/caddy.conf`.
|
||||
Vérifier qu'il est bien `import`-é par `/etc/caddy/Caddyfile` :
|
||||
|
||||
```bash
|
||||
grep -n 'infolab' /etc/caddy/Caddyfile
|
||||
|
||||
# Si manquant, ajouter en fin de Caddyfile :
|
||||
echo 'import /srv/donut/compose/infolab/caddy.conf' | sudo tee -a /etc/caddy/Caddyfile
|
||||
|
||||
# Valider la syntaxe puis recharger
|
||||
sudo caddy validate --config /etc/caddy/Caddyfile
|
||||
sudo systemctl reload caddy
|
||||
```
|
||||
|
||||
Caddy va négocier le certificat Let's Encrypt (à condition que le DNS A
|
||||
record soit en place — voir avertissement en tête de fichier).
|
||||
|
||||
---
|
||||
|
||||
## 6. Vérification finale
|
||||
|
||||
```bash
|
||||
# Depuis la machine de dev (TLS terminé par Caddy)
|
||||
curl -s https://infolab.ledonut-marseille.org/api/health
|
||||
curl -s https://infolab.ledonut-marseille.org/api/categories | head -c 400
|
||||
|
||||
# Smoke tests complets (lit ADMIN_TOKEN depuis le secret côté serveur)
|
||||
ssh arnaud.donut@82.66.40.209 'sudo bash -c "
|
||||
TOKEN=\$(grep ^ADMIN_TOKEN= /srv/donut/secrets/infolab.env | cut -d= -f2-)
|
||||
INFOLAB_URL=https://infolab.ledonut-marseille.org ADMIN_TOKEN=\$TOKEN \
|
||||
/srv/donut/compose/infolab/tests/smoke.sh
|
||||
"'
|
||||
```
|
||||
|
||||
Si tout est vert : `OK : 11 / FAIL : 0`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Cycle de mise à jour (pour plus tard)
|
||||
|
||||
```bash
|
||||
# Code app
|
||||
rsync -avz --delete --exclude '.git' --exclude '.env' \
|
||||
public/ src/ arnaud.donut@82.66.40.209:/srv/donut/web/infolab/
|
||||
|
||||
# Si changement Dockerfile / docker-compose / migrations :
|
||||
rsync -avz docker-compose.yml Dockerfile migrations/ \
|
||||
arnaud.donut@82.66.40.209:/srv/donut/compose/infolab/
|
||||
|
||||
ssh arnaud.donut@82.66.40.209 << 'EOF'
|
||||
sudo /srv/donut/compose/infolab/migrations/apply.sh # nouvelle migration éventuelle
|
||||
cd /srv/donut/compose/infolab
|
||||
sudo docker compose --env-file /srv/donut/secrets/infolab.env up -d --build
|
||||
sudo chown -R 33:33 /srv/donut/web/infolab
|
||||
EOF
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Points à valider une fois en prod
|
||||
|
||||
- [ ] DNS `A infolab.ledonut-marseille.org → 82.66.40.209` ajouté chez OVH
|
||||
- [ ] Certificat Let's Encrypt négocié OK (`caddy.log` côté hôte)
|
||||
- [ ] `ADMIN_TOKEN` archivé dans le gestionnaire de mots de passe partagé
|
||||
- [ ] `tests/smoke.sh` vert depuis l'extérieur
|
||||
- [ ] Conteneur `donut-infolab` visible dans Dozzle (logs Apache + PHP via stderr)
|
||||
- [ ] Pas de fuite : `curl -I https://infolab.ledonut-marseille.org/.env` → 403/404
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
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
|
||||
+506
@@ -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.
|
||||
@@ -0,0 +1,296 @@
|
||||
# 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 <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"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
```
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
|
||||
|
||||
# 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}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
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
|
||||
@@ -0,0 +1,153 @@
|
||||
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;
|
||||
@@ -0,0 +1,7 @@
|
||||
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);
|
||||
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env bash
|
||||
# -----------------------------------------------------------------------------
|
||||
# migrations/apply.sh
|
||||
#
|
||||
# Applique les migrations SQL au conteneur donut-mariadb sur le serveur Donut.
|
||||
# - Lit /srv/donut/secrets/mariadb.env pour le mot de passe root MariaDB
|
||||
# - Lit /srv/donut/secrets/infolab.env pour DB_USER / DB_PASS / DB_NAME (app user)
|
||||
# - Crée la BDD si absente, le user app si absent (idempotent), refresh le mot de passe
|
||||
# - Trace les migrations appliquées dans la table `schema_migrations` :
|
||||
# chaque .sql n'est appliqué qu'une seule fois (relance => skip)
|
||||
# - Affiche un résumé (tables présentes + count categories)
|
||||
#
|
||||
# Usage :
|
||||
# ./migrations/apply.sh
|
||||
#
|
||||
# Pré-requis sur l'hôte :
|
||||
# - docker disponible
|
||||
# - conteneur `donut-mariadb` en marche
|
||||
# - /srv/donut/secrets/{mariadb,infolab}.env lisibles par l'utilisateur courant
|
||||
# -----------------------------------------------------------------------------
|
||||
set -euo pipefail
|
||||
|
||||
DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
MARIADB_ENV="${MARIADB_ENV:-/srv/donut/secrets/mariadb.env}"
|
||||
INFOLAB_ENV="${INFOLAB_ENV:-/srv/donut/secrets/infolab.env}"
|
||||
CONTAINER="${CONTAINER:-donut-mariadb}"
|
||||
|
||||
require_file() {
|
||||
if [[ ! -r "$1" ]]; then
|
||||
echo "ERR: fichier inaccessible : $1" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
env_get() {
|
||||
# Récupère la dernière occurrence de VAR=... dans un fichier .env (1er = à droite).
|
||||
local file="$1" var="$2"
|
||||
grep -E "^${var}=" "$file" | tail -n1 | cut -d= -f2- || true
|
||||
}
|
||||
|
||||
require_file "$MARIADB_ENV"
|
||||
require_file "$INFOLAB_ENV"
|
||||
|
||||
# Le secret root MariaDB peut être nommé MARIADB_ROOT_PASSWORD ou MYSQL_ROOT_PASSWORD.
|
||||
ROOT_PWD="$(env_get "$MARIADB_ENV" MARIADB_ROOT_PASSWORD)"
|
||||
if [[ -z "$ROOT_PWD" ]]; then
|
||||
ROOT_PWD="$(env_get "$MARIADB_ENV" MYSQL_ROOT_PASSWORD)"
|
||||
fi
|
||||
if [[ -z "$ROOT_PWD" ]]; then
|
||||
echo "ERR: ni MARIADB_ROOT_PASSWORD ni MYSQL_ROOT_PASSWORD trouvé dans $MARIADB_ENV" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
APP_USER="$(env_get "$INFOLAB_ENV" DB_USER)"; APP_USER="${APP_USER:-infolab}"
|
||||
APP_PASS="$(env_get "$INFOLAB_ENV" DB_PASS)"
|
||||
APP_DB="$(env_get "$INFOLAB_ENV" DB_NAME)"; APP_DB="${APP_DB:-infolab}"
|
||||
|
||||
if [[ -z "$APP_PASS" || "$APP_PASS" == "CHANGE_ME_STRONG_PASSWORD" ]]; then
|
||||
echo "ERR: DB_PASS vide ou non personnalisé dans $INFOLAB_ENV" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Wrapper docker exec -> mariadb. Pas d'option -t (on streame du SQL via stdin).
|
||||
mysql_root() {
|
||||
docker exec -i "$CONTAINER" mariadb -uroot -p"$ROOT_PWD" "$@"
|
||||
}
|
||||
|
||||
# Test d'accès root avant d'aller plus loin.
|
||||
if ! mysql_root -e 'SELECT 1' >/dev/null 2>&1; then
|
||||
echo "ERR: impossible de se connecter à $CONTAINER en root. Conteneur up ? mot de passe correct ?" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ">> [1/4] Création de la base \`$APP_DB\` et du user \`$APP_USER\`"
|
||||
mysql_root <<SQL
|
||||
CREATE DATABASE IF NOT EXISTS \`$APP_DB\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
CREATE USER IF NOT EXISTS '$APP_USER'@'%' IDENTIFIED BY '$APP_PASS';
|
||||
ALTER USER '$APP_USER'@'%' IDENTIFIED BY '$APP_PASS';
|
||||
GRANT ALL PRIVILEGES ON \`$APP_DB\`.* TO '$APP_USER'@'%';
|
||||
FLUSH PRIVILEGES;
|
||||
SQL
|
||||
|
||||
echo ">> [2/4] Bootstrap de la table schema_migrations (suivi des .sql appliqués)"
|
||||
mysql_root "$APP_DB" <<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
filename VARCHAR(255) NOT NULL,
|
||||
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (filename)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
SQL
|
||||
|
||||
echo ">> [3/4] Application des migrations dans l'ordre alphabétique"
|
||||
shopt -s nullglob
|
||||
applied=0
|
||||
skipped=0
|
||||
for f in $(ls "$DIR"/*.sql 2>/dev/null | sort); do
|
||||
name="$(basename "$f")"
|
||||
# Skip si déjà appliquée (idempotent — on ne ré-applique pas).
|
||||
already="$(mysql_root "$APP_DB" -Nse "SELECT 1 FROM schema_migrations WHERE filename = '$name' LIMIT 1" 2>/dev/null || true)"
|
||||
if [[ -n "$already" ]]; then
|
||||
echo " -- skip $name (déjà appliquée)"
|
||||
skipped=$((skipped+1))
|
||||
continue
|
||||
fi
|
||||
echo " -- apply $name"
|
||||
mysql_root "$APP_DB" < "$f"
|
||||
mysql_root "$APP_DB" -e "INSERT INTO schema_migrations (filename) VALUES ('$name')"
|
||||
applied=$((applied+1))
|
||||
done
|
||||
|
||||
echo ">> [4/4] Résumé"
|
||||
mysql_root "$APP_DB" <<'SQL'
|
||||
SELECT TABLE_NAME AS `table`
|
||||
FROM information_schema.tables
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
ORDER BY TABLE_NAME;
|
||||
SELECT COUNT(*) AS categories_count FROM categories;
|
||||
SQL
|
||||
|
||||
echo
|
||||
echo "OK — migrations appliquées : $applied / sautées : $skipped"
|
||||
@@ -0,0 +1,16 @@
|
||||
# Front-controller : toutes les routes inconnues vont à index.php
|
||||
# (utile si on tourne sous Apache sans Caddy, ex : Laragon en dev local).
|
||||
RewriteEngine On
|
||||
|
||||
# Sert tel quel les fichiers et dossiers existants
|
||||
RewriteCond %{REQUEST_FILENAME} -f [OR]
|
||||
RewriteCond %{REQUEST_FILENAME} -d
|
||||
RewriteRule ^ - [L]
|
||||
|
||||
# Sinon, route vers index.php
|
||||
RewriteRule ^ index.php [QSA,L]
|
||||
|
||||
# Bonus : interdit l'accès direct aux .env qui traîneraient
|
||||
<FilesMatch "^\.env">
|
||||
Require all denied
|
||||
</FilesMatch>
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
// Front controller unique. Toutes les requêtes (sauf fichiers statiques
|
||||
// existants côté Apache) tombent ici via la réécriture .htaccess.
|
||||
|
||||
require __DIR__ . '/../src/bootstrap.php';
|
||||
|
||||
use Infolab\Router;
|
||||
use Infolab\Request;
|
||||
use Infolab\Response;
|
||||
use Infolab\Auth;
|
||||
|
||||
use Infolab\Controllers\HealthController;
|
||||
use Infolab\Controllers\CategoriesController;
|
||||
use Infolab\Controllers\MembersController;
|
||||
use Infolab\Controllers\ProjectsController;
|
||||
use Infolab\Controllers\ProjectMembersController;
|
||||
use Infolab\Controllers\RoadmapController;
|
||||
use Infolab\Controllers\DiscussionsController;
|
||||
use Infolab\Controllers\ResourcesController;
|
||||
use Infolab\Controllers\ActivityController;
|
||||
use Infolab\Controllers\StatsController;
|
||||
|
||||
// CORS systématique, y compris sur la preflight OPTIONS.
|
||||
Response::sendCorsHeaders();
|
||||
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'OPTIONS') {
|
||||
http_response_code(204);
|
||||
return;
|
||||
}
|
||||
|
||||
// On instancie Request DANS le try : le parsing JSON peut lever ApiException 400
|
||||
// si le body est invalide, et on veut une réponse propre plutôt qu'une fatal error.
|
||||
$router = new Router();
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Routes publiques (GET, pas de token).
|
||||
// ----------------------------------------------------------------------------
|
||||
$router->get('/api/health', [HealthController::class, 'index']);
|
||||
|
||||
$router->get('/api/categories', [CategoriesController::class, 'index']);
|
||||
$router->get('/api/categories/:slug', [CategoriesController::class, 'show']);
|
||||
|
||||
$router->get('/api/members', [MembersController::class, 'index']);
|
||||
$router->get('/api/members/:slug', [MembersController::class, 'show']);
|
||||
|
||||
$router->get('/api/projects', [ProjectsController::class, 'index']);
|
||||
$router->get('/api/projects/:slug', [ProjectsController::class, 'show']);
|
||||
$router->get('/api/projects/:slug/roadmap', [RoadmapController::class, 'index']);
|
||||
$router->get('/api/projects/:slug/members', [ProjectMembersController::class, 'index']);
|
||||
$router->get('/api/projects/:slug/discussions', [DiscussionsController::class, 'index']);
|
||||
$router->get('/api/projects/:slug/resources', [ResourcesController::class, 'index']);
|
||||
|
||||
$router->get('/api/activity', [ActivityController::class, 'index']);
|
||||
$router->get('/api/stats', [StatsController::class, 'index']);
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Écritures (POST/PATCH/DELETE) — protégées automatiquement par Bearer token
|
||||
// (le Router marque ces méthodes comme `auth: true`).
|
||||
// ----------------------------------------------------------------------------
|
||||
$router->post ('/api/categories', [CategoriesController::class, 'store']);
|
||||
$router->patch ('/api/categories/:slug', [CategoriesController::class, 'update']);
|
||||
$router->delete('/api/categories/:slug', [CategoriesController::class, 'destroy']);
|
||||
|
||||
$router->post ('/api/members', [MembersController::class, 'store']);
|
||||
$router->patch ('/api/members/:slug', [MembersController::class, 'update']);
|
||||
$router->delete('/api/members/:slug', [MembersController::class, 'destroy']);
|
||||
|
||||
$router->post ('/api/projects', [ProjectsController::class, 'store']);
|
||||
$router->patch ('/api/projects/:slug', [ProjectsController::class, 'update']);
|
||||
$router->delete('/api/projects/:slug', [ProjectsController::class, 'destroy']);
|
||||
|
||||
$router->post ('/api/projects/:slug/members', [ProjectMembersController::class, 'store']);
|
||||
$router->delete('/api/projects/:slug/members/:member_slug', [ProjectMembersController::class, 'destroy']);
|
||||
|
||||
$router->post ('/api/projects/:slug/roadmap', [RoadmapController::class, 'store']);
|
||||
$router->patch ('/api/projects/:slug/roadmap/:id', [RoadmapController::class, 'update']);
|
||||
$router->delete('/api/projects/:slug/roadmap/:id', [RoadmapController::class, 'destroy']);
|
||||
|
||||
$router->post ('/api/projects/:slug/discussions', [DiscussionsController::class, 'store']);
|
||||
$router->patch ('/api/projects/:slug/discussions/:id', [DiscussionsController::class, 'update']);
|
||||
$router->delete('/api/projects/:slug/discussions/:id', [DiscussionsController::class, 'destroy']);
|
||||
|
||||
$router->post ('/api/projects/:slug/resources', [ResourcesController::class, 'store']);
|
||||
$router->patch ('/api/projects/:slug/resources/:id', [ResourcesController::class, 'update']);
|
||||
$router->delete('/api/projects/:slug/resources/:id', [ResourcesController::class, 'destroy']);
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Dispatch + handler global d'exceptions.
|
||||
// ----------------------------------------------------------------------------
|
||||
try {
|
||||
$request = new Request();
|
||||
[$route, $params] = $router->dispatch($request->method(), $request->path());
|
||||
|
||||
if ($route['auth']) {
|
||||
Auth::requireAdmin($request);
|
||||
}
|
||||
|
||||
[$class, $action] = $route['handler'];
|
||||
$controller = new $class();
|
||||
$controller->$action($request, $params);
|
||||
} catch (ApiException $e) {
|
||||
Response::emit($e->statusCode, $e->payload);
|
||||
} catch (\Throwable $e) {
|
||||
// Toujours loggé vers stderr (visible dans Dozzle côté serveur).
|
||||
error_log(sprintf(
|
||||
'[unhandled] %s in %s:%d — %s',
|
||||
$e::class,
|
||||
$e->getFile(),
|
||||
$e->getLine(),
|
||||
$e->getMessage()
|
||||
));
|
||||
$debug = (getenv('APP_DEBUG') ?: 'false') === 'true';
|
||||
if ($debug) {
|
||||
Response::emit(500, [
|
||||
'error' => 'internal_server_error',
|
||||
'message' => $e->getMessage(),
|
||||
'class' => $e::class,
|
||||
'file' => $e->getFile(),
|
||||
'line' => $e->getLine(),
|
||||
]);
|
||||
} else {
|
||||
Response::emit(500, ['error' => 'internal_server_error']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Infolab;
|
||||
|
||||
/**
|
||||
* Helper d'écriture dans `activity_log`. Best-effort : un échec d'écriture
|
||||
* (FK morte, BDD inaccessible) ne doit pas casser la requête en cours.
|
||||
* On loggue dans stderr et on continue.
|
||||
*/
|
||||
final class ActivityLogger
|
||||
{
|
||||
public static function log(
|
||||
string $verb,
|
||||
string $summary,
|
||||
?int $projectId = null,
|
||||
?int $memberId = null,
|
||||
string $actorLabel = 'Vous',
|
||||
): void {
|
||||
try {
|
||||
Database::insert('activity_log', [
|
||||
'project_id' => $projectId,
|
||||
'member_id' => $memberId,
|
||||
'actor_label' => $actorLabel,
|
||||
'verb' => $verb,
|
||||
// Tronque à 255 par sécurité — la colonne est en VARCHAR(255).
|
||||
'summary' => mb_substr($summary, 0, 255),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
error_log('[ActivityLogger] insert failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Infolab;
|
||||
|
||||
/**
|
||||
* Auth admin "cachée" : token Bearer comparé en temps constant à $ADMIN_TOKEN.
|
||||
* Pas d'utilisateur, pas de session. V2 : OIDC via Nextcloud (voir README).
|
||||
*/
|
||||
final class Auth
|
||||
{
|
||||
/** Lance ApiException 401 si le token est manquant, vide ou ne matche pas. */
|
||||
public static function requireAdmin(Request $req): void
|
||||
{
|
||||
$token = $req->bearerToken();
|
||||
$expected = getenv('ADMIN_TOKEN') ?: '';
|
||||
|
||||
if ($expected === '' || $token === null || $token === '' || !hash_equals($expected, $token)) {
|
||||
throw new \ApiException(401, ['error' => 'unauthorized']);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Infolab\Controllers;
|
||||
|
||||
use Infolab\Database;
|
||||
use Infolab\Request;
|
||||
use Infolab\Response;
|
||||
|
||||
/**
|
||||
* GET /api/activity — flux pour le dashboard ("Vous a rejoint le projet X").
|
||||
* Filtres : ?limit=N (1..100, défaut 20), ?project=slug.
|
||||
*/
|
||||
final class ActivityController
|
||||
{
|
||||
public function index(Request $req, array $params): void
|
||||
{
|
||||
$limit = max(1, min(100, (int) $req->query('limit', 20)));
|
||||
|
||||
$where = '1=1';
|
||||
$args = [];
|
||||
|
||||
$projectSlug = $req->query('project');
|
||||
if ($projectSlug !== null && $projectSlug !== '') {
|
||||
$pid = Database::fetchValue('SELECT id FROM projects WHERE slug = :s', [':s' => (string) $projectSlug]);
|
||||
if ($pid === null) {
|
||||
// Projet inconnu : on renvoie un flux vide (pas d'erreur, le slug peut
|
||||
// simplement ne pas exister côté serveur — comportement choisi pour
|
||||
// simplifier le widget côté front).
|
||||
Response::json(['data' => []]);
|
||||
return;
|
||||
}
|
||||
$where = 'project_id = :pid';
|
||||
$args[':pid'] = (int) $pid;
|
||||
}
|
||||
|
||||
$rows = Database::fetchAll(
|
||||
"SELECT id, project_id, member_id, actor_label, verb, summary, occurred_at
|
||||
FROM activity_log WHERE $where
|
||||
ORDER BY occurred_at DESC, id DESC LIMIT $limit",
|
||||
$args,
|
||||
);
|
||||
|
||||
$data = array_map(static fn(array $r): array => [
|
||||
'id' => (int) $r['id'],
|
||||
'project_id' => $r['project_id'] === null ? null : (int) $r['project_id'],
|
||||
'member_id' => $r['member_id'] === null ? null : (int) $r['member_id'],
|
||||
'actor_label' => $r['actor_label'],
|
||||
'verb' => $r['verb'],
|
||||
'summary' => $r['summary'],
|
||||
'occurred_at' => $r['occurred_at'],
|
||||
], $rows);
|
||||
|
||||
Response::json(['data' => $data]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Infolab\Controllers;
|
||||
|
||||
use Infolab\Database;
|
||||
use Infolab\Request;
|
||||
use Infolab\Response;
|
||||
use Infolab\Slugger;
|
||||
use Infolab\Validator;
|
||||
|
||||
/**
|
||||
* CRUD des catégories thématiques (Data, Démocratie, Habitat, …).
|
||||
* Les catégories sont seedées par migration ; les écritures restent disponibles
|
||||
* pour ajustements ponctuels (token requis).
|
||||
*/
|
||||
final class CategoriesController
|
||||
{
|
||||
private const SELECT = 'SELECT id, slug, name, description, color_hex, icon, dimension, sort_order, created_at, updated_at FROM categories';
|
||||
|
||||
public function index(Request $req, array $params): void
|
||||
{
|
||||
$rows = Database::fetchAll(self::SELECT . ' ORDER BY sort_order ASC, id ASC');
|
||||
Response::json(['data' => array_map([self::class, 'present'], $rows)]);
|
||||
}
|
||||
|
||||
public function show(Request $req, array $params): void
|
||||
{
|
||||
$cat = self::findOr404($params['slug']);
|
||||
$cat['projects_count'] = (int) Database::fetchValue(
|
||||
'SELECT COUNT(*) FROM projects WHERE category_id = :id AND deleted_at IS NULL',
|
||||
[':id' => (int) $cat['id']],
|
||||
);
|
||||
Response::json(self::present($cat) + ['projects_count' => $cat['projects_count']]);
|
||||
}
|
||||
|
||||
public function store(Request $req, array $params): void
|
||||
{
|
||||
$body = $req->json();
|
||||
$v = new Validator($body);
|
||||
$v->required('name')->string('name', 128);
|
||||
$v->string('slug', 64);
|
||||
$v->string('description', 5000);
|
||||
$v->colorHex('color_hex');
|
||||
$v->string('icon', 32);
|
||||
$v->in('dimension', ['social', 'ecologique', 'transversal']);
|
||||
$v->integer('sort_order', 0);
|
||||
$v->check();
|
||||
|
||||
$name = (string) $body['name'];
|
||||
$base = (isset($body['slug']) && $body['slug'] !== '')
|
||||
? Slugger::slugify((string) $body['slug'])
|
||||
: Slugger::slugify($name);
|
||||
$slug = Slugger::unique($base, 'categories');
|
||||
|
||||
Database::insert('categories', [
|
||||
'slug' => $slug,
|
||||
'name' => $name,
|
||||
'description' => $body['description'] ?? null,
|
||||
'color_hex' => $body['color_hex'] ?? '#888888',
|
||||
'icon' => $body['icon'] ?? null,
|
||||
'dimension' => $body['dimension'] ?? 'transversal',
|
||||
'sort_order' => isset($body['sort_order']) ? (int) $body['sort_order'] : 0,
|
||||
]);
|
||||
Response::json(self::present(self::findOr404($slug)), 201);
|
||||
}
|
||||
|
||||
public function update(Request $req, array $params): void
|
||||
{
|
||||
$cat = self::findOr404($params['slug']);
|
||||
$body = $req->json();
|
||||
$v = new Validator($body);
|
||||
$v->string('name', 128);
|
||||
$v->string('slug', 64);
|
||||
$v->string('description', 5000);
|
||||
$v->colorHex('color_hex');
|
||||
$v->string('icon', 32);
|
||||
$v->in('dimension', ['social', 'ecologique', 'transversal']);
|
||||
$v->integer('sort_order', 0);
|
||||
$v->check();
|
||||
|
||||
$update = [];
|
||||
foreach (['name', 'description', 'color_hex', 'icon', 'dimension', 'sort_order'] as $f) {
|
||||
if (array_key_exists($f, $body)) {
|
||||
$update[$f] = $body[$f];
|
||||
}
|
||||
}
|
||||
if (isset($update['sort_order'])) {
|
||||
$update['sort_order'] = (int) $update['sort_order'];
|
||||
}
|
||||
if (isset($body['slug']) && $body['slug'] !== '') {
|
||||
$update['slug'] = Slugger::unique(
|
||||
Slugger::slugify((string) $body['slug']),
|
||||
'categories',
|
||||
'slug',
|
||||
(int) $cat['id'],
|
||||
);
|
||||
}
|
||||
if ($update !== []) {
|
||||
Database::update('categories', $update, 'id = :id', [':id' => (int) $cat['id']]);
|
||||
}
|
||||
Response::json(self::present(self::findOr404($update['slug'] ?? $cat['slug'])));
|
||||
}
|
||||
|
||||
public function destroy(Request $req, array $params): void
|
||||
{
|
||||
$cat = self::findOr404($params['slug']);
|
||||
// FK ON DELETE RESTRICT côté projects -> on vérifie nous-mêmes pour un
|
||||
// message propre. Compte aussi les projets soft-deletés (la FK ne fait
|
||||
// pas la différence et bloquerait sinon en cascade).
|
||||
$count = (int) Database::fetchValue(
|
||||
'SELECT COUNT(*) FROM projects WHERE category_id = :id',
|
||||
[':id' => (int) $cat['id']],
|
||||
);
|
||||
if ($count > 0) {
|
||||
throw new \ApiException(409, [
|
||||
'error' => 'conflict',
|
||||
'message' => sprintf('Cette catégorie est utilisée par %d projet(s).', $count),
|
||||
]);
|
||||
}
|
||||
Database::delete('categories', 'id = :id', [':id' => (int) $cat['id']]);
|
||||
Response::noContent();
|
||||
}
|
||||
|
||||
private static function findOr404(string $slug): array
|
||||
{
|
||||
$row = Database::fetchOne(self::SELECT . ' WHERE slug = :slug', [':slug' => $slug]);
|
||||
if ($row === null) {
|
||||
throw new \ApiException(404, ['error' => 'not_found']);
|
||||
}
|
||||
return $row;
|
||||
}
|
||||
|
||||
private static function present(array $row): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) $row['id'],
|
||||
'slug' => $row['slug'],
|
||||
'name' => $row['name'],
|
||||
'description' => $row['description'],
|
||||
'color_hex' => $row['color_hex'],
|
||||
'icon' => $row['icon'],
|
||||
'dimension' => $row['dimension'],
|
||||
'sort_order' => (int) $row['sort_order'],
|
||||
'created_at' => $row['created_at'],
|
||||
'updated_at' => $row['updated_at'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Infolab\Controllers;
|
||||
|
||||
use Infolab\ActivityLogger;
|
||||
use Infolab\Database;
|
||||
use Infolab\Request;
|
||||
use Infolab\Response;
|
||||
use Infolab\Validator;
|
||||
|
||||
/**
|
||||
* Messages d'un projet, arborescence à 1 niveau (parent_id pour les réponses).
|
||||
* Liste renvoyée en arbre : racines DESC date, réponses ASC date sous chaque racine.
|
||||
*/
|
||||
final class DiscussionsController
|
||||
{
|
||||
public function index(Request $req, array $params): void
|
||||
{
|
||||
$p = ProjectsController::findOr404($params['slug']);
|
||||
|
||||
$roots = Database::fetchAll(
|
||||
'SELECT id, project_id, parent_id, author_name, author_member_id, body, created_at, updated_at
|
||||
FROM discussions WHERE project_id = :pid AND parent_id IS NULL
|
||||
ORDER BY created_at DESC, id DESC',
|
||||
[':pid' => (int) $p['id']],
|
||||
);
|
||||
if ($roots === []) {
|
||||
Response::json(['data' => []]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 1 requête en plus pour toutes les réponses, regroupées par parent_id.
|
||||
$rootIds = array_map(static fn(array $r): int => (int) $r['id'], $roots);
|
||||
$in = implode(',', array_fill(0, count($rootIds), '?'));
|
||||
$replies = Database::fetchAll(
|
||||
"SELECT id, project_id, parent_id, author_name, author_member_id, body, created_at, updated_at
|
||||
FROM discussions WHERE parent_id IN ($in) ORDER BY created_at ASC, id ASC",
|
||||
$rootIds,
|
||||
);
|
||||
|
||||
$byParent = [];
|
||||
foreach ($replies as $r) {
|
||||
$byParent[(int) $r['parent_id']][] = self::present($r);
|
||||
}
|
||||
|
||||
$tree = [];
|
||||
foreach ($roots as $r) {
|
||||
$node = self::present($r);
|
||||
$node['replies'] = $byParent[(int) $r['id']] ?? [];
|
||||
$tree[] = $node;
|
||||
}
|
||||
Response::json(['data' => $tree]);
|
||||
}
|
||||
|
||||
public function store(Request $req, array $params): void
|
||||
{
|
||||
$p = ProjectsController::findOr404($params['slug']);
|
||||
$body = $req->json();
|
||||
$v = new Validator($body);
|
||||
$v->required('author_name')->string('author_name', 128);
|
||||
$v->required('body')->string('body');
|
||||
$v->integer('parent_id', 1);
|
||||
$v->integer('author_member_id', 1);
|
||||
$v->check();
|
||||
|
||||
$parentId = null;
|
||||
if (isset($body['parent_id'])) {
|
||||
$parent = Database::fetchOne(
|
||||
'SELECT id, project_id FROM discussions WHERE id = :id',
|
||||
[':id' => (int) $body['parent_id']],
|
||||
);
|
||||
if ($parent === null || (int) $parent['project_id'] !== (int) $p['id']) {
|
||||
$v->setError('parent_id', 'not_found');
|
||||
$v->check();
|
||||
}
|
||||
$parentId = (int) $parent['id'];
|
||||
}
|
||||
|
||||
$authorMemberId = null;
|
||||
if (isset($body['author_member_id'])) {
|
||||
$exists = Database::fetchValue(
|
||||
'SELECT 1 FROM members WHERE id = :id',
|
||||
[':id' => (int) $body['author_member_id']],
|
||||
);
|
||||
if ($exists === null) {
|
||||
$v->setError('author_member_id', 'not_found');
|
||||
$v->check();
|
||||
}
|
||||
$authorMemberId = (int) $body['author_member_id'];
|
||||
}
|
||||
|
||||
$id = Database::insert('discussions', [
|
||||
'project_id' => (int) $p['id'],
|
||||
'parent_id' => $parentId,
|
||||
'author_name' => (string) $body['author_name'],
|
||||
'author_member_id' => $authorMemberId,
|
||||
'body' => (string) $body['body'],
|
||||
]);
|
||||
|
||||
ActivityLogger::log(
|
||||
'posted_discussion',
|
||||
sprintf('a publié un message dans « %s »', $p['title']),
|
||||
(int) $p['id'],
|
||||
$authorMemberId,
|
||||
(string) $body['author_name'],
|
||||
);
|
||||
|
||||
$row = Database::fetchOne(
|
||||
'SELECT * FROM discussions WHERE id = :id',
|
||||
[':id' => $id],
|
||||
);
|
||||
Response::json(self::present($row), 201);
|
||||
}
|
||||
|
||||
public function update(Request $req, array $params): void
|
||||
{
|
||||
$p = ProjectsController::findOr404($params['slug']);
|
||||
$msg = self::findMsgOr404((int) $params['id'], (int) $p['id']);
|
||||
|
||||
$body = $req->json();
|
||||
$v = new Validator($body);
|
||||
$v->string('author_name', 128);
|
||||
$v->string('body');
|
||||
$v->check();
|
||||
|
||||
$update = [];
|
||||
foreach (['author_name', 'body'] as $f) {
|
||||
if (array_key_exists($f, $body)) {
|
||||
$update[$f] = $body[$f];
|
||||
}
|
||||
}
|
||||
if ($update !== []) {
|
||||
Database::update('discussions', $update, 'id = :id', [':id' => (int) $msg['id']]);
|
||||
}
|
||||
$fresh = Database::fetchOne('SELECT * FROM discussions WHERE id = :id', [':id' => (int) $msg['id']]);
|
||||
Response::json(self::present($fresh));
|
||||
}
|
||||
|
||||
public function destroy(Request $req, array $params): void
|
||||
{
|
||||
$p = ProjectsController::findOr404($params['slug']);
|
||||
$msg = self::findMsgOr404((int) $params['id'], (int) $p['id']);
|
||||
// FK parent_id ON DELETE CASCADE -> les réponses partent avec.
|
||||
Database::delete('discussions', 'id = :id', [':id' => (int) $msg['id']]);
|
||||
Response::noContent();
|
||||
}
|
||||
|
||||
private static function findMsgOr404(int $id, int $projectId): array
|
||||
{
|
||||
$row = Database::fetchOne(
|
||||
'SELECT * FROM discussions WHERE id = :id AND project_id = :pid',
|
||||
[':id' => $id, ':pid' => $projectId],
|
||||
);
|
||||
if ($row === null) {
|
||||
throw new \ApiException(404, ['error' => 'not_found']);
|
||||
}
|
||||
return $row;
|
||||
}
|
||||
|
||||
private static function present(array $row): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) $row['id'],
|
||||
'project_id' => (int) $row['project_id'],
|
||||
'parent_id' => $row['parent_id'] === null ? null : (int) $row['parent_id'],
|
||||
'author_name' => $row['author_name'],
|
||||
'author_member_id' => $row['author_member_id'] === null ? null : (int) $row['author_member_id'],
|
||||
'body' => $row['body'],
|
||||
'created_at' => $row['created_at'],
|
||||
'updated_at' => $row['updated_at'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Infolab\Controllers;
|
||||
|
||||
use Infolab\Database;
|
||||
use Infolab\Request;
|
||||
use Infolab\Response;
|
||||
|
||||
/**
|
||||
* GET /api/health
|
||||
* Sonde de vie de l'API + ping rapide BDD. Renvoie 503 si la BDD ne répond pas.
|
||||
*/
|
||||
final class HealthController
|
||||
{
|
||||
public function index(Request $req, array $params): void
|
||||
{
|
||||
try {
|
||||
Database::fetchValue('SELECT 1');
|
||||
} catch (\Throwable $e) {
|
||||
error_log('[Health] DB ping failed: ' . $e->getMessage());
|
||||
Response::json([
|
||||
'status' => 'degraded',
|
||||
'time' => gmdate('Y-m-d\TH:i:s\Z'),
|
||||
], 503);
|
||||
return;
|
||||
}
|
||||
Response::json([
|
||||
'status' => 'ok',
|
||||
'time' => gmdate('Y-m-d\TH:i:s\Z'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Infolab\Controllers;
|
||||
|
||||
use Infolab\Database;
|
||||
use Infolab\Request;
|
||||
use Infolab\Response;
|
||||
use Infolab\Slugger;
|
||||
use Infolab\Validator;
|
||||
|
||||
/**
|
||||
* CRUD des membres (annuaire des contributeurs). Pas une table d'auth.
|
||||
*/
|
||||
final class MembersController
|
||||
{
|
||||
private const SELECT = 'SELECT id, slug, display_name, initials, kind, bio, avatar_url, website_url, contact_email, created_at, updated_at FROM members';
|
||||
|
||||
public function index(Request $req, array $params): void
|
||||
{
|
||||
[$page, $perPage, $offset] = self::pagination($req);
|
||||
$total = (int) Database::fetchValue('SELECT COUNT(*) FROM members');
|
||||
// LIMIT/OFFSET interpolés depuis des entiers validés -> safe.
|
||||
$rows = Database::fetchAll(self::SELECT . " ORDER BY display_name ASC LIMIT $perPage OFFSET $offset");
|
||||
Response::json([
|
||||
'data' => array_map([self::class, 'present'], $rows),
|
||||
'meta' => self::meta($page, $perPage, $total),
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(Request $req, array $params): void
|
||||
{
|
||||
$m = self::findOr404($params['slug']);
|
||||
$projects = Database::fetchAll(
|
||||
'SELECT p.id, p.slug, p.title, p.status, p.progress_percent,
|
||||
c.slug AS category_slug, c.name AS category_name, c.color_hex AS category_color_hex,
|
||||
pm.role, pm.joined_at
|
||||
FROM project_members pm
|
||||
INNER JOIN projects p ON p.id = pm.project_id AND p.deleted_at IS NULL
|
||||
INNER JOIN categories c ON c.id = p.category_id
|
||||
WHERE pm.member_id = :id
|
||||
ORDER BY p.title ASC',
|
||||
[':id' => (int) $m['id']],
|
||||
);
|
||||
$out = self::present($m);
|
||||
$out['projects'] = array_map(static fn(array $r): array => [
|
||||
'id' => (int) $r['id'],
|
||||
'slug' => $r['slug'],
|
||||
'title' => $r['title'],
|
||||
'status' => $r['status'],
|
||||
'progress_percent' => (int) $r['progress_percent'],
|
||||
'role' => $r['role'],
|
||||
'joined_at' => $r['joined_at'],
|
||||
'category' => [
|
||||
'slug' => $r['category_slug'],
|
||||
'name' => $r['category_name'],
|
||||
'color_hex' => $r['category_color_hex'],
|
||||
],
|
||||
], $projects);
|
||||
Response::json($out);
|
||||
}
|
||||
|
||||
public function store(Request $req, array $params): void
|
||||
{
|
||||
$body = $req->json();
|
||||
$v = new Validator($body);
|
||||
$v->required('display_name')->string('display_name', 128);
|
||||
$v->string('slug', 96);
|
||||
$v->string('initials', 8);
|
||||
$v->in('kind', ['person', 'collective', 'org']);
|
||||
$v->string('bio', 5000);
|
||||
$v->url('avatar_url', 512);
|
||||
$v->url('website_url', 512);
|
||||
$v->email('contact_email');
|
||||
$v->check();
|
||||
|
||||
$name = (string) $body['display_name'];
|
||||
$base = (isset($body['slug']) && $body['slug'] !== '')
|
||||
? Slugger::slugify((string) $body['slug'])
|
||||
: Slugger::slugify($name);
|
||||
$slug = Slugger::unique($base, 'members');
|
||||
|
||||
Database::insert('members', [
|
||||
'slug' => $slug,
|
||||
'display_name' => $name,
|
||||
'initials' => $body['initials'] ?? self::autoInitials($name),
|
||||
'kind' => $body['kind'] ?? 'person',
|
||||
'bio' => $body['bio'] ?? null,
|
||||
'avatar_url' => $body['avatar_url'] ?? null,
|
||||
'website_url' => $body['website_url'] ?? null,
|
||||
'contact_email' => $body['contact_email'] ?? null,
|
||||
]);
|
||||
Response::json(self::present(self::findOr404($slug)), 201);
|
||||
}
|
||||
|
||||
public function update(Request $req, array $params): void
|
||||
{
|
||||
$m = self::findOr404($params['slug']);
|
||||
$body = $req->json();
|
||||
$v = new Validator($body);
|
||||
$v->string('display_name', 128);
|
||||
$v->string('slug', 96);
|
||||
$v->string('initials', 8);
|
||||
$v->in('kind', ['person', 'collective', 'org']);
|
||||
$v->string('bio', 5000);
|
||||
$v->url('avatar_url', 512);
|
||||
$v->url('website_url', 512);
|
||||
$v->email('contact_email');
|
||||
$v->check();
|
||||
|
||||
$update = [];
|
||||
foreach (['display_name', 'initials', 'kind', 'bio', 'avatar_url', 'website_url', 'contact_email'] as $f) {
|
||||
if (array_key_exists($f, $body)) {
|
||||
$update[$f] = $body[$f];
|
||||
}
|
||||
}
|
||||
if (isset($body['slug']) && $body['slug'] !== '') {
|
||||
$update['slug'] = Slugger::unique(
|
||||
Slugger::slugify((string) $body['slug']),
|
||||
'members',
|
||||
'slug',
|
||||
(int) $m['id'],
|
||||
);
|
||||
}
|
||||
if ($update !== []) {
|
||||
Database::update('members', $update, 'id = :id', [':id' => (int) $m['id']]);
|
||||
}
|
||||
Response::json(self::present(self::findOr404($update['slug'] ?? $m['slug'])));
|
||||
}
|
||||
|
||||
public function destroy(Request $req, array $params): void
|
||||
{
|
||||
$m = self::findOr404($params['slug']);
|
||||
// FK : project_members CASCADE, discussions/activity_log SET NULL -> hard delete OK.
|
||||
Database::delete('members', 'id = :id', [':id' => (int) $m['id']]);
|
||||
Response::noContent();
|
||||
}
|
||||
|
||||
/** Helper public : utilisé par d'autres contrôleurs (ProjectMembers, etc.). */
|
||||
public static function findOr404(string $slug): array
|
||||
{
|
||||
$row = Database::fetchOne(self::SELECT . ' WHERE slug = :slug', [':slug' => $slug]);
|
||||
if ($row === null) {
|
||||
throw new \ApiException(404, ['error' => 'not_found']);
|
||||
}
|
||||
return $row;
|
||||
}
|
||||
|
||||
/** Génère 1 à 3 initiales depuis un nom complet ("Jean Dupont" -> "JD"). */
|
||||
private static function autoInitials(string $name): string
|
||||
{
|
||||
$parts = preg_split('/\s+/', trim($name)) ?: [];
|
||||
$letters = '';
|
||||
foreach ($parts as $p) {
|
||||
if ($p === '') continue;
|
||||
$letters .= mb_strtoupper(mb_substr($p, 0, 1));
|
||||
if (mb_strlen($letters) >= 3) break;
|
||||
}
|
||||
return $letters !== '' ? mb_substr($letters, 0, 8) : '?';
|
||||
}
|
||||
|
||||
/** @return array{0:int,1:int,2:int} [page, per_page, offset] */
|
||||
private static function pagination(Request $req): array
|
||||
{
|
||||
$page = max(1, (int) $req->query('page', 1));
|
||||
$perPage = max(1, min(100, (int) $req->query('per_page', 20)));
|
||||
return [$page, $perPage, ($page - 1) * $perPage];
|
||||
}
|
||||
|
||||
/** @return array<string,int> */
|
||||
private static function meta(int $page, int $perPage, int $total): array
|
||||
{
|
||||
return [
|
||||
'page' => $page,
|
||||
'per_page' => $perPage,
|
||||
'total' => $total,
|
||||
'total_pages' => $total === 0 ? 0 : (int) ceil($total / $perPage),
|
||||
];
|
||||
}
|
||||
|
||||
private static function present(array $row): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) $row['id'],
|
||||
'slug' => $row['slug'],
|
||||
'display_name' => $row['display_name'],
|
||||
'initials' => $row['initials'],
|
||||
'kind' => $row['kind'],
|
||||
'bio' => $row['bio'],
|
||||
'avatar_url' => $row['avatar_url'],
|
||||
'website_url' => $row['website_url'],
|
||||
'contact_email' => $row['contact_email'],
|
||||
'created_at' => $row['created_at'],
|
||||
'updated_at' => $row['updated_at'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Infolab\Controllers;
|
||||
|
||||
use Infolab\ActivityLogger;
|
||||
use Infolab\Database;
|
||||
use Infolab\Request;
|
||||
use Infolab\Response;
|
||||
use Infolab\Validator;
|
||||
|
||||
/**
|
||||
* Gestion de l'équipe d'un projet : pivot project_members avec un rôle.
|
||||
*/
|
||||
final class ProjectMembersController
|
||||
{
|
||||
public function index(Request $req, array $params): void
|
||||
{
|
||||
$p = ProjectsController::findOr404($params['slug']);
|
||||
$rows = Database::fetchAll(
|
||||
'SELECT m.id, m.slug, m.display_name, m.initials, m.kind, m.avatar_url, m.website_url,
|
||||
pm.role, pm.joined_at
|
||||
FROM project_members pm
|
||||
INNER JOIN members m ON m.id = pm.member_id
|
||||
WHERE pm.project_id = :pid
|
||||
ORDER BY pm.joined_at ASC',
|
||||
[':pid' => (int) $p['id']],
|
||||
);
|
||||
$data = array_map(static fn(array $r): array => [
|
||||
'id' => (int) $r['id'],
|
||||
'slug' => $r['slug'],
|
||||
'display_name' => $r['display_name'],
|
||||
'initials' => $r['initials'],
|
||||
'kind' => $r['kind'],
|
||||
'avatar_url' => $r['avatar_url'],
|
||||
'website_url' => $r['website_url'],
|
||||
'role' => $r['role'],
|
||||
'joined_at' => $r['joined_at'],
|
||||
], $rows);
|
||||
Response::json(['data' => $data]);
|
||||
}
|
||||
|
||||
public function store(Request $req, array $params): void
|
||||
{
|
||||
$p = ProjectsController::findOr404($params['slug']);
|
||||
$body = $req->json();
|
||||
$v = new Validator($body);
|
||||
$v->required('member_slug')->string('member_slug', 96);
|
||||
$v->in('role', ['admin', 'contributor', 'observer']);
|
||||
$v->check();
|
||||
|
||||
$member = Database::fetchOne(
|
||||
'SELECT id, slug, display_name FROM members WHERE slug = :s',
|
||||
[':s' => (string) $body['member_slug']],
|
||||
);
|
||||
if ($member === null) {
|
||||
$v->setError('member_slug', 'not_found');
|
||||
$v->check();
|
||||
}
|
||||
|
||||
$role = $body['role'] ?? 'contributor';
|
||||
|
||||
// PK (project_id, member_id) -> on vérifie pour un 409 propre plutôt qu'une SQLException.
|
||||
$exists = Database::fetchValue(
|
||||
'SELECT 1 FROM project_members WHERE project_id = :pid AND member_id = :mid',
|
||||
[':pid' => (int) $p['id'], ':mid' => (int) $member['id']],
|
||||
);
|
||||
if ($exists !== null) {
|
||||
throw new \ApiException(409, [
|
||||
'error' => 'conflict',
|
||||
'message' => 'Ce membre fait déjà partie du projet.',
|
||||
]);
|
||||
}
|
||||
|
||||
Database::insert('project_members', [
|
||||
'project_id' => (int) $p['id'],
|
||||
'member_id' => (int) $member['id'],
|
||||
'role' => $role,
|
||||
]);
|
||||
|
||||
ActivityLogger::log(
|
||||
'joined',
|
||||
sprintf('a rejoint le projet « %s »', $p['title']),
|
||||
(int) $p['id'],
|
||||
(int) $member['id'],
|
||||
(string) $member['display_name'],
|
||||
);
|
||||
|
||||
Response::json([
|
||||
'project_id' => (int) $p['id'],
|
||||
'member' => [
|
||||
'id' => (int) $member['id'],
|
||||
'slug' => $member['slug'],
|
||||
'display_name' => $member['display_name'],
|
||||
],
|
||||
'role' => $role,
|
||||
'joined_at' => gmdate('Y-m-d H:i:s'),
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function destroy(Request $req, array $params): void
|
||||
{
|
||||
$p = ProjectsController::findOr404($params['slug']);
|
||||
$member = Database::fetchOne(
|
||||
'SELECT id, slug, display_name FROM members WHERE slug = :s',
|
||||
[':s' => $params['member_slug']],
|
||||
);
|
||||
if ($member === null) {
|
||||
throw new \ApiException(404, ['error' => 'not_found']);
|
||||
}
|
||||
$deleted = Database::delete(
|
||||
'project_members',
|
||||
'project_id = :pid AND member_id = :mid',
|
||||
[':pid' => (int) $p['id'], ':mid' => (int) $member['id']],
|
||||
);
|
||||
if ($deleted === 0) {
|
||||
throw new \ApiException(404, ['error' => 'not_found']);
|
||||
}
|
||||
|
||||
ActivityLogger::log(
|
||||
'left',
|
||||
sprintf('a quitté le projet « %s »', $p['title']),
|
||||
(int) $p['id'],
|
||||
(int) $member['id'],
|
||||
(string) $member['display_name'],
|
||||
);
|
||||
Response::noContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Infolab\Controllers;
|
||||
|
||||
use Infolab\ActivityLogger;
|
||||
use Infolab\Database;
|
||||
use Infolab\Request;
|
||||
use Infolab\Response;
|
||||
use Infolab\Slugger;
|
||||
use Infolab\Validator;
|
||||
|
||||
/**
|
||||
* CRUD projets — entité centrale. Soft delete via `deleted_at`.
|
||||
* Les sous-ressources (roadmap, members, discussions, resources) ont leurs
|
||||
* propres contrôleurs, mais s'appuient sur ProjectsController::findOr404().
|
||||
*/
|
||||
final class ProjectsController
|
||||
{
|
||||
public function index(Request $req, array $params): void
|
||||
{
|
||||
$page = max(1, (int) $req->query('page', 1));
|
||||
$perPage = max(1, min(100, (int) $req->query('per_page', 20)));
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
// Construction dynamique du WHERE — chaque valeur est bindée.
|
||||
$where = ['p.deleted_at IS NULL'];
|
||||
$args = [];
|
||||
|
||||
if (null !== ($cat = $req->query('category')) && $cat !== '') {
|
||||
$where[] = 'c.slug = :cat';
|
||||
$args[':cat'] = (string) $cat;
|
||||
}
|
||||
if (null !== ($status = $req->query('status')) && $status !== '') {
|
||||
if (!in_array($status, ['active', 'completed', 'archived'], true)) {
|
||||
throw new \ApiException(400, ['error' => 'bad_request', 'message' => 'status invalide']);
|
||||
}
|
||||
$where[] = 'p.status = :status';
|
||||
$args[':status'] = (string) $status;
|
||||
}
|
||||
if (null !== ($q = $req->query('q')) && $q !== '') {
|
||||
// Échappe les wildcards SQL avant le LIKE.
|
||||
$like = '%' . str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], (string) $q) . '%';
|
||||
$where[] = '(p.title LIKE :q OR p.short_description LIKE :q)';
|
||||
$args[':q'] = $like;
|
||||
}
|
||||
$whereSql = implode(' AND ', $where);
|
||||
|
||||
$total = (int) Database::fetchValue(
|
||||
"SELECT COUNT(*) FROM projects p INNER JOIN categories c ON c.id = p.category_id WHERE $whereSql",
|
||||
$args,
|
||||
);
|
||||
$rows = Database::fetchAll(
|
||||
"SELECT p.id, p.slug, p.title, p.status, p.territory, p.short_description,
|
||||
p.cover_image_url, p.external_url, p.progress_percent,
|
||||
p.started_at, p.completed_at, p.created_at, p.updated_at,
|
||||
c.id AS category_id, c.slug AS category_slug, c.name AS category_name,
|
||||
c.color_hex AS category_color_hex, c.dimension AS category_dimension
|
||||
FROM projects p
|
||||
INNER JOIN categories c ON c.id = p.category_id
|
||||
WHERE $whereSql
|
||||
ORDER BY p.created_at DESC, p.id DESC
|
||||
LIMIT $perPage OFFSET $offset",
|
||||
$args,
|
||||
);
|
||||
|
||||
Response::json([
|
||||
'data' => array_map([self::class, 'presentListRow'], $rows),
|
||||
'meta' => [
|
||||
'page' => $page,
|
||||
'per_page' => $perPage,
|
||||
'total' => $total,
|
||||
'total_pages' => $total === 0 ? 0 : (int) ceil($total / $perPage),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(Request $req, array $params): void
|
||||
{
|
||||
Response::json(self::presentDetail(self::findOr404($params['slug'])));
|
||||
}
|
||||
|
||||
public function store(Request $req, array $params): void
|
||||
{
|
||||
$body = $req->json();
|
||||
$v = new Validator($body);
|
||||
$v->required('title')->string('title', 200);
|
||||
$v->required('category_id')->integer('category_id', 1);
|
||||
$v->string('slug', 160);
|
||||
$v->in('status', ['active', 'completed', 'archived']);
|
||||
$v->string('territory', 160);
|
||||
$v->string('short_description', 5000);
|
||||
$v->string('context_description');
|
||||
$v->string('objectives');
|
||||
$v->url('cover_image_url', 512);
|
||||
$v->url('external_url', 512);
|
||||
$v->integer('progress_percent', 0, 100);
|
||||
$v->date('started_at');
|
||||
$v->date('completed_at');
|
||||
|
||||
// Lookup category_id seulement si le format est OK pour éviter le bruit.
|
||||
if ($v->has('category_id') && !$v->fails()) {
|
||||
$exists = Database::fetchValue(
|
||||
'SELECT 1 FROM categories WHERE id = :id',
|
||||
[':id' => (int) $v->value('category_id')],
|
||||
);
|
||||
if ($exists === null) {
|
||||
$v->setError('category_id', 'not_found');
|
||||
}
|
||||
}
|
||||
$v->check();
|
||||
|
||||
$title = (string) $body['title'];
|
||||
$base = (isset($body['slug']) && $body['slug'] !== '')
|
||||
? Slugger::slugify((string) $body['slug'])
|
||||
: Slugger::slugify($title);
|
||||
$slug = Slugger::unique($base, 'projects');
|
||||
|
||||
$id = Database::insert('projects', [
|
||||
'slug' => $slug,
|
||||
'title' => $title,
|
||||
'category_id' => (int) $body['category_id'],
|
||||
'status' => $body['status'] ?? 'active',
|
||||
'territory' => $body['territory'] ?? 'Marseille',
|
||||
'short_description' => $body['short_description'] ?? null,
|
||||
'context_description' => $body['context_description'] ?? null,
|
||||
'objectives' => $body['objectives'] ?? null,
|
||||
'cover_image_url' => $body['cover_image_url'] ?? null,
|
||||
'external_url' => $body['external_url'] ?? null,
|
||||
'progress_percent' => isset($body['progress_percent']) ? (int) $body['progress_percent'] : 0,
|
||||
'started_at' => $body['started_at'] ?? null,
|
||||
'completed_at' => $body['completed_at'] ?? null,
|
||||
]);
|
||||
|
||||
ActivityLogger::log(
|
||||
'created_project',
|
||||
sprintf('a créé le projet « %s »', $title),
|
||||
$id,
|
||||
);
|
||||
|
||||
Response::json(self::presentDetail(self::findOr404($slug)), 201);
|
||||
}
|
||||
|
||||
public function update(Request $req, array $params): void
|
||||
{
|
||||
$p = self::findOr404($params['slug']);
|
||||
$body = $req->json();
|
||||
$v = new Validator($body);
|
||||
$v->string('title', 200);
|
||||
$v->string('slug', 160);
|
||||
$v->integer('category_id', 1);
|
||||
$v->in('status', ['active', 'completed', 'archived']);
|
||||
$v->string('territory', 160);
|
||||
$v->string('short_description', 5000);
|
||||
$v->string('context_description');
|
||||
$v->string('objectives');
|
||||
$v->url('cover_image_url', 512);
|
||||
$v->url('external_url', 512);
|
||||
$v->integer('progress_percent', 0, 100);
|
||||
$v->date('started_at');
|
||||
$v->date('completed_at');
|
||||
|
||||
if (array_key_exists('category_id', $body) && !$v->fails()) {
|
||||
$exists = Database::fetchValue(
|
||||
'SELECT 1 FROM categories WHERE id = :id',
|
||||
[':id' => (int) $body['category_id']],
|
||||
);
|
||||
if ($exists === null) {
|
||||
$v->setError('category_id', 'not_found');
|
||||
}
|
||||
}
|
||||
$v->check();
|
||||
|
||||
$update = [];
|
||||
$fields = ['title','category_id','status','territory','short_description','context_description',
|
||||
'objectives','cover_image_url','external_url','progress_percent','started_at','completed_at'];
|
||||
foreach ($fields as $f) {
|
||||
if (array_key_exists($f, $body)) {
|
||||
$update[$f] = $body[$f];
|
||||
}
|
||||
}
|
||||
if (isset($update['progress_percent'])) $update['progress_percent'] = (int) $update['progress_percent'];
|
||||
if (isset($update['category_id'])) $update['category_id'] = (int) $update['category_id'];
|
||||
|
||||
if (isset($body['slug']) && $body['slug'] !== '') {
|
||||
$update['slug'] = Slugger::unique(
|
||||
Slugger::slugify((string) $body['slug']),
|
||||
'projects',
|
||||
'slug',
|
||||
(int) $p['id'],
|
||||
);
|
||||
}
|
||||
|
||||
// Transition status -> completed : remplit completed_at si vide.
|
||||
$becameCompleted = false;
|
||||
if (isset($update['status']) && $update['status'] === 'completed' && $p['status'] !== 'completed') {
|
||||
$becameCompleted = true;
|
||||
if (!array_key_exists('completed_at', $update) || $update['completed_at'] === null || $update['completed_at'] === '') {
|
||||
$update['completed_at'] = gmdate('Y-m-d');
|
||||
}
|
||||
}
|
||||
|
||||
if ($update !== []) {
|
||||
Database::update('projects', $update, 'id = :id', [':id' => (int) $p['id']]);
|
||||
}
|
||||
|
||||
$title = $update['title'] ?? $p['title'];
|
||||
ActivityLogger::log(
|
||||
'updated_project',
|
||||
sprintf('a mis à jour le projet « %s »', $title),
|
||||
(int) $p['id'],
|
||||
);
|
||||
if ($becameCompleted) {
|
||||
ActivityLogger::log(
|
||||
'completed_project',
|
||||
sprintf('a terminé le projet « %s »', $title),
|
||||
(int) $p['id'],
|
||||
);
|
||||
}
|
||||
|
||||
Response::json(self::presentDetail(self::findOr404($update['slug'] ?? $p['slug'])));
|
||||
}
|
||||
|
||||
public function destroy(Request $req, array $params): void
|
||||
{
|
||||
$p = self::findOr404($params['slug']);
|
||||
// Soft delete : on garde l'historique, on cache de la liste publique.
|
||||
Database::update(
|
||||
'projects',
|
||||
['deleted_at' => gmdate('Y-m-d H:i:s')],
|
||||
'id = :id',
|
||||
[':id' => (int) $p['id']],
|
||||
);
|
||||
Response::noContent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper public : récupère un projet non soft-deleted par son slug, ou 404.
|
||||
* Utilisé par tous les sous-contrôleurs (Roadmap, ProjectMembers, etc.).
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public static function findOr404(string $slug): array
|
||||
{
|
||||
$row = Database::fetchOne(
|
||||
'SELECT * FROM projects WHERE slug = :slug AND deleted_at IS NULL',
|
||||
[':slug' => $slug],
|
||||
);
|
||||
if ($row === null) {
|
||||
throw new \ApiException(404, ['error' => 'not_found']);
|
||||
}
|
||||
return $row;
|
||||
}
|
||||
|
||||
/** Ligne de liste (projet + catégorie jointe). */
|
||||
private static function presentListRow(array $row): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) $row['id'],
|
||||
'slug' => $row['slug'],
|
||||
'title' => $row['title'],
|
||||
'status' => $row['status'],
|
||||
'territory' => $row['territory'],
|
||||
'short_description' => $row['short_description'],
|
||||
'cover_image_url' => $row['cover_image_url'],
|
||||
'external_url' => $row['external_url'],
|
||||
'progress_percent' => (int) $row['progress_percent'],
|
||||
'started_at' => $row['started_at'],
|
||||
'completed_at' => $row['completed_at'],
|
||||
'created_at' => $row['created_at'],
|
||||
'updated_at' => $row['updated_at'],
|
||||
'category' => [
|
||||
'id' => (int) $row['category_id'],
|
||||
'slug' => $row['category_slug'],
|
||||
'name' => $row['category_name'],
|
||||
'color_hex' => $row['category_color_hex'],
|
||||
'dimension' => $row['category_dimension'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/** Détail complet d'un projet : catégorie, membres, roadmap, compteurs. */
|
||||
private static function presentDetail(array $p): array
|
||||
{
|
||||
$pid = (int) $p['id'];
|
||||
|
||||
$cat = Database::fetchOne(
|
||||
'SELECT id, slug, name, color_hex, icon, dimension FROM categories WHERE id = :id',
|
||||
[':id' => (int) $p['category_id']],
|
||||
);
|
||||
|
||||
$members = Database::fetchAll(
|
||||
'SELECT m.id, m.slug, m.display_name, m.initials, m.kind, m.avatar_url,
|
||||
pm.role, pm.joined_at
|
||||
FROM project_members pm
|
||||
INNER JOIN members m ON m.id = pm.member_id
|
||||
WHERE pm.project_id = :pid
|
||||
ORDER BY pm.joined_at ASC',
|
||||
[':pid' => $pid],
|
||||
);
|
||||
|
||||
$roadmap = Database::fetchAll(
|
||||
'SELECT id, title, description, step_date, is_done, sort_order, created_at, updated_at
|
||||
FROM roadmap_steps WHERE project_id = :pid ORDER BY sort_order ASC, id ASC',
|
||||
[':pid' => $pid],
|
||||
);
|
||||
foreach ($roadmap as &$s) {
|
||||
$s['id'] = (int) $s['id'];
|
||||
$s['is_done'] = (bool) $s['is_done'];
|
||||
$s['sort_order'] = (int) $s['sort_order'];
|
||||
}
|
||||
unset($s);
|
||||
|
||||
$discCount = (int) Database::fetchValue('SELECT COUNT(*) FROM discussions WHERE project_id = :pid', [':pid' => $pid]);
|
||||
$resCount = (int) Database::fetchValue('SELECT COUNT(*) FROM resources WHERE project_id = :pid', [':pid' => $pid]);
|
||||
|
||||
return [
|
||||
'id' => $pid,
|
||||
'slug' => $p['slug'],
|
||||
'title' => $p['title'],
|
||||
'status' => $p['status'],
|
||||
'territory' => $p['territory'],
|
||||
'short_description' => $p['short_description'],
|
||||
'context_description' => $p['context_description'],
|
||||
'objectives' => $p['objectives'],
|
||||
'cover_image_url' => $p['cover_image_url'],
|
||||
'external_url' => $p['external_url'],
|
||||
'progress_percent' => (int) $p['progress_percent'],
|
||||
'started_at' => $p['started_at'],
|
||||
'completed_at' => $p['completed_at'],
|
||||
'created_at' => $p['created_at'],
|
||||
'updated_at' => $p['updated_at'],
|
||||
'category' => $cat === null ? null : [
|
||||
'id' => (int) $cat['id'],
|
||||
'slug' => $cat['slug'],
|
||||
'name' => $cat['name'],
|
||||
'color_hex' => $cat['color_hex'],
|
||||
'icon' => $cat['icon'],
|
||||
'dimension' => $cat['dimension'],
|
||||
],
|
||||
'members' => array_map(static fn(array $m): array => [
|
||||
'id' => (int) $m['id'],
|
||||
'slug' => $m['slug'],
|
||||
'display_name' => $m['display_name'],
|
||||
'initials' => $m['initials'],
|
||||
'kind' => $m['kind'],
|
||||
'avatar_url' => $m['avatar_url'],
|
||||
'role' => $m['role'],
|
||||
'joined_at' => $m['joined_at'],
|
||||
], $members),
|
||||
'roadmap' => $roadmap,
|
||||
'counts' => [
|
||||
'discussions' => $discCount,
|
||||
'resources' => $resCount,
|
||||
'members' => count($members),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Infolab\Controllers;
|
||||
|
||||
use Infolab\ActivityLogger;
|
||||
use Infolab\Database;
|
||||
use Infolab\Request;
|
||||
use Infolab\Response;
|
||||
use Infolab\Validator;
|
||||
|
||||
/**
|
||||
* Ressources d'un projet (liens, docs, datasets, vidéos).
|
||||
*/
|
||||
final class ResourcesController
|
||||
{
|
||||
private const KINDS = ['link', 'document', 'dataset', 'video', 'other'];
|
||||
|
||||
public function index(Request $req, array $params): void
|
||||
{
|
||||
$p = ProjectsController::findOr404($params['slug']);
|
||||
$rows = Database::fetchAll(
|
||||
'SELECT id, project_id, title, description, url, kind, sort_order, created_at, updated_at
|
||||
FROM resources WHERE project_id = :pid ORDER BY sort_order ASC, id ASC',
|
||||
[':pid' => (int) $p['id']],
|
||||
);
|
||||
Response::json(['data' => array_map([self::class, 'present'], $rows)]);
|
||||
}
|
||||
|
||||
public function store(Request $req, array $params): void
|
||||
{
|
||||
$p = ProjectsController::findOr404($params['slug']);
|
||||
$body = $req->json();
|
||||
$v = new Validator($body);
|
||||
$v->required('title')->string('title', 200);
|
||||
$v->required('url')->url('url', 1024);
|
||||
$v->string('description', 5000);
|
||||
$v->in('kind', self::KINDS);
|
||||
$v->integer('sort_order', 0);
|
||||
$v->check();
|
||||
|
||||
$sortOrder = isset($body['sort_order'])
|
||||
? (int) $body['sort_order']
|
||||
: self::nextSortOrder((int) $p['id']);
|
||||
|
||||
$id = Database::insert('resources', [
|
||||
'project_id' => (int) $p['id'],
|
||||
'title' => (string) $body['title'],
|
||||
'description' => $body['description'] ?? null,
|
||||
'url' => (string) $body['url'],
|
||||
'kind' => $body['kind'] ?? 'link',
|
||||
'sort_order' => $sortOrder,
|
||||
]);
|
||||
|
||||
ActivityLogger::log(
|
||||
'added_resource',
|
||||
sprintf('a ajouté la ressource « %s » au projet « %s »', (string) $body['title'], $p['title']),
|
||||
(int) $p['id'],
|
||||
);
|
||||
|
||||
$row = Database::fetchOne('SELECT * FROM resources WHERE id = :id', [':id' => $id]);
|
||||
Response::json(self::present($row), 201);
|
||||
}
|
||||
|
||||
public function update(Request $req, array $params): void
|
||||
{
|
||||
$p = ProjectsController::findOr404($params['slug']);
|
||||
$r = self::findOr404((int) $params['id'], (int) $p['id']);
|
||||
|
||||
$body = $req->json();
|
||||
$v = new Validator($body);
|
||||
$v->string('title', 200);
|
||||
$v->string('description', 5000);
|
||||
$v->url('url', 1024);
|
||||
$v->in('kind', self::KINDS);
|
||||
$v->integer('sort_order', 0);
|
||||
$v->check();
|
||||
|
||||
$update = [];
|
||||
foreach (['title', 'description', 'url', 'kind', 'sort_order'] as $f) {
|
||||
if (array_key_exists($f, $body)) {
|
||||
$update[$f] = $body[$f];
|
||||
}
|
||||
}
|
||||
if (isset($update['sort_order'])) {
|
||||
$update['sort_order'] = (int) $update['sort_order'];
|
||||
}
|
||||
if ($update !== []) {
|
||||
Database::update('resources', $update, 'id = :id', [':id' => (int) $r['id']]);
|
||||
}
|
||||
$fresh = Database::fetchOne('SELECT * FROM resources WHERE id = :id', [':id' => (int) $r['id']]);
|
||||
Response::json(self::present($fresh));
|
||||
}
|
||||
|
||||
public function destroy(Request $req, array $params): void
|
||||
{
|
||||
$p = ProjectsController::findOr404($params['slug']);
|
||||
$r = self::findOr404((int) $params['id'], (int) $p['id']);
|
||||
Database::delete('resources', 'id = :id', [':id' => (int) $r['id']]);
|
||||
Response::noContent();
|
||||
}
|
||||
|
||||
private static function findOr404(int $id, int $projectId): array
|
||||
{
|
||||
$row = Database::fetchOne(
|
||||
'SELECT * FROM resources WHERE id = :id AND project_id = :pid',
|
||||
[':id' => $id, ':pid' => $projectId],
|
||||
);
|
||||
if ($row === null) {
|
||||
throw new \ApiException(404, ['error' => 'not_found']);
|
||||
}
|
||||
return $row;
|
||||
}
|
||||
|
||||
private static function nextSortOrder(int $projectId): int
|
||||
{
|
||||
return (int) Database::fetchValue(
|
||||
'SELECT COALESCE(MAX(sort_order), 0) + 10 FROM resources WHERE project_id = :pid',
|
||||
[':pid' => $projectId],
|
||||
);
|
||||
}
|
||||
|
||||
private static function present(array $row): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) $row['id'],
|
||||
'project_id' => (int) $row['project_id'],
|
||||
'title' => $row['title'],
|
||||
'description' => $row['description'],
|
||||
'url' => $row['url'],
|
||||
'kind' => $row['kind'],
|
||||
'sort_order' => (int) $row['sort_order'],
|
||||
'created_at' => $row['created_at'],
|
||||
'updated_at' => $row['updated_at'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Infolab\Controllers;
|
||||
|
||||
use Infolab\ActivityLogger;
|
||||
use Infolab\Database;
|
||||
use Infolab\Request;
|
||||
use Infolab\Response;
|
||||
use Infolab\Validator;
|
||||
|
||||
/**
|
||||
* Étapes de feuille de route d'un projet.
|
||||
*/
|
||||
final class RoadmapController
|
||||
{
|
||||
public function index(Request $req, array $params): void
|
||||
{
|
||||
$p = ProjectsController::findOr404($params['slug']);
|
||||
$rows = Database::fetchAll(
|
||||
'SELECT id, project_id, title, description, step_date, is_done, sort_order, created_at, updated_at
|
||||
FROM roadmap_steps WHERE project_id = :pid ORDER BY sort_order ASC, id ASC',
|
||||
[':pid' => (int) $p['id']],
|
||||
);
|
||||
Response::json(['data' => array_map([self::class, 'present'], $rows)]);
|
||||
}
|
||||
|
||||
public function store(Request $req, array $params): void
|
||||
{
|
||||
$p = ProjectsController::findOr404($params['slug']);
|
||||
$body = $req->json();
|
||||
$v = new Validator($body);
|
||||
$v->required('title')->string('title', 200);
|
||||
$v->string('description', 5000);
|
||||
$v->date('step_date');
|
||||
$v->boolean('is_done');
|
||||
$v->integer('sort_order', 0);
|
||||
$v->check();
|
||||
|
||||
$sortOrder = isset($body['sort_order'])
|
||||
? (int) $body['sort_order']
|
||||
: self::nextSortOrder((int) $p['id']);
|
||||
|
||||
$id = Database::insert('roadmap_steps', [
|
||||
'project_id' => (int) $p['id'],
|
||||
'title' => (string) $body['title'],
|
||||
'description' => $body['description'] ?? null,
|
||||
'step_date' => $body['step_date'] ?? null,
|
||||
'is_done' => self::toBool($body['is_done'] ?? false) ? 1 : 0,
|
||||
'sort_order' => $sortOrder,
|
||||
]);
|
||||
|
||||
ActivityLogger::log(
|
||||
'added_step',
|
||||
sprintf('a ajouté l\'étape « %s » au projet « %s »', (string) $body['title'], $p['title']),
|
||||
(int) $p['id'],
|
||||
);
|
||||
|
||||
Response::json(self::present(self::findStepOr404($id, (int) $p['id'])), 201);
|
||||
}
|
||||
|
||||
public function update(Request $req, array $params): void
|
||||
{
|
||||
$p = ProjectsController::findOr404($params['slug']);
|
||||
$step = self::findStepOr404((int) $params['id'], (int) $p['id']);
|
||||
|
||||
$body = $req->json();
|
||||
$v = new Validator($body);
|
||||
$v->string('title', 200);
|
||||
$v->string('description', 5000);
|
||||
$v->date('step_date');
|
||||
$v->boolean('is_done');
|
||||
$v->integer('sort_order', 0);
|
||||
$v->check();
|
||||
|
||||
$update = [];
|
||||
foreach (['title', 'description', 'step_date', 'sort_order'] as $f) {
|
||||
if (array_key_exists($f, $body)) {
|
||||
$update[$f] = $body[$f];
|
||||
}
|
||||
}
|
||||
if (isset($update['sort_order'])) {
|
||||
$update['sort_order'] = (int) $update['sort_order'];
|
||||
}
|
||||
|
||||
$becameDone = false;
|
||||
if (array_key_exists('is_done', $body)) {
|
||||
$newDone = self::toBool($body['is_done']);
|
||||
$oldDone = (bool) $step['is_done'];
|
||||
$update['is_done'] = $newDone ? 1 : 0;
|
||||
if ($newDone && !$oldDone) {
|
||||
$becameDone = true;
|
||||
}
|
||||
}
|
||||
if ($update !== []) {
|
||||
Database::update('roadmap_steps', $update, 'id = :id', [':id' => (int) $step['id']]);
|
||||
}
|
||||
if ($becameDone) {
|
||||
ActivityLogger::log(
|
||||
'completed_step',
|
||||
sprintf('a terminé l\'étape « %s » du projet « %s »', $step['title'], $p['title']),
|
||||
(int) $p['id'],
|
||||
);
|
||||
}
|
||||
|
||||
Response::json(self::present(self::findStepOr404((int) $step['id'], (int) $p['id'])));
|
||||
}
|
||||
|
||||
public function destroy(Request $req, array $params): void
|
||||
{
|
||||
$p = ProjectsController::findOr404($params['slug']);
|
||||
$step = self::findStepOr404((int) $params['id'], (int) $p['id']);
|
||||
Database::delete('roadmap_steps', 'id = :id', [':id' => (int) $step['id']]);
|
||||
Response::noContent();
|
||||
}
|
||||
|
||||
private static function findStepOr404(int $id, int $projectId): array
|
||||
{
|
||||
$row = Database::fetchOne(
|
||||
'SELECT id, project_id, title, description, step_date, is_done, sort_order, created_at, updated_at
|
||||
FROM roadmap_steps WHERE id = :id AND project_id = :pid',
|
||||
[':id' => $id, ':pid' => $projectId],
|
||||
);
|
||||
if ($row === null) {
|
||||
throw new \ApiException(404, ['error' => 'not_found']);
|
||||
}
|
||||
return $row;
|
||||
}
|
||||
|
||||
/** Sort order par défaut : MAX + 10 par projet (laisse de l'espace pour insertion ultérieure). */
|
||||
private static function nextSortOrder(int $projectId): int
|
||||
{
|
||||
return (int) Database::fetchValue(
|
||||
'SELECT COALESCE(MAX(sort_order), 0) + 10 FROM roadmap_steps WHERE project_id = :pid',
|
||||
[':pid' => $projectId],
|
||||
);
|
||||
}
|
||||
|
||||
private static function toBool(mixed $v): bool
|
||||
{
|
||||
if (is_bool($v)) return $v;
|
||||
if (is_int($v)) return $v !== 0;
|
||||
if (is_string($v)) return in_array(strtolower($v), ['true', '1', 'yes', 'on'], true);
|
||||
return (bool) $v;
|
||||
}
|
||||
|
||||
private static function present(array $row): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) $row['id'],
|
||||
'project_id' => (int) $row['project_id'],
|
||||
'title' => $row['title'],
|
||||
'description' => $row['description'],
|
||||
'step_date' => $row['step_date'],
|
||||
'is_done' => (bool) $row['is_done'],
|
||||
'sort_order' => (int) $row['sort_order'],
|
||||
'created_at' => $row['created_at'],
|
||||
'updated_at' => $row['updated_at'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Infolab\Controllers;
|
||||
|
||||
use Infolab\Database;
|
||||
use Infolab\Request;
|
||||
use Infolab\Response;
|
||||
|
||||
/**
|
||||
* GET /api/stats — KPIs du dashboard.
|
||||
*
|
||||
* - projects_{active,completed,archived,total} : projets non soft-deletés
|
||||
* - participants_total : total des membres de l'annuaire
|
||||
* (interprétation : "annuaire des contributeurs" = participants potentiels)
|
||||
* - by_category : un objet { slug, name, color_hex, count } par catégorie
|
||||
* - by_dimension : { social, ecologique, transversal } sommés sur projets non-deleted
|
||||
*/
|
||||
final class StatsController
|
||||
{
|
||||
public function index(Request $req, array $params): void
|
||||
{
|
||||
// Compteurs par status (projets non soft-deletés).
|
||||
$statusCounts = Database::fetchAll(
|
||||
'SELECT status, COUNT(*) AS c FROM projects WHERE deleted_at IS NULL GROUP BY status',
|
||||
);
|
||||
$byStatus = ['active' => 0, 'completed' => 0, 'archived' => 0];
|
||||
foreach ($statusCounts as $r) {
|
||||
$byStatus[$r['status']] = (int) $r['c'];
|
||||
}
|
||||
|
||||
$participantsTotal = (int) Database::fetchValue('SELECT COUNT(*) FROM members');
|
||||
|
||||
// Compteur par catégorie : LEFT JOIN pour inclure les catégories à 0 projet.
|
||||
$byCategoryRows = Database::fetchAll(
|
||||
'SELECT c.slug, c.name, c.color_hex, c.dimension,
|
||||
COALESCE(SUM(CASE WHEN p.deleted_at IS NULL THEN 1 ELSE 0 END), 0) AS count
|
||||
FROM categories c
|
||||
LEFT JOIN projects p ON p.category_id = c.id
|
||||
GROUP BY c.id, c.slug, c.name, c.color_hex, c.dimension
|
||||
ORDER BY c.sort_order ASC',
|
||||
);
|
||||
|
||||
$byDimension = ['social' => 0, 'ecologique' => 0, 'transversal' => 0];
|
||||
$byCategory = [];
|
||||
foreach ($byCategoryRows as $r) {
|
||||
$count = (int) $r['count'];
|
||||
$byCategory[] = [
|
||||
'slug' => $r['slug'],
|
||||
'name' => $r['name'],
|
||||
'color_hex' => $r['color_hex'],
|
||||
'count' => $count,
|
||||
];
|
||||
$byDimension[$r['dimension']] = ($byDimension[$r['dimension']] ?? 0) + $count;
|
||||
}
|
||||
|
||||
Response::json([
|
||||
'projects_active' => $byStatus['active'],
|
||||
'projects_completed' => $byStatus['completed'],
|
||||
'projects_archived' => $byStatus['archived'],
|
||||
'projects_total' => $byStatus['active'] + $byStatus['completed'] + $byStatus['archived'],
|
||||
'participants_total' => $participantsTotal,
|
||||
'by_category' => $byCategory,
|
||||
'by_dimension' => $byDimension,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Infolab;
|
||||
|
||||
use PDO;
|
||||
use PDOException;
|
||||
use PDOStatement;
|
||||
|
||||
/**
|
||||
* Singleton PDO + helpers de base.
|
||||
*
|
||||
* Toutes les requêtes passent par des prepared statements (paramètres nommés ou
|
||||
* positionnels). Aucune concaténation de variable utilisateur dans le SQL.
|
||||
* Les noms de tables/colonnes utilisés en interne sont quotés avec des backticks
|
||||
* et viennent toujours du code, jamais d'une entrée utilisateur.
|
||||
*/
|
||||
final class Database
|
||||
{
|
||||
private static ?PDO $pdo = null;
|
||||
|
||||
/** Construit (ou retourne) l'instance PDO partagée. */
|
||||
public static function pdo(): PDO
|
||||
{
|
||||
if (self::$pdo !== null) {
|
||||
return self::$pdo;
|
||||
}
|
||||
|
||||
$host = getenv('DB_HOST') ?: '127.0.0.1';
|
||||
$port = getenv('DB_PORT') ?: '3306';
|
||||
$name = getenv('DB_NAME') ?: 'infolab';
|
||||
$user = getenv('DB_USER') ?: 'infolab';
|
||||
$pass = getenv('DB_PASS') ?: '';
|
||||
|
||||
$dsn = sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $host, $port, $name);
|
||||
try {
|
||||
self::$pdo = new PDO($dsn, $user, $pass, [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
// EMULATE_PREPARES=false + STRINGIFY_FETCHES=false : les colonnes
|
||||
// INT reviennent en int PHP, pas en string. JSON propre par défaut.
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
PDO::ATTR_STRINGIFY_FETCHES => false,
|
||||
// Force UTC côté connexion : les TIMESTAMP/DATETIME sont stockés tels quels.
|
||||
PDO::MYSQL_ATTR_INIT_COMMAND => "SET time_zone = '+00:00', NAMES utf8mb4",
|
||||
]);
|
||||
} catch (PDOException $e) {
|
||||
// Visible dans Dozzle via stderr ; détail jamais renvoyé au client.
|
||||
error_log('[Database] connection failed: ' . $e->getMessage());
|
||||
throw new \ApiException(500, ['error' => 'internal_server_error']);
|
||||
}
|
||||
|
||||
return self::$pdo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prépare + exécute. Retourne le PDOStatement pour fetch ultérieur si besoin.
|
||||
*
|
||||
* @param array<string|int,mixed> $params
|
||||
*/
|
||||
public static function run(string $sql, array $params = []): PDOStatement
|
||||
{
|
||||
$stmt = self::pdo()->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
return $stmt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère une seule ligne (ou null).
|
||||
*
|
||||
* @param array<string|int,mixed> $params
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
public static function fetchOne(string $sql, array $params = []): ?array
|
||||
{
|
||||
$row = self::run($sql, $params)->fetch();
|
||||
return $row === false ? null : $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère toutes les lignes (liste indexée numériquement, lignes assoc).
|
||||
*
|
||||
* @param array<string|int,mixed> $params
|
||||
* @return array<int,array<string,mixed>>
|
||||
*/
|
||||
public static function fetchAll(string $sql, array $params = []): array
|
||||
{
|
||||
return self::run($sql, $params)->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère la 1re colonne de la 1re ligne (utile pour COUNT, SELECT id, etc.).
|
||||
*
|
||||
* @param array<string|int,mixed> $params
|
||||
*/
|
||||
public static function fetchValue(string $sql, array $params = []): mixed
|
||||
{
|
||||
$row = self::run($sql, $params)->fetch(PDO::FETCH_NUM);
|
||||
return $row === false ? null : $row[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* INSERT générique à partir d'un tableau colonne => valeur.
|
||||
* Retourne l'auto-increment.
|
||||
*
|
||||
* @param array<string,mixed> $data
|
||||
*/
|
||||
public static function insert(string $table, array $data): int
|
||||
{
|
||||
if ($data === []) {
|
||||
throw new \InvalidArgumentException('Database::insert: data vide');
|
||||
}
|
||||
$cols = array_keys($data);
|
||||
$colsSql = implode(',', array_map(static fn($c) => '`' . $c . '`', $cols));
|
||||
$phSql = implode(',', array_map(static fn($c) => ':' . $c, $cols));
|
||||
$sql = sprintf('INSERT INTO `%s` (%s) VALUES (%s)', $table, $colsSql, $phSql);
|
||||
|
||||
$params = [];
|
||||
foreach ($data as $k => $v) {
|
||||
$params[':' . $k] = $v;
|
||||
}
|
||||
self::run($sql, $params);
|
||||
return (int) self::pdo()->lastInsertId();
|
||||
}
|
||||
|
||||
/**
|
||||
* UPDATE générique. Le WHERE est fourni en SQL + ses propres params
|
||||
* (préfixés avec : pour éviter les collisions avec les colonnes SET).
|
||||
*
|
||||
* @param array<string,mixed> $data
|
||||
* @param array<string,mixed> $whereParams ex: [':id' => 42]
|
||||
*/
|
||||
public static function update(string $table, array $data, string $whereSql, array $whereParams = []): int
|
||||
{
|
||||
if ($data === []) {
|
||||
return 0;
|
||||
}
|
||||
$set = implode(',', array_map(static fn($c) => '`' . $c . '`=:' . $c, array_keys($data)));
|
||||
$sql = sprintf('UPDATE `%s` SET %s WHERE %s', $table, $set, $whereSql);
|
||||
|
||||
$params = [];
|
||||
foreach ($data as $k => $v) {
|
||||
$params[':' . $k] = $v;
|
||||
}
|
||||
foreach ($whereParams as $k => $v) {
|
||||
$params[$k] = $v;
|
||||
}
|
||||
return self::run($sql, $params)->rowCount();
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE générique.
|
||||
*
|
||||
* @param array<string,mixed> $whereParams
|
||||
*/
|
||||
public static function delete(string $table, string $whereSql, array $whereParams = []): int
|
||||
{
|
||||
$sql = sprintf('DELETE FROM `%s` WHERE %s', $table, $whereSql);
|
||||
return self::run($sql, $whereParams)->rowCount();
|
||||
}
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Infolab;
|
||||
|
||||
/**
|
||||
* Façade lecture seule sur la requête HTTP courante.
|
||||
* Parse les headers, le body JSON (méthodes non-GET) et expose les query params.
|
||||
*/
|
||||
final class Request
|
||||
{
|
||||
/** @var array<string,mixed> */
|
||||
private array $jsonBody;
|
||||
|
||||
/** @var array<string,mixed> */
|
||||
private array $query;
|
||||
|
||||
/** @var array<string,string> headers en clés lowercase */
|
||||
private array $headers;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->query = $_GET;
|
||||
$this->headers = $this->loadHeaders();
|
||||
$this->jsonBody = $this->loadJsonBody();
|
||||
}
|
||||
|
||||
/** @return array<string,string> */
|
||||
private function loadHeaders(): array
|
||||
{
|
||||
$h = [];
|
||||
// getallheaders() existe avec Apache et le serveur intégré PHP récent.
|
||||
if (function_exists('getallheaders')) {
|
||||
$all = getallheaders();
|
||||
if (is_array($all)) {
|
||||
foreach ($all as $k => $v) {
|
||||
$h[strtolower((string) $k)] = (string) $v;
|
||||
}
|
||||
return $h;
|
||||
}
|
||||
}
|
||||
// Fallback : reconstruit depuis $_SERVER (CGI/FPM).
|
||||
foreach ($_SERVER as $k => $v) {
|
||||
if (is_string($k) && str_starts_with($k, 'HTTP_')) {
|
||||
$name = strtolower(str_replace('_', '-', substr($k, 5)));
|
||||
$h[$name] = (string) $v;
|
||||
}
|
||||
}
|
||||
// Content-Type / Content-Length ne sont pas préfixés HTTP_
|
||||
if (isset($_SERVER['CONTENT_TYPE'])) $h['content-type'] = (string) $_SERVER['CONTENT_TYPE'];
|
||||
if (isset($_SERVER['CONTENT_LENGTH'])) $h['content-length'] = (string) $_SERVER['CONTENT_LENGTH'];
|
||||
return $h;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function loadJsonBody(): array
|
||||
{
|
||||
$method = $this->method();
|
||||
if (!in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'], true)) {
|
||||
return [];
|
||||
}
|
||||
$raw = file_get_contents('php://input');
|
||||
if ($raw === false || $raw === '') {
|
||||
return [];
|
||||
}
|
||||
$ct = $this->header('content-type', '') ?? '';
|
||||
if ($ct !== '' && !str_contains(strtolower($ct), 'application/json')) {
|
||||
// Body non-JSON : on n'essaie pas de parser. Les contrôleurs traiteront comme vide.
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
$decoded = json_decode($raw, true, 64, JSON_THROW_ON_ERROR);
|
||||
} catch (\JsonException) {
|
||||
throw new \ApiException(400, ['error' => 'bad_request', 'message' => 'invalid JSON body']);
|
||||
}
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
public function method(): string
|
||||
{
|
||||
return strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'));
|
||||
}
|
||||
|
||||
/** Chemin sans query string. */
|
||||
public function path(): string
|
||||
{
|
||||
$uri = (string) ($_SERVER['REQUEST_URI'] ?? '/');
|
||||
$q = strpos($uri, '?');
|
||||
return $q === false ? $uri : substr($uri, 0, $q);
|
||||
}
|
||||
|
||||
public function header(string $name, ?string $default = null): ?string
|
||||
{
|
||||
return $this->headers[strtolower($name)] ?? $default;
|
||||
}
|
||||
|
||||
/** Extrait le token Bearer de l'header Authorization, ou null. */
|
||||
public function bearerToken(): ?string
|
||||
{
|
||||
$auth = $this->header('authorization');
|
||||
if ($auth === null) {
|
||||
return null;
|
||||
}
|
||||
if (preg_match('/^Bearer\s+(.+)$/i', $auth, $m)) {
|
||||
return trim($m[1]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public function query(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return $this->query[$key] ?? $default;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function allQuery(): array
|
||||
{
|
||||
return $this->query;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> payload JSON décodé (vide si pas de body) */
|
||||
public function json(): array
|
||||
{
|
||||
return $this->jsonBody;
|
||||
}
|
||||
|
||||
public function input(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return $this->jsonBody[$key] ?? $default;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Infolab;
|
||||
|
||||
/**
|
||||
* Helpers d'émission de réponse JSON.
|
||||
* Toujours `Content-Type: application/json; charset=utf-8`.
|
||||
* Les contrôleurs ne devraient jamais `echo` directement — passer par ici.
|
||||
*/
|
||||
final class Response
|
||||
{
|
||||
/** Réponse JSON standard (objet ou liste). */
|
||||
public static function json(mixed $data, int $status = 200): void
|
||||
{
|
||||
self::emit($status, $data);
|
||||
}
|
||||
|
||||
/** Réponse 204 No Content (ou autre statut sans body). */
|
||||
public static function noContent(int $status = 204): void
|
||||
{
|
||||
http_response_code($status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Erreur uniforme. Toujours { "error": code, "message"?: ..., ...extra }.
|
||||
*
|
||||
* @param array<string,mixed> $extra
|
||||
*/
|
||||
public static function error(int $status, string $code, ?string $message = null, array $extra = []): void
|
||||
{
|
||||
$payload = ['error' => $code];
|
||||
if ($message !== null) {
|
||||
$payload['message'] = $message;
|
||||
}
|
||||
$payload += $extra;
|
||||
self::emit($status, $payload);
|
||||
}
|
||||
|
||||
/** Émet une réponse JSON brute. Utilisé par le handler d'exception global. */
|
||||
public static function emit(int $status, mixed $data): void
|
||||
{
|
||||
http_response_code($status);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
|
||||
/** Émet les headers CORS basés sur l'env CORS_ORIGIN (défaut '*'). */
|
||||
public static function sendCorsHeaders(): void
|
||||
{
|
||||
$origin = getenv('CORS_ORIGIN') ?: '*';
|
||||
header('Access-Control-Allow-Origin: ' . $origin);
|
||||
header('Vary: Origin');
|
||||
header('Access-Control-Allow-Methods: GET, POST, PATCH, DELETE, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Authorization, Content-Type, Accept');
|
||||
header('Access-Control-Max-Age: 86400');
|
||||
}
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Infolab;
|
||||
|
||||
/**
|
||||
* Router minimaliste. Supporte les paramètres `:nom` dans les chemins
|
||||
* (capturés comme [^/]+) et marque chaque route comme protégée par token ou non.
|
||||
*
|
||||
* En cas de chemin connu sur une autre méthode -> 405 method_not_allowed.
|
||||
* En cas de chemin inconnu -> 404 not_found. Les deux passent par ApiException.
|
||||
*/
|
||||
final class Router
|
||||
{
|
||||
/**
|
||||
* @var array<int,array{
|
||||
* method:string,
|
||||
* pattern:string,
|
||||
* paramNames:array<int,string>,
|
||||
* handler:array{0:class-string,1:string},
|
||||
* auth:bool
|
||||
* }>
|
||||
*/
|
||||
private array $routes = [];
|
||||
|
||||
/** Enregistre une route GET (publique, pas de token). */
|
||||
public function get(string $path, array $handler): void
|
||||
{
|
||||
$this->add('GET', $path, $handler, false);
|
||||
}
|
||||
|
||||
/** Enregistre une route POST (token requis). */
|
||||
public function post(string $path, array $handler): void
|
||||
{
|
||||
$this->add('POST', $path, $handler, true);
|
||||
}
|
||||
|
||||
/** Enregistre une route PATCH (token requis). */
|
||||
public function patch(string $path, array $handler): void
|
||||
{
|
||||
$this->add('PATCH', $path, $handler, true);
|
||||
}
|
||||
|
||||
/** Enregistre une route DELETE (token requis). */
|
||||
public function delete(string $path, array $handler): void
|
||||
{
|
||||
$this->add('DELETE', $path, $handler, true);
|
||||
}
|
||||
|
||||
public function add(string $method, string $path, array $handler, bool $requiresAuth): void
|
||||
{
|
||||
// /api/projects/:slug -> /api/projects/([^/]+), avec ['slug'] dans paramNames.
|
||||
$paramNames = [];
|
||||
$pattern = preg_replace_callback(
|
||||
'#:([a-zA-Z_][a-zA-Z0-9_]*)#',
|
||||
static function (array $m) use (&$paramNames): string {
|
||||
$paramNames[] = $m[1];
|
||||
return '([^/]+)';
|
||||
},
|
||||
$path
|
||||
);
|
||||
|
||||
$this->routes[] = [
|
||||
'method' => $method,
|
||||
'pattern' => '#^' . $pattern . '$#',
|
||||
'paramNames' => $paramNames,
|
||||
'handler' => $handler,
|
||||
'auth' => $requiresAuth,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cherche la route matchant méthode+chemin.
|
||||
*
|
||||
* @return array{0:array<string,mixed>,1:array<string,string>} couple (route, params nommés)
|
||||
* @throws \ApiException 405 si chemin connu sur une autre méthode, 404 sinon
|
||||
*/
|
||||
public function dispatch(string $method, string $path): array
|
||||
{
|
||||
$methodAllowed = [];
|
||||
foreach ($this->routes as $route) {
|
||||
if (!preg_match($route['pattern'], $path, $m)) {
|
||||
continue;
|
||||
}
|
||||
if ($route['method'] !== $method) {
|
||||
$methodAllowed[$route['method']] = true;
|
||||
continue;
|
||||
}
|
||||
array_shift($m); // enlève le match complet
|
||||
$params = [];
|
||||
foreach ($route['paramNames'] as $i => $name) {
|
||||
$params[$name] = urldecode($m[$i]);
|
||||
}
|
||||
return [$route, $params];
|
||||
}
|
||||
|
||||
if ($methodAllowed !== []) {
|
||||
throw new \ApiException(405, [
|
||||
'error' => 'method_not_allowed',
|
||||
'allowed' => array_keys($methodAllowed),
|
||||
]);
|
||||
}
|
||||
throw new \ApiException(404, ['error' => 'not_found']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Infolab;
|
||||
|
||||
/**
|
||||
* Génère et vérifie l'unicité des slugs. Translittération ASCII via
|
||||
* l'extension intl (Transliterator), avec fallback bas niveau.
|
||||
*/
|
||||
final class Slugger
|
||||
{
|
||||
/**
|
||||
* Slugifie un texte : minuscules, accents retirés, non-alphanum -> tirets.
|
||||
* Toujours non vide (retourne "item" en dernier recours).
|
||||
*/
|
||||
public static function slugify(string $text): string
|
||||
{
|
||||
$text = trim($text);
|
||||
if ($text === '') {
|
||||
return 'item';
|
||||
}
|
||||
|
||||
// Transliteration intl si dispo (cas de Marseille -> marseille, é -> e).
|
||||
if (class_exists(\Transliterator::class)) {
|
||||
$tr = \Transliterator::create('Any-Latin; Latin-ASCII; Lower()');
|
||||
if ($tr !== null) {
|
||||
$translit = $tr->transliterate($text);
|
||||
if (is_string($translit)) {
|
||||
$text = $translit;
|
||||
}
|
||||
}
|
||||
}
|
||||
$text = mb_strtolower($text);
|
||||
// Remplace toute séquence non-[a-z0-9] par un tiret, puis trim les tirets de bord.
|
||||
$text = preg_replace('/[^a-z0-9]+/', '-', $text) ?? '';
|
||||
$text = trim($text, '-');
|
||||
return $text === '' ? 'item' : $text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Garantit l'unicité d'un slug dans une table : suffixe `-2`, `-3`, …
|
||||
* Si `$excludeId` est fourni, ignore cette ligne (cas PATCH d'un slug existant).
|
||||
*/
|
||||
public static function unique(string $base, string $table, string $column = 'slug', ?int $excludeId = null): string
|
||||
{
|
||||
$candidate = $base;
|
||||
$n = 2;
|
||||
while (self::exists($candidate, $table, $column, $excludeId)) {
|
||||
$candidate = $base . '-' . $n;
|
||||
$n++;
|
||||
}
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
private static function exists(string $slug, string $table, string $column, ?int $excludeId): bool
|
||||
{
|
||||
// Noms de table/colonne câblés depuis le code, jamais depuis l'utilisateur.
|
||||
$sql = sprintf('SELECT 1 FROM `%s` WHERE `%s` = :slug', $table, $column);
|
||||
$params = [':slug' => $slug];
|
||||
if ($excludeId !== null) {
|
||||
$sql .= ' AND id <> :id';
|
||||
$params[':id'] = $excludeId;
|
||||
}
|
||||
$sql .= ' LIMIT 1';
|
||||
return Database::fetchValue($sql, $params) !== null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Infolab;
|
||||
|
||||
/**
|
||||
* Validation fluente des payloads JSON. Accumule les erreurs par champ, puis
|
||||
* `check()` lance une ApiException 422 conforme au contrat documenté :
|
||||
*
|
||||
* { "error": "validation_failed", "fields": { "title": "required", ... } }
|
||||
*
|
||||
* Toutes les règles autres que `required()` sont silencieuses si la clé
|
||||
* n'existe pas — ainsi les PATCH partiels passent sans déclarer les champs absents.
|
||||
*/
|
||||
final class Validator
|
||||
{
|
||||
/** @var array<string,mixed> */
|
||||
private array $data;
|
||||
|
||||
/** @var array<string,string> code d'erreur par champ */
|
||||
private array $errors = [];
|
||||
|
||||
/** @param array<string,mixed> $data */
|
||||
public function __construct(array $data)
|
||||
{
|
||||
$this->data = $data;
|
||||
}
|
||||
|
||||
public function has(string $key): bool
|
||||
{
|
||||
return array_key_exists($key, $this->data);
|
||||
}
|
||||
|
||||
public function value(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return $this->data[$key] ?? $default;
|
||||
}
|
||||
|
||||
/** Définit manuellement une erreur sur un champ (ex: "not_found" après lookup BDD). */
|
||||
public function setError(string $key, string $code): self
|
||||
{
|
||||
$this->errors[$key] = $code;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function required(string $key): self
|
||||
{
|
||||
if (!$this->has($key) || $this->data[$key] === null || $this->data[$key] === '') {
|
||||
$this->errors[$key] ??= 'required';
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function string(string $key, ?int $max = null, ?int $min = null): self
|
||||
{
|
||||
if (!$this->has($key) || $this->data[$key] === null) {
|
||||
return $this;
|
||||
}
|
||||
$v = $this->data[$key];
|
||||
if (!is_string($v)) {
|
||||
$this->errors[$key] ??= 'must_be_string';
|
||||
return $this;
|
||||
}
|
||||
$len = mb_strlen($v);
|
||||
if ($min !== null && $len < $min) $this->errors[$key] ??= 'too_short';
|
||||
if ($max !== null && $len > $max) $this->errors[$key] ??= 'too_long';
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function integer(string $key, ?int $min = null, ?int $max = null): self
|
||||
{
|
||||
if (!$this->has($key) || $this->data[$key] === null) {
|
||||
return $this;
|
||||
}
|
||||
$v = $this->data[$key];
|
||||
if (is_int($v)) {
|
||||
$i = $v;
|
||||
} elseif (is_string($v) && preg_match('/^-?\d+$/', $v)) {
|
||||
$i = (int) $v;
|
||||
} else {
|
||||
$this->errors[$key] ??= 'must_be_integer';
|
||||
return $this;
|
||||
}
|
||||
if ($min !== null && $i < $min) $this->errors[$key] ??= 'too_small';
|
||||
if ($max !== null && $i > $max) $this->errors[$key] ??= 'too_large';
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function boolean(string $key): self
|
||||
{
|
||||
if (!$this->has($key) || $this->data[$key] === null) {
|
||||
return $this;
|
||||
}
|
||||
$v = $this->data[$key];
|
||||
if (!is_bool($v) && !in_array($v, [0, 1, '0', '1', 'true', 'false'], true)) {
|
||||
$this->errors[$key] ??= 'must_be_boolean';
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @param array<int,mixed> $allowed */
|
||||
public function in(string $key, array $allowed): self
|
||||
{
|
||||
if (!$this->has($key) || $this->data[$key] === null) {
|
||||
return $this;
|
||||
}
|
||||
if (!in_array($this->data[$key], $allowed, true)) {
|
||||
$this->errors[$key] ??= 'invalid_value';
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function url(string $key, int $max = 1024): self
|
||||
{
|
||||
if (!$this->has($key) || $this->data[$key] === null || $this->data[$key] === '') {
|
||||
return $this;
|
||||
}
|
||||
$v = $this->data[$key];
|
||||
if (!is_string($v) || filter_var($v, FILTER_VALIDATE_URL) === false || mb_strlen($v) > $max) {
|
||||
$this->errors[$key] ??= 'invalid_url';
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function email(string $key): self
|
||||
{
|
||||
if (!$this->has($key) || $this->data[$key] === null || $this->data[$key] === '') {
|
||||
return $this;
|
||||
}
|
||||
if (!is_string($this->data[$key]) || filter_var($this->data[$key], FILTER_VALIDATE_EMAIL) === false) {
|
||||
$this->errors[$key] ??= 'invalid_email';
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** Format ISO date YYYY-MM-DD strict. */
|
||||
public function date(string $key): self
|
||||
{
|
||||
if (!$this->has($key) || $this->data[$key] === null || $this->data[$key] === '') {
|
||||
return $this;
|
||||
}
|
||||
$v = $this->data[$key];
|
||||
if (!is_string($v)) {
|
||||
$this->errors[$key] ??= 'invalid_date';
|
||||
return $this;
|
||||
}
|
||||
$d = \DateTimeImmutable::createFromFormat('Y-m-d', $v);
|
||||
if ($d === false || $d->format('Y-m-d') !== $v) {
|
||||
$this->errors[$key] ??= 'invalid_date';
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function colorHex(string $key): self
|
||||
{
|
||||
if (!$this->has($key) || $this->data[$key] === null || $this->data[$key] === '') {
|
||||
return $this;
|
||||
}
|
||||
if (!is_string($this->data[$key]) || !preg_match('/^#[0-9A-Fa-f]{6}$/', $this->data[$key])) {
|
||||
$this->errors[$key] ??= 'invalid_color_hex';
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function fails(): bool
|
||||
{
|
||||
return $this->errors !== [];
|
||||
}
|
||||
|
||||
/** @return array<string,string> */
|
||||
public function errors(): array
|
||||
{
|
||||
return $this->errors;
|
||||
}
|
||||
|
||||
/** Termine la validation : lance ApiException 422 si au moins une erreur. */
|
||||
public function check(): void
|
||||
{
|
||||
if ($this->errors !== []) {
|
||||
throw new \ApiException(422, [
|
||||
'error' => 'validation_failed',
|
||||
'fields' => $this->errors,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// bootstrap.php
|
||||
// Initialise l'environnement PHP : timezone UTC, exception applicative,
|
||||
// chargement .env (dev local), autoload PSR-4 pour le namespace Infolab\,
|
||||
// et conversion des warnings PHP en exceptions.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// Tout en UTC en interne, les clients gèrent leur fuseau côté front.
|
||||
date_default_timezone_set('UTC');
|
||||
|
||||
/**
|
||||
* Exception applicative : porte un code HTTP et le payload JSON à renvoyer.
|
||||
* Levée par les contrôleurs et helpers, attrapée par le handler global de index.php.
|
||||
* Définie dans le namespace racine pour être accessible depuis tout le code.
|
||||
*/
|
||||
final class ApiException extends \RuntimeException
|
||||
{
|
||||
/** @param array<string,mixed> $payload */
|
||||
public function __construct(public readonly int $statusCode, public readonly array $payload)
|
||||
{
|
||||
parent::__construct($payload['error'] ?? 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Raccourci pour interrompre rapidement le traitement avec une erreur typée.
|
||||
*
|
||||
* @param array<string,mixed> $extra champs additionnels à inclure dans le payload JSON
|
||||
*/
|
||||
function api_abort(int $status, string $error, ?string $message = null, array $extra = []): never
|
||||
{
|
||||
$payload = ['error' => $error];
|
||||
if ($message !== null) {
|
||||
$payload['message'] = $message;
|
||||
}
|
||||
if ($extra !== []) {
|
||||
$payload += $extra;
|
||||
}
|
||||
throw new ApiException($status, $payload);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Chargement minimal d'un fichier .env (pour le dev local sous Laragon).
|
||||
// En prod docker, c'est `env_file:` qui pousse les variables — on ne fait rien.
|
||||
// -----------------------------------------------------------------------------
|
||||
$envFile = dirname(__DIR__) . '/.env';
|
||||
if (is_file($envFile)) {
|
||||
foreach (file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
|
||||
$line = trim($line);
|
||||
if ($line === '' || $line[0] === '#' || !str_contains($line, '=')) {
|
||||
continue;
|
||||
}
|
||||
[$k, $v] = explode('=', $line, 2);
|
||||
$k = trim($k);
|
||||
$v = trim($v);
|
||||
// Strip guillemets simples ou doubles s'ils encadrent la valeur.
|
||||
if (strlen($v) >= 2 && (
|
||||
($v[0] === '"' && substr($v, -1) === '"') ||
|
||||
($v[0] === "'" && substr($v, -1) === "'")
|
||||
)) {
|
||||
$v = substr($v, 1, -1);
|
||||
}
|
||||
// Ne pas écraser un env déjà défini (ex: par docker, par le shell de CI...)
|
||||
if (getenv($k) === false) {
|
||||
putenv("$k=$v");
|
||||
$_ENV[$k] = $v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Autoload PSR-4 pour le namespace Infolab\ -> src/
|
||||
// On évite Composer : pas de dépendance externe, pas de `composer install`.
|
||||
// -----------------------------------------------------------------------------
|
||||
spl_autoload_register(static function (string $class): void {
|
||||
$prefix = 'Infolab\\';
|
||||
if (!str_starts_with($class, $prefix)) {
|
||||
return;
|
||||
}
|
||||
$relative = substr($class, strlen($prefix));
|
||||
$path = __DIR__ . '/' . str_replace('\\', '/', $relative) . '.php';
|
||||
if (is_file($path)) {
|
||||
require $path;
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Convertit les warnings/notices PHP en exceptions (sauf ceux supprimés par @).
|
||||
// Centralise tout le tracking d'erreurs dans le handler de index.php.
|
||||
// -----------------------------------------------------------------------------
|
||||
set_error_handler(static function (int $severity, string $message, string $file, int $line): bool {
|
||||
if (!(error_reporting() & $severity)) {
|
||||
return false;
|
||||
}
|
||||
throw new \ErrorException($message, 0, $severity, $file, $line);
|
||||
});
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env bash
|
||||
# -----------------------------------------------------------------------------
|
||||
# tests/smoke.sh
|
||||
#
|
||||
# Appelle les endpoints clés de l'API et vérifie les codes HTTP attendus.
|
||||
# Sortie non-zéro si au moins un appel échoue.
|
||||
#
|
||||
# Usage :
|
||||
# ADMIN_TOKEN=xxxxx [INFOLAB_URL=http://localhost:8003] ./tests/smoke.sh
|
||||
#
|
||||
# Idempotent : un cleanup best-effort en début de script supprime les ressources
|
||||
# de test (projet "nos-ecoles" + membre "collectif-des-ecoles-de-marseille").
|
||||
# -----------------------------------------------------------------------------
|
||||
set -uo pipefail
|
||||
|
||||
URL="${INFOLAB_URL:-http://localhost:8003}"
|
||||
TOKEN="${ADMIN_TOKEN:-}"
|
||||
|
||||
if [[ -z "$TOKEN" ]]; then
|
||||
echo "ERR: variable d'env ADMIN_TOKEN requise" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
BODY="$(mktemp -t smoke.XXXXXX 2>/dev/null || mktemp)"
|
||||
trap 'rm -f "$BODY"' EXIT
|
||||
|
||||
# Appelle un endpoint et vérifie le code HTTP. Logue un extrait du body.
|
||||
# Args : label expected_code method path [body_json]
|
||||
call() {
|
||||
local label="$1" expected="$2" method="$3" path="$4" body="${5:-}"
|
||||
local -a headers=(-H 'Accept: application/json')
|
||||
if [[ -n "$body" ]]; then
|
||||
headers+=(-H 'Content-Type: application/json')
|
||||
fi
|
||||
if [[ "$method" != "GET" ]]; then
|
||||
headers+=(-H "Authorization: Bearer $TOKEN")
|
||||
fi
|
||||
|
||||
local code
|
||||
if [[ -n "$body" ]]; then
|
||||
# Important : on pipe le body via stdin avec --data-binary @-, pas via argv.
|
||||
# Sur Windows/Git Bash, passer un body UTF-8 en argument à curl convertit
|
||||
# les bytes multi-octets en cp1252 et casse le JSON. stdin = binary safe.
|
||||
code=$(printf '%s' "$body" \
|
||||
| curl -sS -o "$BODY" -w '%{http_code}' -X "$method" "${headers[@]}" --data-binary @- "$URL$path")
|
||||
else
|
||||
code=$(curl -sS -o "$BODY" -w '%{http_code}' -X "$method" "${headers[@]}" "$URL$path")
|
||||
fi
|
||||
|
||||
local snip
|
||||
snip=$(head -c 220 "$BODY" 2>/dev/null || echo '')
|
||||
|
||||
if [[ "$code" == "$expected" ]]; then
|
||||
printf ' OK [%s] %-6s %s — %s\n' "$code" "$method" "$path" "$label"
|
||||
printf ' > %s\n' "$snip"
|
||||
PASS=$((PASS+1))
|
||||
else
|
||||
printf ' FAIL [%s, attendu %s] %-6s %s — %s\n' "$code" "$expected" "$method" "$path" "$label"
|
||||
printf ' > %s\n' "$snip"
|
||||
FAIL=$((FAIL+1))
|
||||
fi
|
||||
}
|
||||
|
||||
# Extrait le 1er "id":N du body courant.
|
||||
extract_id() {
|
||||
sed -n 's/.*"id":[[:space:]]*\([0-9][0-9]*\).*/\1/p' "$BODY" | head -1
|
||||
}
|
||||
|
||||
# Compte les occurrences de "id":N dans le body (approximation suffisante pour les listes).
|
||||
count_ids() {
|
||||
grep -oE '"id":[[:space:]]*[0-9]+' "$BODY" | wc -l | tr -d ' '
|
||||
}
|
||||
|
||||
echo "== Smoke test infolab @ $URL =="
|
||||
|
||||
# Cleanup best-effort : autorise les ré-exécutions.
|
||||
curl -sS -o /dev/null -X DELETE -H "Authorization: Bearer $TOKEN" "$URL/api/projects/nos-ecoles" >/dev/null 2>&1 || true
|
||||
curl -sS -o /dev/null -X DELETE -H "Authorization: Bearer $TOKEN" "$URL/api/members/collectif-des-ecoles-de-marseille" >/dev/null 2>&1 || true
|
||||
|
||||
# 1) Sanity
|
||||
call "Health" 200 GET /api/health
|
||||
|
||||
# 2) Catégories seedées
|
||||
call "Categories list" 200 GET /api/categories
|
||||
count=$(count_ids)
|
||||
if [[ "$count" == "6" ]]; then
|
||||
echo " OK categories.length = 6"
|
||||
PASS=$((PASS+1))
|
||||
else
|
||||
echo " FAIL categories.length = $count (attendu 6)"
|
||||
FAIL=$((FAIL+1))
|
||||
fi
|
||||
|
||||
# Récupère l'id de "education-ecoles" pour ne pas dépendre de l'auto-increment.
|
||||
curl -sS -o "$BODY" "$URL/api/categories/education-ecoles" >/dev/null
|
||||
CAT_ID=$(extract_id)
|
||||
CAT_ID="${CAT_ID:-6}"
|
||||
|
||||
# 3) Création du membre
|
||||
MEMBER_BODY=$(cat <<'JSON'
|
||||
{"display_name":"Collectif des écoles de Marseille","kind":"collective","bio":"Réseau citoyen pour les écoles publiques marseillaises."}
|
||||
JSON
|
||||
)
|
||||
call "Create member" 201 POST /api/members "$MEMBER_BODY"
|
||||
|
||||
# 4) Création du projet (ré-utilise CAT_ID dynamique)
|
||||
PROJECT_BODY=$(cat <<JSON
|
||||
{"title":"Nos Écoles","category_id":$CAT_ID,"status":"active","short_description":"Plateforme citoyenne pour partager l'état des écoles publiques.","context_description":"Issu de la mobilisation citoyenne de 2024.","objectives":"Cartographier, partager, alerter.","progress_percent":25}
|
||||
JSON
|
||||
)
|
||||
call "Create project" 201 POST /api/projects "$PROJECT_BODY"
|
||||
|
||||
# 5) Rattachement du membre au projet
|
||||
call "Add team member" 201 POST /api/projects/nos-ecoles/members \
|
||||
'{"member_slug":"collectif-des-ecoles-de-marseille","role":"admin"}'
|
||||
|
||||
# 6) Étape de roadmap (apostrophe via heredoc pour éviter les soucis de quoting)
|
||||
ROADMAP_BODY=$(cat <<'JSON'
|
||||
{"title":"Création de l'outil 15min","description":"Premier MVP en 15 minutes.","is_done":false}
|
||||
JSON
|
||||
)
|
||||
call "Add roadmap step" 201 POST /api/projects/nos-ecoles/roadmap "$ROADMAP_BODY"
|
||||
|
||||
# 7) Vérifie le détail complet du projet
|
||||
call "Project detail" 200 GET /api/projects/nos-ecoles
|
||||
|
||||
# 8) KPIs et flux d'activité
|
||||
call "Stats" 200 GET /api/stats
|
||||
call "Activity" 200 GET /api/activity
|
||||
|
||||
echo
|
||||
echo "==== Résumé ===="
|
||||
echo " OK : $PASS"
|
||||
echo " FAIL : $FAIL"
|
||||
|
||||
if [[ "$FAIL" -gt 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
Reference in New Issue
Block a user