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, } 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(path: string, init?: RequestInit): Promise { 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 { 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) => { 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, email: string) => request(`${base(editionId)}/notifications/test`, { method: 'POST', body: JSON.stringify({ email }), }), }, 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`), }, }; }