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

162 lines
5.5 KiB
PHP

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