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
@@ -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();
}
}