import { dayBoundaryFromDayParts, festivalDay } from '@/lib/planning'; import { hoursBetween, naiveMinutes } from '@/lib/time'; import type { AccountRead, ArtistCreate, ArtistRead, ArtistUpdate, AssignmentRead, AssignmentValidateResult, BadgeFieldRead, BadgeFieldsSet, BadgeGenerateRequest, BadgeSizeUpdate, BadgeTemplateRead, DayPartCreate, DayPartRead, DayPartUpdate, EditionUpdate, FormFieldsSet, FormPaymentOptionCreate, FormPaymentOptionRead, FormPaymentOptionUpdate, FormSubmissionResult, FormTemplateRead, FormTemplateUpdate, ImportAnalyzeResponse, ImportMapping, ImportPreviewRow, ImportReport, ImportRowReport, MatrixResponse, MecenatInfoSubmit, PendingSubmissionStatus, PlanningNotificationRequest, PositionSheetsRequest, PublicFormConfig, PublicFormSubmission, ShiftRead, SolverRunResult, SolverSuggestion, TokenResponse, Violation, VolunteerRead, EditionStats, } from '../schema'; import { getAuthToken } from '../session'; import { ApiError, type PlanningApi } from '../types'; import { buildMockState, type MockPendingSubmission, type MockState } from './dataset'; // Implémentation en mémoire de `PlanningApi`. Un délai réseau simulé rend le // comportement (chargements, invalidations) proche du back-end réel. const LATENCY_MS = 120; const MAGIC_LINK_TTL_MS = 15 * 60 * 1000; let state: MockState = buildMockState(); /** token de session -> id de compte. */ const sessions = new Map(); /** token magique brut -> compte + expiration. */ const magicTokens = new Map(); /** email (minuscule) -> dernier token magique émis (démo : l'email est stubé). */ const lastMagicLink = new Map(); /** Réinitialise le jeu de démonstration (utile en test). */ export function resetMockState(): void { state = buildMockState(); sessions.clear(); magicTokens.clear(); lastMagicLink.clear(); } /** * Mode mock uniquement : récupère le dernier lien magique émis pour un email. * En mode HTTP le back-end l'envoie par mail (ici stubé dans ses logs). */ export function peekMockMagicToken(email: string): string | undefined { return lastMagicLink.get(email.trim().toLowerCase()); } function accountByEmail(email: string): AccountRead | undefined { const target = email.trim().toLowerCase(); return state.accounts.find((a) => a.email.toLowerCase() === target); } function issue(account: AccountRead): TokenResponse { const token = `mock.${account.id}.${Math.random().toString(36).slice(2, 10)}`; sessions.set(token, account.id); return { access_token: token, token_type: 'bearer', account }; } const delay = (value: T): Promise => new Promise((resolve) => setTimeout(() => resolve(structuredClone(value)), LATENCY_MS)); const notFound = (): never => { throw new ApiError(404, 'error.not_found'); }; // Format CR80 (85,6 × 54 mm) par défaut tant qu'aucune image de recto n'est // importée — sinon le format du badge est dérivé de ses pixels (voir // `imageSizeMm`), comme côté back-end (`app/badges/pdf.py:image_size_mm`). const DEFAULT_BADGE_WIDTH_MM = 85.6; const DEFAULT_BADGE_HEIGHT_MM = 54; const BADGE_ASSUMED_DPI = 300; const imageSizeMm = async (file: File): Promise<{ width_mm: number; height_mm: number }> => { const bitmap = await createImageBitmap(file); const width_mm = (bitmap.width / BADGE_ASSUMED_DPI) * 25.4; const height_mm = (bitmap.height / BADGE_ASSUMED_DPI) * 25.4; bitmap.close(); return { width_mm, height_mm }; }; // Approche de `str.title()` (Python) utilisé côté back-end à la soumission // du formulaire public — capitalise chaque mot (« - » compris). /** Miroir de `app/api/forms.py:_free_amount` (montant libre). */ const freeAmount = (raw: string | undefined, minimum: number): number => { const text = (raw ?? '').trim().replace(',', '.'); const value = Number(text); if (!/^\d+(\.\d{1,2})?$/.test(text) || value < minimum || value > 10000) { throw new ApiError(422, 'form.invalid_free_amount'); } return value; }; const titleCase = (s: string): string => s.toLowerCase().replace(/\b\p{L}/gu, (c) => c.toUpperCase()); const fileToDataUrl = (file: File): Promise => new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result as string); reader.onerror = () => reject(reader.error); reader.readAsDataURL(file); }); const sumupEnabled = (): boolean => Boolean( state.formTemplate.sumupMerchantCode && state.formTemplate.sumupApiKey && state.formPaymentOptions.length > 0, ); const formTemplateRead = (): FormTemplateRead => ({ edition_id: state.edition.id, is_open: state.formTemplate.isOpen, fields: state.formTemplate.fields, has_logo: state.formTemplate.logo !== null, description: state.formTemplate.description, sumup_merchant_code: state.formTemplate.sumupMerchantCode, sumup_api_key_set: state.formTemplate.sumupApiKey !== null, access_token: state.formTemplate.accessToken, }); const sortedPaymentOptions = (): FormPaymentOptionRead[] => [...state.formPaymentOptions].sort((a, b) => a.order - b.order || a.label.localeCompare(b.label)); // PDF valide minimal (une page blanche) — remplace la vraie génération // (réservée au back-end réel) pour que le téléchargement fonctionne en mode // démonstration. const MOCK_PDF_BLOB = () => new Blob( [ '%PDF-1.4\n1 0 obj<>endobj\n' + '2 0 obj<>endobj\n' + '3 0 obj<>endobj\n' + 'trailer<>\n%%EOF', ], { type: 'application/pdf' }, ); // Contenu factice (pas un vrai classeur) — la génération réelle (openpyxl) // est réservée au back-end ; en mode démonstration, seul le déclenchement // du téléchargement est simulé. const MOCK_XLSX_BLOB = () => new Blob(['Export bénévoles (démonstration)'], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', }); const touch = () => new Date().toISOString().replace(/\.\d+Z$/, ''); const addDays = (day: string, n: number): string => { const [y, m, d] = day.split('-').map(Number); return new Date(Date.UTC(y, m - 1, d + n)).toISOString().slice(0, 10); }; const slugCode = (label: string): string => label .trim() .toUpperCase() .normalize('NFD') .replace(/[^A-Z0-9]+/g, '_') .replace(/^_+|_+$/g, ''); /** « HH:MM » ou « HH:MM:SS » -> « HH:MM:SS ». */ const hms = (time: string): string => (time.length === 5 ? `${time}:00` : time); /** Vœux du bénévole : dé-doublonne, ordre conservé ; 422 si un id est étranger. */ function resolveWishes( pool: T[], ids: string[], code: string, ): string[] { const wanted = [...new Set(ids)]; const known = new Set(pool.map((x) => x.id)); if (wanted.some((id) => !known.has(id))) throw new ApiError(422, code); return wanted; } /** Comme `resolveWishes` mais silencieux : filtre les ids toujours valides * sans jamais lever — utilisé à la promotion d'une inscription en attente * (le paiement a déjà eu lieu, voir `promotePending`). */ function filterKnownIds(pool: T[], ids: string[]): string[] { const known = new Set(pool.map((x) => x.id)); return [...new Set(ids)].filter((id) => known.has(id)); } /** Paiement confirmé : crée enfin le `Volunteer` (voir le docstring de * `PendingFormSubmission` côté back-end) puis vide les champs nominatifs de * l'inscription en attente. */ function promotePending(pending: MockPendingSubmission): VolunteerRead { const now = touch(); const volunteer: VolunteerRead = { id: `v${state.seq++}`, edition_id: state.edition.id, first_name: pending.firstName ?? '', last_name: pending.lastName ?? '', email: pending.email, phone: pending.phone, comment: pending.comment, artist_ids: filterKnownIds(state.artists, pending.artistIds), wished_position_ids: filterKnownIds(state.positions, pending.positionIds), wished_companion_ids: [], has_photo: pending.photo !== null, companion_wish_note: pending.companionWishNote, self_declared_available: pending.available, willing_to_pay_fee: true, fee_payment_label: pending.paymentLabel, fee_amount: pending.paymentAmount, mecenat_address: pending.mecenatAddress, mecenat_city: pending.mecenatCity, mecenat_postal_code: pending.mecenatPostalCode, mecenat_country: pending.mecenatCountry, fee_payment_status: 'PAID', fee_paid_at: now, created_at: now, updated_at: now, }; if (pending.photo) state.photos.set(volunteer.id, pending.photo); state.volunteers.push(volunteer); pending.promotedVolunteerId = volunteer.id; pending.status = 'PAID'; pending.firstName = null; pending.lastName = null; pending.email = null; pending.phone = null; pending.comment = null; pending.companionWishNote = null; pending.photo = null; pending.mecenatAddress = null; pending.mecenatCity = null; pending.mecenatPostalCode = null; pending.mecenatCountry = null; return volunteer; } /** Simule la création (ou relance) d'un checkout SumUp — pas de vrai appel * réseau en mode démonstration, voir `app/api/forms.py:_create_pending_checkout`. */ function createPendingCheckout(pending: MockPendingSubmission): string { pending.checkoutId = `co-mock-${state.seq++}`; pending.status = 'PENDING'; pending.pollCount = 0; return `https://checkout.sumup.com/pay/mock-${pending.checkoutId}`; } /** Vœux de compagnon : comme `resolveWishes`, plus le rejet de soi-même. */ function resolveCompanionWishes(ids: string[], selfId?: string): string[] { const resolved = resolveWishes(state.volunteers, ids, 'volunteer.unknown'); if (selfId != null && resolved.includes(selfId)) { throw new ApiError(422, 'volunteer.cannot_wish_self'); } return resolved; } /** `lead_account_id` d'un poste : doit désigner un compte référent (LEAD) * existant — sinon le poste se retrouve sans référent utilisable. */ function checkLeadAccount(leadAccountId: string | null | undefined): void { if (leadAccountId == null) return; const lead = state.accounts.find((a) => a.id === leadAccountId); if (!lead || lead.role !== 'LEAD') { throw new ApiError(422, 'position.lead_must_be_referent_account'); } } function shiftWindow(day: string, dp: DayPartRead): [string, string] { const endDay = dp.end_time <= dp.start_time ? addDays(day, 1) : day; return [`${day}T${dp.start_time}`, `${endDay}T${dp.end_time}`]; } function generateMissingShifts(positionId: string): number { const existing = new Set( state.shifts .filter((s) => s.position_id === positionId) .map((s) => `${s.day_part_id}|${s.starts_at.slice(0, 10)}`), ); let created = 0; let day = state.edition.start_date; while (day <= state.edition.end_date) { for (const dp of state.dayParts) { if (existing.has(`${dp.id}|${day}`)) continue; const [starts_at, ends_at] = shiftWindow(day, dp); const now = touch(); state.shifts.push({ id: `s${state.seq++}`, edition_id: state.edition.id, position_id: positionId, day_part_id: dp.id, starts_at, ends_at, min_staff: 0, ideal_staff: 0, created_at: now, updated_at: now, }); created += 1; } day = addDays(day, 1); } return created; } function shiftById(id: string): ShiftRead { const shift = state.shifts.find((s) => s.id === id); if (!shift) return notFound(); return shift; } // --------------------------------------------------------------------------- // // Import de bénévoles (mode démo : fichiers CSV uniquement) // // --------------------------------------------------------------------------- // const IMPORT_HINTS: Record = { email: ['mail', 'courriel'], first_name: ['prenom', 'first name', 'firstname', 'given name'], last_name: ['nom', 'last name', 'lastname', 'surname', 'famille'], phone: ['tel', 'phone', 'portable', 'mobile', 'gsm'], comment: ['commentaire', 'remarque', 'note', 'precision'], }; const IMPORT_EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; function normalizeText(value: string): string { return (value ?? '') .trim() .toLowerCase() .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .replace(/\s+/g, ' '); } function splitCsvLine(line: string, delimiter: string): string[] { const cells: string[] = []; let cur = ''; let quoted = false; for (let i = 0; i < line.length; i += 1) { const ch = line[i]; if (quoted) { if (ch === '"' && line[i + 1] === '"') { cur += '"'; i += 1; } else if (ch === '"') { quoted = false; } else { cur += ch; } } else if (ch === '"') { quoted = true; } else if (ch === delimiter) { cells.push(cur); cur = ''; } else { cur += ch; } } cells.push(cur); return cells.map((c) => c.trim()); } async function readImportTable(file: File): Promise<{ headers: string[]; rows: string[][] }> { const name = (file.name || '').toLowerCase(); const buf = new Uint8Array(await file.arrayBuffer()); const isXlsx = name.endsWith('.xlsx') || name.endsWith('.xlsm') || (buf[0] === 0x50 && buf[1] === 0x4b && buf[2] === 0x03 && buf[3] === 0x04); if (isXlsx) throw new ApiError(422, 'import.error.xlsx_demo_only'); const text = new TextDecoder('utf-8') .decode(buf) .replace(/^\uFEFF/, '') .replace(/\r\n?/g, '\n') .trim(); if (!text) throw new ApiError(400, 'import.error.empty_file'); const lines = text.split('\n').filter((l) => l.trim() !== ''); const delimiter = [';', '\t', ','].find((d) => lines[0].includes(d)) ?? ','; const headers = splitCsvLine(lines[0], delimiter); const rows = lines.slice(1).map((l) => splitCsvLine(l, delimiter)); if (headers.length === 0) throw new ApiError(400, 'import.error.empty_file'); return { headers, rows }; } function suggestImportMapping(headers: string[]): Record { const result: Record = {}; const used = new Set(); for (const [field, hints] of Object.entries(IMPORT_HINTS)) { for (const header of headers) { if (used.has(header)) continue; if (hints.some((h) => normalizeText(header).includes(h))) { result[field] = header; used.add(header); break; } } } return result; } function overlaps(a: ShiftRead, b: ShiftRead): boolean { return a.starts_at < b.ends_at && b.starts_at < a.ends_at; } /** Portage frontend du moteur (`backend/app/validation/engine.py`). */ function runValidation(volunteerId: string, candidate: ShiftRead, excludeAssignmentId?: string): Violation[] { const volunteer = state.volunteers.find((v) => v.id === volunteerId); if (!volunteer) return notFound(); const existing = state.assignments .filter( (a) => a.volunteer_id === volunteerId && a.id !== excludeAssignmentId && a.shift_id !== candidate.id, ) .map((a) => shiftById(a.shift_id)); const violations: Violation[] = []; // SHIFT_OVERLAP (bloquante) for (const other of existing) { if (overlaps(candidate, other)) { violations.push({ code: 'SHIFT_OVERLAP', severity: 'BLOCKING', params: { other_shift_id: other.id, other_starts_at: other.starts_at, other_ends_at: other.ends_at, }, }); } } return violations; } function hasBlocking(violations: Violation[]): boolean { return violations.some((v) => v.severity === 'BLOCKING'); } /** Miroir de `app/api/stats.py` (mêmes règles, données du mock). */ function buildStats(): EditionStats { const vols = state.volunteers; const round = (n: number) => Math.round(n * 100) / 100; const addDay = (d: string) => new Date(Date.UTC(+d.slice(0, 4), +d.slice(5, 7) - 1, +d.slice(8, 10) + 1)) .toISOString() .slice(0, 10); const perDay = new Map(); for (const v of vols) perDay.set(v.created_at.slice(0, 10), (perDay.get(v.created_at.slice(0, 10)) ?? 0) + 1); const byDay: EditionStats['registrations']['by_day'] = []; const days = [...perDay.keys()].sort(); if (days.length) { let cumulative = 0; for (let d = days[0]; d <= days[days.length - 1]; d = addDay(d)) { cumulative += perDay.get(d) ?? 0; byDay.push({ date: d, count: perDay.get(d) ?? 0, cumulative }); } } const latest = days.length ? days[days.length - 1] : ''; const weekAgo = latest ? new Date(Date.parse(`${latest}T00:00:00Z`) - 6 * 86400000).toISOString().slice(0, 10) : ''; const paid = vols.filter((v) => v.fee_payment_status === 'PAID'); const byLabel = new Map(); for (const v of paid) byLabel.set(v.fee_payment_label ?? '', [...(byLabel.get(v.fee_payment_label ?? '') ?? []), v]); const mecenat = paid.filter((v) => v.mecenat_address); const sum = (list: VolunteerRead[]) => round(list.reduce((t, v) => t + (v.fee_amount ?? 0), 0)); const boundary = dayBoundaryFromDayParts(state.dayParts); type Row = { shifts: number; min: number; ideal: number; assigned: number }; const rows = new Map(); const row = (d: string) => { if (!rows.has(d)) rows.set(d, { shifts: 0, min: 0, ideal: 0, assigned: 0 }); return rows.get(d) as Row; }; const shiftsById = new Map(state.shifts.map((s) => [s.id, s])); for (const s of state.shifts) { const r = row(festivalDay(s.starts_at, boundary)); const h = hoursBetween(s.starts_at, s.ends_at); r.shifts += 1; r.min += h * s.min_staff; r.ideal += h * s.ideal_staff; } for (const a of state.assignments) { const s = shiftsById.get(a.shift_id); if (s) row(festivalDay(s.starts_at, boundary)).assigned += hoursBetween(s.starts_at, s.ends_at); } const assignedByShift = new Map(); for (const a of state.assignments) assignedByShift.set(a.shift_id, (assignedByShift.get(a.shift_id) ?? 0) + 1); return { registrations: { total: vols.length, last_7_days: vols.filter((v) => v.created_at.slice(0, 10) >= weekAgo).length, by_day: byDay, }, answers: { total: vols.length, email: vols.filter((v) => v.email).length, phone: vols.filter((v) => v.phone).length, photo: vols.filter((v) => v.has_photo).length, comment: vols.filter((v) => v.comment).length, companion_note: vols.filter((v) => v.companion_wish_note).length, available: vols.filter((v) => v.self_declared_available).length, position_wishes: vols.filter((v) => v.wished_position_ids.length).length, artist_wishes: vols.filter((v) => v.artist_ids.length).length, companion_wishes: vols.filter((v) => v.wished_companion_ids.length).length, }, payments: { online: vols.filter((v) => v.fee_payment_status !== null).length, on_site: vols.filter((v) => v.fee_payment_status === null && v.willing_to_pay_fee).length, none: vols.filter((v) => v.fee_payment_status === null && !v.willing_to_pay_fee).length, pending_unfinished: [...state.pendingSubmissions.values()].filter((p) => p.promotedVolunteerId === null).length, total_collected: sum(paid), mecenat_count: mecenat.length, mecenat_total: sum(mecenat), by_option: [...byLabel.entries()] .sort(([a], [b]) => a.localeCompare(b)) .map(([label, group]) => ({ label, mecenat: group.some((v) => v.mecenat_address), count: group.length, amount_total: sum(group), })), }, artist_wishes: [...state.artists] .sort((a, b) => a.performance_date.localeCompare(b.performance_date) || a.start_time.localeCompare(b.start_time)) .map((a) => ({ id: a.id, name: a.name, date: a.performance_date, count: vols.filter((v) => v.artist_ids.includes(a.id)).length, })), needs: { by_day: [...rows.entries()] .sort(([a], [b]) => a.localeCompare(b)) .map(([date, r]) => ({ date, shifts: r.shifts, min_hours: round(r.min), ideal_hours: round(r.ideal), assigned_hours: round(r.assigned), })), by_position: [...state.positions] .sort((a, b) => a.name.localeCompare(b.name)) .map((p) => { const own = state.shifts.filter((s) => s.position_id === p.id); return { position_id: p.id, name: p.name, shifts: own.length, min_slots: own.reduce((t, s) => t + s.min_staff, 0), ideal_slots: own.reduce((t, s) => t + s.ideal_staff, 0), assigned: own.reduce((t, s) => t + (assignedByShift.get(s.id) ?? 0), 0), filled: own.reduce((t, s) => t + Math.min(assignedByShift.get(s.id) ?? 0, s.ideal_staff), 0), wishes: vols.filter((v) => v.wished_position_ids.includes(p.id)).length, }; }), volunteers_with_assignment: new Set(state.assignments.map((a) => a.volunteer_id)).size, }, }; } function buildMatrix(): MatrixResponse { const counts = new Map(); for (const a of state.assignments) { counts.set(a.shift_id, (counts.get(a.shift_id) ?? 0) + 1); } return { edition_id: state.positions[0]?.edition_id ?? '', positions: [...state.positions] .sort((a, b) => a.name.localeCompare(b.name, 'fr')) .map((position) => ({ id: position.id, name: position.name, category: position.category, location: position.location, shifts: state.shifts .filter((s) => s.position_id === position.id) .sort((a, b) => a.starts_at.localeCompare(b.starts_at)) .map((s) => ({ shift_id: s.id, starts_at: s.starts_at, ends_at: s.ends_at, min_staff: s.min_staff, ideal_staff: s.ideal_staff, assigned: counts.get(s.id) ?? 0, })), })), }; } // --------------------------------------------------------------------------- // // Solveur (suggestions) — portage glouton du moteur CP-SAT du back-end // // (`backend/app/solver/engine.py`). Approximation : traite les créneaux les // // plus urgents en premier et choisit, pour chacun, les meilleurs candidats // // libres — pas une optimisation jointe comme CP-SAT, mais un ordre de // // grandeur cohérent pour la démonstration. Ne modifie jamais `state`. // // --------------------------------------------------------------------------- // const SOLVER_URGENT_BASE = 1000; const SOLVER_NORMAL_BASE = 20; const SOLVER_WISH_BONUS = 30; const SOLVER_ARTIST_CONFLICT_PENALTY = 80; const SOLVER_FAIRNESS_PENALTY_PER_HOUR = 2; // Le bénévole a déjà tenu le créneau immédiatement précédent (même jour, // n'importe quel poste) : limite les enchaînements de shifts plutôt que de // faire tourner un poste en particulier. const SOLVER_CHAIN_PENALTY = 40; // Par compagnon souhaité déjà affecté au créneau (0 à N vœux par bénévole), // traité comme réciproque : si A souhaite B, B en profite aussi (voir // `buildEffectiveCompanions`). const SOLVER_COMPANION_BONUS_PER_MATCH = 25; type TimeWindow = { starts_at: string; ends_at: string }; function timeRangesOverlap(a: TimeWindow, b: TimeWindow): boolean { return a.starts_at < b.ends_at && b.starts_at < a.ends_at; } function volunteerExistingShifts(volunteerId: string): ShiftRead[] { return state.assignments .filter((a) => a.volunteer_id === volunteerId) .map((a) => shiftById(a.shift_id)); } /** Bénévoles déjà affectés (décisions déjà prises) à un créneau — sert au * bonus de compagnon de route (voir `solverScore`). */ function assignedVolunteerIds(shiftId: string): Set { return new Set( state.assignments.filter((a) => a.shift_id === shiftId).map((a) => a.volunteer_id), ); } /** Vœu de compagnon traité comme réciproque : si A souhaite B, B profite * aussi de la présence de A (même s'il ne l'a pas demandé). Indexé par id de * bénévole — sert au bonus de compagnon de route (voir `solverScore`). */ function buildEffectiveCompanions(): Map> { const effective = new Map>(); for (const v of state.volunteers) { effective.set(v.id, new Set(v.wished_companion_ids)); } for (const v of state.volunteers) { for (const companionId of v.wished_companion_ids) { effective.get(companionId)?.add(v.id); } } return effective; } /** Créneau(x) — n'importe quel poste — dont la fin coïncide avec le début de * chaque créneau, le même jour (indexé par id de créneau) — sert au malus de * chaînage (voir `solverScore`), pour limiter les enchaînements de shifts * plutôt que cibler la rotation d'un poste en particulier. */ function buildPredecessorMap(): Map> { const byDay = new Map(); for (const s of state.shifts) { const day = s.starts_at.slice(0, 10); const list = byDay.get(day) ?? []; list.push(s); byDay.set(day, list); } const predecessor = new Map>(); for (const dayShifts of byDay.values()) { const idsByEnd = new Map(); for (const s of dayShifts) { const list = idsByEnd.get(s.ends_at) ?? []; list.push(s.id); idsByEnd.set(s.ends_at, list); } const distinctEnds = [...idsByEnd.keys()].sort(); for (const s of dayShifts) { const earlierEnds = distinctEnds.filter((e) => e <= s.starts_at); if (earlierEnds.length > 0) { const latest = earlierEnds[earlierEnds.length - 1]; predecessor.set(s.id, new Set(idsByEnd.get(latest))); } } } return predecessor; } /** Fenêtres datées des passages que le bénévole veut voir (un créneau qui * chevauche l'une d'elles lui ferait manquer le passage). */ function wishedArtistWindows(volunteer: VolunteerRead): TimeWindow[] { return volunteer.artist_ids .map((id) => state.artists.find((a) => a.id === id)) .filter((a): a is ArtistRead => Boolean(a)) .map((a) => { const endDate = a.end_time > a.start_time ? a.performance_date : addDays(a.performance_date, 1); return { starts_at: `${a.performance_date}T${a.start_time}`, ends_at: `${endDate}T${a.end_time}` }; }); } function solverScore( volunteer: VolunteerRead, shift: ShiftRead, assignedCount: number, existing: ShiftRead[], wishedArtists: TimeWindow[], chainedFromPredecessor: boolean, alreadyAssignedIds: Set, effectiveCompanionIds: Set, ): number { let score = assignedCount < shift.min_staff ? SOLVER_URGENT_BASE : SOLVER_NORMAL_BASE; if (volunteer.wished_position_ids.includes(shift.position_id)) score += SOLVER_WISH_BONUS; if (wishedArtists.some((w) => timeRangesOverlap(shift, w))) { score -= SOLVER_ARTIST_CONFLICT_PENALTY; } if (chainedFromPredecessor) score -= SOLVER_CHAIN_PENALTY; let companionsPresent = 0; for (const id of alreadyAssignedIds) { if (effectiveCompanionIds.has(id)) companionsPresent += 1; } score += SOLVER_COMPANION_BONUS_PER_MATCH * companionsPresent; const existingMinutes = existing.reduce( (sum, s) => sum + (naiveMinutes(s.ends_at) - naiveMinutes(s.starts_at)), 0, ); score -= Math.floor((SOLVER_FAIRNESS_PENALTY_PER_HOUR * existingMinutes) / 60); return score; } function runSolver(): SolverRunResult { const assignedCount = new Map(); for (const a of state.assignments) { assignedCount.set(a.shift_id, (assignedCount.get(a.shift_id) ?? 0) + 1); } const candidateShifts = state.shifts.filter( (s) => (assignedCount.get(s.id) ?? 0) < s.ideal_staff, ); const existingByVolunteer = new Map(); const artistWindowsByVolunteer = new Map(); for (const v of state.volunteers) { existingByVolunteer.set(v.id, volunteerExistingShifts(v.id)); artistWindowsByVolunteer.set(v.id, wishedArtistWindows(v)); } const predecessorByShift = buildPredecessorMap(); const effectiveCompanions = buildEffectiveCompanions(); // Suggestions déjà retenues dans ce calcul, pour ne jamais proposer deux // fois un même bénévole sur des créneaux qui se chevauchent. const takenByVolunteer = new Map(); const ordered = [...candidateShifts].sort((a, b) => { const urgent = (s: ShiftRead) => ((assignedCount.get(s.id) ?? 0) < s.min_staff ? 0 : 1); return urgent(a) - urgent(b); }); const suggestions: SolverSuggestion[] = []; for (const shift of ordered) { const gap = shift.ideal_staff - (assignedCount.get(shift.id) ?? 0); if (gap <= 0) continue; const alreadyAssignedIds = assignedVolunteerIds(shift.id); const picked = state.volunteers .filter((v) => { const busy = [ ...(existingByVolunteer.get(v.id) ?? []), ...(takenByVolunteer.get(v.id) ?? []), ]; return !busy.some((other) => overlaps(shift, other)); }) .map((v) => { const existing = existingByVolunteer.get(v.id) ?? []; const predecessorIds = predecessorByShift.get(shift.id); const chained = predecessorIds != null && existing.some((s) => predecessorIds.has(s.id)); return { volunteer: v, score: solverScore( v, shift, assignedCount.get(shift.id) ?? 0, existing, artistWindowsByVolunteer.get(v.id) ?? [], chained, alreadyAssignedIds, effectiveCompanions.get(v.id) ?? new Set(), ), }; }) .filter((c) => c.score > 0) .sort((a, b) => b.score - a.score) .slice(0, gap); if (picked.length > 0) { suggestions.push({ shift_id: shift.id, volunteer_ids: picked.map((c) => c.volunteer.id), }); for (const c of picked) { const list = takenByVolunteer.get(c.volunteer.id) ?? []; list.push(shift); takenByVolunteer.set(c.volunteer.id, list); } } } return { suggestions, shifts_considered: candidateShifts.length, volunteers_considered: state.volunteers.length, }; } export function createMockApi(): PlanningApi { return { editions: { get: () => delay(state.edition), update: (_edition, body: EditionUpdate) => { const next = { ...state.edition }; if ('name' in body) next.name = body.name ?? null; if (body.year != null) next.year = body.year; if (body.start_date) next.start_date = body.start_date; if (body.end_date) next.end_date = body.end_date; if ('retention_days' in body) next.retention_days = body.retention_days ?? null; if (next.end_date < next.start_date) { throw new ApiError(422, 'edition.dates_inconsistent'); } next.updated_at = new Date().toISOString(); state.edition = next; return delay(state.edition); }, }, dayParts: { list: () => delay([...state.dayParts].sort((a, b) => a.order - b.order || a.start_time.localeCompare(b.start_time))), create: (_edition, body: DayPartCreate) => { const code = (body.code ?? slugCode(body.label)).trim(); if (!code) throw new ApiError(422, 'day_part.code_required'); if (state.dayParts.some((d) => d.code === code)) { throw new ApiError(409, 'day_part.duplicate_code'); } const now = touch(); const dp: DayPartRead = { id: `dp_${state.seq++}`, edition_id: state.edition.id, code, label: body.label.trim(), start_time: body.start_time, end_time: body.end_time, order: body.order ?? 0, created_at: now, updated_at: now, }; state.dayParts.push(dp); return delay(dp); }, update: (_edition, id, body: DayPartUpdate) => { const dp = state.dayParts.find((d) => d.id === id) ?? notFound(); Object.assign(dp, { ...('label' in body ? { label: body.label } : {}), ...('start_time' in body ? { start_time: body.start_time } : {}), ...('end_time' in body ? { end_time: body.end_time } : {}), ...('order' in body ? { order: body.order } : {}), updated_at: touch(), }); return delay(dp); }, remove: (_edition, id) => { state.dayParts = state.dayParts.filter((d) => d.id !== id); state.shifts.forEach((s) => { if (s.day_part_id === id) s.day_part_id = null; }); return delay(undefined); }, }, artists: { list: () => delay( [...state.artists].sort( (a, b) => a.performance_date.localeCompare(b.performance_date) || a.name.localeCompare(b.name, 'fr'), ), ), create: (_edition, body: ArtistCreate) => { const name = body.name.trim(); if (!name) throw new ApiError(422, 'artist.name_required'); if ( state.artists.some( (a) => a.name === name && a.performance_date === body.performance_date, ) ) { throw new ApiError(409, 'artist.duplicate'); } const now = touch(); const artist: ArtistRead = { id: `art_${state.seq++}`, edition_id: state.edition.id, name, performance_date: body.performance_date, start_time: hms(body.start_time), end_time: hms(body.end_time), created_at: now, updated_at: now, }; state.artists.push(artist); return delay(artist); }, update: (_edition, id, body: ArtistUpdate) => { const artist = state.artists.find((a) => a.id === id) ?? notFound(); const name = 'name' in body ? (body.name ?? '').trim() : artist.name; if (!name) throw new ApiError(422, 'artist.name_required'); const date = 'performance_date' in body && body.performance_date ? body.performance_date : artist.performance_date; if ( state.artists.some( (a) => a.id !== id && a.name === name && a.performance_date === date, ) ) { throw new ApiError(409, 'artist.duplicate'); } Object.assign(artist, { name, performance_date: date, ...(body.start_time ? { start_time: hms(body.start_time) } : {}), ...(body.end_time ? { end_time: hms(body.end_time) } : {}), updated_at: touch(), }); return delay(artist); }, remove: (_edition, id) => { state.artists = state.artists.filter((a) => a.id !== id); state.volunteers.forEach((v) => { v.artist_ids = v.artist_ids.filter((aid) => aid !== id); }); return delay(undefined); }, }, volunteers: { list: () => delay( [...state.volunteers].sort( (a, b) => a.last_name.localeCompare(b.last_name, 'fr') || a.first_name.localeCompare(b.first_name, 'fr'), ), ), get: (_edition, id) => { const v = state.volunteers.find((x) => x.id === id) ?? notFound(); return delay(v); }, create: (_edition, body) => { const now = touch(); const volunteer = { id: `v${state.seq++}`, edition_id: state.edition.id, first_name: body.first_name, last_name: body.last_name, email: body.email ?? null, phone: body.phone ?? null, comment: body.comment ?? null, artist_ids: resolveWishes(state.artists, body.artist_ids ?? [], 'artist.unknown'), wished_position_ids: resolveWishes( state.positions, body.wished_position_ids ?? [], 'position.unknown', ), wished_companion_ids: resolveCompanionWishes(body.wished_companion_ids ?? []), has_photo: false, companion_wish_note: null, self_declared_available: false, willing_to_pay_fee: false, fee_payment_label: null, fee_amount: null, mecenat_address: null, mecenat_city: null, mecenat_postal_code: null, mecenat_country: null, fee_payment_status: null, fee_paid_at: null, created_at: now, updated_at: now, }; state.volunteers.push(volunteer); return delay(volunteer); }, update: (_edition, id, body) => { const v = state.volunteers.find((x) => x.id === id) ?? notFound(); Object.assign(v, { ...('first_name' in body ? { first_name: body.first_name } : {}), ...('last_name' in body ? { last_name: body.last_name } : {}), ...('email' in body ? { email: body.email ?? null } : {}), ...('phone' in body ? { phone: body.phone ?? null } : {}), ...('comment' in body ? { comment: body.comment ?? null } : {}), ...(body.artist_ids != null ? { artist_ids: resolveWishes(state.artists, body.artist_ids, 'artist.unknown') } : {}), ...(body.wished_position_ids != null ? { wished_position_ids: resolveWishes( state.positions, body.wished_position_ids, 'position.unknown', ), } : {}), ...(body.wished_companion_ids != null ? { wished_companion_ids: resolveCompanionWishes(body.wished_companion_ids, v.id) } : {}), ...('companion_wish_note' in body ? { companion_wish_note: body.companion_wish_note ?? null } : {}), ...('self_declared_available' in body && body.self_declared_available != null ? { self_declared_available: body.self_declared_available } : {}), ...('willing_to_pay_fee' in body && body.willing_to_pay_fee != null ? { willing_to_pay_fee: body.willing_to_pay_fee } : {}), updated_at: touch(), }); return delay(v); }, remove: (_edition, id) => { state.volunteers = state.volunteers.filter((x) => x.id !== id); state.assignments = state.assignments.filter((a) => a.volunteer_id !== id); state.photos.delete(id); // RGPD : effacement complet, y compris le compte de connexion éventuel // et les vœux de compagnon dont il était la cible. state.accounts = state.accounts.filter((a) => a.volunteer_id !== id); state.volunteers.forEach((v) => { v.wished_companion_ids = v.wished_companion_ids.filter((cid) => cid !== id); }); return delay(undefined); }, photoBlob: async (_edition, id) => { const dataUrl = state.photos.get(id); if (!dataUrl) return null; return (await fetch(dataUrl)).blob(); }, exportXlsx: () => delay(MOCK_XLSX_BLOB()), exportMecenatReceipts: () => { const paid = state.volunteers.some( (v) => v.fee_payment_status === 'PAID' && v.mecenat_address !== null, ); if (!paid) throw new ApiError(422, 'mecenat.no_receipts'); return delay(MOCK_PDF_BLOB()); }, planning: (_edition, id) => { if (!state.volunteers.some((v) => v.id === id)) return notFound(); const entries = state.assignments .filter((a) => a.volunteer_id === id) .map((a) => { const shift = state.shifts.find((s) => s.id === a.shift_id); const position = shift ? state.positions.find((p) => p.id === shift.position_id) : undefined; const referentAccount = position?.lead_account_id ? state.accounts.find((acc) => acc.id === position.lead_account_id) : undefined; const referent = referentAccount?.volunteer_id ? state.volunteers.find((v) => v.id === referentAccount.volunteer_id) : undefined; return { assignment_id: a.id, shift_id: a.shift_id, starts_at: shift?.starts_at ?? '', ends_at: shift?.ends_at ?? '', position_name: position?.name ?? '', position_location: position?.location ?? null, referent_name: referent ? `${referent.first_name} ${referent.last_name}` : null, referent_phone: referent?.phone ?? null, }; }) .sort((a, b) => a.starts_at.localeCompare(b.starts_at)); return delay(entries); }, }, accounts: { list: () => delay([...state.accounts].sort((a, b) => a.email.localeCompare(b.email, 'fr'))), create: (_edition, body) => { const email = body.email.trim(); if (state.accounts.some((a) => a.email.toLowerCase() === email.toLowerCase())) { throw new ApiError(409, 'account.duplicate'); } if (body.volunteer_id && !state.volunteers.some((v) => v.id === body.volunteer_id)) { throw new ApiError(422, 'account.volunteer_wrong_edition'); } if (body.role === 'COORDINATOR') { if (!body.password || body.password.length < 10) { throw new ApiError(422, 'account.password_required'); } } else if (body.password) { throw new ApiError(422, 'account.password_not_allowed'); } const now = touch(); const account: AccountRead = { id: `acc${state.seq++}`, edition_id: state.edition.id, email, role: body.role, volunteer_id: body.volunteer_id ?? null, auth_method: body.role === 'COORDINATOR' ? 'password' : 'magic_link', created_at: now, updated_at: now, }; state.accounts.push(account); return delay(account); }, update: (_edition, id, body) => { const account = state.accounts.find((a) => a.id === id) ?? notFound(); if (body.role != null && body.role !== account.role) { if (account.role === 'COORDINATOR' || body.role === 'COORDINATOR') { throw new ApiError(422, 'account.role_change_not_allowed'); } account.role = body.role; account.auth_method = 'magic_link'; } account.updated_at = touch(); return delay(account); }, remove: (_edition, id) => { state.accounts = state.accounts.filter((a) => a.id !== id); return delay(undefined); }, }, imports: { analyze: async (_edition, file: File) => { const { headers, rows } = await readImportTable(file); const preview: ImportPreviewRow[] = rows.slice(0, 5).map((cells, i) => ({ line: i + 2, values: Object.fromEntries(headers.map((h, c) => [h, cells[c] ?? ''])), })); return delay({ filename: file.name || '', format: 'csv', headers, row_count: rows.length, preview, suggested_mapping: suggestImportMapping(headers), }); }, execute: async (_edition, file: File, mapping: ImportMapping) => { const { headers, rows } = await readImportTable(file); const cols = mapping.columns; const has = (h?: string | null) => Boolean(h) && headers.includes(h as string); const cell = (row: string[], h?: string | null) => { const i = h ? headers.indexOf(h) : -1; return i >= 0 ? (row[i] ?? '').trim() : ''; }; const report: ImportReport = { dry_run: mapping.dry_run, total_rows: rows.length, created: 0, updated: 0, skipped: 0, rows: [], }; const rowSeen = new Map(); // clé `${email}|${nom normalisé}` const emailSeen = new Map(); // On travaille sur des copies : `state` n'est touché qu'en fin si !dry_run. const working = state.volunteers.map((v) => ({ ...v })); rows.forEach((row, i) => { const line = i + 2; const entry: ImportRowReport = { line, action: 'SKIPPED', volunteer_id: null, issues: [], }; const first = cell(row, cols.first_name); const last = cell(row, cols.last_name); const emailRaw = cell(row, cols.email); const email = emailRaw.toLowerCase() || null; if (!first || !last) { entry.issues.push({ code: 'import.row.missing_required_field', severity: 'ERROR', field: !first ? 'first_name' : 'last_name', params: {}, }); report.rows.push(entry); report.skipped += 1; return; } if (email && !IMPORT_EMAIL_RE.test(email)) { entry.issues.push({ code: 'import.row.invalid_email', severity: 'ERROR', field: 'email', params: { value: emailRaw }, }); report.rows.push(entry); report.skipped += 1; return; } const nameKey = `${normalizeText(last)}|${normalizeText(first)}`; if (email) { const rowKey = `${email}|${nameKey}`; const rowSeenAt = rowSeen.get(rowKey); if (rowSeenAt != null) { // Même email ET même nom déjà vus : ligne répétée dans le // fichier (copier-coller), pas deux bénévoles distincts. entry.issues.push({ code: 'import.row.duplicate_email_in_file', severity: 'ERROR', field: 'email', params: { value: emailRaw, first_line: rowSeenAt }, }); report.rows.push(entry); report.skipped += 1; return; } const emailSeenAt = emailSeen.get(email); if (emailSeenAt != null) { // Même email, nom différent : deux bénévoles peuvent partager // une adresse (couple, famille) — on les crée tous les deux. entry.issues.push({ code: 'import.row.shared_email', severity: 'WARNING', field: 'email', params: { value: emailRaw, first_line: emailSeenAt }, }); } rowSeen.set(rowKey, line); if (emailSeenAt == null) emailSeen.set(email, line); } let vol = email ? working.find( (v) => (v.email ?? '').toLowerCase() === email && normalizeText(v.last_name) === normalizeText(last) && normalizeText(v.first_name) === normalizeText(first), ) : undefined; if (vol) { entry.action = 'UPDATED'; entry.issues.push({ code: 'import.row.updated_existing', severity: 'WARNING', params: { volunteer_id: vol.id }, }); } else { if (!email) { const match = working.find( (v) => !v.email && normalizeText(v.last_name) === normalizeText(last) && normalizeText(v.first_name) === normalizeText(first), ); if (match) { entry.issues.push({ code: 'import.row.possible_duplicate_name', severity: 'WARNING', params: { volunteer_id: match.id }, }); } } const now = touch(); vol = { id: `v${state.seq++}`, edition_id: state.edition.id, first_name: first, last_name: last, email: null, phone: null, comment: null, artist_ids: [], wished_position_ids: [], wished_companion_ids: [], has_photo: false, companion_wish_note: null, self_declared_available: false, willing_to_pay_fee: false, fee_payment_label: null, fee_amount: null, mecenat_address: null, mecenat_city: null, mecenat_postal_code: null, mecenat_country: null, fee_payment_status: null, fee_paid_at: null, created_at: now, updated_at: now, }; working.push(vol); entry.action = 'CREATED'; } vol.first_name = first; vol.last_name = last; if (has(cols.email)) vol.email = emailRaw || null; if (has(cols.phone)) vol.phone = cell(row, cols.phone) || null; if (has(cols.comment)) vol.comment = cell(row, cols.comment) || null; entry.volunteer_id = vol.id; report.rows.push(entry); if (entry.action === 'CREATED') report.created += 1; else report.updated += 1; }); if (!mapping.dry_run) { state.volunteers = working; } return delay(report); }, }, positions: { list: () => delay([...state.positions].sort((a, b) => a.name.localeCompare(b.name, 'fr'))), get: (_edition, id) => delay(state.positions.find((x) => x.id === id) ?? notFound()), create: (_edition, body) => { checkLeadAccount(body.lead_account_id); const now = touch(); const position = { id: `p${state.seq++}`, edition_id: state.positions[0].edition_id, name: body.name, location: body.location ?? null, category: body.category, lead_account_id: body.lead_account_id ?? null, created_at: now, updated_at: now, }; state.positions.push(position); // Plus de génération automatique : la coordination ajoute les // créneaux à la main, ou via `generateShifts` (opt-in) ci-dessous. return delay(position); }, update: (_edition, id, body) => { const p = state.positions.find((x) => x.id === id) ?? notFound(); if ('lead_account_id' in body) checkLeadAccount(body.lead_account_id); Object.assign(p, { ...('name' in body ? { name: body.name } : {}), ...('location' in body ? { location: body.location ?? null } : {}), ...('category' in body ? { category: body.category } : {}), ...('lead_account_id' in body ? { lead_account_id: body.lead_account_id ?? null } : {}), updated_at: touch(), }); return delay(p); }, remove: (_edition, id) => { const shiftIds = new Set(state.shifts.filter((s) => s.position_id === id).map((s) => s.id)); state.positions = state.positions.filter((x) => x.id !== id); state.shifts = state.shifts.filter((s) => s.position_id !== id); state.assignments = state.assignments.filter((a) => !shiftIds.has(a.shift_id)); state.volunteers.forEach((v) => { v.wished_position_ids = v.wished_position_ids.filter((pid) => pid !== id); }); return delay(undefined); }, generateShifts: (_edition, id) => { if (!state.positions.some((x) => x.id === id)) return notFound(); return delay({ created: generateMissingShifts(id) }); }, }, shifts: { list: (_edition, params) => delay( [...state.shifts] .filter((s) => !params?.positionId || s.position_id === params.positionId) .sort((a, b) => a.starts_at.localeCompare(b.starts_at)), ), get: (_edition, id) => delay(shiftById(id)), create: (_edition, body) => { if (naiveMinutes(body.ends_at) <= naiveMinutes(body.starts_at)) { throw new ApiError(422, 'shift.ends_not_after_starts'); } if (!state.positions.some((p) => p.id === body.position_id)) { throw new ApiError(422, 'shift.position_wrong_edition'); } const now = touch(); const shift: ShiftRead = { id: `s${state.seq++}`, edition_id: state.positions[0].edition_id, position_id: body.position_id, day_part_id: null, starts_at: body.starts_at, ends_at: body.ends_at, min_staff: body.min_staff ?? 0, ideal_staff: body.ideal_staff ?? 0, created_at: now, updated_at: now, }; state.shifts.push(shift); return delay(shift); }, update: (_edition, id, body) => { const s = shiftById(id); Object.assign(s, { ...('position_id' in body ? { position_id: body.position_id } : {}), ...('starts_at' in body ? { starts_at: body.starts_at } : {}), ...('ends_at' in body ? { ends_at: body.ends_at } : {}), ...('min_staff' in body ? { min_staff: body.min_staff } : {}), ...('ideal_staff' in body ? { ideal_staff: body.ideal_staff } : {}), updated_at: touch(), }); if (naiveMinutes(s.ends_at) <= naiveMinutes(s.starts_at)) { throw new ApiError(422, 'shift.ends_not_after_starts'); } return delay(s); }, remove: (_edition, id) => { state.shifts = state.shifts.filter((x) => x.id !== id); state.assignments = state.assignments.filter((a) => a.shift_id !== id); return delay(undefined); }, }, assignments: { list: (_edition, params) => delay( state.assignments.filter( (a) => (!params?.volunteerId || a.volunteer_id === params.volunteerId) && (!params?.shiftId || a.shift_id === params.shiftId), ), ), get: (_edition, id) => delay(state.assignments.find((x) => x.id === id) ?? notFound()), create: (_edition, body) => { const shift = shiftById(body.shift_id); if (!state.volunteers.some((v) => v.id === body.volunteer_id)) { throw new ApiError(422, 'assignment.wrong_edition'); } if ( state.assignments.some( (a) => a.volunteer_id === body.volunteer_id && a.shift_id === body.shift_id, ) ) { throw new ApiError(409, 'assignment.duplicate'); } // Une affectation est toujours validée à la création : une violation // bloquante l'interdit, les avertissements sont renvoyés. const warnings = runValidation(body.volunteer_id, shift); if (hasBlocking(warnings)) { throw new ApiError(422, 'error.validation_failed', warnings); } const now = touch(); const assignment: AssignmentRead = { id: `a${state.seq++}`, edition_id: shift.edition_id, volunteer_id: body.volunteer_id, shift_id: body.shift_id, source: body.source ?? 'MANUAL', locked: body.locked ?? false, created_at: now, updated_at: now, }; state.assignments.push(assignment); return delay({ assignment, warnings }); }, update: (_edition, id, body) => { const a = state.assignments.find((x) => x.id === id) ?? notFound(); Object.assign(a, { ...('source' in body ? { source: body.source } : {}), ...('locked' in body ? { locked: body.locked } : {}), updated_at: touch(), }); return delay({ assignment: a, warnings: [] as Violation[] }); }, remove: (_edition, id) => { state.assignments = state.assignments.filter((x) => x.id !== id); return delay(undefined); }, validate: (_edition, body): Promise => { const violations = runValidation(body.volunteer_id, shiftById(body.shift_id)); return delay({ blocking: hasBlocking(violations), violations }); }, }, matrix: { get: () => delay(buildMatrix()), }, stats: { get: () => delay(buildStats()), }, badges: { getTemplate: (): Promise => delay({ edition_id: state.edition.id, has_front_image: state.badgeTemplate.frontImage !== null, has_back_image: state.badgeTemplate.backImage !== null, badge_width_mm: state.badgeTemplate.frontImageWidthMm ?? DEFAULT_BADGE_WIDTH_MM, badge_height_mm: state.badgeTemplate.frontImageHeightMm ?? DEFAULT_BADGE_HEIGHT_MM, fields: state.badgeTemplate.fields, }), uploadFrontImage: async (_edition, file: File) => { const { width_mm, height_mm } = await imageSizeMm(file); state.badgeTemplate.frontImage = await fileToDataUrl(file); state.badgeTemplate.frontImageType = file.type; state.badgeTemplate.frontImageWidthMm = width_mm; state.badgeTemplate.frontImageHeightMm = height_mm; return delay({ edition_id: state.edition.id, has_front_image: true, has_back_image: state.badgeTemplate.backImage !== null, badge_width_mm: width_mm, badge_height_mm: height_mm, fields: state.badgeTemplate.fields, }); }, uploadBackImage: async (_edition, file: File) => { state.badgeTemplate.backImage = await fileToDataUrl(file); state.badgeTemplate.backImageType = file.type; return delay({ edition_id: state.edition.id, has_front_image: state.badgeTemplate.frontImage !== null, has_back_image: true, badge_width_mm: state.badgeTemplate.frontImageWidthMm ?? DEFAULT_BADGE_WIDTH_MM, badge_height_mm: state.badgeTemplate.frontImageHeightMm ?? DEFAULT_BADGE_HEIGHT_MM, fields: state.badgeTemplate.fields, }); }, frontImageBlob: async () => { if (!state.badgeTemplate.frontImage) return null; return (await fetch(state.badgeTemplate.frontImage)).blob(); }, backImageBlob: async () => { if (!state.badgeTemplate.backImage) return null; return (await fetch(state.badgeTemplate.backImage)).blob(); }, setFields: (_edition, body: BadgeFieldsSet) => { const seen = new Set(); for (const f of body.fields) { if (seen.has(f.field_type)) throw new ApiError(422, 'badge.duplicate_field_type'); seen.add(f.field_type); } const fields: BadgeFieldRead[] = body.fields.map((f) => ({ ...f, id: `bf${state.seq++}`, edition_id: state.edition.id, })); state.badgeTemplate.fields = fields; return delay(fields); }, setSize: (_edition, body: BadgeSizeUpdate) => { if (body.badge_width_mm <= 0 || body.badge_height_mm <= 0) { throw new ApiError(422, 'badge.invalid_size'); } state.badgeTemplate.frontImageWidthMm = body.badge_width_mm; state.badgeTemplate.frontImageHeightMm = body.badge_height_mm; return delay({ edition_id: state.edition.id, has_front_image: state.badgeTemplate.frontImage !== null, has_back_image: state.badgeTemplate.backImage !== null, badge_width_mm: body.badge_width_mm, badge_height_mm: body.badge_height_mm, fields: state.badgeTemplate.fields, }); }, generate: (_edition, body: BadgeGenerateRequest) => { if (state.badgeTemplate.fields.length === 0) { throw new ApiError(422, 'badge.no_fields'); } const ids = body.volunteer_ids ?? state.volunteers.map((v) => v.id); const matching = state.volunteers.filter((v) => ids.includes(v.id)); if (matching.length === 0) throw new ApiError(422, 'badge.no_volunteers'); // La composition réelle du PDF (reportlab) est réservée au back-end : // le mode démonstration renvoie un PDF minimal valide, téléchargeable. return delay(MOCK_PDF_BLOB()); }, }, forms: { getTemplate: (): Promise => delay(formTemplateRead()), setFields: (_edition, body: FormFieldsSet) => { state.formTemplate.fields = [...new Set(body.fields)]; return delay(formTemplateRead()); }, setOpen: (_edition, body: FormTemplateUpdate) => { if (body.is_open !== undefined) state.formTemplate.isOpen = body.is_open; if (body.description !== undefined) state.formTemplate.description = body.description; if (body.sumup_merchant_code !== undefined) { state.formTemplate.sumupMerchantCode = body.sumup_merchant_code; } if (body.sumup_api_key !== undefined) { state.formTemplate.sumupApiKey = body.sumup_api_key || null; } return delay(formTemplateRead()); }, uploadLogo: async (_edition, file: File) => { state.formTemplate.logo = await fileToDataUrl(file); state.formTemplate.logoType = file.type; return delay(formTemplateRead()); }, regenerateToken: () => { state.formTemplate.accessToken = `demo-access-token-${state.seq++}`; return delay(formTemplateRead()); }, logoBlob: async (_edition, token) => { if (token !== state.formTemplate.accessToken) notFound(); if (!state.formTemplate.logo) return null; return (await fetch(state.formTemplate.logo)).blob(); }, getPublicConfig: (_edition, token): Promise => { if (token !== state.formTemplate.accessToken) notFound(); const fields = state.formTemplate.fields; return delay({ is_open: state.formTemplate.isOpen, fields, positions: fields.includes('POSITION_WISHES') ? state.positions.map((p) => ({ id: p.id, name: p.name })) : [], artists: fields.includes('ARTIST_WISHES') ? state.artists.map((a) => ({ id: a.id, name: a.name, performance_date: a.performance_date, })) : [], has_logo: state.formTemplate.logo !== null, description: state.formTemplate.description, sumup_enabled: sumupEnabled(), payment_options: sortedPaymentOptions().map((o) => ({ id: o.id, label: o.label, amount: o.amount, requires_mecenat_info: o.requires_mecenat_info, free_amount: o.free_amount, })), }); }, paymentOptions: { list: (): Promise => delay(sortedPaymentOptions()), create: (_edition, body: FormPaymentOptionCreate) => { if (body.amount <= 0) throw new ApiError(422, 'form.invalid_sumup_amount'); const label = body.label.trim(); if (state.formPaymentOptions.some((o) => o.label === label)) { throw new ApiError(409, 'form.duplicate_payment_option_label'); } const now = touch(); const option: FormPaymentOptionRead = { id: `pay${state.seq++}`, edition_id: state.edition.id, label, amount: body.amount, order: body.order ?? 0, requires_mecenat_info: body.requires_mecenat_info ?? false, free_amount: body.free_amount ?? false, created_at: now, updated_at: now, }; state.formPaymentOptions.push(option); return delay(option); }, update: (_edition, id, body: FormPaymentOptionUpdate) => { const option = state.formPaymentOptions.find((o) => o.id === id) ?? notFound(); if (body.amount !== undefined && body.amount <= 0) { throw new ApiError(422, 'form.invalid_sumup_amount'); } const label = body.label !== undefined ? body.label.trim() : option.label; if ( label !== option.label && state.formPaymentOptions.some((o) => o.id !== id && o.label === label) ) { throw new ApiError(409, 'form.duplicate_payment_option_label'); } Object.assign(option, { label, ...(body.amount !== undefined ? { amount: body.amount } : {}), ...(body.order !== undefined ? { order: body.order } : {}), ...(body.requires_mecenat_info !== undefined ? { requires_mecenat_info: body.requires_mecenat_info } : {}), ...(body.free_amount !== undefined ? { free_amount: body.free_amount } : {}), updated_at: touch(), }); return delay(option); }, remove: (_edition, id) => { const index = state.formPaymentOptions.findIndex((o) => o.id === id); if (index === -1) notFound(); state.formPaymentOptions.splice(index, 1); return delay(undefined); }, }, submitPublic: async (_edition, token, body: PublicFormSubmission) => { if (token !== state.formTemplate.accessToken) notFound(); if (!state.formTemplate.isOpen) throw new ApiError(422, 'form.closed'); if (!body.hcaptcha_token) throw new ApiError(422, 'form.captcha_failed'); if (!body.first_name.trim() || !body.last_name.trim()) { throw new ApiError(422, 'import.row.missing_required_field'); } const fields = new Set(state.formTemplate.fields); const email = body.email?.trim() || null; const phone = body.phone?.trim() || null; const available = Boolean(body.available); if (fields.has('EMAIL') && !email) throw new ApiError(422, 'import.row.missing_required_field'); if (fields.has('PHONE') && !phone) throw new ApiError(422, 'import.row.missing_required_field'); if (fields.has('AVAILABLE') && !available) { throw new ApiError(422, 'import.row.missing_required_field'); } const positionIds = fields.has('POSITION_WISHES') ? (body.position_wish_ids ?? []) : []; if (positionIds.length > 2) throw new ApiError(422, 'form.too_many_position_wishes'); if (positionIds.length) resolveWishes(state.positions, positionIds, 'position.unknown'); const artistIds = fields.has('ARTIST_WISHES') ? (body.artist_wish_ids ?? []) : []; const artistDates = artistIds.map( (id) => state.artists.find((a) => a.id === id)?.performance_date, ); if (new Set(artistDates).size !== artistDates.length) { throw new ApiError(422, 'form.too_many_artists_same_day'); } if (artistIds.length) resolveWishes(state.artists, artistIds, 'artist.unknown'); let paymentOption: FormPaymentOptionRead | null = null; if (fields.has('WILLING_TO_PAY_FEE') && body.payment_option_id) { paymentOption = state.formPaymentOptions.find((o) => o.id === body.payment_option_id) ?? null; if (!paymentOption) throw new ApiError(422, 'form.unknown_payment_option'); } const comment = fields.has('COMMENT') ? body.comment?.trim() || null : null; const companionWishNote = fields.has('COMPANION_WISH') ? body.companion_wish_note?.trim() || null : null; const photo = fields.has('PHOTO') && body.photo ? await fileToDataUrl(body.photo) : null; if (paymentOption === null) { // Pas de paiement en ligne (case désactivée, ou « je paierai sur // place ») : rien à attendre, le bénévole est créé tout de suite. const now = touch(); const volunteer: VolunteerRead = { id: `v${state.seq++}`, edition_id: state.edition.id, first_name: titleCase(body.first_name.trim()), last_name: titleCase(body.last_name.trim()), email: fields.has('EMAIL') ? email : null, phone: fields.has('PHONE') ? phone : null, comment, artist_ids: resolveWishes(state.artists, artistIds, 'artist.unknown'), wished_position_ids: resolveWishes(state.positions, positionIds, 'position.unknown'), wished_companion_ids: [], has_photo: photo !== null, companion_wish_note: companionWishNote, self_declared_available: fields.has('AVAILABLE') ? available : false, willing_to_pay_fee: fields.has('WILLING_TO_PAY_FEE'), fee_payment_label: null, fee_amount: null, mecenat_address: null, mecenat_city: null, mecenat_postal_code: null, mecenat_country: null, fee_payment_status: null, fee_paid_at: null, created_at: now, updated_at: now, }; if (photo) state.photos.set(volunteer.id, photo); state.volunteers.push(volunteer); return delay({ volunteer_id: volunteer.id, pending_token: null, requires_mecenat_info: false, sumup_checkout_url: null, }); } // Paiement en ligne choisi : rien en base tant que le paiement n'est // pas confirmé — voir `PendingFormSubmission` côté back-end. const pending: MockPendingSubmission = { id: `pend${state.seq++}`, editionId: state.edition.id, firstName: titleCase(body.first_name.trim()), lastName: titleCase(body.last_name.trim()), email: fields.has('EMAIL') ? email : null, phone: fields.has('PHONE') ? phone : null, comment, companionWishNote, available: fields.has('AVAILABLE') ? available : false, positionIds, artistIds, photo, paymentLabel: paymentOption.label, paymentAmount: paymentOption.free_amount ? freeAmount(body.payment_amount, paymentOption.amount) : paymentOption.amount, requiresMecenatInfo: paymentOption.requires_mecenat_info, mecenatAddress: null, mecenatCity: null, mecenatPostalCode: null, mecenatCountry: null, checkoutId: null, status: null, promotedVolunteerId: null, pollCount: 0, }; state.pendingSubmissions.set(pending.id, pending); if (pending.requiresMecenatInfo) { return delay({ volunteer_id: null, pending_token: pending.id, requires_mecenat_info: true, sumup_checkout_url: null, }); } const checkoutUrl = createPendingCheckout(pending); return delay({ volunteer_id: null, pending_token: pending.id, requires_mecenat_info: false, sumup_checkout_url: checkoutUrl, }); }, submitMecenatInfo: (_edition, token, body: MecenatInfoSubmit) => { const pending = state.pendingSubmissions.get(token) ?? notFound(); if (pending.promotedVolunteerId !== null) { throw new ApiError(422, 'form.pending_already_paid'); } if (!pending.requiresMecenatInfo) { throw new ApiError(422, 'form.pending_not_awaiting_info'); } const address = body.address.trim(); const city = body.city.trim(); const postalCode = body.postal_code.trim(); const country = body.country.trim(); if (!address || !city || !postalCode || !country) { throw new ApiError(422, 'import.row.missing_required_field'); } pending.mecenatAddress = address; pending.mecenatCity = city; pending.mecenatPostalCode = postalCode; pending.mecenatCountry = country; const checkoutUrl = createPendingCheckout(pending); return delay({ volunteer_id: null, pending_token: pending.id, requires_mecenat_info: false, sumup_checkout_url: checkoutUrl, }); }, getPendingStatus: (_edition, token) => { const pending = state.pendingSubmissions.get(token) ?? notFound(); // Simule le délai de confirmation réel (webhook asynchrone) : la // première vérification reste PENDING, la suivante confirme. if (pending.promotedVolunteerId === null && pending.checkoutId !== null) { if (pending.pollCount === 0) { pending.pollCount += 1; } else { promotePending(pending); } } return delay({ status: pending.promotedVolunteerId !== null ? 'PAID' : (pending.status ?? 'PENDING'), label: pending.paymentLabel, amount: pending.paymentAmount, volunteer_id: pending.promotedVolunteerId, }); }, retryPendingPayment: (_edition, token) => { const pending = state.pendingSubmissions.get(token) ?? notFound(); if (pending.promotedVolunteerId !== null) { throw new ApiError(422, 'form.pending_already_paid'); } if (pending.requiresMecenatInfo && pending.mecenatAddress === null) { throw new ApiError(422, 'form.pending_not_awaiting_info'); } const checkoutUrl = createPendingCheckout(pending); return delay({ volunteer_id: null, pending_token: pending.id, requires_mecenat_info: false, sumup_checkout_url: checkoutUrl, }); }, }, positionSheets: { generate: (_edition, body: PositionSheetsRequest) => { const ids = body.position_ids ?? state.positions.map((p) => p.id); const matching = state.positions.filter((p) => ids.includes(p.id)); if (matching.length === 0) throw new ApiError(422, 'position_sheets.no_positions'); const hasShifts = state.shifts.some((s) => matching.some((p) => p.id === s.position_id)); if (!hasShifts) throw new ApiError(422, 'position_sheets.no_shifts'); // La composition réelle du PDF (reportlab) est réservée au back-end : // le mode démonstration renvoie un PDF minimal valide, téléchargeable. return delay(MOCK_PDF_BLOB()); }, }, solver: { run: () => delay(runSolver()), }, notifications: { sendPlanning: (_edition, body: PlanningNotificationRequest) => { const ids = body.volunteer_ids ?? null; const targets = state.volunteers.filter((v) => ids === null || ids.includes(v.id)); const withEmail = targets.filter((v) => v.email); const wantsLink = (body.subject ?? '').includes('{lien_connexion}') || (body.body ?? '').includes('{lien_connexion}'); for (const volunteer of withEmail) { if (!wantsLink) continue; const account = state.accounts.find( (a) => a.volunteer_id === volunteer.id && a.auth_method === 'magic_link', ); if (!account) continue; const raw = `mlt.${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`; magicTokens.set(raw, { accountId: account.id, expiresAt: Date.now() + MAGIC_LINK_TTL_MS, used: false, }); lastMagicLink.set(account.email.toLowerCase(), raw); } return delay({ queued: withEmail.length, skipped_no_email: targets.length - withEmail.length, recipients: withEmail.map((v) => v.id), }); }, }, auth: { login: (_edition, { email, password }) => { const account = accountByEmail(email); if (!account || account.auth_method !== 'password' || state.passwords.get(account.id) !== password) { throw new ApiError(401, 'auth.invalid_credentials'); } return delay(issue(account)); }, requestMagicLink: (_edition, { email }) => { const account = accountByEmail(email); if (account && account.auth_method === 'magic_link') { const raw = `mlt.${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`; magicTokens.set(raw, { accountId: account.id, expiresAt: Date.now() + MAGIC_LINK_TTL_MS, used: false, }); lastMagicLink.set(account.email.toLowerCase(), raw); } // Réponse identique dans tous les cas (pas de divulgation). return delay(undefined); }, verifyMagicLink: (_edition, { token }) => { const entry = magicTokens.get(token.trim()); if (!entry || entry.used || entry.expiresAt < Date.now()) { throw new ApiError(401, 'auth.invalid_token'); } entry.used = true; const account = state.accounts.find((a) => a.id === entry.accountId); if (!account) throw new ApiError(401, 'auth.invalid_token'); return delay(issue(account)); }, me: () => { const token = getAuthToken(); const accountId = token ? sessions.get(token) : undefined; const account = accountId ? state.accounts.find((a) => a.id === accountId) : undefined; if (!account) throw new ApiError(401, 'error.unauthorized'); return delay(account); }, }, }; } /** Heures affectées d'un bénévole (utilitaire mock, exposé pour les tests). */ export function mockVolunteerHours(volunteerId: string): number { return state.assignments .filter((a) => a.volunteer_id === volunteerId) .reduce((sum, a) => { const s = state.shifts.find((x) => x.id === a.shift_id); return s ? sum + hoursBetween(s.starts_at, s.ends_at) : sum; }, 0); }