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,56 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Infolab\Controllers;
|
||||
|
||||
use Infolab\Database;
|
||||
use Infolab\Request;
|
||||
use Infolab\Response;
|
||||
|
||||
/**
|
||||
* GET /api/activity — flux pour le dashboard ("Vous a rejoint le projet X").
|
||||
* Filtres : ?limit=N (1..100, défaut 20), ?project=slug.
|
||||
*/
|
||||
final class ActivityController
|
||||
{
|
||||
public function index(Request $req, array $params): void
|
||||
{
|
||||
$limit = max(1, min(100, (int) $req->query('limit', 20)));
|
||||
|
||||
$where = '1=1';
|
||||
$args = [];
|
||||
|
||||
$projectSlug = $req->query('project');
|
||||
if ($projectSlug !== null && $projectSlug !== '') {
|
||||
$pid = Database::fetchValue('SELECT id FROM projects WHERE slug = :s', [':s' => (string) $projectSlug]);
|
||||
if ($pid === null) {
|
||||
// Projet inconnu : on renvoie un flux vide (pas d'erreur, le slug peut
|
||||
// simplement ne pas exister côté serveur — comportement choisi pour
|
||||
// simplifier le widget côté front).
|
||||
Response::json(['data' => []]);
|
||||
return;
|
||||
}
|
||||
$where = 'project_id = :pid';
|
||||
$args[':pid'] = (int) $pid;
|
||||
}
|
||||
|
||||
$rows = Database::fetchAll(
|
||||
"SELECT id, project_id, member_id, actor_label, verb, summary, occurred_at
|
||||
FROM activity_log WHERE $where
|
||||
ORDER BY occurred_at DESC, id DESC LIMIT $limit",
|
||||
$args,
|
||||
);
|
||||
|
||||
$data = array_map(static fn(array $r): array => [
|
||||
'id' => (int) $r['id'],
|
||||
'project_id' => $r['project_id'] === null ? null : (int) $r['project_id'],
|
||||
'member_id' => $r['member_id'] === null ? null : (int) $r['member_id'],
|
||||
'actor_label' => $r['actor_label'],
|
||||
'verb' => $r['verb'],
|
||||
'summary' => $r['summary'],
|
||||
'occurred_at' => $r['occurred_at'],
|
||||
], $rows);
|
||||
|
||||
Response::json(['data' => $data]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
<?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 catégories thématiques (Data, Démocratie, Habitat, …).
|
||||
* Les catégories sont seedées par migration ; les écritures restent disponibles
|
||||
* pour ajustements ponctuels (token requis).
|
||||
*/
|
||||
final class CategoriesController
|
||||
{
|
||||
private const SELECT = 'SELECT id, slug, name, description, color_hex, icon, dimension, sort_order, created_at, updated_at FROM categories';
|
||||
|
||||
public function index(Request $req, array $params): void
|
||||
{
|
||||
$rows = Database::fetchAll(self::SELECT . ' ORDER BY sort_order ASC, id ASC');
|
||||
Response::json(['data' => array_map([self::class, 'present'], $rows)]);
|
||||
}
|
||||
|
||||
public function show(Request $req, array $params): void
|
||||
{
|
||||
$cat = self::findOr404($params['slug']);
|
||||
$cat['projects_count'] = (int) Database::fetchValue(
|
||||
'SELECT COUNT(*) FROM projects WHERE category_id = :id AND deleted_at IS NULL',
|
||||
[':id' => (int) $cat['id']],
|
||||
);
|
||||
Response::json(self::present($cat) + ['projects_count' => $cat['projects_count']]);
|
||||
}
|
||||
|
||||
public function store(Request $req, array $params): void
|
||||
{
|
||||
$body = $req->json();
|
||||
$v = new Validator($body);
|
||||
$v->required('name')->string('name', 128);
|
||||
$v->string('slug', 64);
|
||||
$v->string('description', 5000);
|
||||
$v->colorHex('color_hex');
|
||||
$v->string('icon', 32);
|
||||
$v->in('dimension', ['social', 'ecologique', 'transversal']);
|
||||
$v->integer('sort_order', 0);
|
||||
$v->check();
|
||||
|
||||
$name = (string) $body['name'];
|
||||
$base = (isset($body['slug']) && $body['slug'] !== '')
|
||||
? Slugger::slugify((string) $body['slug'])
|
||||
: Slugger::slugify($name);
|
||||
$slug = Slugger::unique($base, 'categories');
|
||||
|
||||
Database::insert('categories', [
|
||||
'slug' => $slug,
|
||||
'name' => $name,
|
||||
'description' => $body['description'] ?? null,
|
||||
'color_hex' => $body['color_hex'] ?? '#888888',
|
||||
'icon' => $body['icon'] ?? null,
|
||||
'dimension' => $body['dimension'] ?? 'transversal',
|
||||
'sort_order' => isset($body['sort_order']) ? (int) $body['sort_order'] : 0,
|
||||
]);
|
||||
Response::json(self::present(self::findOr404($slug)), 201);
|
||||
}
|
||||
|
||||
public function update(Request $req, array $params): void
|
||||
{
|
||||
$cat = self::findOr404($params['slug']);
|
||||
$body = $req->json();
|
||||
$v = new Validator($body);
|
||||
$v->string('name', 128);
|
||||
$v->string('slug', 64);
|
||||
$v->string('description', 5000);
|
||||
$v->colorHex('color_hex');
|
||||
$v->string('icon', 32);
|
||||
$v->in('dimension', ['social', 'ecologique', 'transversal']);
|
||||
$v->integer('sort_order', 0);
|
||||
$v->check();
|
||||
|
||||
$update = [];
|
||||
foreach (['name', 'description', 'color_hex', 'icon', 'dimension', 'sort_order'] as $f) {
|
||||
if (array_key_exists($f, $body)) {
|
||||
$update[$f] = $body[$f];
|
||||
}
|
||||
}
|
||||
if (isset($update['sort_order'])) {
|
||||
$update['sort_order'] = (int) $update['sort_order'];
|
||||
}
|
||||
if (isset($body['slug']) && $body['slug'] !== '') {
|
||||
$update['slug'] = Slugger::unique(
|
||||
Slugger::slugify((string) $body['slug']),
|
||||
'categories',
|
||||
'slug',
|
||||
(int) $cat['id'],
|
||||
);
|
||||
}
|
||||
if ($update !== []) {
|
||||
Database::update('categories', $update, 'id = :id', [':id' => (int) $cat['id']]);
|
||||
}
|
||||
Response::json(self::present(self::findOr404($update['slug'] ?? $cat['slug'])));
|
||||
}
|
||||
|
||||
public function destroy(Request $req, array $params): void
|
||||
{
|
||||
$cat = self::findOr404($params['slug']);
|
||||
// FK ON DELETE RESTRICT côté projects -> on vérifie nous-mêmes pour un
|
||||
// message propre. Compte aussi les projets soft-deletés (la FK ne fait
|
||||
// pas la différence et bloquerait sinon en cascade).
|
||||
$count = (int) Database::fetchValue(
|
||||
'SELECT COUNT(*) FROM projects WHERE category_id = :id',
|
||||
[':id' => (int) $cat['id']],
|
||||
);
|
||||
if ($count > 0) {
|
||||
throw new \ApiException(409, [
|
||||
'error' => 'conflict',
|
||||
'message' => sprintf('Cette catégorie est utilisée par %d projet(s).', $count),
|
||||
]);
|
||||
}
|
||||
Database::delete('categories', 'id = :id', [':id' => (int) $cat['id']]);
|
||||
Response::noContent();
|
||||
}
|
||||
|
||||
private 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;
|
||||
}
|
||||
|
||||
private static function present(array $row): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) $row['id'],
|
||||
'slug' => $row['slug'],
|
||||
'name' => $row['name'],
|
||||
'description' => $row['description'],
|
||||
'color_hex' => $row['color_hex'],
|
||||
'icon' => $row['icon'],
|
||||
'dimension' => $row['dimension'],
|
||||
'sort_order' => (int) $row['sort_order'],
|
||||
'created_at' => $row['created_at'],
|
||||
'updated_at' => $row['updated_at'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
<?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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Infolab\Controllers;
|
||||
|
||||
use Infolab\Database;
|
||||
use Infolab\Request;
|
||||
use Infolab\Response;
|
||||
|
||||
/**
|
||||
* GET /api/health
|
||||
* Sonde de vie de l'API + ping rapide BDD. Renvoie 503 si la BDD ne répond pas.
|
||||
*/
|
||||
final class HealthController
|
||||
{
|
||||
public function index(Request $req, array $params): void
|
||||
{
|
||||
try {
|
||||
Database::fetchValue('SELECT 1');
|
||||
} catch (\Throwable $e) {
|
||||
error_log('[Health] DB ping failed: ' . $e->getMessage());
|
||||
Response::json([
|
||||
'status' => 'degraded',
|
||||
'time' => gmdate('Y-m-d\TH:i:s\Z'),
|
||||
], 503);
|
||||
return;
|
||||
}
|
||||
Response::json([
|
||||
'status' => 'ok',
|
||||
'time' => gmdate('Y-m-d\TH:i:s\Z'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
<?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),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Infolab\Controllers;
|
||||
|
||||
use Infolab\ActivityLogger;
|
||||
use Infolab\Database;
|
||||
use Infolab\Request;
|
||||
use Infolab\Response;
|
||||
use Infolab\Validator;
|
||||
|
||||
/**
|
||||
* Ressources d'un projet (liens, docs, datasets, vidéos).
|
||||
*/
|
||||
final class ResourcesController
|
||||
{
|
||||
private const KINDS = ['link', 'document', 'dataset', 'video', 'other'];
|
||||
|
||||
public function index(Request $req, array $params): void
|
||||
{
|
||||
$p = ProjectsController::findOr404($params['slug']);
|
||||
$rows = Database::fetchAll(
|
||||
'SELECT id, project_id, title, description, url, kind, sort_order, created_at, updated_at
|
||||
FROM resources WHERE project_id = :pid ORDER BY sort_order ASC, id ASC',
|
||||
[':pid' => (int) $p['id']],
|
||||
);
|
||||
Response::json(['data' => array_map([self::class, 'present'], $rows)]);
|
||||
}
|
||||
|
||||
public function store(Request $req, array $params): void
|
||||
{
|
||||
$p = ProjectsController::findOr404($params['slug']);
|
||||
$body = $req->json();
|
||||
$v = new Validator($body);
|
||||
$v->required('title')->string('title', 200);
|
||||
$v->required('url')->url('url', 1024);
|
||||
$v->string('description', 5000);
|
||||
$v->in('kind', self::KINDS);
|
||||
$v->integer('sort_order', 0);
|
||||
$v->check();
|
||||
|
||||
$sortOrder = isset($body['sort_order'])
|
||||
? (int) $body['sort_order']
|
||||
: self::nextSortOrder((int) $p['id']);
|
||||
|
||||
$id = Database::insert('resources', [
|
||||
'project_id' => (int) $p['id'],
|
||||
'title' => (string) $body['title'],
|
||||
'description' => $body['description'] ?? null,
|
||||
'url' => (string) $body['url'],
|
||||
'kind' => $body['kind'] ?? 'link',
|
||||
'sort_order' => $sortOrder,
|
||||
]);
|
||||
|
||||
ActivityLogger::log(
|
||||
'added_resource',
|
||||
sprintf('a ajouté la ressource « %s » au projet « %s »', (string) $body['title'], $p['title']),
|
||||
(int) $p['id'],
|
||||
);
|
||||
|
||||
$row = Database::fetchOne('SELECT * FROM resources WHERE id = :id', [':id' => $id]);
|
||||
Response::json(self::present($row), 201);
|
||||
}
|
||||
|
||||
public function update(Request $req, array $params): void
|
||||
{
|
||||
$p = ProjectsController::findOr404($params['slug']);
|
||||
$r = self::findOr404((int) $params['id'], (int) $p['id']);
|
||||
|
||||
$body = $req->json();
|
||||
$v = new Validator($body);
|
||||
$v->string('title', 200);
|
||||
$v->string('description', 5000);
|
||||
$v->url('url', 1024);
|
||||
$v->in('kind', self::KINDS);
|
||||
$v->integer('sort_order', 0);
|
||||
$v->check();
|
||||
|
||||
$update = [];
|
||||
foreach (['title', 'description', 'url', 'kind', 'sort_order'] as $f) {
|
||||
if (array_key_exists($f, $body)) {
|
||||
$update[$f] = $body[$f];
|
||||
}
|
||||
}
|
||||
if (isset($update['sort_order'])) {
|
||||
$update['sort_order'] = (int) $update['sort_order'];
|
||||
}
|
||||
if ($update !== []) {
|
||||
Database::update('resources', $update, 'id = :id', [':id' => (int) $r['id']]);
|
||||
}
|
||||
$fresh = Database::fetchOne('SELECT * FROM resources WHERE id = :id', [':id' => (int) $r['id']]);
|
||||
Response::json(self::present($fresh));
|
||||
}
|
||||
|
||||
public function destroy(Request $req, array $params): void
|
||||
{
|
||||
$p = ProjectsController::findOr404($params['slug']);
|
||||
$r = self::findOr404((int) $params['id'], (int) $p['id']);
|
||||
Database::delete('resources', 'id = :id', [':id' => (int) $r['id']]);
|
||||
Response::noContent();
|
||||
}
|
||||
|
||||
private static function findOr404(int $id, int $projectId): array
|
||||
{
|
||||
$row = Database::fetchOne(
|
||||
'SELECT * FROM resources 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 nextSortOrder(int $projectId): int
|
||||
{
|
||||
return (int) Database::fetchValue(
|
||||
'SELECT COALESCE(MAX(sort_order), 0) + 10 FROM resources WHERE project_id = :pid',
|
||||
[':pid' => $projectId],
|
||||
);
|
||||
}
|
||||
|
||||
private static function present(array $row): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) $row['id'],
|
||||
'project_id' => (int) $row['project_id'],
|
||||
'title' => $row['title'],
|
||||
'description' => $row['description'],
|
||||
'url' => $row['url'],
|
||||
'kind' => $row['kind'],
|
||||
'sort_order' => (int) $row['sort_order'],
|
||||
'created_at' => $row['created_at'],
|
||||
'updated_at' => $row['updated_at'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Infolab\Controllers;
|
||||
|
||||
use Infolab\ActivityLogger;
|
||||
use Infolab\Database;
|
||||
use Infolab\Request;
|
||||
use Infolab\Response;
|
||||
use Infolab\Validator;
|
||||
|
||||
/**
|
||||
* Étapes de feuille de route d'un projet.
|
||||
*/
|
||||
final class RoadmapController
|
||||
{
|
||||
public function index(Request $req, array $params): void
|
||||
{
|
||||
$p = ProjectsController::findOr404($params['slug']);
|
||||
$rows = Database::fetchAll(
|
||||
'SELECT id, project_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' => (int) $p['id']],
|
||||
);
|
||||
Response::json(['data' => array_map([self::class, 'present'], $rows)]);
|
||||
}
|
||||
|
||||
public function store(Request $req, array $params): void
|
||||
{
|
||||
$p = ProjectsController::findOr404($params['slug']);
|
||||
$body = $req->json();
|
||||
$v = new Validator($body);
|
||||
$v->required('title')->string('title', 200);
|
||||
$v->string('description', 5000);
|
||||
$v->date('step_date');
|
||||
$v->boolean('is_done');
|
||||
$v->integer('sort_order', 0);
|
||||
$v->check();
|
||||
|
||||
$sortOrder = isset($body['sort_order'])
|
||||
? (int) $body['sort_order']
|
||||
: self::nextSortOrder((int) $p['id']);
|
||||
|
||||
$id = Database::insert('roadmap_steps', [
|
||||
'project_id' => (int) $p['id'],
|
||||
'title' => (string) $body['title'],
|
||||
'description' => $body['description'] ?? null,
|
||||
'step_date' => $body['step_date'] ?? null,
|
||||
'is_done' => self::toBool($body['is_done'] ?? false) ? 1 : 0,
|
||||
'sort_order' => $sortOrder,
|
||||
]);
|
||||
|
||||
ActivityLogger::log(
|
||||
'added_step',
|
||||
sprintf('a ajouté l\'étape « %s » au projet « %s »', (string) $body['title'], $p['title']),
|
||||
(int) $p['id'],
|
||||
);
|
||||
|
||||
Response::json(self::present(self::findStepOr404($id, (int) $p['id'])), 201);
|
||||
}
|
||||
|
||||
public function update(Request $req, array $params): void
|
||||
{
|
||||
$p = ProjectsController::findOr404($params['slug']);
|
||||
$step = self::findStepOr404((int) $params['id'], (int) $p['id']);
|
||||
|
||||
$body = $req->json();
|
||||
$v = new Validator($body);
|
||||
$v->string('title', 200);
|
||||
$v->string('description', 5000);
|
||||
$v->date('step_date');
|
||||
$v->boolean('is_done');
|
||||
$v->integer('sort_order', 0);
|
||||
$v->check();
|
||||
|
||||
$update = [];
|
||||
foreach (['title', 'description', 'step_date', 'sort_order'] as $f) {
|
||||
if (array_key_exists($f, $body)) {
|
||||
$update[$f] = $body[$f];
|
||||
}
|
||||
}
|
||||
if (isset($update['sort_order'])) {
|
||||
$update['sort_order'] = (int) $update['sort_order'];
|
||||
}
|
||||
|
||||
$becameDone = false;
|
||||
if (array_key_exists('is_done', $body)) {
|
||||
$newDone = self::toBool($body['is_done']);
|
||||
$oldDone = (bool) $step['is_done'];
|
||||
$update['is_done'] = $newDone ? 1 : 0;
|
||||
if ($newDone && !$oldDone) {
|
||||
$becameDone = true;
|
||||
}
|
||||
}
|
||||
if ($update !== []) {
|
||||
Database::update('roadmap_steps', $update, 'id = :id', [':id' => (int) $step['id']]);
|
||||
}
|
||||
if ($becameDone) {
|
||||
ActivityLogger::log(
|
||||
'completed_step',
|
||||
sprintf('a terminé l\'étape « %s » du projet « %s »', $step['title'], $p['title']),
|
||||
(int) $p['id'],
|
||||
);
|
||||
}
|
||||
|
||||
Response::json(self::present(self::findStepOr404((int) $step['id'], (int) $p['id'])));
|
||||
}
|
||||
|
||||
public function destroy(Request $req, array $params): void
|
||||
{
|
||||
$p = ProjectsController::findOr404($params['slug']);
|
||||
$step = self::findStepOr404((int) $params['id'], (int) $p['id']);
|
||||
Database::delete('roadmap_steps', 'id = :id', [':id' => (int) $step['id']]);
|
||||
Response::noContent();
|
||||
}
|
||||
|
||||
private static function findStepOr404(int $id, int $projectId): array
|
||||
{
|
||||
$row = Database::fetchOne(
|
||||
'SELECT id, project_id, title, description, step_date, is_done, sort_order, created_at, updated_at
|
||||
FROM roadmap_steps WHERE id = :id AND project_id = :pid',
|
||||
[':id' => $id, ':pid' => $projectId],
|
||||
);
|
||||
if ($row === null) {
|
||||
throw new \ApiException(404, ['error' => 'not_found']);
|
||||
}
|
||||
return $row;
|
||||
}
|
||||
|
||||
/** Sort order par défaut : MAX + 10 par projet (laisse de l'espace pour insertion ultérieure). */
|
||||
private static function nextSortOrder(int $projectId): int
|
||||
{
|
||||
return (int) Database::fetchValue(
|
||||
'SELECT COALESCE(MAX(sort_order), 0) + 10 FROM roadmap_steps WHERE project_id = :pid',
|
||||
[':pid' => $projectId],
|
||||
);
|
||||
}
|
||||
|
||||
private static function toBool(mixed $v): bool
|
||||
{
|
||||
if (is_bool($v)) return $v;
|
||||
if (is_int($v)) return $v !== 0;
|
||||
if (is_string($v)) return in_array(strtolower($v), ['true', '1', 'yes', 'on'], true);
|
||||
return (bool) $v;
|
||||
}
|
||||
|
||||
private static function present(array $row): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) $row['id'],
|
||||
'project_id' => (int) $row['project_id'],
|
||||
'title' => $row['title'],
|
||||
'description' => $row['description'],
|
||||
'step_date' => $row['step_date'],
|
||||
'is_done' => (bool) $row['is_done'],
|
||||
'sort_order' => (int) $row['sort_order'],
|
||||
'created_at' => $row['created_at'],
|
||||
'updated_at' => $row['updated_at'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Infolab\Controllers;
|
||||
|
||||
use Infolab\Database;
|
||||
use Infolab\Request;
|
||||
use Infolab\Response;
|
||||
|
||||
/**
|
||||
* GET /api/stats — KPIs du dashboard.
|
||||
*
|
||||
* - projects_{active,completed,archived,total} : projets non soft-deletés
|
||||
* - participants_total : total des membres de l'annuaire
|
||||
* (interprétation : "annuaire des contributeurs" = participants potentiels)
|
||||
* - by_category : un objet { slug, name, color_hex, count } par catégorie
|
||||
* - by_dimension : { social, ecologique, transversal } sommés sur projets non-deleted
|
||||
*/
|
||||
final class StatsController
|
||||
{
|
||||
public function index(Request $req, array $params): void
|
||||
{
|
||||
// Compteurs par status (projets non soft-deletés).
|
||||
$statusCounts = Database::fetchAll(
|
||||
'SELECT status, COUNT(*) AS c FROM projects WHERE deleted_at IS NULL GROUP BY status',
|
||||
);
|
||||
$byStatus = ['active' => 0, 'completed' => 0, 'archived' => 0];
|
||||
foreach ($statusCounts as $r) {
|
||||
$byStatus[$r['status']] = (int) $r['c'];
|
||||
}
|
||||
|
||||
$participantsTotal = (int) Database::fetchValue('SELECT COUNT(*) FROM members');
|
||||
|
||||
// Compteur par catégorie : LEFT JOIN pour inclure les catégories à 0 projet.
|
||||
$byCategoryRows = Database::fetchAll(
|
||||
'SELECT c.slug, c.name, c.color_hex, c.dimension,
|
||||
COALESCE(SUM(CASE WHEN p.deleted_at IS NULL THEN 1 ELSE 0 END), 0) AS count
|
||||
FROM categories c
|
||||
LEFT JOIN projects p ON p.category_id = c.id
|
||||
GROUP BY c.id, c.slug, c.name, c.color_hex, c.dimension
|
||||
ORDER BY c.sort_order ASC',
|
||||
);
|
||||
|
||||
$byDimension = ['social' => 0, 'ecologique' => 0, 'transversal' => 0];
|
||||
$byCategory = [];
|
||||
foreach ($byCategoryRows as $r) {
|
||||
$count = (int) $r['count'];
|
||||
$byCategory[] = [
|
||||
'slug' => $r['slug'],
|
||||
'name' => $r['name'],
|
||||
'color_hex' => $r['color_hex'],
|
||||
'count' => $count,
|
||||
];
|
||||
$byDimension[$r['dimension']] = ($byDimension[$r['dimension']] ?? 0) + $count;
|
||||
}
|
||||
|
||||
Response::json([
|
||||
'projects_active' => $byStatus['active'],
|
||||
'projects_completed' => $byStatus['completed'],
|
||||
'projects_archived' => $byStatus['archived'],
|
||||
'projects_total' => $byStatus['active'] + $byStatus['completed'] + $byStatus['archived'],
|
||||
'participants_total' => $participantsTotal,
|
||||
'by_category' => $byCategory,
|
||||
'by_dimension' => $byDimension,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user