Files
Arnaud 3a32e93ab1 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>
2026-05-28 19:55:28 +02:00

360 lines
14 KiB
PHP

<?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),
],
];
}
}