(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 update(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']); } $body = $req->json(); $v = new Validator($body); $v->required('role')->in('role', ['admin', 'contributor', 'observer']); $v->check(); $updated = Database::update( 'project_members', ['role' => (string) $body['role']], 'project_id = :pid AND member_id = :mid', [':pid' => (int) $p['id'], ':mid' => (int) $member['id']], ); if ($updated === 0) { // Soit la ligne pivot n'existe pas, soit le rôle est déjà le même. // Vérifie pour distinguer 404 vs no-op. $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(404, ['error' => 'not_found']); } } Response::json([ 'project_id' => (int) $p['id'], 'member' => [ 'id' => (int) $member['id'], 'slug' => $member['slug'], 'display_name' => $member['display_name'], ], 'role' => (string) $body['role'], ]); } 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(); } }