Files
benevoles/frontend/src/api/http-client.ts
T
AdamThomasandClaude Opus 5.5 c49ce520e3
ci-cd / build-backend (push) Successful in 33s
ci-cd / build-frontend (push) Successful in 18s
ci-cd / deploy (push) Successful in 12s
Type de paiement à montant libre (pensé pour le mécénat)
FormPaymentOption.free_amount (migration 0021) : case « Montant libre »
sur l'écran Inscriptions ; le montant configuré devient le minimum et le
bénévole saisit le sien sur le formulaire public (payment_amount, virgule
ou point, 2 décimales max, plafond 10 000 € contre une faute de frappe —
422 form.invalid_free_amount sinon). Validé côté client avant l'envoi,
le serveur restant seul juge. Le montant choisi est celui du checkout
SumUp, recopié sur la fiche, l'email de confirmation et le cerfa.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 13:49:27 +02:00

384 lines
16 KiB
TypeScript

import type {
AccountCreate,
AccountUpdate,
ArtistCreate,
ArtistUpdate,
AssignmentCreate,
AssignmentUpdate,
AssignmentValidateRequest,
AvailabilitySet,
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();
throw new ApiError(res.status, code, violations);
}
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, []);
}
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`),
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' }),
},
availability: {
list: (editionId, volunteerId) =>
request(`${base(editionId)}/volunteers/${volunteerId}/availability`),
replace: (editionId, volunteerId, body: AvailabilitySet) =>
request(`${base(editionId)}/volunteers/${volunteerId}/availability`, {
method: 'PUT',
body: JSON.stringify(body),
}),
listEdition: (editionId) => request(`${base(editionId)}/availability`),
},
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`),
},
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`),
},
};
}