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:
co-authored by
Claude Sonnet 5
parent
db11d9a3ba
commit
8fcc22115f
@@ -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({
|
||||
|
||||
@@ -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' }),
|
||||
},
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user