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 */ 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'], ]; } }