Gestion des référents depuis la fiche bénévole

Un poste avait bien un `lead_account_id`, lu par l'écran référent
(`/mobile-referent`) et par les droits d'écriture (`ensure_can_write_position`),
mais rien ne permettait de le renseigner : aucun écran ne l'exposait, et
l'API ne validait même pas que la valeur envoyée désignait un compte référent
existant de la bonne édition.

Backend :
- `PATCH /editions/{id}/accounts/{account_id}` (nouveau) : change le `role`
  d'un compte, seulement entre LEAD et VOLUNTEER (lien magique dans les deux
  cas) — 422 `account.role_change_not_allowed` dès que COORDINATOR est
  impliqué (mot de passe en jeu, jamais touché ici). C'est ce qui permet de
  faire passer le compte d'un bénévole en référent.
- `POST`/`PATCH /positions` : `lead_account_id` vérifié — doit désigner un
  compte `Role.LEAD` de la même édition, sinon 422
  `position.lead_must_be_referent_account` (silencieusement accepté
  jusqu'ici, y compris un compte d'une autre édition ou du mauvais rôle).

Frontend (`ConfigVolunteers.tsx`) : nouvelle section « Référent des postes »
sur la fiche du bénévole, juste après l'accès de connexion — mêmes badges à
cocher que les vœux de poste/artiste. Cocher un poste crée ou met à niveau
(LEAD) l'accès de connexion du bénévole automatiquement ; décocher retire
seulement le poste, sans jamais rétrograder l'accès. Un bénévole dont le
compte est déjà COORDINATOR ne peut pas être désigné référent (il a déjà
accès à tout). Miroir complet côté mock (`accounts.update`,
`checkLeadAccount`) pour que la démonstration se comporte pareil.

Vérifié : 204 tests backend pertinents (14 nouveaux : 6 sur `PATCH accounts`,
8 sur la validation `lead_account_id`), ruff, alembic check ; typecheck/
lint/build/smoke frontend ; parcours complet testé en direct sur le jeu de
démo (création de compte, promotion LEAD, rejet COORDINATOR, affectation
référent) avant nettoyage des données de test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
This commit is contained in:
AdamThomas
2026-09-15 23:19:56 +02:00
co-authored by Claude Sonnet 5
parent db11d9a3ba
commit 8fcc22115f
14 changed files with 373 additions and 7 deletions
+10
View File
@@ -8,6 +8,7 @@ import {
import { EDITION_ID, api } from './index';
import type {
AccountCreate,
AccountUpdate,
ArtistCreate,
ArtistUpdate,
AssignmentCreate,
@@ -253,6 +254,15 @@ export function useCreateAccount() {
});
}
export function useUpdateAccount() {
const invalidate = useInvalidate();
return useMutation({
mutationFn: ({ id, body }: { id: string; body: AccountUpdate }) =>
api.accounts.update(edition, id, body),
onSuccess: invalidate.accounts,
});
}
export function useDeleteAccount() {
const invalidate = useInvalidate();
return useMutation({
+3
View File
@@ -1,5 +1,6 @@
import type {
AccountCreate,
AccountUpdate,
ArtistCreate,
ArtistUpdate,
AssignmentCreate,
@@ -114,6 +115,8 @@ export function createHttpApi(baseUrl: string): PlanningApi {
list: (editionId) => request(`${base(editionId)}/accounts`),
create: (editionId, body: AccountCreate) =>
request(`${base(editionId)}/accounts`, { method: 'POST', body: JSON.stringify(body) }),
update: (editionId, id, body: AccountUpdate) =>
request(`${base(editionId)}/accounts/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
remove: (editionId, id) =>
request(`${base(editionId)}/accounts/${id}`, { method: 'DELETE' }),
},
+24
View File
@@ -129,6 +129,16 @@ function resolveCompanionWishes(ids: string[], selfId?: string): string[] {
return resolved;
}
/** `lead_account_id` d'un poste : doit désigner un compte référent (LEAD)
* existant — sinon le poste se retrouve sans référent utilisable. */
function checkLeadAccount(leadAccountId: string | null | undefined): void {
if (leadAccountId == null) return;
const lead = state.accounts.find((a) => a.id === leadAccountId);
if (!lead || lead.role !== 'LEAD') {
throw new ApiError(422, 'position.lead_must_be_referent_account');
}
}
function shiftWindow(day: string, dp: DayPartRead): [string, string] {
const endDay = dp.end_time <= dp.start_time ? addDays(day, 1) : day;
return [`${day}T${dp.start_time}`, `${endDay}T${dp.end_time}`];
@@ -859,6 +869,18 @@ export function createMockApi(): PlanningApi {
state.accounts.push(account);
return delay(account);
},
update: (_edition, id, body) => {
const account = state.accounts.find((a) => a.id === id) ?? notFound();
if (body.role != null && body.role !== account.role) {
if (account.role === 'COORDINATOR' || body.role === 'COORDINATOR') {
throw new ApiError(422, 'account.role_change_not_allowed');
}
account.role = body.role;
account.auth_method = 'magic_link';
}
account.updated_at = touch();
return delay(account);
},
remove: (_edition, id) => {
state.accounts = state.accounts.filter((a) => a.id !== id);
return delay(undefined);
@@ -1090,6 +1112,7 @@ export function createMockApi(): PlanningApi {
delay([...state.positions].sort((a, b) => a.name.localeCompare(b.name, 'fr'))),
get: (_edition, id) => delay(state.positions.find((x) => x.id === id) ?? notFound()),
create: (_edition, body) => {
checkLeadAccount(body.lead_account_id);
const now = touch();
const position = {
id: `p${state.seq++}`,
@@ -1107,6 +1130,7 @@ export function createMockApi(): PlanningApi {
},
update: (_edition, id, body) => {
const p = state.positions.find((x) => x.id === id) ?? notFound();
if ('lead_account_id' in body) checkLeadAccount(body.lead_account_id);
Object.assign(p, {
...('name' in body ? { name: body.name } : {}),
...('location' in body ? { location: body.location ?? null } : {}),
+5
View File
@@ -421,6 +421,11 @@ export interface AccountCreate {
password?: string | null;
}
export interface AccountUpdate {
/** Seulement entre LEAD et VOLUNTEER — jamais vers/depuis COORDINATOR. */
role?: Role;
}
export interface TokenResponse {
access_token: string;
token_type: string;
+3
View File
@@ -1,6 +1,7 @@
import type {
AccountCreate,
AccountRead,
AccountUpdate,
ArtistCreate,
ArtistRead,
ArtistUpdate,
@@ -73,6 +74,8 @@ export interface PlanningApi {
/** Comptes de connexion (mot de passe ou lien magique) de l'édition. */
list(editionId: string): Promise<AccountRead[]>;
create(editionId: string, body: AccountCreate): Promise<AccountRead>;
/** Changement de rôle seulement (LEAD ↔ VOLUNTEER) — voir `AccountUpdate`. */
update(editionId: string, id: string, body: AccountUpdate): Promise<AccountRead>;
remove(editionId: string, id: string): Promise<void>;
};
availability: {