diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..eeaadc1 --- /dev/null +++ b/.env.example @@ -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 diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..fdcb4ea --- /dev/null +++ b/DEPLOY.md @@ -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 < /usr/local/etc/php/conf.d/donut.ini + +EXPOSE 80 diff --git a/INSTRUCTIONS.md b/INSTRUCTIONS.md new file mode 100644 index 0000000..f605072 --- /dev/null +++ b/INSTRUCTIONS.md @@ -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//caddy.conf` et importés par `/etc/caddy/Caddyfile`. +- **Convention de nommage des conteneurs** : `donut-` (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 `) 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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f284ca8 --- /dev/null +++ b/README.md @@ -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 `) + +| 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 +``` diff --git a/caddy.conf b/caddy.conf new file mode 100644 index 0000000..98eb387 --- /dev/null +++ b/caddy.conf @@ -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} + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3ff169b --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/migrations/001_init_schema.sql b/migrations/001_init_schema.sql new file mode 100644 index 0000000..f308591 --- /dev/null +++ b/migrations/001_init_schema.sql @@ -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; diff --git a/migrations/002_seed_categories.sql b/migrations/002_seed_categories.sql new file mode 100644 index 0000000..2f0e78a --- /dev/null +++ b/migrations/002_seed_categories.sql @@ -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); diff --git a/migrations/apply.sh b/migrations/apply.sh new file mode 100644 index 0000000..7385bdc --- /dev/null +++ b/migrations/apply.sh @@ -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 <> [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" diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 0000000..9524277 --- /dev/null +++ b/public/.htaccess @@ -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 + + Require all denied + diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..607bf9b --- /dev/null +++ b/public/index.php @@ -0,0 +1,126 @@ +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']); + } +} diff --git a/src/ActivityLogger.php b/src/ActivityLogger.php new file mode 100644 index 0000000..05e2d98 --- /dev/null +++ b/src/ActivityLogger.php @@ -0,0 +1,33 @@ + $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()); + } + } +} diff --git a/src/Auth.php b/src/Auth.php new file mode 100644 index 0000000..9d998ec --- /dev/null +++ b/src/Auth.php @@ -0,0 +1,22 @@ +bearerToken(); + $expected = getenv('ADMIN_TOKEN') ?: ''; + + if ($expected === '' || $token === null || $token === '' || !hash_equals($expected, $token)) { + throw new \ApiException(401, ['error' => 'unauthorized']); + } + } +} diff --git a/src/Controllers/ActivityController.php b/src/Controllers/ActivityController.php new file mode 100644 index 0000000..9573953 --- /dev/null +++ b/src/Controllers/ActivityController.php @@ -0,0 +1,56 @@ +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]); + } +} diff --git a/src/Controllers/CategoriesController.php b/src/Controllers/CategoriesController.php new file mode 100644 index 0000000..2c68cf0 --- /dev/null +++ b/src/Controllers/CategoriesController.php @@ -0,0 +1,149 @@ + 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'], + ]; + } +} diff --git a/src/Controllers/DiscussionsController.php b/src/Controllers/DiscussionsController.php new file mode 100644 index 0000000..79a9ddf --- /dev/null +++ b/src/Controllers/DiscussionsController.php @@ -0,0 +1,174 @@ + (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'], + ]; + } +} diff --git a/src/Controllers/HealthController.php b/src/Controllers/HealthController.php new file mode 100644 index 0000000..9696614 --- /dev/null +++ b/src/Controllers/HealthController.php @@ -0,0 +1,33 @@ +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'), + ]); + } +} diff --git a/src/Controllers/MembersController.php b/src/Controllers/MembersController.php new file mode 100644 index 0000000..26d647a --- /dev/null +++ b/src/Controllers/MembersController.php @@ -0,0 +1,197 @@ + 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 */ + 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'], + ]; + } +} diff --git a/src/Controllers/ProjectMembersController.php b/src/Controllers/ProjectMembersController.php new file mode 100644 index 0000000..ca09f70 --- /dev/null +++ b/src/Controllers/ProjectMembersController.php @@ -0,0 +1,129 @@ + (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(); + } +} diff --git a/src/Controllers/ProjectsController.php b/src/Controllers/ProjectsController.php new file mode 100644 index 0000000..fa14117 --- /dev/null +++ b/src/Controllers/ProjectsController.php @@ -0,0 +1,359 @@ +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 + */ + 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), + ], + ]; + } +} diff --git a/src/Controllers/ResourcesController.php b/src/Controllers/ResourcesController.php new file mode 100644 index 0000000..4f5fb14 --- /dev/null +++ b/src/Controllers/ResourcesController.php @@ -0,0 +1,137 @@ + (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'], + ]; + } +} diff --git a/src/Controllers/RoadmapController.php b/src/Controllers/RoadmapController.php new file mode 100644 index 0000000..ddac3d9 --- /dev/null +++ b/src/Controllers/RoadmapController.php @@ -0,0 +1,161 @@ + (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'], + ]; + } +} diff --git a/src/Controllers/StatsController.php b/src/Controllers/StatsController.php new file mode 100644 index 0000000..4545edb --- /dev/null +++ b/src/Controllers/StatsController.php @@ -0,0 +1,67 @@ + 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, + ]); + } +} diff --git a/src/Database.php b/src/Database.php new file mode 100644 index 0000000..9ee47c9 --- /dev/null +++ b/src/Database.php @@ -0,0 +1,161 @@ + 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 $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 $params + * @return array|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 $params + * @return array> + */ + 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 $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 $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 $data + * @param array $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 $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(); + } +} diff --git a/src/Request.php b/src/Request.php new file mode 100644 index 0000000..d75dabb --- /dev/null +++ b/src/Request.php @@ -0,0 +1,131 @@ + */ + private array $jsonBody; + + /** @var array */ + private array $query; + + /** @var array headers en clés lowercase */ + private array $headers; + + public function __construct() + { + $this->query = $_GET; + $this->headers = $this->loadHeaders(); + $this->jsonBody = $this->loadJsonBody(); + } + + /** @return array */ + 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 */ + 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 */ + public function allQuery(): array + { + return $this->query; + } + + /** @return array 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; + } +} diff --git a/src/Response.php b/src/Response.php new file mode 100644 index 0000000..27c996e --- /dev/null +++ b/src/Response.php @@ -0,0 +1,58 @@ + $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'); + } +} diff --git a/src/Router.php b/src/Router.php new file mode 100644 index 0000000..e69a596 --- /dev/null +++ b/src/Router.php @@ -0,0 +1,105 @@ + 405 method_not_allowed. + * En cas de chemin inconnu -> 404 not_found. Les deux passent par ApiException. + */ +final class Router +{ + /** + * @var array, + * 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,1:array} 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']); + } +} diff --git a/src/Slugger.php b/src/Slugger.php new file mode 100644 index 0000000..7a7e91c --- /dev/null +++ b/src/Slugger.php @@ -0,0 +1,67 @@ + 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; + } +} diff --git a/src/Validator.php b/src/Validator.php new file mode 100644 index 0000000..b76e864 --- /dev/null +++ b/src/Validator.php @@ -0,0 +1,186 @@ + */ + private array $data; + + /** @var array code d'erreur par champ */ + private array $errors = []; + + /** @param array $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 $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 */ + 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, + ]); + } + } +} diff --git a/src/bootstrap.php b/src/bootstrap.php new file mode 100644 index 0000000..1baef7e --- /dev/null +++ b/src/bootstrap.php @@ -0,0 +1,99 @@ + $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 $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); +}); diff --git a/tests/smoke.sh b/tests/smoke.sh new file mode 100644 index 0000000..2b408a6 --- /dev/null +++ b/tests/smoke.sh @@ -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 <