689dfeee14
Backend
- ProjectMembersController::update : nouveau PATCH /api/projects/:slug/members/:member_slug
pour changer le rôle (admin/contributor/observer). 404 si pivot absent,
no-op idempotent si déjà même rôle, 422 si rôle invalide. Route câblée
dans public/index.php.
Frontend
- JoinProjectModal : 2 onglets (Membre existant / Nouveau membre)
* "existant" : <select> de l'annuaire filtré sur excludeSlugs (membres
déjà sur le projet), preview avatar+bio du membre sélectionné
* "nouveau" : flow d'origine (créer le membre puis l'attacher)
* sélecteur de rôle visible dans les 2 modes
* onJoined remonte un Member complet (au lieu de {id,slug,name})
-> ProjectDetailPage peut pousser un ProjectMemberEntry avec ses
vraies initials/avatar/kind dans la liste optimiste
- ProjectDetailPage panneau Équipe (mode admin uniquement) :
* Rôle inline éditable via <select> stylé minimal -> PATCH optimiste
avec rollback en cas d'échec
* Bouton retirer-membre par ligne (DELETE) avec confirm
* Le badge "Vous" / "Admin connecté" ne change pas
Code-review (high effort, --fix appliqué) :
- loadDirectory affiche maintenant une vraie erreur réseau au lieu de
silencieusement présenter "tous les membres déjà sur ce projet"
- loadDirectory est lazy : on ne fetche l'annuaire que si l'onglet
"existant" est actif (économise 1 requête si admin saute direct à "nouveau")
- Aperçu du membre sélectionné : computed `selectedMember` au lieu de
<template v-for><template v-if>
- Bouton retirer-membre : opacity-60 sur mobile (sm: et plus garde le
behavior hover-to-reveal) -> reste atteignable sur touch
- + label `aria-label` pour l'accessibilité
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
175 lines
6.0 KiB
PHP
175 lines
6.0 KiB
PHP
<?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 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();
|
|
}
|
|
}
|