Files
benevoles/frontend/src/api/http-client.ts
T
AdamThomasandClaude Opus 5.5 440466ccf9
ci-cd / build-backend (push) Successful in 46s
ci-cd / deploy (push) Successful in 12s
ci-cd / build-frontend (push) Successful in 21s
Email de test : récapitulatif de planning compilé
L'email de test reprend le modèle en cours d'édition, balises remplies
avec le planning d'un bénévole au choix (premier avec un créneau par
défaut), option lien de connexion sans vrai jeton, sujet « [Test] … » —
même fonction de rendu que l'envoi réel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 19:44:14 +01:00

399 lines
17 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,
AttendanceUpdate,
TestEmailRequest,
} 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' }),
setAttendance: (editionId, id, body: AttendanceUpdate) =>
request(`${base(editionId)}/assignments/${id}/attendance`, {
method: 'PUT',
body: JSON.stringify(body),
}),
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),
}),
sendTest: (editionId, body: TestEmailRequest) =>
request(`${base(editionId)}/notifications/test`, {
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`),
},
};
}