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,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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user