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