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,67 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Infolab;
|
||||
|
||||
/**
|
||||
* Génère et vérifie l'unicité des slugs. Translittération ASCII via
|
||||
* l'extension intl (Transliterator), avec fallback bas niveau.
|
||||
*/
|
||||
final class Slugger
|
||||
{
|
||||
/**
|
||||
* Slugifie un texte : minuscules, accents retirés, non-alphanum -> tirets.
|
||||
* Toujours non vide (retourne "item" en dernier recours).
|
||||
*/
|
||||
public static function slugify(string $text): string
|
||||
{
|
||||
$text = trim($text);
|
||||
if ($text === '') {
|
||||
return 'item';
|
||||
}
|
||||
|
||||
// Transliteration intl si dispo (cas de Marseille -> marseille, é -> e).
|
||||
if (class_exists(\Transliterator::class)) {
|
||||
$tr = \Transliterator::create('Any-Latin; Latin-ASCII; Lower()');
|
||||
if ($tr !== null) {
|
||||
$translit = $tr->transliterate($text);
|
||||
if (is_string($translit)) {
|
||||
$text = $translit;
|
||||
}
|
||||
}
|
||||
}
|
||||
$text = mb_strtolower($text);
|
||||
// Remplace toute séquence non-[a-z0-9] par un tiret, puis trim les tirets de bord.
|
||||
$text = preg_replace('/[^a-z0-9]+/', '-', $text) ?? '';
|
||||
$text = trim($text, '-');
|
||||
return $text === '' ? 'item' : $text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Garantit l'unicité d'un slug dans une table : suffixe `-2`, `-3`, …
|
||||
* Si `$excludeId` est fourni, ignore cette ligne (cas PATCH d'un slug existant).
|
||||
*/
|
||||
public static function unique(string $base, string $table, string $column = 'slug', ?int $excludeId = null): string
|
||||
{
|
||||
$candidate = $base;
|
||||
$n = 2;
|
||||
while (self::exists($candidate, $table, $column, $excludeId)) {
|
||||
$candidate = $base . '-' . $n;
|
||||
$n++;
|
||||
}
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
private static function exists(string $slug, string $table, string $column, ?int $excludeId): bool
|
||||
{
|
||||
// Noms de table/colonne câblés depuis le code, jamais depuis l'utilisateur.
|
||||
$sql = sprintf('SELECT 1 FROM `%s` WHERE `%s` = :slug', $table, $column);
|
||||
$params = [':slug' => $slug];
|
||||
if ($excludeId !== null) {
|
||||
$sql .= ' AND id <> :id';
|
||||
$params[':id'] = $excludeId;
|
||||
}
|
||||
$sql .= ' LIMIT 1';
|
||||
return Database::fetchValue($sql, $params) !== null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user