feat: initial API skeleton — routes, controllers, migrations, deploy docs
Implements the JSON API for Le Donut Infolab per INSTRUCTIONS.md.
Schema (migrations/001-002):
- 8 tables: categories, members, projects, project_members, roadmap_steps,
discussions, resources, activity_log
- 6 categories seeded with colors / dimensions (social, ecologique, transversal)
- FK contraintes + soft delete sur projects, CHECK sur progress_percent
Bare PHP framework (src/):
- Front controller public/index.php + router :params + auth flag par route
- PSR-4 autoload sans Composer (bootstrap.php)
- Database PDO singleton + helpers fetchOne/All/insert/update/delete
- Validator fluent -> 422 {error: validation_failed, fields: {...}}
- Bearer token admin (hash_equals) sur POST/PATCH/DELETE
- Slugger avec unicité (suffixe -2, -3...) via intl Transliterator
- ActivityLogger best-effort sur activity_log
- Erreurs uniformes : 400/401/404/405/409/422/500 + CORS configurable
10 contrôleurs (Health, Categories, Members, Projects, ProjectMembers,
Roadmap, Discussions, Resources, Activity, Stats). Transitions de statut
loggées (created_project, completed_step, joined, etc.).
Cible prod :
- Dockerfile (php:8.3-apache + pdo_mysql + intl + opcache)
- docker-compose.yml sur réseau donut-net, bind 127.0.0.1:8003
- caddy.conf pour infolab.ledonut-marseille.org
- migrations/apply.sh idempotent (table schema_migrations)
- DEPLOY.md avec commandes SSH exactes
Tests :
- tests/smoke.sh exerce 10 endpoints clés, body envoyé via stdin
(--data-binary @-) pour gérer l UTF-8 sous Git Bash Windows.
Vérifié localement sous Laragon Apache + MySQL 8.4 : 10/10 OK + cas
négatifs (401, 404, 405, 409, 422) conformes au contrat.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
SET NAMES utf8mb4;
|
||||
SET FOREIGN_KEY_CHECKS = 0;
|
||||
|
||||
-- categories : 6 catégories thématiques de la maquette
|
||||
DROP TABLE IF EXISTS categories;
|
||||
CREATE TABLE categories (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
slug VARCHAR(64) NOT NULL,
|
||||
name VARCHAR(128) NOT NULL,
|
||||
description TEXT NULL,
|
||||
color_hex CHAR(7) NOT NULL DEFAULT '#888888',
|
||||
icon VARCHAR(32) NULL,
|
||||
dimension ENUM('social', 'ecologique', 'transversal') NOT NULL DEFAULT 'transversal',
|
||||
sort_order INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_categories_slug (slug)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- members : annuaire des contributeurs (pas une table d'auth)
|
||||
DROP TABLE IF EXISTS members;
|
||||
CREATE TABLE members (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
slug VARCHAR(96) NOT NULL,
|
||||
display_name VARCHAR(128) NOT NULL,
|
||||
initials VARCHAR(8) NULL,
|
||||
kind ENUM('person', 'collective', 'org') NOT NULL DEFAULT 'person',
|
||||
bio TEXT NULL,
|
||||
avatar_url VARCHAR(512) NULL,
|
||||
website_url VARCHAR(512) NULL,
|
||||
contact_email VARCHAR(255) NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_members_slug (slug)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- projects : entité centrale
|
||||
DROP TABLE IF EXISTS projects;
|
||||
CREATE TABLE projects (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
slug VARCHAR(160) NOT NULL,
|
||||
title VARCHAR(200) NOT NULL,
|
||||
category_id BIGINT UNSIGNED NOT NULL,
|
||||
status ENUM('active', 'completed', 'archived') NOT NULL DEFAULT 'active',
|
||||
territory VARCHAR(160) NOT NULL DEFAULT 'Marseille',
|
||||
short_description TEXT NULL,
|
||||
context_description LONGTEXT NULL,
|
||||
objectives LONGTEXT NULL,
|
||||
cover_image_url VARCHAR(512) NULL,
|
||||
external_url VARCHAR(512) NULL,
|
||||
progress_percent TINYINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
started_at DATE NULL,
|
||||
completed_at DATE NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
deleted_at DATETIME NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_projects_slug (slug),
|
||||
KEY idx_projects_category (category_id),
|
||||
KEY idx_projects_status (status),
|
||||
KEY idx_projects_deleted (deleted_at),
|
||||
CONSTRAINT fk_projects_category FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE RESTRICT,
|
||||
CONSTRAINT chk_projects_progress CHECK (progress_percent BETWEEN 0 AND 100)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- project_members : pivot N..N projet/contributeur avec rôle
|
||||
DROP TABLE IF EXISTS project_members;
|
||||
CREATE TABLE project_members (
|
||||
project_id BIGINT UNSIGNED NOT NULL,
|
||||
member_id BIGINT UNSIGNED NOT NULL,
|
||||
role ENUM('admin', 'contributor', 'observer') NOT NULL DEFAULT 'contributor',
|
||||
joined_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (project_id, member_id),
|
||||
KEY idx_pm_member (member_id),
|
||||
CONSTRAINT fk_pm_project FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_pm_member FOREIGN KEY (member_id) REFERENCES members(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- roadmap_steps : feuille de route
|
||||
DROP TABLE IF EXISTS roadmap_steps;
|
||||
CREATE TABLE roadmap_steps (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
project_id BIGINT UNSIGNED NOT NULL,
|
||||
title VARCHAR(200) NOT NULL,
|
||||
description TEXT NULL,
|
||||
step_date DATE NULL,
|
||||
is_done BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
sort_order INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_roadmap_project (project_id, sort_order),
|
||||
CONSTRAINT fk_roadmap_project FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- discussions : onglet Discussions, arborescent (parent_id pour les réponses)
|
||||
DROP TABLE IF EXISTS discussions;
|
||||
CREATE TABLE discussions (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
project_id BIGINT UNSIGNED NOT NULL,
|
||||
parent_id BIGINT UNSIGNED NULL,
|
||||
author_name VARCHAR(128) NOT NULL,
|
||||
author_member_id BIGINT UNSIGNED NULL,
|
||||
body LONGTEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_disc_project (project_id, created_at),
|
||||
KEY idx_disc_parent (parent_id),
|
||||
CONSTRAINT fk_disc_project FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_disc_parent FOREIGN KEY (parent_id) REFERENCES discussions(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_disc_member FOREIGN KEY (author_member_id) REFERENCES members(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- resources : onglet Ressources (liens, docs, datasets)
|
||||
DROP TABLE IF EXISTS resources;
|
||||
CREATE TABLE resources (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
project_id BIGINT UNSIGNED NOT NULL,
|
||||
title VARCHAR(200) NOT NULL,
|
||||
description TEXT NULL,
|
||||
url VARCHAR(1024) NOT NULL,
|
||||
kind ENUM('link', 'document', 'dataset', 'video', 'other') NOT NULL DEFAULT 'link',
|
||||
sort_order INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_res_project (project_id, sort_order),
|
||||
CONSTRAINT fk_res_project FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- activity_log : flux du dashboard ("Vous a rejoint le projet X")
|
||||
DROP TABLE IF EXISTS activity_log;
|
||||
CREATE TABLE activity_log (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
project_id BIGINT UNSIGNED NULL,
|
||||
member_id BIGINT UNSIGNED NULL,
|
||||
actor_label VARCHAR(128) NOT NULL DEFAULT 'Vous',
|
||||
verb ENUM('joined', 'left', 'created_project', 'updated_project',
|
||||
'completed_step', 'added_step', 'posted_discussion',
|
||||
'added_resource', 'completed_project') NOT NULL,
|
||||
summary VARCHAR(255) NOT NULL,
|
||||
occurred_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_act_project (project_id, occurred_at),
|
||||
KEY idx_act_occurred (occurred_at),
|
||||
CONSTRAINT fk_act_project FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_act_member FOREIGN KEY (member_id) REFERENCES members(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
@@ -0,0 +1,7 @@
|
||||
INSERT INTO categories (slug, name, description, color_hex, icon, dimension, sort_order) VALUES
|
||||
('data-open-data', 'Data & Open Data', 'Projets autour de la donnée ouverte, de la médiation et de la littératie des données.', '#3B82F6', 'database', 'transversal', 10),
|
||||
('democratie-citoyennete','Démocratie & Citoyenneté', 'Projets favorisant la participation citoyenne, la transparence et la gouvernance ouverte.', '#EF4444', 'users', 'social', 20),
|
||||
('habitat-solidarite', 'Habitat & Solidarité', 'Projets autour du logement, de la fracture numérique et de la solidarité.', '#14B8A6', 'home', 'social', 30),
|
||||
('territoire-urbanisme', 'Territoire & Urbanisme', 'Projets liés à l''aménagement du territoire, la renaturation et la vie locale.', '#F97316', 'map', 'transversal', 40),
|
||||
('ecologie-biodiversite', 'Écologie & Biodiversité', 'Projets liés aux enjeux écologiques, à la biodiversité et à la nature en ville.', '#22C55E', 'leaf', 'ecologique', 50),
|
||||
('education-ecoles', 'Éducation & Écoles', 'Projets autour de l''école publique, de la formation et de l''éducation aux données.', '#A855F7', 'graduation-cap','social', 60);
|
||||
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env bash
|
||||
# -----------------------------------------------------------------------------
|
||||
# migrations/apply.sh
|
||||
#
|
||||
# Applique les migrations SQL au conteneur donut-mariadb sur le serveur Donut.
|
||||
# - Lit /srv/donut/secrets/mariadb.env pour le mot de passe root MariaDB
|
||||
# - Lit /srv/donut/secrets/infolab.env pour DB_USER / DB_PASS / DB_NAME (app user)
|
||||
# - Crée la BDD si absente, le user app si absent (idempotent), refresh le mot de passe
|
||||
# - Trace les migrations appliquées dans la table `schema_migrations` :
|
||||
# chaque .sql n'est appliqué qu'une seule fois (relance => skip)
|
||||
# - Affiche un résumé (tables présentes + count categories)
|
||||
#
|
||||
# Usage :
|
||||
# ./migrations/apply.sh
|
||||
#
|
||||
# Pré-requis sur l'hôte :
|
||||
# - docker disponible
|
||||
# - conteneur `donut-mariadb` en marche
|
||||
# - /srv/donut/secrets/{mariadb,infolab}.env lisibles par l'utilisateur courant
|
||||
# -----------------------------------------------------------------------------
|
||||
set -euo pipefail
|
||||
|
||||
DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
MARIADB_ENV="${MARIADB_ENV:-/srv/donut/secrets/mariadb.env}"
|
||||
INFOLAB_ENV="${INFOLAB_ENV:-/srv/donut/secrets/infolab.env}"
|
||||
CONTAINER="${CONTAINER:-donut-mariadb}"
|
||||
|
||||
require_file() {
|
||||
if [[ ! -r "$1" ]]; then
|
||||
echo "ERR: fichier inaccessible : $1" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
env_get() {
|
||||
# Récupère la dernière occurrence de VAR=... dans un fichier .env (1er = à droite).
|
||||
local file="$1" var="$2"
|
||||
grep -E "^${var}=" "$file" | tail -n1 | cut -d= -f2- || true
|
||||
}
|
||||
|
||||
require_file "$MARIADB_ENV"
|
||||
require_file "$INFOLAB_ENV"
|
||||
|
||||
# Le secret root MariaDB peut être nommé MARIADB_ROOT_PASSWORD ou MYSQL_ROOT_PASSWORD.
|
||||
ROOT_PWD="$(env_get "$MARIADB_ENV" MARIADB_ROOT_PASSWORD)"
|
||||
if [[ -z "$ROOT_PWD" ]]; then
|
||||
ROOT_PWD="$(env_get "$MARIADB_ENV" MYSQL_ROOT_PASSWORD)"
|
||||
fi
|
||||
if [[ -z "$ROOT_PWD" ]]; then
|
||||
echo "ERR: ni MARIADB_ROOT_PASSWORD ni MYSQL_ROOT_PASSWORD trouvé dans $MARIADB_ENV" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
APP_USER="$(env_get "$INFOLAB_ENV" DB_USER)"; APP_USER="${APP_USER:-infolab}"
|
||||
APP_PASS="$(env_get "$INFOLAB_ENV" DB_PASS)"
|
||||
APP_DB="$(env_get "$INFOLAB_ENV" DB_NAME)"; APP_DB="${APP_DB:-infolab}"
|
||||
|
||||
if [[ -z "$APP_PASS" || "$APP_PASS" == "CHANGE_ME_STRONG_PASSWORD" ]]; then
|
||||
echo "ERR: DB_PASS vide ou non personnalisé dans $INFOLAB_ENV" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Wrapper docker exec -> mariadb. Pas d'option -t (on streame du SQL via stdin).
|
||||
mysql_root() {
|
||||
docker exec -i "$CONTAINER" mariadb -uroot -p"$ROOT_PWD" "$@"
|
||||
}
|
||||
|
||||
# Test d'accès root avant d'aller plus loin.
|
||||
if ! mysql_root -e 'SELECT 1' >/dev/null 2>&1; then
|
||||
echo "ERR: impossible de se connecter à $CONTAINER en root. Conteneur up ? mot de passe correct ?" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ">> [1/4] Création de la base \`$APP_DB\` et du user \`$APP_USER\`"
|
||||
mysql_root <<SQL
|
||||
CREATE DATABASE IF NOT EXISTS \`$APP_DB\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
CREATE USER IF NOT EXISTS '$APP_USER'@'%' IDENTIFIED BY '$APP_PASS';
|
||||
ALTER USER '$APP_USER'@'%' IDENTIFIED BY '$APP_PASS';
|
||||
GRANT ALL PRIVILEGES ON \`$APP_DB\`.* TO '$APP_USER'@'%';
|
||||
FLUSH PRIVILEGES;
|
||||
SQL
|
||||
|
||||
echo ">> [2/4] Bootstrap de la table schema_migrations (suivi des .sql appliqués)"
|
||||
mysql_root "$APP_DB" <<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
filename VARCHAR(255) NOT NULL,
|
||||
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (filename)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
SQL
|
||||
|
||||
echo ">> [3/4] Application des migrations dans l'ordre alphabétique"
|
||||
shopt -s nullglob
|
||||
applied=0
|
||||
skipped=0
|
||||
for f in $(ls "$DIR"/*.sql 2>/dev/null | sort); do
|
||||
name="$(basename "$f")"
|
||||
# Skip si déjà appliquée (idempotent — on ne ré-applique pas).
|
||||
already="$(mysql_root "$APP_DB" -Nse "SELECT 1 FROM schema_migrations WHERE filename = '$name' LIMIT 1" 2>/dev/null || true)"
|
||||
if [[ -n "$already" ]]; then
|
||||
echo " -- skip $name (déjà appliquée)"
|
||||
skipped=$((skipped+1))
|
||||
continue
|
||||
fi
|
||||
echo " -- apply $name"
|
||||
mysql_root "$APP_DB" < "$f"
|
||||
mysql_root "$APP_DB" -e "INSERT INTO schema_migrations (filename) VALUES ('$name')"
|
||||
applied=$((applied+1))
|
||||
done
|
||||
|
||||
echo ">> [4/4] Résumé"
|
||||
mysql_root "$APP_DB" <<'SQL'
|
||||
SELECT TABLE_NAME AS `table`
|
||||
FROM information_schema.tables
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
ORDER BY TABLE_NAME;
|
||||
SELECT COUNT(*) AS categories_count FROM categories;
|
||||
SQL
|
||||
|
||||
echo
|
||||
echo "OK — migrations appliquées : $applied / sautées : $skipped"
|
||||
Reference in New Issue
Block a user