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:
2026-05-28 19:53:50 +02:00
parent 6679fa8b30
commit 3a32e93ab1
32 changed files with 3994 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Infolab;
/**
* Helper d'écriture dans `activity_log`. Best-effort : un échec d'écriture
* (FK morte, BDD inaccessible) ne doit pas casser la requête en cours.
* On loggue dans stderr et on continue.
*/
final class ActivityLogger
{
public static function log(
string $verb,
string $summary,
?int $projectId = null,
?int $memberId = null,
string $actorLabel = 'Vous',
): void {
try {
Database::insert('activity_log', [
'project_id' => $projectId,
'member_id' => $memberId,
'actor_label' => $actorLabel,
'verb' => $verb,
// Tronque à 255 par sécurité — la colonne est en VARCHAR(255).
'summary' => mb_substr($summary, 0, 255),
]);
} catch (\Throwable $e) {
error_log('[ActivityLogger] insert failed: ' . $e->getMessage());
}
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Infolab;
/**
* Auth admin "cachée" : token Bearer comparé en temps constant à $ADMIN_TOKEN.
* Pas d'utilisateur, pas de session. V2 : OIDC via Nextcloud (voir README).
*/
final class Auth
{
/** Lance ApiException 401 si le token est manquant, vide ou ne matche pas. */
public static function requireAdmin(Request $req): void
{
$token = $req->bearerToken();
$expected = getenv('ADMIN_TOKEN') ?: '';
if ($expected === '' || $token === null || $token === '' || !hash_equals($expected, $token)) {
throw new \ApiException(401, ['error' => 'unauthorized']);
}
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Infolab\Controllers;
use Infolab\Database;
use Infolab\Request;
use Infolab\Response;
/**
* GET /api/activity — flux pour le dashboard ("Vous a rejoint le projet X").
* Filtres : ?limit=N (1..100, défaut 20), ?project=slug.
*/
final class ActivityController
{
public function index(Request $req, array $params): void
{
$limit = max(1, min(100, (int) $req->query('limit', 20)));
$where = '1=1';
$args = [];
$projectSlug = $req->query('project');
if ($projectSlug !== null && $projectSlug !== '') {
$pid = Database::fetchValue('SELECT id FROM projects WHERE slug = :s', [':s' => (string) $projectSlug]);
if ($pid === null) {
// Projet inconnu : on renvoie un flux vide (pas d'erreur, le slug peut
// simplement ne pas exister côté serveur — comportement choisi pour
// simplifier le widget côté front).
Response::json(['data' => []]);
return;
}
$where = 'project_id = :pid';
$args[':pid'] = (int) $pid;
}
$rows = Database::fetchAll(
"SELECT id, project_id, member_id, actor_label, verb, summary, occurred_at
FROM activity_log WHERE $where
ORDER BY occurred_at DESC, id DESC LIMIT $limit",
$args,
);
$data = array_map(static fn(array $r): array => [
'id' => (int) $r['id'],
'project_id' => $r['project_id'] === null ? null : (int) $r['project_id'],
'member_id' => $r['member_id'] === null ? null : (int) $r['member_id'],
'actor_label' => $r['actor_label'],
'verb' => $r['verb'],
'summary' => $r['summary'],
'occurred_at' => $r['occurred_at'],
], $rows);
Response::json(['data' => $data]);
}
}
+149
View File
@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
namespace Infolab\Controllers;
use Infolab\Database;
use Infolab\Request;
use Infolab\Response;
use Infolab\Slugger;
use Infolab\Validator;
/**
* CRUD des catégories thématiques (Data, Démocratie, Habitat, …).
* Les catégories sont seedées par migration ; les écritures restent disponibles
* pour ajustements ponctuels (token requis).
*/
final class CategoriesController
{
private const SELECT = 'SELECT id, slug, name, description, color_hex, icon, dimension, sort_order, created_at, updated_at FROM categories';
public function index(Request $req, array $params): void
{
$rows = Database::fetchAll(self::SELECT . ' ORDER BY sort_order ASC, id ASC');
Response::json(['data' => array_map([self::class, 'present'], $rows)]);
}
public function show(Request $req, array $params): void
{
$cat = self::findOr404($params['slug']);
$cat['projects_count'] = (int) Database::fetchValue(
'SELECT COUNT(*) FROM projects WHERE category_id = :id AND deleted_at IS NULL',
[':id' => (int) $cat['id']],
);
Response::json(self::present($cat) + ['projects_count' => $cat['projects_count']]);
}
public function store(Request $req, array $params): void
{
$body = $req->json();
$v = new Validator($body);
$v->required('name')->string('name', 128);
$v->string('slug', 64);
$v->string('description', 5000);
$v->colorHex('color_hex');
$v->string('icon', 32);
$v->in('dimension', ['social', 'ecologique', 'transversal']);
$v->integer('sort_order', 0);
$v->check();
$name = (string) $body['name'];
$base = (isset($body['slug']) && $body['slug'] !== '')
? Slugger::slugify((string) $body['slug'])
: Slugger::slugify($name);
$slug = Slugger::unique($base, 'categories');
Database::insert('categories', [
'slug' => $slug,
'name' => $name,
'description' => $body['description'] ?? null,
'color_hex' => $body['color_hex'] ?? '#888888',
'icon' => $body['icon'] ?? null,
'dimension' => $body['dimension'] ?? 'transversal',
'sort_order' => isset($body['sort_order']) ? (int) $body['sort_order'] : 0,
]);
Response::json(self::present(self::findOr404($slug)), 201);
}
public function update(Request $req, array $params): void
{
$cat = self::findOr404($params['slug']);
$body = $req->json();
$v = new Validator($body);
$v->string('name', 128);
$v->string('slug', 64);
$v->string('description', 5000);
$v->colorHex('color_hex');
$v->string('icon', 32);
$v->in('dimension', ['social', 'ecologique', 'transversal']);
$v->integer('sort_order', 0);
$v->check();
$update = [];
foreach (['name', 'description', 'color_hex', 'icon', 'dimension', 'sort_order'] as $f) {
if (array_key_exists($f, $body)) {
$update[$f] = $body[$f];
}
}
if (isset($update['sort_order'])) {
$update['sort_order'] = (int) $update['sort_order'];
}
if (isset($body['slug']) && $body['slug'] !== '') {
$update['slug'] = Slugger::unique(
Slugger::slugify((string) $body['slug']),
'categories',
'slug',
(int) $cat['id'],
);
}
if ($update !== []) {
Database::update('categories', $update, 'id = :id', [':id' => (int) $cat['id']]);
}
Response::json(self::present(self::findOr404($update['slug'] ?? $cat['slug'])));
}
public function destroy(Request $req, array $params): void
{
$cat = self::findOr404($params['slug']);
// FK ON DELETE RESTRICT côté projects -> on vérifie nous-mêmes pour un
// message propre. Compte aussi les projets soft-deletés (la FK ne fait
// pas la différence et bloquerait sinon en cascade).
$count = (int) Database::fetchValue(
'SELECT COUNT(*) FROM projects WHERE category_id = :id',
[':id' => (int) $cat['id']],
);
if ($count > 0) {
throw new \ApiException(409, [
'error' => 'conflict',
'message' => sprintf('Cette catégorie est utilisée par %d projet(s).', $count),
]);
}
Database::delete('categories', 'id = :id', [':id' => (int) $cat['id']]);
Response::noContent();
}
private static function findOr404(string $slug): array
{
$row = Database::fetchOne(self::SELECT . ' WHERE slug = :slug', [':slug' => $slug]);
if ($row === null) {
throw new \ApiException(404, ['error' => 'not_found']);
}
return $row;
}
private static function present(array $row): array
{
return [
'id' => (int) $row['id'],
'slug' => $row['slug'],
'name' => $row['name'],
'description' => $row['description'],
'color_hex' => $row['color_hex'],
'icon' => $row['icon'],
'dimension' => $row['dimension'],
'sort_order' => (int) $row['sort_order'],
'created_at' => $row['created_at'],
'updated_at' => $row['updated_at'],
];
}
}
+174
View File
@@ -0,0 +1,174 @@
<?php
declare(strict_types=1);
namespace Infolab\Controllers;
use Infolab\ActivityLogger;
use Infolab\Database;
use Infolab\Request;
use Infolab\Response;
use Infolab\Validator;
/**
* Messages d'un projet, arborescence à 1 niveau (parent_id pour les réponses).
* Liste renvoyée en arbre : racines DESC date, réponses ASC date sous chaque racine.
*/
final class DiscussionsController
{
public function index(Request $req, array $params): void
{
$p = ProjectsController::findOr404($params['slug']);
$roots = Database::fetchAll(
'SELECT id, project_id, parent_id, author_name, author_member_id, body, created_at, updated_at
FROM discussions WHERE project_id = :pid AND parent_id IS NULL
ORDER BY created_at DESC, id DESC',
[':pid' => (int) $p['id']],
);
if ($roots === []) {
Response::json(['data' => []]);
return;
}
// 1 requête en plus pour toutes les réponses, regroupées par parent_id.
$rootIds = array_map(static fn(array $r): int => (int) $r['id'], $roots);
$in = implode(',', array_fill(0, count($rootIds), '?'));
$replies = Database::fetchAll(
"SELECT id, project_id, parent_id, author_name, author_member_id, body, created_at, updated_at
FROM discussions WHERE parent_id IN ($in) ORDER BY created_at ASC, id ASC",
$rootIds,
);
$byParent = [];
foreach ($replies as $r) {
$byParent[(int) $r['parent_id']][] = self::present($r);
}
$tree = [];
foreach ($roots as $r) {
$node = self::present($r);
$node['replies'] = $byParent[(int) $r['id']] ?? [];
$tree[] = $node;
}
Response::json(['data' => $tree]);
}
public function store(Request $req, array $params): void
{
$p = ProjectsController::findOr404($params['slug']);
$body = $req->json();
$v = new Validator($body);
$v->required('author_name')->string('author_name', 128);
$v->required('body')->string('body');
$v->integer('parent_id', 1);
$v->integer('author_member_id', 1);
$v->check();
$parentId = null;
if (isset($body['parent_id'])) {
$parent = Database::fetchOne(
'SELECT id, project_id FROM discussions WHERE id = :id',
[':id' => (int) $body['parent_id']],
);
if ($parent === null || (int) $parent['project_id'] !== (int) $p['id']) {
$v->setError('parent_id', 'not_found');
$v->check();
}
$parentId = (int) $parent['id'];
}
$authorMemberId = null;
if (isset($body['author_member_id'])) {
$exists = Database::fetchValue(
'SELECT 1 FROM members WHERE id = :id',
[':id' => (int) $body['author_member_id']],
);
if ($exists === null) {
$v->setError('author_member_id', 'not_found');
$v->check();
}
$authorMemberId = (int) $body['author_member_id'];
}
$id = Database::insert('discussions', [
'project_id' => (int) $p['id'],
'parent_id' => $parentId,
'author_name' => (string) $body['author_name'],
'author_member_id' => $authorMemberId,
'body' => (string) $body['body'],
]);
ActivityLogger::log(
'posted_discussion',
sprintf('a publié un message dans « %s »', $p['title']),
(int) $p['id'],
$authorMemberId,
(string) $body['author_name'],
);
$row = Database::fetchOne(
'SELECT * FROM discussions WHERE id = :id',
[':id' => $id],
);
Response::json(self::present($row), 201);
}
public function update(Request $req, array $params): void
{
$p = ProjectsController::findOr404($params['slug']);
$msg = self::findMsgOr404((int) $params['id'], (int) $p['id']);
$body = $req->json();
$v = new Validator($body);
$v->string('author_name', 128);
$v->string('body');
$v->check();
$update = [];
foreach (['author_name', 'body'] as $f) {
if (array_key_exists($f, $body)) {
$update[$f] = $body[$f];
}
}
if ($update !== []) {
Database::update('discussions', $update, 'id = :id', [':id' => (int) $msg['id']]);
}
$fresh = Database::fetchOne('SELECT * FROM discussions WHERE id = :id', [':id' => (int) $msg['id']]);
Response::json(self::present($fresh));
}
public function destroy(Request $req, array $params): void
{
$p = ProjectsController::findOr404($params['slug']);
$msg = self::findMsgOr404((int) $params['id'], (int) $p['id']);
// FK parent_id ON DELETE CASCADE -> les réponses partent avec.
Database::delete('discussions', 'id = :id', [':id' => (int) $msg['id']]);
Response::noContent();
}
private static function findMsgOr404(int $id, int $projectId): array
{
$row = Database::fetchOne(
'SELECT * FROM discussions WHERE id = :id AND project_id = :pid',
[':id' => $id, ':pid' => $projectId],
);
if ($row === null) {
throw new \ApiException(404, ['error' => 'not_found']);
}
return $row;
}
private static function present(array $row): array
{
return [
'id' => (int) $row['id'],
'project_id' => (int) $row['project_id'],
'parent_id' => $row['parent_id'] === null ? null : (int) $row['parent_id'],
'author_name' => $row['author_name'],
'author_member_id' => $row['author_member_id'] === null ? null : (int) $row['author_member_id'],
'body' => $row['body'],
'created_at' => $row['created_at'],
'updated_at' => $row['updated_at'],
];
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Infolab\Controllers;
use Infolab\Database;
use Infolab\Request;
use Infolab\Response;
/**
* GET /api/health
* Sonde de vie de l'API + ping rapide BDD. Renvoie 503 si la BDD ne répond pas.
*/
final class HealthController
{
public function index(Request $req, array $params): void
{
try {
Database::fetchValue('SELECT 1');
} catch (\Throwable $e) {
error_log('[Health] DB ping failed: ' . $e->getMessage());
Response::json([
'status' => 'degraded',
'time' => gmdate('Y-m-d\TH:i:s\Z'),
], 503);
return;
}
Response::json([
'status' => 'ok',
'time' => gmdate('Y-m-d\TH:i:s\Z'),
]);
}
}
+197
View File
@@ -0,0 +1,197 @@
<?php
declare(strict_types=1);
namespace Infolab\Controllers;
use Infolab\Database;
use Infolab\Request;
use Infolab\Response;
use Infolab\Slugger;
use Infolab\Validator;
/**
* CRUD des membres (annuaire des contributeurs). Pas une table d'auth.
*/
final class MembersController
{
private const SELECT = 'SELECT id, slug, display_name, initials, kind, bio, avatar_url, website_url, contact_email, created_at, updated_at FROM members';
public function index(Request $req, array $params): void
{
[$page, $perPage, $offset] = self::pagination($req);
$total = (int) Database::fetchValue('SELECT COUNT(*) FROM members');
// LIMIT/OFFSET interpolés depuis des entiers validés -> safe.
$rows = Database::fetchAll(self::SELECT . " ORDER BY display_name ASC LIMIT $perPage OFFSET $offset");
Response::json([
'data' => array_map([self::class, 'present'], $rows),
'meta' => self::meta($page, $perPage, $total),
]);
}
public function show(Request $req, array $params): void
{
$m = self::findOr404($params['slug']);
$projects = Database::fetchAll(
'SELECT p.id, p.slug, p.title, p.status, p.progress_percent,
c.slug AS category_slug, c.name AS category_name, c.color_hex AS category_color_hex,
pm.role, pm.joined_at
FROM project_members pm
INNER JOIN projects p ON p.id = pm.project_id AND p.deleted_at IS NULL
INNER JOIN categories c ON c.id = p.category_id
WHERE pm.member_id = :id
ORDER BY p.title ASC',
[':id' => (int) $m['id']],
);
$out = self::present($m);
$out['projects'] = array_map(static fn(array $r): array => [
'id' => (int) $r['id'],
'slug' => $r['slug'],
'title' => $r['title'],
'status' => $r['status'],
'progress_percent' => (int) $r['progress_percent'],
'role' => $r['role'],
'joined_at' => $r['joined_at'],
'category' => [
'slug' => $r['category_slug'],
'name' => $r['category_name'],
'color_hex' => $r['category_color_hex'],
],
], $projects);
Response::json($out);
}
public function store(Request $req, array $params): void
{
$body = $req->json();
$v = new Validator($body);
$v->required('display_name')->string('display_name', 128);
$v->string('slug', 96);
$v->string('initials', 8);
$v->in('kind', ['person', 'collective', 'org']);
$v->string('bio', 5000);
$v->url('avatar_url', 512);
$v->url('website_url', 512);
$v->email('contact_email');
$v->check();
$name = (string) $body['display_name'];
$base = (isset($body['slug']) && $body['slug'] !== '')
? Slugger::slugify((string) $body['slug'])
: Slugger::slugify($name);
$slug = Slugger::unique($base, 'members');
Database::insert('members', [
'slug' => $slug,
'display_name' => $name,
'initials' => $body['initials'] ?? self::autoInitials($name),
'kind' => $body['kind'] ?? 'person',
'bio' => $body['bio'] ?? null,
'avatar_url' => $body['avatar_url'] ?? null,
'website_url' => $body['website_url'] ?? null,
'contact_email' => $body['contact_email'] ?? null,
]);
Response::json(self::present(self::findOr404($slug)), 201);
}
public function update(Request $req, array $params): void
{
$m = self::findOr404($params['slug']);
$body = $req->json();
$v = new Validator($body);
$v->string('display_name', 128);
$v->string('slug', 96);
$v->string('initials', 8);
$v->in('kind', ['person', 'collective', 'org']);
$v->string('bio', 5000);
$v->url('avatar_url', 512);
$v->url('website_url', 512);
$v->email('contact_email');
$v->check();
$update = [];
foreach (['display_name', 'initials', 'kind', 'bio', 'avatar_url', 'website_url', 'contact_email'] as $f) {
if (array_key_exists($f, $body)) {
$update[$f] = $body[$f];
}
}
if (isset($body['slug']) && $body['slug'] !== '') {
$update['slug'] = Slugger::unique(
Slugger::slugify((string) $body['slug']),
'members',
'slug',
(int) $m['id'],
);
}
if ($update !== []) {
Database::update('members', $update, 'id = :id', [':id' => (int) $m['id']]);
}
Response::json(self::present(self::findOr404($update['slug'] ?? $m['slug'])));
}
public function destroy(Request $req, array $params): void
{
$m = self::findOr404($params['slug']);
// FK : project_members CASCADE, discussions/activity_log SET NULL -> hard delete OK.
Database::delete('members', 'id = :id', [':id' => (int) $m['id']]);
Response::noContent();
}
/** Helper public : utilisé par d'autres contrôleurs (ProjectMembers, etc.). */
public static function findOr404(string $slug): array
{
$row = Database::fetchOne(self::SELECT . ' WHERE slug = :slug', [':slug' => $slug]);
if ($row === null) {
throw new \ApiException(404, ['error' => 'not_found']);
}
return $row;
}
/** Génère 1 à 3 initiales depuis un nom complet ("Jean Dupont" -> "JD"). */
private static function autoInitials(string $name): string
{
$parts = preg_split('/\s+/', trim($name)) ?: [];
$letters = '';
foreach ($parts as $p) {
if ($p === '') continue;
$letters .= mb_strtoupper(mb_substr($p, 0, 1));
if (mb_strlen($letters) >= 3) break;
}
return $letters !== '' ? mb_substr($letters, 0, 8) : '?';
}
/** @return array{0:int,1:int,2:int} [page, per_page, offset] */
private static function pagination(Request $req): array
{
$page = max(1, (int) $req->query('page', 1));
$perPage = max(1, min(100, (int) $req->query('per_page', 20)));
return [$page, $perPage, ($page - 1) * $perPage];
}
/** @return array<string,int> */
private static function meta(int $page, int $perPage, int $total): array
{
return [
'page' => $page,
'per_page' => $perPage,
'total' => $total,
'total_pages' => $total === 0 ? 0 : (int) ceil($total / $perPage),
];
}
private static function present(array $row): array
{
return [
'id' => (int) $row['id'],
'slug' => $row['slug'],
'display_name' => $row['display_name'],
'initials' => $row['initials'],
'kind' => $row['kind'],
'bio' => $row['bio'],
'avatar_url' => $row['avatar_url'],
'website_url' => $row['website_url'],
'contact_email' => $row['contact_email'],
'created_at' => $row['created_at'],
'updated_at' => $row['updated_at'],
];
}
}
@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
namespace Infolab\Controllers;
use Infolab\ActivityLogger;
use Infolab\Database;
use Infolab\Request;
use Infolab\Response;
use Infolab\Validator;
/**
* Gestion de l'équipe d'un projet : pivot project_members avec un rôle.
*/
final class ProjectMembersController
{
public function index(Request $req, array $params): void
{
$p = ProjectsController::findOr404($params['slug']);
$rows = Database::fetchAll(
'SELECT m.id, m.slug, m.display_name, m.initials, m.kind, m.avatar_url, m.website_url,
pm.role, pm.joined_at
FROM project_members pm
INNER JOIN members m ON m.id = pm.member_id
WHERE pm.project_id = :pid
ORDER BY pm.joined_at ASC',
[':pid' => (int) $p['id']],
);
$data = array_map(static fn(array $r): array => [
'id' => (int) $r['id'],
'slug' => $r['slug'],
'display_name' => $r['display_name'],
'initials' => $r['initials'],
'kind' => $r['kind'],
'avatar_url' => $r['avatar_url'],
'website_url' => $r['website_url'],
'role' => $r['role'],
'joined_at' => $r['joined_at'],
], $rows);
Response::json(['data' => $data]);
}
public function store(Request $req, array $params): void
{
$p = ProjectsController::findOr404($params['slug']);
$body = $req->json();
$v = new Validator($body);
$v->required('member_slug')->string('member_slug', 96);
$v->in('role', ['admin', 'contributor', 'observer']);
$v->check();
$member = Database::fetchOne(
'SELECT id, slug, display_name FROM members WHERE slug = :s',
[':s' => (string) $body['member_slug']],
);
if ($member === null) {
$v->setError('member_slug', 'not_found');
$v->check();
}
$role = $body['role'] ?? 'contributor';
// PK (project_id, member_id) -> on vérifie pour un 409 propre plutôt qu'une SQLException.
$exists = Database::fetchValue(
'SELECT 1 FROM project_members WHERE project_id = :pid AND member_id = :mid',
[':pid' => (int) $p['id'], ':mid' => (int) $member['id']],
);
if ($exists !== null) {
throw new \ApiException(409, [
'error' => 'conflict',
'message' => 'Ce membre fait déjà partie du projet.',
]);
}
Database::insert('project_members', [
'project_id' => (int) $p['id'],
'member_id' => (int) $member['id'],
'role' => $role,
]);
ActivityLogger::log(
'joined',
sprintf('a rejoint le projet « %s »', $p['title']),
(int) $p['id'],
(int) $member['id'],
(string) $member['display_name'],
);
Response::json([
'project_id' => (int) $p['id'],
'member' => [
'id' => (int) $member['id'],
'slug' => $member['slug'],
'display_name' => $member['display_name'],
],
'role' => $role,
'joined_at' => gmdate('Y-m-d H:i:s'),
], 201);
}
public function destroy(Request $req, array $params): void
{
$p = ProjectsController::findOr404($params['slug']);
$member = Database::fetchOne(
'SELECT id, slug, display_name FROM members WHERE slug = :s',
[':s' => $params['member_slug']],
);
if ($member === null) {
throw new \ApiException(404, ['error' => 'not_found']);
}
$deleted = Database::delete(
'project_members',
'project_id = :pid AND member_id = :mid',
[':pid' => (int) $p['id'], ':mid' => (int) $member['id']],
);
if ($deleted === 0) {
throw new \ApiException(404, ['error' => 'not_found']);
}
ActivityLogger::log(
'left',
sprintf('a quitté le projet « %s »', $p['title']),
(int) $p['id'],
(int) $member['id'],
(string) $member['display_name'],
);
Response::noContent();
}
}
+359
View File
@@ -0,0 +1,359 @@
<?php
declare(strict_types=1);
namespace Infolab\Controllers;
use Infolab\ActivityLogger;
use Infolab\Database;
use Infolab\Request;
use Infolab\Response;
use Infolab\Slugger;
use Infolab\Validator;
/**
* CRUD projets — entité centrale. Soft delete via `deleted_at`.
* Les sous-ressources (roadmap, members, discussions, resources) ont leurs
* propres contrôleurs, mais s'appuient sur ProjectsController::findOr404().
*/
final class ProjectsController
{
public function index(Request $req, array $params): void
{
$page = max(1, (int) $req->query('page', 1));
$perPage = max(1, min(100, (int) $req->query('per_page', 20)));
$offset = ($page - 1) * $perPage;
// Construction dynamique du WHERE — chaque valeur est bindée.
$where = ['p.deleted_at IS NULL'];
$args = [];
if (null !== ($cat = $req->query('category')) && $cat !== '') {
$where[] = 'c.slug = :cat';
$args[':cat'] = (string) $cat;
}
if (null !== ($status = $req->query('status')) && $status !== '') {
if (!in_array($status, ['active', 'completed', 'archived'], true)) {
throw new \ApiException(400, ['error' => 'bad_request', 'message' => 'status invalide']);
}
$where[] = 'p.status = :status';
$args[':status'] = (string) $status;
}
if (null !== ($q = $req->query('q')) && $q !== '') {
// Échappe les wildcards SQL avant le LIKE.
$like = '%' . str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], (string) $q) . '%';
$where[] = '(p.title LIKE :q OR p.short_description LIKE :q)';
$args[':q'] = $like;
}
$whereSql = implode(' AND ', $where);
$total = (int) Database::fetchValue(
"SELECT COUNT(*) FROM projects p INNER JOIN categories c ON c.id = p.category_id WHERE $whereSql",
$args,
);
$rows = Database::fetchAll(
"SELECT p.id, p.slug, p.title, p.status, p.territory, p.short_description,
p.cover_image_url, p.external_url, p.progress_percent,
p.started_at, p.completed_at, p.created_at, p.updated_at,
c.id AS category_id, c.slug AS category_slug, c.name AS category_name,
c.color_hex AS category_color_hex, c.dimension AS category_dimension
FROM projects p
INNER JOIN categories c ON c.id = p.category_id
WHERE $whereSql
ORDER BY p.created_at DESC, p.id DESC
LIMIT $perPage OFFSET $offset",
$args,
);
Response::json([
'data' => array_map([self::class, 'presentListRow'], $rows),
'meta' => [
'page' => $page,
'per_page' => $perPage,
'total' => $total,
'total_pages' => $total === 0 ? 0 : (int) ceil($total / $perPage),
],
]);
}
public function show(Request $req, array $params): void
{
Response::json(self::presentDetail(self::findOr404($params['slug'])));
}
public function store(Request $req, array $params): void
{
$body = $req->json();
$v = new Validator($body);
$v->required('title')->string('title', 200);
$v->required('category_id')->integer('category_id', 1);
$v->string('slug', 160);
$v->in('status', ['active', 'completed', 'archived']);
$v->string('territory', 160);
$v->string('short_description', 5000);
$v->string('context_description');
$v->string('objectives');
$v->url('cover_image_url', 512);
$v->url('external_url', 512);
$v->integer('progress_percent', 0, 100);
$v->date('started_at');
$v->date('completed_at');
// Lookup category_id seulement si le format est OK pour éviter le bruit.
if ($v->has('category_id') && !$v->fails()) {
$exists = Database::fetchValue(
'SELECT 1 FROM categories WHERE id = :id',
[':id' => (int) $v->value('category_id')],
);
if ($exists === null) {
$v->setError('category_id', 'not_found');
}
}
$v->check();
$title = (string) $body['title'];
$base = (isset($body['slug']) && $body['slug'] !== '')
? Slugger::slugify((string) $body['slug'])
: Slugger::slugify($title);
$slug = Slugger::unique($base, 'projects');
$id = Database::insert('projects', [
'slug' => $slug,
'title' => $title,
'category_id' => (int) $body['category_id'],
'status' => $body['status'] ?? 'active',
'territory' => $body['territory'] ?? 'Marseille',
'short_description' => $body['short_description'] ?? null,
'context_description' => $body['context_description'] ?? null,
'objectives' => $body['objectives'] ?? null,
'cover_image_url' => $body['cover_image_url'] ?? null,
'external_url' => $body['external_url'] ?? null,
'progress_percent' => isset($body['progress_percent']) ? (int) $body['progress_percent'] : 0,
'started_at' => $body['started_at'] ?? null,
'completed_at' => $body['completed_at'] ?? null,
]);
ActivityLogger::log(
'created_project',
sprintf('a créé le projet « %s »', $title),
$id,
);
Response::json(self::presentDetail(self::findOr404($slug)), 201);
}
public function update(Request $req, array $params): void
{
$p = self::findOr404($params['slug']);
$body = $req->json();
$v = new Validator($body);
$v->string('title', 200);
$v->string('slug', 160);
$v->integer('category_id', 1);
$v->in('status', ['active', 'completed', 'archived']);
$v->string('territory', 160);
$v->string('short_description', 5000);
$v->string('context_description');
$v->string('objectives');
$v->url('cover_image_url', 512);
$v->url('external_url', 512);
$v->integer('progress_percent', 0, 100);
$v->date('started_at');
$v->date('completed_at');
if (array_key_exists('category_id', $body) && !$v->fails()) {
$exists = Database::fetchValue(
'SELECT 1 FROM categories WHERE id = :id',
[':id' => (int) $body['category_id']],
);
if ($exists === null) {
$v->setError('category_id', 'not_found');
}
}
$v->check();
$update = [];
$fields = ['title','category_id','status','territory','short_description','context_description',
'objectives','cover_image_url','external_url','progress_percent','started_at','completed_at'];
foreach ($fields as $f) {
if (array_key_exists($f, $body)) {
$update[$f] = $body[$f];
}
}
if (isset($update['progress_percent'])) $update['progress_percent'] = (int) $update['progress_percent'];
if (isset($update['category_id'])) $update['category_id'] = (int) $update['category_id'];
if (isset($body['slug']) && $body['slug'] !== '') {
$update['slug'] = Slugger::unique(
Slugger::slugify((string) $body['slug']),
'projects',
'slug',
(int) $p['id'],
);
}
// Transition status -> completed : remplit completed_at si vide.
$becameCompleted = false;
if (isset($update['status']) && $update['status'] === 'completed' && $p['status'] !== 'completed') {
$becameCompleted = true;
if (!array_key_exists('completed_at', $update) || $update['completed_at'] === null || $update['completed_at'] === '') {
$update['completed_at'] = gmdate('Y-m-d');
}
}
if ($update !== []) {
Database::update('projects', $update, 'id = :id', [':id' => (int) $p['id']]);
}
$title = $update['title'] ?? $p['title'];
ActivityLogger::log(
'updated_project',
sprintf('a mis à jour le projet « %s »', $title),
(int) $p['id'],
);
if ($becameCompleted) {
ActivityLogger::log(
'completed_project',
sprintf('a terminé le projet « %s »', $title),
(int) $p['id'],
);
}
Response::json(self::presentDetail(self::findOr404($update['slug'] ?? $p['slug'])));
}
public function destroy(Request $req, array $params): void
{
$p = self::findOr404($params['slug']);
// Soft delete : on garde l'historique, on cache de la liste publique.
Database::update(
'projects',
['deleted_at' => gmdate('Y-m-d H:i:s')],
'id = :id',
[':id' => (int) $p['id']],
);
Response::noContent();
}
/**
* Helper public : récupère un projet non soft-deleted par son slug, ou 404.
* Utilisé par tous les sous-contrôleurs (Roadmap, ProjectMembers, etc.).
*
* @return array<string,mixed>
*/
public static function findOr404(string $slug): array
{
$row = Database::fetchOne(
'SELECT * FROM projects WHERE slug = :slug AND deleted_at IS NULL',
[':slug' => $slug],
);
if ($row === null) {
throw new \ApiException(404, ['error' => 'not_found']);
}
return $row;
}
/** Ligne de liste (projet + catégorie jointe). */
private static function presentListRow(array $row): array
{
return [
'id' => (int) $row['id'],
'slug' => $row['slug'],
'title' => $row['title'],
'status' => $row['status'],
'territory' => $row['territory'],
'short_description' => $row['short_description'],
'cover_image_url' => $row['cover_image_url'],
'external_url' => $row['external_url'],
'progress_percent' => (int) $row['progress_percent'],
'started_at' => $row['started_at'],
'completed_at' => $row['completed_at'],
'created_at' => $row['created_at'],
'updated_at' => $row['updated_at'],
'category' => [
'id' => (int) $row['category_id'],
'slug' => $row['category_slug'],
'name' => $row['category_name'],
'color_hex' => $row['category_color_hex'],
'dimension' => $row['category_dimension'],
],
];
}
/** Détail complet d'un projet : catégorie, membres, roadmap, compteurs. */
private static function presentDetail(array $p): array
{
$pid = (int) $p['id'];
$cat = Database::fetchOne(
'SELECT id, slug, name, color_hex, icon, dimension FROM categories WHERE id = :id',
[':id' => (int) $p['category_id']],
);
$members = Database::fetchAll(
'SELECT m.id, m.slug, m.display_name, m.initials, m.kind, m.avatar_url,
pm.role, pm.joined_at
FROM project_members pm
INNER JOIN members m ON m.id = pm.member_id
WHERE pm.project_id = :pid
ORDER BY pm.joined_at ASC',
[':pid' => $pid],
);
$roadmap = Database::fetchAll(
'SELECT id, title, description, step_date, is_done, sort_order, created_at, updated_at
FROM roadmap_steps WHERE project_id = :pid ORDER BY sort_order ASC, id ASC',
[':pid' => $pid],
);
foreach ($roadmap as &$s) {
$s['id'] = (int) $s['id'];
$s['is_done'] = (bool) $s['is_done'];
$s['sort_order'] = (int) $s['sort_order'];
}
unset($s);
$discCount = (int) Database::fetchValue('SELECT COUNT(*) FROM discussions WHERE project_id = :pid', [':pid' => $pid]);
$resCount = (int) Database::fetchValue('SELECT COUNT(*) FROM resources WHERE project_id = :pid', [':pid' => $pid]);
return [
'id' => $pid,
'slug' => $p['slug'],
'title' => $p['title'],
'status' => $p['status'],
'territory' => $p['territory'],
'short_description' => $p['short_description'],
'context_description' => $p['context_description'],
'objectives' => $p['objectives'],
'cover_image_url' => $p['cover_image_url'],
'external_url' => $p['external_url'],
'progress_percent' => (int) $p['progress_percent'],
'started_at' => $p['started_at'],
'completed_at' => $p['completed_at'],
'created_at' => $p['created_at'],
'updated_at' => $p['updated_at'],
'category' => $cat === null ? null : [
'id' => (int) $cat['id'],
'slug' => $cat['slug'],
'name' => $cat['name'],
'color_hex' => $cat['color_hex'],
'icon' => $cat['icon'],
'dimension' => $cat['dimension'],
],
'members' => array_map(static fn(array $m): array => [
'id' => (int) $m['id'],
'slug' => $m['slug'],
'display_name' => $m['display_name'],
'initials' => $m['initials'],
'kind' => $m['kind'],
'avatar_url' => $m['avatar_url'],
'role' => $m['role'],
'joined_at' => $m['joined_at'],
], $members),
'roadmap' => $roadmap,
'counts' => [
'discussions' => $discCount,
'resources' => $resCount,
'members' => count($members),
],
];
}
}
+137
View File
@@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
namespace Infolab\Controllers;
use Infolab\ActivityLogger;
use Infolab\Database;
use Infolab\Request;
use Infolab\Response;
use Infolab\Validator;
/**
* Ressources d'un projet (liens, docs, datasets, vidéos).
*/
final class ResourcesController
{
private const KINDS = ['link', 'document', 'dataset', 'video', 'other'];
public function index(Request $req, array $params): void
{
$p = ProjectsController::findOr404($params['slug']);
$rows = Database::fetchAll(
'SELECT id, project_id, title, description, url, kind, sort_order, created_at, updated_at
FROM resources WHERE project_id = :pid ORDER BY sort_order ASC, id ASC',
[':pid' => (int) $p['id']],
);
Response::json(['data' => array_map([self::class, 'present'], $rows)]);
}
public function store(Request $req, array $params): void
{
$p = ProjectsController::findOr404($params['slug']);
$body = $req->json();
$v = new Validator($body);
$v->required('title')->string('title', 200);
$v->required('url')->url('url', 1024);
$v->string('description', 5000);
$v->in('kind', self::KINDS);
$v->integer('sort_order', 0);
$v->check();
$sortOrder = isset($body['sort_order'])
? (int) $body['sort_order']
: self::nextSortOrder((int) $p['id']);
$id = Database::insert('resources', [
'project_id' => (int) $p['id'],
'title' => (string) $body['title'],
'description' => $body['description'] ?? null,
'url' => (string) $body['url'],
'kind' => $body['kind'] ?? 'link',
'sort_order' => $sortOrder,
]);
ActivityLogger::log(
'added_resource',
sprintf('a ajouté la ressource « %s » au projet « %s »', (string) $body['title'], $p['title']),
(int) $p['id'],
);
$row = Database::fetchOne('SELECT * FROM resources WHERE id = :id', [':id' => $id]);
Response::json(self::present($row), 201);
}
public function update(Request $req, array $params): void
{
$p = ProjectsController::findOr404($params['slug']);
$r = self::findOr404((int) $params['id'], (int) $p['id']);
$body = $req->json();
$v = new Validator($body);
$v->string('title', 200);
$v->string('description', 5000);
$v->url('url', 1024);
$v->in('kind', self::KINDS);
$v->integer('sort_order', 0);
$v->check();
$update = [];
foreach (['title', 'description', 'url', 'kind', 'sort_order'] as $f) {
if (array_key_exists($f, $body)) {
$update[$f] = $body[$f];
}
}
if (isset($update['sort_order'])) {
$update['sort_order'] = (int) $update['sort_order'];
}
if ($update !== []) {
Database::update('resources', $update, 'id = :id', [':id' => (int) $r['id']]);
}
$fresh = Database::fetchOne('SELECT * FROM resources WHERE id = :id', [':id' => (int) $r['id']]);
Response::json(self::present($fresh));
}
public function destroy(Request $req, array $params): void
{
$p = ProjectsController::findOr404($params['slug']);
$r = self::findOr404((int) $params['id'], (int) $p['id']);
Database::delete('resources', 'id = :id', [':id' => (int) $r['id']]);
Response::noContent();
}
private static function findOr404(int $id, int $projectId): array
{
$row = Database::fetchOne(
'SELECT * FROM resources WHERE id = :id AND project_id = :pid',
[':id' => $id, ':pid' => $projectId],
);
if ($row === null) {
throw new \ApiException(404, ['error' => 'not_found']);
}
return $row;
}
private static function nextSortOrder(int $projectId): int
{
return (int) Database::fetchValue(
'SELECT COALESCE(MAX(sort_order), 0) + 10 FROM resources WHERE project_id = :pid',
[':pid' => $projectId],
);
}
private static function present(array $row): array
{
return [
'id' => (int) $row['id'],
'project_id' => (int) $row['project_id'],
'title' => $row['title'],
'description' => $row['description'],
'url' => $row['url'],
'kind' => $row['kind'],
'sort_order' => (int) $row['sort_order'],
'created_at' => $row['created_at'],
'updated_at' => $row['updated_at'],
];
}
}
+161
View File
@@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
namespace Infolab\Controllers;
use Infolab\ActivityLogger;
use Infolab\Database;
use Infolab\Request;
use Infolab\Response;
use Infolab\Validator;
/**
* Étapes de feuille de route d'un projet.
*/
final class RoadmapController
{
public function index(Request $req, array $params): void
{
$p = ProjectsController::findOr404($params['slug']);
$rows = Database::fetchAll(
'SELECT id, project_id, title, description, step_date, is_done, sort_order, created_at, updated_at
FROM roadmap_steps WHERE project_id = :pid ORDER BY sort_order ASC, id ASC',
[':pid' => (int) $p['id']],
);
Response::json(['data' => array_map([self::class, 'present'], $rows)]);
}
public function store(Request $req, array $params): void
{
$p = ProjectsController::findOr404($params['slug']);
$body = $req->json();
$v = new Validator($body);
$v->required('title')->string('title', 200);
$v->string('description', 5000);
$v->date('step_date');
$v->boolean('is_done');
$v->integer('sort_order', 0);
$v->check();
$sortOrder = isset($body['sort_order'])
? (int) $body['sort_order']
: self::nextSortOrder((int) $p['id']);
$id = Database::insert('roadmap_steps', [
'project_id' => (int) $p['id'],
'title' => (string) $body['title'],
'description' => $body['description'] ?? null,
'step_date' => $body['step_date'] ?? null,
'is_done' => self::toBool($body['is_done'] ?? false) ? 1 : 0,
'sort_order' => $sortOrder,
]);
ActivityLogger::log(
'added_step',
sprintf('a ajouté l\'étape « %s » au projet « %s »', (string) $body['title'], $p['title']),
(int) $p['id'],
);
Response::json(self::present(self::findStepOr404($id, (int) $p['id'])), 201);
}
public function update(Request $req, array $params): void
{
$p = ProjectsController::findOr404($params['slug']);
$step = self::findStepOr404((int) $params['id'], (int) $p['id']);
$body = $req->json();
$v = new Validator($body);
$v->string('title', 200);
$v->string('description', 5000);
$v->date('step_date');
$v->boolean('is_done');
$v->integer('sort_order', 0);
$v->check();
$update = [];
foreach (['title', 'description', 'step_date', 'sort_order'] as $f) {
if (array_key_exists($f, $body)) {
$update[$f] = $body[$f];
}
}
if (isset($update['sort_order'])) {
$update['sort_order'] = (int) $update['sort_order'];
}
$becameDone = false;
if (array_key_exists('is_done', $body)) {
$newDone = self::toBool($body['is_done']);
$oldDone = (bool) $step['is_done'];
$update['is_done'] = $newDone ? 1 : 0;
if ($newDone && !$oldDone) {
$becameDone = true;
}
}
if ($update !== []) {
Database::update('roadmap_steps', $update, 'id = :id', [':id' => (int) $step['id']]);
}
if ($becameDone) {
ActivityLogger::log(
'completed_step',
sprintf('a terminé l\'étape « %s » du projet « %s »', $step['title'], $p['title']),
(int) $p['id'],
);
}
Response::json(self::present(self::findStepOr404((int) $step['id'], (int) $p['id'])));
}
public function destroy(Request $req, array $params): void
{
$p = ProjectsController::findOr404($params['slug']);
$step = self::findStepOr404((int) $params['id'], (int) $p['id']);
Database::delete('roadmap_steps', 'id = :id', [':id' => (int) $step['id']]);
Response::noContent();
}
private static function findStepOr404(int $id, int $projectId): array
{
$row = Database::fetchOne(
'SELECT id, project_id, title, description, step_date, is_done, sort_order, created_at, updated_at
FROM roadmap_steps WHERE id = :id AND project_id = :pid',
[':id' => $id, ':pid' => $projectId],
);
if ($row === null) {
throw new \ApiException(404, ['error' => 'not_found']);
}
return $row;
}
/** Sort order par défaut : MAX + 10 par projet (laisse de l'espace pour insertion ultérieure). */
private static function nextSortOrder(int $projectId): int
{
return (int) Database::fetchValue(
'SELECT COALESCE(MAX(sort_order), 0) + 10 FROM roadmap_steps WHERE project_id = :pid',
[':pid' => $projectId],
);
}
private static function toBool(mixed $v): bool
{
if (is_bool($v)) return $v;
if (is_int($v)) return $v !== 0;
if (is_string($v)) return in_array(strtolower($v), ['true', '1', 'yes', 'on'], true);
return (bool) $v;
}
private static function present(array $row): array
{
return [
'id' => (int) $row['id'],
'project_id' => (int) $row['project_id'],
'title' => $row['title'],
'description' => $row['description'],
'step_date' => $row['step_date'],
'is_done' => (bool) $row['is_done'],
'sort_order' => (int) $row['sort_order'],
'created_at' => $row['created_at'],
'updated_at' => $row['updated_at'],
];
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace Infolab\Controllers;
use Infolab\Database;
use Infolab\Request;
use Infolab\Response;
/**
* GET /api/stats — KPIs du dashboard.
*
* - projects_{active,completed,archived,total} : projets non soft-deletés
* - participants_total : total des membres de l'annuaire
* (interprétation : "annuaire des contributeurs" = participants potentiels)
* - by_category : un objet { slug, name, color_hex, count } par catégorie
* - by_dimension : { social, ecologique, transversal } sommés sur projets non-deleted
*/
final class StatsController
{
public function index(Request $req, array $params): void
{
// Compteurs par status (projets non soft-deletés).
$statusCounts = Database::fetchAll(
'SELECT status, COUNT(*) AS c FROM projects WHERE deleted_at IS NULL GROUP BY status',
);
$byStatus = ['active' => 0, 'completed' => 0, 'archived' => 0];
foreach ($statusCounts as $r) {
$byStatus[$r['status']] = (int) $r['c'];
}
$participantsTotal = (int) Database::fetchValue('SELECT COUNT(*) FROM members');
// Compteur par catégorie : LEFT JOIN pour inclure les catégories à 0 projet.
$byCategoryRows = Database::fetchAll(
'SELECT c.slug, c.name, c.color_hex, c.dimension,
COALESCE(SUM(CASE WHEN p.deleted_at IS NULL THEN 1 ELSE 0 END), 0) AS count
FROM categories c
LEFT JOIN projects p ON p.category_id = c.id
GROUP BY c.id, c.slug, c.name, c.color_hex, c.dimension
ORDER BY c.sort_order ASC',
);
$byDimension = ['social' => 0, 'ecologique' => 0, 'transversal' => 0];
$byCategory = [];
foreach ($byCategoryRows as $r) {
$count = (int) $r['count'];
$byCategory[] = [
'slug' => $r['slug'],
'name' => $r['name'],
'color_hex' => $r['color_hex'],
'count' => $count,
];
$byDimension[$r['dimension']] = ($byDimension[$r['dimension']] ?? 0) + $count;
}
Response::json([
'projects_active' => $byStatus['active'],
'projects_completed' => $byStatus['completed'],
'projects_archived' => $byStatus['archived'],
'projects_total' => $byStatus['active'] + $byStatus['completed'] + $byStatus['archived'],
'participants_total' => $participantsTotal,
'by_category' => $byCategory,
'by_dimension' => $byDimension,
]);
}
}
+161
View File
@@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
namespace Infolab;
use PDO;
use PDOException;
use PDOStatement;
/**
* Singleton PDO + helpers de base.
*
* Toutes les requêtes passent par des prepared statements (paramètres nommés ou
* positionnels). Aucune concaténation de variable utilisateur dans le SQL.
* Les noms de tables/colonnes utilisés en interne sont quotés avec des backticks
* et viennent toujours du code, jamais d'une entrée utilisateur.
*/
final class Database
{
private static ?PDO $pdo = null;
/** Construit (ou retourne) l'instance PDO partagée. */
public static function pdo(): PDO
{
if (self::$pdo !== null) {
return self::$pdo;
}
$host = getenv('DB_HOST') ?: '127.0.0.1';
$port = getenv('DB_PORT') ?: '3306';
$name = getenv('DB_NAME') ?: 'infolab';
$user = getenv('DB_USER') ?: 'infolab';
$pass = getenv('DB_PASS') ?: '';
$dsn = sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $host, $port, $name);
try {
self::$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
// EMULATE_PREPARES=false + STRINGIFY_FETCHES=false : les colonnes
// INT reviennent en int PHP, pas en string. JSON propre par défaut.
PDO::ATTR_EMULATE_PREPARES => false,
PDO::ATTR_STRINGIFY_FETCHES => false,
// Force UTC côté connexion : les TIMESTAMP/DATETIME sont stockés tels quels.
PDO::MYSQL_ATTR_INIT_COMMAND => "SET time_zone = '+00:00', NAMES utf8mb4",
]);
} catch (PDOException $e) {
// Visible dans Dozzle via stderr ; détail jamais renvoyé au client.
error_log('[Database] connection failed: ' . $e->getMessage());
throw new \ApiException(500, ['error' => 'internal_server_error']);
}
return self::$pdo;
}
/**
* Prépare + exécute. Retourne le PDOStatement pour fetch ultérieur si besoin.
*
* @param array<string|int,mixed> $params
*/
public static function run(string $sql, array $params = []): PDOStatement
{
$stmt = self::pdo()->prepare($sql);
$stmt->execute($params);
return $stmt;
}
/**
* Récupère une seule ligne (ou null).
*
* @param array<string|int,mixed> $params
* @return array<string,mixed>|null
*/
public static function fetchOne(string $sql, array $params = []): ?array
{
$row = self::run($sql, $params)->fetch();
return $row === false ? null : $row;
}
/**
* Récupère toutes les lignes (liste indexée numériquement, lignes assoc).
*
* @param array<string|int,mixed> $params
* @return array<int,array<string,mixed>>
*/
public static function fetchAll(string $sql, array $params = []): array
{
return self::run($sql, $params)->fetchAll();
}
/**
* Récupère la 1re colonne de la 1re ligne (utile pour COUNT, SELECT id, etc.).
*
* @param array<string|int,mixed> $params
*/
public static function fetchValue(string $sql, array $params = []): mixed
{
$row = self::run($sql, $params)->fetch(PDO::FETCH_NUM);
return $row === false ? null : $row[0];
}
/**
* INSERT générique à partir d'un tableau colonne => valeur.
* Retourne l'auto-increment.
*
* @param array<string,mixed> $data
*/
public static function insert(string $table, array $data): int
{
if ($data === []) {
throw new \InvalidArgumentException('Database::insert: data vide');
}
$cols = array_keys($data);
$colsSql = implode(',', array_map(static fn($c) => '`' . $c . '`', $cols));
$phSql = implode(',', array_map(static fn($c) => ':' . $c, $cols));
$sql = sprintf('INSERT INTO `%s` (%s) VALUES (%s)', $table, $colsSql, $phSql);
$params = [];
foreach ($data as $k => $v) {
$params[':' . $k] = $v;
}
self::run($sql, $params);
return (int) self::pdo()->lastInsertId();
}
/**
* UPDATE générique. Le WHERE est fourni en SQL + ses propres params
* (préfixés avec : pour éviter les collisions avec les colonnes SET).
*
* @param array<string,mixed> $data
* @param array<string,mixed> $whereParams ex: [':id' => 42]
*/
public static function update(string $table, array $data, string $whereSql, array $whereParams = []): int
{
if ($data === []) {
return 0;
}
$set = implode(',', array_map(static fn($c) => '`' . $c . '`=:' . $c, array_keys($data)));
$sql = sprintf('UPDATE `%s` SET %s WHERE %s', $table, $set, $whereSql);
$params = [];
foreach ($data as $k => $v) {
$params[':' . $k] = $v;
}
foreach ($whereParams as $k => $v) {
$params[$k] = $v;
}
return self::run($sql, $params)->rowCount();
}
/**
* DELETE générique.
*
* @param array<string,mixed> $whereParams
*/
public static function delete(string $table, string $whereSql, array $whereParams = []): int
{
$sql = sprintf('DELETE FROM `%s` WHERE %s', $table, $whereSql);
return self::run($sql, $whereParams)->rowCount();
}
}
+131
View File
@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace Infolab;
/**
* Façade lecture seule sur la requête HTTP courante.
* Parse les headers, le body JSON (méthodes non-GET) et expose les query params.
*/
final class Request
{
/** @var array<string,mixed> */
private array $jsonBody;
/** @var array<string,mixed> */
private array $query;
/** @var array<string,string> headers en clés lowercase */
private array $headers;
public function __construct()
{
$this->query = $_GET;
$this->headers = $this->loadHeaders();
$this->jsonBody = $this->loadJsonBody();
}
/** @return array<string,string> */
private function loadHeaders(): array
{
$h = [];
// getallheaders() existe avec Apache et le serveur intégré PHP récent.
if (function_exists('getallheaders')) {
$all = getallheaders();
if (is_array($all)) {
foreach ($all as $k => $v) {
$h[strtolower((string) $k)] = (string) $v;
}
return $h;
}
}
// Fallback : reconstruit depuis $_SERVER (CGI/FPM).
foreach ($_SERVER as $k => $v) {
if (is_string($k) && str_starts_with($k, 'HTTP_')) {
$name = strtolower(str_replace('_', '-', substr($k, 5)));
$h[$name] = (string) $v;
}
}
// Content-Type / Content-Length ne sont pas préfixés HTTP_
if (isset($_SERVER['CONTENT_TYPE'])) $h['content-type'] = (string) $_SERVER['CONTENT_TYPE'];
if (isset($_SERVER['CONTENT_LENGTH'])) $h['content-length'] = (string) $_SERVER['CONTENT_LENGTH'];
return $h;
}
/** @return array<string,mixed> */
private function loadJsonBody(): array
{
$method = $this->method();
if (!in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'], true)) {
return [];
}
$raw = file_get_contents('php://input');
if ($raw === false || $raw === '') {
return [];
}
$ct = $this->header('content-type', '') ?? '';
if ($ct !== '' && !str_contains(strtolower($ct), 'application/json')) {
// Body non-JSON : on n'essaie pas de parser. Les contrôleurs traiteront comme vide.
return [];
}
try {
$decoded = json_decode($raw, true, 64, JSON_THROW_ON_ERROR);
} catch (\JsonException) {
throw new \ApiException(400, ['error' => 'bad_request', 'message' => 'invalid JSON body']);
}
return is_array($decoded) ? $decoded : [];
}
public function method(): string
{
return strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'));
}
/** Chemin sans query string. */
public function path(): string
{
$uri = (string) ($_SERVER['REQUEST_URI'] ?? '/');
$q = strpos($uri, '?');
return $q === false ? $uri : substr($uri, 0, $q);
}
public function header(string $name, ?string $default = null): ?string
{
return $this->headers[strtolower($name)] ?? $default;
}
/** Extrait le token Bearer de l'header Authorization, ou null. */
public function bearerToken(): ?string
{
$auth = $this->header('authorization');
if ($auth === null) {
return null;
}
if (preg_match('/^Bearer\s+(.+)$/i', $auth, $m)) {
return trim($m[1]);
}
return null;
}
public function query(string $key, mixed $default = null): mixed
{
return $this->query[$key] ?? $default;
}
/** @return array<string,mixed> */
public function allQuery(): array
{
return $this->query;
}
/** @return array<string,mixed> payload JSON décodé (vide si pas de body) */
public function json(): array
{
return $this->jsonBody;
}
public function input(string $key, mixed $default = null): mixed
{
return $this->jsonBody[$key] ?? $default;
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace Infolab;
/**
* Helpers d'émission de réponse JSON.
* Toujours `Content-Type: application/json; charset=utf-8`.
* Les contrôleurs ne devraient jamais `echo` directement — passer par ici.
*/
final class Response
{
/** Réponse JSON standard (objet ou liste). */
public static function json(mixed $data, int $status = 200): void
{
self::emit($status, $data);
}
/** Réponse 204 No Content (ou autre statut sans body). */
public static function noContent(int $status = 204): void
{
http_response_code($status);
}
/**
* Erreur uniforme. Toujours { "error": code, "message"?: ..., ...extra }.
*
* @param array<string,mixed> $extra
*/
public static function error(int $status, string $code, ?string $message = null, array $extra = []): void
{
$payload = ['error' => $code];
if ($message !== null) {
$payload['message'] = $message;
}
$payload += $extra;
self::emit($status, $payload);
}
/** Émet une réponse JSON brute. Utilisé par le handler d'exception global. */
public static function emit(int $status, mixed $data): void
{
http_response_code($status);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
/** Émet les headers CORS basés sur l'env CORS_ORIGIN (défaut '*'). */
public static function sendCorsHeaders(): void
{
$origin = getenv('CORS_ORIGIN') ?: '*';
header('Access-Control-Allow-Origin: ' . $origin);
header('Vary: Origin');
header('Access-Control-Allow-Methods: GET, POST, PATCH, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Authorization, Content-Type, Accept');
header('Access-Control-Max-Age: 86400');
}
}
+105
View File
@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace Infolab;
/**
* Router minimaliste. Supporte les paramètres `:nom` dans les chemins
* (capturés comme [^/]+) et marque chaque route comme protégée par token ou non.
*
* En cas de chemin connu sur une autre méthode -> 405 method_not_allowed.
* En cas de chemin inconnu -> 404 not_found. Les deux passent par ApiException.
*/
final class Router
{
/**
* @var array<int,array{
* method:string,
* pattern:string,
* paramNames:array<int,string>,
* handler:array{0:class-string,1:string},
* auth:bool
* }>
*/
private array $routes = [];
/** Enregistre une route GET (publique, pas de token). */
public function get(string $path, array $handler): void
{
$this->add('GET', $path, $handler, false);
}
/** Enregistre une route POST (token requis). */
public function post(string $path, array $handler): void
{
$this->add('POST', $path, $handler, true);
}
/** Enregistre une route PATCH (token requis). */
public function patch(string $path, array $handler): void
{
$this->add('PATCH', $path, $handler, true);
}
/** Enregistre une route DELETE (token requis). */
public function delete(string $path, array $handler): void
{
$this->add('DELETE', $path, $handler, true);
}
public function add(string $method, string $path, array $handler, bool $requiresAuth): void
{
// /api/projects/:slug -> /api/projects/([^/]+), avec ['slug'] dans paramNames.
$paramNames = [];
$pattern = preg_replace_callback(
'#:([a-zA-Z_][a-zA-Z0-9_]*)#',
static function (array $m) use (&$paramNames): string {
$paramNames[] = $m[1];
return '([^/]+)';
},
$path
);
$this->routes[] = [
'method' => $method,
'pattern' => '#^' . $pattern . '$#',
'paramNames' => $paramNames,
'handler' => $handler,
'auth' => $requiresAuth,
];
}
/**
* Cherche la route matchant méthode+chemin.
*
* @return array{0:array<string,mixed>,1:array<string,string>} couple (route, params nommés)
* @throws \ApiException 405 si chemin connu sur une autre méthode, 404 sinon
*/
public function dispatch(string $method, string $path): array
{
$methodAllowed = [];
foreach ($this->routes as $route) {
if (!preg_match($route['pattern'], $path, $m)) {
continue;
}
if ($route['method'] !== $method) {
$methodAllowed[$route['method']] = true;
continue;
}
array_shift($m); // enlève le match complet
$params = [];
foreach ($route['paramNames'] as $i => $name) {
$params[$name] = urldecode($m[$i]);
}
return [$route, $params];
}
if ($methodAllowed !== []) {
throw new \ApiException(405, [
'error' => 'method_not_allowed',
'allowed' => array_keys($methodAllowed),
]);
}
throw new \ApiException(404, ['error' => 'not_found']);
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace Infolab;
/**
* Génère et vérifie l'unicité des slugs. Translittération ASCII via
* l'extension intl (Transliterator), avec fallback bas niveau.
*/
final class Slugger
{
/**
* Slugifie un texte : minuscules, accents retirés, non-alphanum -> tirets.
* Toujours non vide (retourne "item" en dernier recours).
*/
public static function slugify(string $text): string
{
$text = trim($text);
if ($text === '') {
return 'item';
}
// Transliteration intl si dispo (cas de Marseille -> marseille, é -> e).
if (class_exists(\Transliterator::class)) {
$tr = \Transliterator::create('Any-Latin; Latin-ASCII; Lower()');
if ($tr !== null) {
$translit = $tr->transliterate($text);
if (is_string($translit)) {
$text = $translit;
}
}
}
$text = mb_strtolower($text);
// Remplace toute séquence non-[a-z0-9] par un tiret, puis trim les tirets de bord.
$text = preg_replace('/[^a-z0-9]+/', '-', $text) ?? '';
$text = trim($text, '-');
return $text === '' ? 'item' : $text;
}
/**
* Garantit l'unicité d'un slug dans une table : suffixe `-2`, `-3`, …
* Si `$excludeId` est fourni, ignore cette ligne (cas PATCH d'un slug existant).
*/
public static function unique(string $base, string $table, string $column = 'slug', ?int $excludeId = null): string
{
$candidate = $base;
$n = 2;
while (self::exists($candidate, $table, $column, $excludeId)) {
$candidate = $base . '-' . $n;
$n++;
}
return $candidate;
}
private static function exists(string $slug, string $table, string $column, ?int $excludeId): bool
{
// Noms de table/colonne câblés depuis le code, jamais depuis l'utilisateur.
$sql = sprintf('SELECT 1 FROM `%s` WHERE `%s` = :slug', $table, $column);
$params = [':slug' => $slug];
if ($excludeId !== null) {
$sql .= ' AND id <> :id';
$params[':id'] = $excludeId;
}
$sql .= ' LIMIT 1';
return Database::fetchValue($sql, $params) !== null;
}
}
+186
View File
@@ -0,0 +1,186 @@
<?php
declare(strict_types=1);
namespace Infolab;
/**
* Validation fluente des payloads JSON. Accumule les erreurs par champ, puis
* `check()` lance une ApiException 422 conforme au contrat documenté :
*
* { "error": "validation_failed", "fields": { "title": "required", ... } }
*
* Toutes les règles autres que `required()` sont silencieuses si la clé
* n'existe pas — ainsi les PATCH partiels passent sans déclarer les champs absents.
*/
final class Validator
{
/** @var array<string,mixed> */
private array $data;
/** @var array<string,string> code d'erreur par champ */
private array $errors = [];
/** @param array<string,mixed> $data */
public function __construct(array $data)
{
$this->data = $data;
}
public function has(string $key): bool
{
return array_key_exists($key, $this->data);
}
public function value(string $key, mixed $default = null): mixed
{
return $this->data[$key] ?? $default;
}
/** Définit manuellement une erreur sur un champ (ex: "not_found" après lookup BDD). */
public function setError(string $key, string $code): self
{
$this->errors[$key] = $code;
return $this;
}
public function required(string $key): self
{
if (!$this->has($key) || $this->data[$key] === null || $this->data[$key] === '') {
$this->errors[$key] ??= 'required';
}
return $this;
}
public function string(string $key, ?int $max = null, ?int $min = null): self
{
if (!$this->has($key) || $this->data[$key] === null) {
return $this;
}
$v = $this->data[$key];
if (!is_string($v)) {
$this->errors[$key] ??= 'must_be_string';
return $this;
}
$len = mb_strlen($v);
if ($min !== null && $len < $min) $this->errors[$key] ??= 'too_short';
if ($max !== null && $len > $max) $this->errors[$key] ??= 'too_long';
return $this;
}
public function integer(string $key, ?int $min = null, ?int $max = null): self
{
if (!$this->has($key) || $this->data[$key] === null) {
return $this;
}
$v = $this->data[$key];
if (is_int($v)) {
$i = $v;
} elseif (is_string($v) && preg_match('/^-?\d+$/', $v)) {
$i = (int) $v;
} else {
$this->errors[$key] ??= 'must_be_integer';
return $this;
}
if ($min !== null && $i < $min) $this->errors[$key] ??= 'too_small';
if ($max !== null && $i > $max) $this->errors[$key] ??= 'too_large';
return $this;
}
public function boolean(string $key): self
{
if (!$this->has($key) || $this->data[$key] === null) {
return $this;
}
$v = $this->data[$key];
if (!is_bool($v) && !in_array($v, [0, 1, '0', '1', 'true', 'false'], true)) {
$this->errors[$key] ??= 'must_be_boolean';
}
return $this;
}
/** @param array<int,mixed> $allowed */
public function in(string $key, array $allowed): self
{
if (!$this->has($key) || $this->data[$key] === null) {
return $this;
}
if (!in_array($this->data[$key], $allowed, true)) {
$this->errors[$key] ??= 'invalid_value';
}
return $this;
}
public function url(string $key, int $max = 1024): self
{
if (!$this->has($key) || $this->data[$key] === null || $this->data[$key] === '') {
return $this;
}
$v = $this->data[$key];
if (!is_string($v) || filter_var($v, FILTER_VALIDATE_URL) === false || mb_strlen($v) > $max) {
$this->errors[$key] ??= 'invalid_url';
}
return $this;
}
public function email(string $key): self
{
if (!$this->has($key) || $this->data[$key] === null || $this->data[$key] === '') {
return $this;
}
if (!is_string($this->data[$key]) || filter_var($this->data[$key], FILTER_VALIDATE_EMAIL) === false) {
$this->errors[$key] ??= 'invalid_email';
}
return $this;
}
/** Format ISO date YYYY-MM-DD strict. */
public function date(string $key): self
{
if (!$this->has($key) || $this->data[$key] === null || $this->data[$key] === '') {
return $this;
}
$v = $this->data[$key];
if (!is_string($v)) {
$this->errors[$key] ??= 'invalid_date';
return $this;
}
$d = \DateTimeImmutable::createFromFormat('Y-m-d', $v);
if ($d === false || $d->format('Y-m-d') !== $v) {
$this->errors[$key] ??= 'invalid_date';
}
return $this;
}
public function colorHex(string $key): self
{
if (!$this->has($key) || $this->data[$key] === null || $this->data[$key] === '') {
return $this;
}
if (!is_string($this->data[$key]) || !preg_match('/^#[0-9A-Fa-f]{6}$/', $this->data[$key])) {
$this->errors[$key] ??= 'invalid_color_hex';
}
return $this;
}
public function fails(): bool
{
return $this->errors !== [];
}
/** @return array<string,string> */
public function errors(): array
{
return $this->errors;
}
/** Termine la validation : lance ApiException 422 si au moins une erreur. */
public function check(): void
{
if ($this->errors !== []) {
throw new \ApiException(422, [
'error' => 'validation_failed',
'fields' => $this->errors,
]);
}
}
}
+99
View File
@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
// -----------------------------------------------------------------------------
// bootstrap.php
// Initialise l'environnement PHP : timezone UTC, exception applicative,
// chargement .env (dev local), autoload PSR-4 pour le namespace Infolab\,
// et conversion des warnings PHP en exceptions.
// -----------------------------------------------------------------------------
// Tout en UTC en interne, les clients gèrent leur fuseau côté front.
date_default_timezone_set('UTC');
/**
* Exception applicative : porte un code HTTP et le payload JSON à renvoyer.
* Levée par les contrôleurs et helpers, attrapée par le handler global de index.php.
* Définie dans le namespace racine pour être accessible depuis tout le code.
*/
final class ApiException extends \RuntimeException
{
/** @param array<string,mixed> $payload */
public function __construct(public readonly int $statusCode, public readonly array $payload)
{
parent::__construct($payload['error'] ?? 'error');
}
}
/**
* Raccourci pour interrompre rapidement le traitement avec une erreur typée.
*
* @param array<string,mixed> $extra champs additionnels à inclure dans le payload JSON
*/
function api_abort(int $status, string $error, ?string $message = null, array $extra = []): never
{
$payload = ['error' => $error];
if ($message !== null) {
$payload['message'] = $message;
}
if ($extra !== []) {
$payload += $extra;
}
throw new ApiException($status, $payload);
}
// -----------------------------------------------------------------------------
// Chargement minimal d'un fichier .env (pour le dev local sous Laragon).
// En prod docker, c'est `env_file:` qui pousse les variables — on ne fait rien.
// -----------------------------------------------------------------------------
$envFile = dirname(__DIR__) . '/.env';
if (is_file($envFile)) {
foreach (file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
$line = trim($line);
if ($line === '' || $line[0] === '#' || !str_contains($line, '=')) {
continue;
}
[$k, $v] = explode('=', $line, 2);
$k = trim($k);
$v = trim($v);
// Strip guillemets simples ou doubles s'ils encadrent la valeur.
if (strlen($v) >= 2 && (
($v[0] === '"' && substr($v, -1) === '"') ||
($v[0] === "'" && substr($v, -1) === "'")
)) {
$v = substr($v, 1, -1);
}
// Ne pas écraser un env déjà défini (ex: par docker, par le shell de CI...)
if (getenv($k) === false) {
putenv("$k=$v");
$_ENV[$k] = $v;
}
}
}
// -----------------------------------------------------------------------------
// Autoload PSR-4 pour le namespace Infolab\ -> src/
// On évite Composer : pas de dépendance externe, pas de `composer install`.
// -----------------------------------------------------------------------------
spl_autoload_register(static function (string $class): void {
$prefix = 'Infolab\\';
if (!str_starts_with($class, $prefix)) {
return;
}
$relative = substr($class, strlen($prefix));
$path = __DIR__ . '/' . str_replace('\\', '/', $relative) . '.php';
if (is_file($path)) {
require $path;
}
});
// -----------------------------------------------------------------------------
// Convertit les warnings/notices PHP en exceptions (sauf ceux supprimés par @).
// Centralise tout le tracking d'erreurs dans le handler de index.php.
// -----------------------------------------------------------------------------
set_error_handler(static function (int $severity, string $message, string $file, int $line): bool {
if (!(error_reporting() & $severity)) {
return false;
}
throw new \ErrorException($message, 0, $severity, $file, $line);
});