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:
2026-05-28 19:53:50 +02:00
parent 6679fa8b30
commit 3a32e93ab1
32 changed files with 3994 additions and 0 deletions
+186
View File
@@ -0,0 +1,186 @@
<?php
declare(strict_types=1);
namespace Infolab;
/**
* Validation fluente des payloads JSON. Accumule les erreurs par champ, puis
* `check()` lance une ApiException 422 conforme au contrat documenté :
*
* { "error": "validation_failed", "fields": { "title": "required", ... } }
*
* Toutes les règles autres que `required()` sont silencieuses si la clé
* n'existe pas — ainsi les PATCH partiels passent sans déclarer les champs absents.
*/
final class Validator
{
/** @var array<string,mixed> */
private array $data;
/** @var array<string,string> code d'erreur par champ */
private array $errors = [];
/** @param array<string,mixed> $data */
public function __construct(array $data)
{
$this->data = $data;
}
public function has(string $key): bool
{
return array_key_exists($key, $this->data);
}
public function value(string $key, mixed $default = null): mixed
{
return $this->data[$key] ?? $default;
}
/** Définit manuellement une erreur sur un champ (ex: "not_found" après lookup BDD). */
public function setError(string $key, string $code): self
{
$this->errors[$key] = $code;
return $this;
}
public function required(string $key): self
{
if (!$this->has($key) || $this->data[$key] === null || $this->data[$key] === '') {
$this->errors[$key] ??= 'required';
}
return $this;
}
public function string(string $key, ?int $max = null, ?int $min = null): self
{
if (!$this->has($key) || $this->data[$key] === null) {
return $this;
}
$v = $this->data[$key];
if (!is_string($v)) {
$this->errors[$key] ??= 'must_be_string';
return $this;
}
$len = mb_strlen($v);
if ($min !== null && $len < $min) $this->errors[$key] ??= 'too_short';
if ($max !== null && $len > $max) $this->errors[$key] ??= 'too_long';
return $this;
}
public function integer(string $key, ?int $min = null, ?int $max = null): self
{
if (!$this->has($key) || $this->data[$key] === null) {
return $this;
}
$v = $this->data[$key];
if (is_int($v)) {
$i = $v;
} elseif (is_string($v) && preg_match('/^-?\d+$/', $v)) {
$i = (int) $v;
} else {
$this->errors[$key] ??= 'must_be_integer';
return $this;
}
if ($min !== null && $i < $min) $this->errors[$key] ??= 'too_small';
if ($max !== null && $i > $max) $this->errors[$key] ??= 'too_large';
return $this;
}
public function boolean(string $key): self
{
if (!$this->has($key) || $this->data[$key] === null) {
return $this;
}
$v = $this->data[$key];
if (!is_bool($v) && !in_array($v, [0, 1, '0', '1', 'true', 'false'], true)) {
$this->errors[$key] ??= 'must_be_boolean';
}
return $this;
}
/** @param array<int,mixed> $allowed */
public function in(string $key, array $allowed): self
{
if (!$this->has($key) || $this->data[$key] === null) {
return $this;
}
if (!in_array($this->data[$key], $allowed, true)) {
$this->errors[$key] ??= 'invalid_value';
}
return $this;
}
public function url(string $key, int $max = 1024): self
{
if (!$this->has($key) || $this->data[$key] === null || $this->data[$key] === '') {
return $this;
}
$v = $this->data[$key];
if (!is_string($v) || filter_var($v, FILTER_VALIDATE_URL) === false || mb_strlen($v) > $max) {
$this->errors[$key] ??= 'invalid_url';
}
return $this;
}
public function email(string $key): self
{
if (!$this->has($key) || $this->data[$key] === null || $this->data[$key] === '') {
return $this;
}
if (!is_string($this->data[$key]) || filter_var($this->data[$key], FILTER_VALIDATE_EMAIL) === false) {
$this->errors[$key] ??= 'invalid_email';
}
return $this;
}
/** Format ISO date YYYY-MM-DD strict. */
public function date(string $key): self
{
if (!$this->has($key) || $this->data[$key] === null || $this->data[$key] === '') {
return $this;
}
$v = $this->data[$key];
if (!is_string($v)) {
$this->errors[$key] ??= 'invalid_date';
return $this;
}
$d = \DateTimeImmutable::createFromFormat('Y-m-d', $v);
if ($d === false || $d->format('Y-m-d') !== $v) {
$this->errors[$key] ??= 'invalid_date';
}
return $this;
}
public function colorHex(string $key): self
{
if (!$this->has($key) || $this->data[$key] === null || $this->data[$key] === '') {
return $this;
}
if (!is_string($this->data[$key]) || !preg_match('/^#[0-9A-Fa-f]{6}$/', $this->data[$key])) {
$this->errors[$key] ??= 'invalid_color_hex';
}
return $this;
}
public function fails(): bool
{
return $this->errors !== [];
}
/** @return array<string,string> */
public function errors(): array
{
return $this->errors;
}
/** Termine la validation : lance ApiException 422 si au moins une erreur. */
public function check(): void
{
if ($this->errors !== []) {
throw new \ApiException(422, [
'error' => 'validation_failed',
'fields' => $this->errors,
]);
}
}
}