Étape 67 : adresse + bouton dans l'onglet « Messages » pour envoyer tout de suite un email de test, résultat réel affiché (envoyé, désactivé, connexion ou message refusé, réponse du serveur SMTP) — POST .../notifications/test. Étape 66 : prénom et nom facultatifs à l'ajout manuel d'un bénévole ; un bénévole sans nom s'affiche « Sans nom » / « ? » partout. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
398 lines
17 KiB
TypeScript
398 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,
|
|
} 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, 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`),
|
|
},
|
|
};
|
|
}
|