3a32e93ab1
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>
175 lines
6.1 KiB
PHP
175 lines
6.1 KiB
PHP
<?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'],
|
|
];
|
|
}
|
|
}
|