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:
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user