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>
360 lines
14 KiB
PHP
360 lines
14 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace Infolab\Controllers;
|
|
|
|
use Infolab\ActivityLogger;
|
|
use Infolab\Database;
|
|
use Infolab\Request;
|
|
use Infolab\Response;
|
|
use Infolab\Slugger;
|
|
use Infolab\Validator;
|
|
|
|
/**
|
|
* CRUD projets — entité centrale. Soft delete via `deleted_at`.
|
|
* Les sous-ressources (roadmap, members, discussions, resources) ont leurs
|
|
* propres contrôleurs, mais s'appuient sur ProjectsController::findOr404().
|
|
*/
|
|
final class ProjectsController
|
|
{
|
|
public function index(Request $req, array $params): void
|
|
{
|
|
$page = max(1, (int) $req->query('page', 1));
|
|
$perPage = max(1, min(100, (int) $req->query('per_page', 20)));
|
|
$offset = ($page - 1) * $perPage;
|
|
|
|
// Construction dynamique du WHERE — chaque valeur est bindée.
|
|
$where = ['p.deleted_at IS NULL'];
|
|
$args = [];
|
|
|
|
if (null !== ($cat = $req->query('category')) && $cat !== '') {
|
|
$where[] = 'c.slug = :cat';
|
|
$args[':cat'] = (string) $cat;
|
|
}
|
|
if (null !== ($status = $req->query('status')) && $status !== '') {
|
|
if (!in_array($status, ['active', 'completed', 'archived'], true)) {
|
|
throw new \ApiException(400, ['error' => 'bad_request', 'message' => 'status invalide']);
|
|
}
|
|
$where[] = 'p.status = :status';
|
|
$args[':status'] = (string) $status;
|
|
}
|
|
if (null !== ($q = $req->query('q')) && $q !== '') {
|
|
// Échappe les wildcards SQL avant le LIKE.
|
|
$like = '%' . str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], (string) $q) . '%';
|
|
$where[] = '(p.title LIKE :q OR p.short_description LIKE :q)';
|
|
$args[':q'] = $like;
|
|
}
|
|
$whereSql = implode(' AND ', $where);
|
|
|
|
$total = (int) Database::fetchValue(
|
|
"SELECT COUNT(*) FROM projects p INNER JOIN categories c ON c.id = p.category_id WHERE $whereSql",
|
|
$args,
|
|
);
|
|
$rows = Database::fetchAll(
|
|
"SELECT p.id, p.slug, p.title, p.status, p.territory, p.short_description,
|
|
p.cover_image_url, p.external_url, p.progress_percent,
|
|
p.started_at, p.completed_at, p.created_at, p.updated_at,
|
|
c.id AS category_id, c.slug AS category_slug, c.name AS category_name,
|
|
c.color_hex AS category_color_hex, c.dimension AS category_dimension
|
|
FROM projects p
|
|
INNER JOIN categories c ON c.id = p.category_id
|
|
WHERE $whereSql
|
|
ORDER BY p.created_at DESC, p.id DESC
|
|
LIMIT $perPage OFFSET $offset",
|
|
$args,
|
|
);
|
|
|
|
Response::json([
|
|
'data' => array_map([self::class, 'presentListRow'], $rows),
|
|
'meta' => [
|
|
'page' => $page,
|
|
'per_page' => $perPage,
|
|
'total' => $total,
|
|
'total_pages' => $total === 0 ? 0 : (int) ceil($total / $perPage),
|
|
],
|
|
]);
|
|
}
|
|
|
|
public function show(Request $req, array $params): void
|
|
{
|
|
Response::json(self::presentDetail(self::findOr404($params['slug'])));
|
|
}
|
|
|
|
public function store(Request $req, array $params): void
|
|
{
|
|
$body = $req->json();
|
|
$v = new Validator($body);
|
|
$v->required('title')->string('title', 200);
|
|
$v->required('category_id')->integer('category_id', 1);
|
|
$v->string('slug', 160);
|
|
$v->in('status', ['active', 'completed', 'archived']);
|
|
$v->string('territory', 160);
|
|
$v->string('short_description', 5000);
|
|
$v->string('context_description');
|
|
$v->string('objectives');
|
|
$v->url('cover_image_url', 512);
|
|
$v->url('external_url', 512);
|
|
$v->integer('progress_percent', 0, 100);
|
|
$v->date('started_at');
|
|
$v->date('completed_at');
|
|
|
|
// Lookup category_id seulement si le format est OK pour éviter le bruit.
|
|
if ($v->has('category_id') && !$v->fails()) {
|
|
$exists = Database::fetchValue(
|
|
'SELECT 1 FROM categories WHERE id = :id',
|
|
[':id' => (int) $v->value('category_id')],
|
|
);
|
|
if ($exists === null) {
|
|
$v->setError('category_id', 'not_found');
|
|
}
|
|
}
|
|
$v->check();
|
|
|
|
$title = (string) $body['title'];
|
|
$base = (isset($body['slug']) && $body['slug'] !== '')
|
|
? Slugger::slugify((string) $body['slug'])
|
|
: Slugger::slugify($title);
|
|
$slug = Slugger::unique($base, 'projects');
|
|
|
|
$id = Database::insert('projects', [
|
|
'slug' => $slug,
|
|
'title' => $title,
|
|
'category_id' => (int) $body['category_id'],
|
|
'status' => $body['status'] ?? 'active',
|
|
'territory' => $body['territory'] ?? 'Marseille',
|
|
'short_description' => $body['short_description'] ?? null,
|
|
'context_description' => $body['context_description'] ?? null,
|
|
'objectives' => $body['objectives'] ?? null,
|
|
'cover_image_url' => $body['cover_image_url'] ?? null,
|
|
'external_url' => $body['external_url'] ?? null,
|
|
'progress_percent' => isset($body['progress_percent']) ? (int) $body['progress_percent'] : 0,
|
|
'started_at' => $body['started_at'] ?? null,
|
|
'completed_at' => $body['completed_at'] ?? null,
|
|
]);
|
|
|
|
ActivityLogger::log(
|
|
'created_project',
|
|
sprintf('a créé le projet « %s »', $title),
|
|
$id,
|
|
);
|
|
|
|
Response::json(self::presentDetail(self::findOr404($slug)), 201);
|
|
}
|
|
|
|
public function update(Request $req, array $params): void
|
|
{
|
|
$p = self::findOr404($params['slug']);
|
|
$body = $req->json();
|
|
$v = new Validator($body);
|
|
$v->string('title', 200);
|
|
$v->string('slug', 160);
|
|
$v->integer('category_id', 1);
|
|
$v->in('status', ['active', 'completed', 'archived']);
|
|
$v->string('territory', 160);
|
|
$v->string('short_description', 5000);
|
|
$v->string('context_description');
|
|
$v->string('objectives');
|
|
$v->url('cover_image_url', 512);
|
|
$v->url('external_url', 512);
|
|
$v->integer('progress_percent', 0, 100);
|
|
$v->date('started_at');
|
|
$v->date('completed_at');
|
|
|
|
if (array_key_exists('category_id', $body) && !$v->fails()) {
|
|
$exists = Database::fetchValue(
|
|
'SELECT 1 FROM categories WHERE id = :id',
|
|
[':id' => (int) $body['category_id']],
|
|
);
|
|
if ($exists === null) {
|
|
$v->setError('category_id', 'not_found');
|
|
}
|
|
}
|
|
$v->check();
|
|
|
|
$update = [];
|
|
$fields = ['title','category_id','status','territory','short_description','context_description',
|
|
'objectives','cover_image_url','external_url','progress_percent','started_at','completed_at'];
|
|
foreach ($fields as $f) {
|
|
if (array_key_exists($f, $body)) {
|
|
$update[$f] = $body[$f];
|
|
}
|
|
}
|
|
if (isset($update['progress_percent'])) $update['progress_percent'] = (int) $update['progress_percent'];
|
|
if (isset($update['category_id'])) $update['category_id'] = (int) $update['category_id'];
|
|
|
|
if (isset($body['slug']) && $body['slug'] !== '') {
|
|
$update['slug'] = Slugger::unique(
|
|
Slugger::slugify((string) $body['slug']),
|
|
'projects',
|
|
'slug',
|
|
(int) $p['id'],
|
|
);
|
|
}
|
|
|
|
// Transition status -> completed : remplit completed_at si vide.
|
|
$becameCompleted = false;
|
|
if (isset($update['status']) && $update['status'] === 'completed' && $p['status'] !== 'completed') {
|
|
$becameCompleted = true;
|
|
if (!array_key_exists('completed_at', $update) || $update['completed_at'] === null || $update['completed_at'] === '') {
|
|
$update['completed_at'] = gmdate('Y-m-d');
|
|
}
|
|
}
|
|
|
|
if ($update !== []) {
|
|
Database::update('projects', $update, 'id = :id', [':id' => (int) $p['id']]);
|
|
}
|
|
|
|
$title = $update['title'] ?? $p['title'];
|
|
ActivityLogger::log(
|
|
'updated_project',
|
|
sprintf('a mis à jour le projet « %s »', $title),
|
|
(int) $p['id'],
|
|
);
|
|
if ($becameCompleted) {
|
|
ActivityLogger::log(
|
|
'completed_project',
|
|
sprintf('a terminé le projet « %s »', $title),
|
|
(int) $p['id'],
|
|
);
|
|
}
|
|
|
|
Response::json(self::presentDetail(self::findOr404($update['slug'] ?? $p['slug'])));
|
|
}
|
|
|
|
public function destroy(Request $req, array $params): void
|
|
{
|
|
$p = self::findOr404($params['slug']);
|
|
// Soft delete : on garde l'historique, on cache de la liste publique.
|
|
Database::update(
|
|
'projects',
|
|
['deleted_at' => gmdate('Y-m-d H:i:s')],
|
|
'id = :id',
|
|
[':id' => (int) $p['id']],
|
|
);
|
|
Response::noContent();
|
|
}
|
|
|
|
/**
|
|
* Helper public : récupère un projet non soft-deleted par son slug, ou 404.
|
|
* Utilisé par tous les sous-contrôleurs (Roadmap, ProjectMembers, etc.).
|
|
*
|
|
* @return array<string,mixed>
|
|
*/
|
|
public static function findOr404(string $slug): array
|
|
{
|
|
$row = Database::fetchOne(
|
|
'SELECT * FROM projects WHERE slug = :slug AND deleted_at IS NULL',
|
|
[':slug' => $slug],
|
|
);
|
|
if ($row === null) {
|
|
throw new \ApiException(404, ['error' => 'not_found']);
|
|
}
|
|
return $row;
|
|
}
|
|
|
|
/** Ligne de liste (projet + catégorie jointe). */
|
|
private static function presentListRow(array $row): array
|
|
{
|
|
return [
|
|
'id' => (int) $row['id'],
|
|
'slug' => $row['slug'],
|
|
'title' => $row['title'],
|
|
'status' => $row['status'],
|
|
'territory' => $row['territory'],
|
|
'short_description' => $row['short_description'],
|
|
'cover_image_url' => $row['cover_image_url'],
|
|
'external_url' => $row['external_url'],
|
|
'progress_percent' => (int) $row['progress_percent'],
|
|
'started_at' => $row['started_at'],
|
|
'completed_at' => $row['completed_at'],
|
|
'created_at' => $row['created_at'],
|
|
'updated_at' => $row['updated_at'],
|
|
'category' => [
|
|
'id' => (int) $row['category_id'],
|
|
'slug' => $row['category_slug'],
|
|
'name' => $row['category_name'],
|
|
'color_hex' => $row['category_color_hex'],
|
|
'dimension' => $row['category_dimension'],
|
|
],
|
|
];
|
|
}
|
|
|
|
/** Détail complet d'un projet : catégorie, membres, roadmap, compteurs. */
|
|
private static function presentDetail(array $p): array
|
|
{
|
|
$pid = (int) $p['id'];
|
|
|
|
$cat = Database::fetchOne(
|
|
'SELECT id, slug, name, color_hex, icon, dimension FROM categories WHERE id = :id',
|
|
[':id' => (int) $p['category_id']],
|
|
);
|
|
|
|
$members = Database::fetchAll(
|
|
'SELECT m.id, m.slug, m.display_name, m.initials, m.kind, m.avatar_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' => $pid],
|
|
);
|
|
|
|
$roadmap = Database::fetchAll(
|
|
'SELECT id, title, description, step_date, is_done, sort_order, created_at, updated_at
|
|
FROM roadmap_steps WHERE project_id = :pid ORDER BY sort_order ASC, id ASC',
|
|
[':pid' => $pid],
|
|
);
|
|
foreach ($roadmap as &$s) {
|
|
$s['id'] = (int) $s['id'];
|
|
$s['is_done'] = (bool) $s['is_done'];
|
|
$s['sort_order'] = (int) $s['sort_order'];
|
|
}
|
|
unset($s);
|
|
|
|
$discCount = (int) Database::fetchValue('SELECT COUNT(*) FROM discussions WHERE project_id = :pid', [':pid' => $pid]);
|
|
$resCount = (int) Database::fetchValue('SELECT COUNT(*) FROM resources WHERE project_id = :pid', [':pid' => $pid]);
|
|
|
|
return [
|
|
'id' => $pid,
|
|
'slug' => $p['slug'],
|
|
'title' => $p['title'],
|
|
'status' => $p['status'],
|
|
'territory' => $p['territory'],
|
|
'short_description' => $p['short_description'],
|
|
'context_description' => $p['context_description'],
|
|
'objectives' => $p['objectives'],
|
|
'cover_image_url' => $p['cover_image_url'],
|
|
'external_url' => $p['external_url'],
|
|
'progress_percent' => (int) $p['progress_percent'],
|
|
'started_at' => $p['started_at'],
|
|
'completed_at' => $p['completed_at'],
|
|
'created_at' => $p['created_at'],
|
|
'updated_at' => $p['updated_at'],
|
|
'category' => $cat === null ? null : [
|
|
'id' => (int) $cat['id'],
|
|
'slug' => $cat['slug'],
|
|
'name' => $cat['name'],
|
|
'color_hex' => $cat['color_hex'],
|
|
'icon' => $cat['icon'],
|
|
'dimension' => $cat['dimension'],
|
|
],
|
|
'members' => array_map(static fn(array $m): array => [
|
|
'id' => (int) $m['id'],
|
|
'slug' => $m['slug'],
|
|
'display_name' => $m['display_name'],
|
|
'initials' => $m['initials'],
|
|
'kind' => $m['kind'],
|
|
'avatar_url' => $m['avatar_url'],
|
|
'role' => $m['role'],
|
|
'joined_at' => $m['joined_at'],
|
|
], $members),
|
|
'roadmap' => $roadmap,
|
|
'counts' => [
|
|
'discussions' => $discCount,
|
|
'resources' => $resCount,
|
|
'members' => count($members),
|
|
],
|
|
];
|
|
}
|
|
}
|