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