Files
benevoles/frontend/src/api/http-client.ts
T
AdamThomasandClaude Opus 5.5 6892e0307d
ci-cd / build-backend (push) Successful in 33s
ci-cd / build-frontend (push) Successful in 20s
ci-cd / deploy (push) Successful in 14s
Photo du bénévole ajoutée par la coordination
Section « Photo » sur la fiche bénévole (ajouter / remplacer / retirer),
PUT/DELETE /editions/{id}/volunteers/{vid}/photo (COORDINATION),
validation de l'image partagée avec le formulaire public (read_photo).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 18:17:25 +01:00

387 lines
16 KiB
TypeScript

import type {
AccountCreate,
AccountUpdate,
ArtistCreate,
ArtistUpdate,
AssignmentCreate,
AssignmentUpdate,
AssignmentValidateRequest,
BadgeFieldsSet,
BadgeGenerateRequest,
BadgeSizeUpdate,
DayPartCreate,
DayPartUpdate,
EditionUpdate,
FormFieldsSet,
FormPaymentOptionCreate,
FormPaymentOptionUpdate,
FormTemplateUpdate,
ImportMapping,
LoginRequest,
MagicLinkRequest,
MecenatInfoSubmit,
PlanningNotificationRequest,
PositionCreate,
PositionSheetsRequest,
PositionUpdate,
PublicFormSubmission,
ShiftCreate,
ShiftUpdate,
VerifyRequest,
VolunteerCreate,
VolunteerUpdate,
} from './schema';
import { getAuthToken, notifyUnauthorized } from './session';
import { ApiError, type PlanningApi } from './types';
// Implémentation HTTP réelle. En dev, Vite proxifie `/api` vers le back-end.
export function createHttpApi(baseUrl: string): PlanningApi {
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const token = getAuthToken();
// Pour un envoi `multipart/form-data`, on laisse le navigateur fixer
// lui-même l'en-tête `Content-Type` (avec la frontière).
const isForm = init?.body instanceof FormData;
const res = await fetch(`${baseUrl}${path}`, {
...init,
headers: {
...(isForm ? {} : { 'Content-Type': 'application/json' }),
...(token ? { Authorization: `Bearer ${token}` } : {}),
...init?.headers,
},
credentials: 'include',
});
if (res.status === 204) return undefined as T;
const isJson = res.headers.get('content-type')?.includes('application/json');
const body: unknown = isJson ? await res.json() : await res.text();
if (!res.ok) {
const detail =
body && typeof body === 'object' && 'detail' in body
? (body as { detail: unknown }).detail
: body;
const code =
detail && typeof detail === 'object' && 'code' in detail
? String((detail as { code: unknown }).code)
: 'error.generic';
const violations =
detail && typeof detail === 'object' && 'violations' in detail
? ((detail as { violations: [] }).violations ?? [])
: [];
if (res.status === 401 && !path.includes('/auth/')) notifyUnauthorized();
const requestId = res.headers.get('X-Request-ID');
if (res.status >= 500) {
console.error(`API ${res.status} ${path} — référence ${requestId ?? '?'}`, detail);
}
throw new ApiError(res.status, code, violations, requestId);
}
return body as T;
}
// Réponses binaires (images du badge, PDF généré) : pas de JSON, on ne
// veut que les octets (ou `null` pour une image absente, en 404).
async function requestBlob(path: string, init?: RequestInit): Promise<Blob | null> {
const token = getAuthToken();
const isForm = init?.body instanceof FormData;
const res = await fetch(`${baseUrl}${path}`, {
...init,
headers: {
...(isForm ? {} : { 'Content-Type': 'application/json' }),
...(token ? { Authorization: `Bearer ${token}` } : {}),
...init?.headers,
},
credentials: 'include',
});
if (res.status === 404) return null;
if (!res.ok) {
let code = 'error.generic';
try {
const body = (await res.json()) as { detail?: { code?: string } };
code = body.detail?.code ?? code;
} catch {
// Corps non JSON (ex. erreur réseau) : garde le code générique.
}
if (res.status === 401) notifyUnauthorized();
throw new ApiError(res.status, code, [], res.headers.get('X-Request-ID'));
}
return res.blob();
}
const query = (params: Record<string, string | undefined>) => {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value != null) search.set(key, value);
}
const str = search.toString();
return str ? `?${str}` : '';
};
const base = (editionId: string) => `/editions/${editionId}`;
return {
editions: {
get: (editionId) => request(base(editionId)),
update: (editionId, body: EditionUpdate) =>
request(base(editionId), { method: 'PATCH', body: JSON.stringify(body) }),
},
dayParts: {
list: (editionId) => request(`${base(editionId)}/day-parts`),
create: (editionId, body: DayPartCreate) =>
request(`${base(editionId)}/day-parts`, { method: 'POST', body: JSON.stringify(body) }),
update: (editionId, id, body: DayPartUpdate) =>
request(`${base(editionId)}/day-parts/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
remove: (editionId, id) =>
request(`${base(editionId)}/day-parts/${id}`, { method: 'DELETE' }),
},
artists: {
list: (editionId) => request(`${base(editionId)}/artists`),
create: (editionId, body: ArtistCreate) =>
request(`${base(editionId)}/artists`, { method: 'POST', body: JSON.stringify(body) }),
update: (editionId, id, body: ArtistUpdate) =>
request(`${base(editionId)}/artists/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
remove: (editionId, id) =>
request(`${base(editionId)}/artists/${id}`, { method: 'DELETE' }),
},
volunteers: {
list: (editionId) => request(`${base(editionId)}/volunteers`),
get: (editionId, id) => request(`${base(editionId)}/volunteers/${id}`),
create: (editionId, body: VolunteerCreate) =>
request(`${base(editionId)}/volunteers`, { method: 'POST', body: JSON.stringify(body) }),
update: (editionId, id, body: VolunteerUpdate) =>
request(`${base(editionId)}/volunteers/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
remove: (editionId, id) =>
request(`${base(editionId)}/volunteers/${id}`, { method: 'DELETE' }),
planning: (editionId, id) =>
request(`${base(editionId)}/volunteers/${id}/planning`),
photoBlob: (editionId, id) => requestBlob(`${base(editionId)}/volunteers/${id}/photo`),
setPhoto: (editionId, id, file: File) => {
const form = new FormData();
form.append('file', file);
return request(`${base(editionId)}/volunteers/${id}/photo`, { method: 'PUT', body: form });
},
removePhoto: (editionId, id) =>
request(`${base(editionId)}/volunteers/${id}/photo`, { method: 'DELETE' }),
exportXlsx: async (editionId) => {
const blob = await requestBlob(`${base(editionId)}/volunteers/export`);
if (!blob) throw new ApiError(500, 'error.generic', []);
return blob;
},
exportMecenatReceipts: async (editionId) => {
const blob = await requestBlob(`${base(editionId)}/volunteers/mecenat-receipts`);
if (!blob) throw new ApiError(500, 'error.generic', []);
return blob;
},
},
accounts: {
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' }),
},
imports: {
analyze: (editionId, file: File) => {
const form = new FormData();
form.append('file', file);
return request(`${base(editionId)}/import/analyze`, { method: 'POST', body: form });
},
execute: (editionId, file: File, mapping: ImportMapping) => {
const form = new FormData();
form.append('file', file);
form.append('mapping', JSON.stringify(mapping));
return request(`${base(editionId)}/import/execute`, { method: 'POST', body: form });
},
},
positions: {
list: (editionId) => request(`${base(editionId)}/positions`),
get: (editionId, id) => request(`${base(editionId)}/positions/${id}`),
create: (editionId, body: PositionCreate) =>
request(`${base(editionId)}/positions`, { method: 'POST', body: JSON.stringify(body) }),
update: (editionId, id, body: PositionUpdate) =>
request(`${base(editionId)}/positions/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
remove: (editionId, id) =>
request(`${base(editionId)}/positions/${id}`, { method: 'DELETE' }),
generateShifts: (editionId, id) =>
request(`${base(editionId)}/positions/${id}/generate-shifts`, { method: 'POST' }),
},
shifts: {
list: (editionId, params) =>
request(`${base(editionId)}/shifts${query({ position_id: params?.positionId })}`),
get: (editionId, id) => request(`${base(editionId)}/shifts/${id}`),
create: (editionId, body: ShiftCreate) =>
request(`${base(editionId)}/shifts`, { method: 'POST', body: JSON.stringify(body) }),
update: (editionId, id, body: ShiftUpdate) =>
request(`${base(editionId)}/shifts/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
remove: (editionId, id) => request(`${base(editionId)}/shifts/${id}`, { method: 'DELETE' }),
},
assignments: {
list: (editionId, params) =>
request(
`${base(editionId)}/assignments${query({
volunteer_id: params?.volunteerId,
shift_id: params?.shiftId,
})}`,
),
get: (editionId, id) => request(`${base(editionId)}/assignments/${id}`),
create: (editionId, body: AssignmentCreate) =>
request(`${base(editionId)}/assignments`, { method: 'POST', body: JSON.stringify(body) }),
update: (editionId, id, body: AssignmentUpdate) =>
request(`${base(editionId)}/assignments/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
remove: (editionId, id) =>
request(`${base(editionId)}/assignments/${id}`, { method: 'DELETE' }),
validate: (editionId, body: AssignmentValidateRequest) =>
request(`${base(editionId)}/assignments/validate`, {
method: 'POST',
body: JSON.stringify(body),
}),
},
matrix: {
get: (editionId) => request(`${base(editionId)}/matrix`),
},
stats: {
get: (editionId) => request(`${base(editionId)}/stats`),
},
badges: {
getTemplate: (editionId) => request(`${base(editionId)}/badges/template`),
uploadFrontImage: (editionId, file: File) => {
const form = new FormData();
form.append('file', file);
return request(`${base(editionId)}/badges/template/front-image`, {
method: 'PUT',
body: form,
});
},
uploadBackImage: (editionId, file: File) => {
const form = new FormData();
form.append('file', file);
return request(`${base(editionId)}/badges/template/back-image`, {
method: 'PUT',
body: form,
});
},
frontImageBlob: (editionId) => requestBlob(`${base(editionId)}/badges/template/front-image`),
backImageBlob: (editionId) => requestBlob(`${base(editionId)}/badges/template/back-image`),
setFields: (editionId, body: BadgeFieldsSet) =>
request(`${base(editionId)}/badges/template/fields`, {
method: 'PUT',
body: JSON.stringify(body),
}),
setSize: (editionId, body: BadgeSizeUpdate) =>
request(`${base(editionId)}/badges/template/size`, {
method: 'PUT',
body: JSON.stringify(body),
}),
generate: async (editionId, body: BadgeGenerateRequest) => {
const blob = await requestBlob(`${base(editionId)}/badges/generate`, {
method: 'POST',
body: JSON.stringify(body),
});
if (!blob) throw new ApiError(500, 'error.generic', []);
return blob;
},
},
forms: {
getTemplate: (editionId) => request(`${base(editionId)}/form`),
setFields: (editionId, body: FormFieldsSet) =>
request(`${base(editionId)}/form/fields`, {
method: 'PUT',
body: JSON.stringify(body),
}),
setOpen: (editionId, body: FormTemplateUpdate) =>
request(`${base(editionId)}/form`, { method: 'PATCH', body: JSON.stringify(body) }),
uploadLogo: (editionId, file: File) => {
const form = new FormData();
form.append('file', file);
return request(`${base(editionId)}/form/logo`, { method: 'PUT', body: form });
},
regenerateToken: (editionId) =>
request(`${base(editionId)}/form/regenerate-token`, { method: 'POST' }),
getPublicConfig: (editionId, token) =>
request(`${base(editionId)}/form/public?token=${encodeURIComponent(token)}`),
logoBlob: (editionId, token) =>
requestBlob(`${base(editionId)}/form/logo?token=${encodeURIComponent(token)}`),
submitPublic: (editionId, token, body: PublicFormSubmission) => {
const form = new FormData();
form.append('access_token', token);
form.append('first_name', body.first_name);
form.append('last_name', body.last_name);
form.append('hcaptcha_token', body.hcaptcha_token);
if (body.email) form.append('email', body.email);
if (body.phone) form.append('phone', body.phone);
if (body.comment) form.append('comment', body.comment);
if (body.companion_wish_note) form.append('companion_wish_note', body.companion_wish_note);
if (body.available !== undefined) form.append('available', String(body.available));
if (body.payment_option_id) form.append('payment_option_id', body.payment_option_id);
if (body.payment_amount) form.append('payment_amount', body.payment_amount);
if (body.position_wish_ids) {
form.append('position_wish_ids', JSON.stringify(body.position_wish_ids));
}
if (body.artist_wish_ids) {
form.append('artist_wish_ids', JSON.stringify(body.artist_wish_ids));
}
if (body.photo) form.append('photo', body.photo);
return request(`${base(editionId)}/form/public/submit`, { method: 'POST', body: form });
},
paymentOptions: {
list: (editionId) => request(`${base(editionId)}/form/payment-options`),
create: (editionId, body: FormPaymentOptionCreate) =>
request(`${base(editionId)}/form/payment-options`, {
method: 'POST',
body: JSON.stringify(body),
}),
update: (editionId, id, body: FormPaymentOptionUpdate) =>
request(`${base(editionId)}/form/payment-options/${id}`, {
method: 'PATCH',
body: JSON.stringify(body),
}),
remove: (editionId, id) =>
request(`${base(editionId)}/form/payment-options/${id}`, { method: 'DELETE' }),
},
submitMecenatInfo: (editionId, token, body: MecenatInfoSubmit) =>
request(`${base(editionId)}/form/public/pending/${token}/mecenat-info`, {
method: 'POST',
body: JSON.stringify(body),
}),
getPendingStatus: (editionId, token) =>
request(`${base(editionId)}/form/public/pending/${token}`),
retryPendingPayment: (editionId, token) =>
request(`${base(editionId)}/form/public/pending/${token}/retry-payment`, {
method: 'POST',
}),
},
positionSheets: {
generate: async (editionId, body: PositionSheetsRequest) => {
const blob = await requestBlob(`${base(editionId)}/position-sheets/generate`, {
method: 'POST',
body: JSON.stringify(body),
});
if (!blob) throw new ApiError(500, 'error.generic', []);
return blob;
},
},
solver: {
run: (editionId) =>
request(`${base(editionId)}/solver/suggestions`, { method: 'POST' }),
},
notifications: {
sendPlanning: (editionId, body: PlanningNotificationRequest) =>
request(`${base(editionId)}/notifications/planning`, {
method: 'POST',
body: JSON.stringify(body),
}),
},
auth: {
login: (editionId, body: LoginRequest) =>
request(`${base(editionId)}/auth/login`, { method: 'POST', body: JSON.stringify(body) }),
requestMagicLink: (editionId, body: MagicLinkRequest) =>
request(`${base(editionId)}/auth/magic-link`, { method: 'POST', body: JSON.stringify(body) }),
verifyMagicLink: (editionId, body: VerifyRequest) =>
request(`${base(editionId)}/auth/verify`, { method: 'POST', body: JSON.stringify(body) }),
me: () => request(`/auth/me`),
},
};
}