import { uncoveredMinutes } from '@/lib/planning'; import { hoursBetween, naiveMinutes } from '@/lib/time'; import type { AccountRead, ArtistCreate, ArtistRead, ArtistUpdate, AssignmentRead, AssignmentValidateResult, AvailabilityRead, AvailabilitySet, BadgeFieldRead, BadgeFieldsSet, BadgeGenerateRequest, BadgeTemplateRead, DayPartCreate, DayPartRead, DayPartUpdate, EditionUpdate, ImportAnalyzeResponse, ImportMapping, ImportPreviewRow, ImportReport, ImportRowReport, MatrixResponse, PlanningNotificationRequest, ShiftRead, SolverRunResult, SolverSuggestion, TokenResponse, Violation, VolunteerRead, } from '../schema'; import { getAuthToken } from '../session'; import { ApiError, type PlanningApi } from '../types'; import { buildMockState, 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 EIGHT_HOURS_MIN = 8 * 60; 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'); }; 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); }); // 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' }, ); 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); const sortWindows = (ws: AvailabilityRead[]): AvailabilityRead[] => [...ws].sort( (a, b) => a.volunteer_id.localeCompare(b.volunteer_id) || a.date.localeCompare(b.date) || a.start_time.localeCompare(b.start_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; } /** 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, }, }); } } // OUTSIDE_AVAILABILITY (avertissement) — créneau non couvert par l'union des // fenêtres de disponibilité déclarées. const gapMin = uncoveredMinutes( state.availability.get(volunteerId) ?? [], candidate.starts_at, candidate.ends_at, ); if (gapMin > 0) { violations.push({ code: 'OUTSIDE_AVAILABILITY', severity: 'WARNING', params: { date: candidate.starts_at.slice(0, 10), uncovered_minutes: gapMin, }, }); } // DAILY_HOURS_EXCEEDED (avertissement) — journée = date de début du créneau. const dayKey = candidate.starts_at.slice(0, 10); let totalMin = (naiveMinutes(candidate.ends_at) - naiveMinutes(candidate.starts_at)); for (const other of existing) { if (other.starts_at.slice(0, 10) === dayKey) { totalMin += naiveMinutes(other.ends_at) - naiveMinutes(other.starts_at); } } if (totalMin > EIGHT_HOURS_MIN) { violations.push({ code: 'DAILY_HOURS_EXCEEDED', severity: 'WARNING', params: { date: dayKey, total_minutes: totalMin }, }); } // INSUFFICIENT_REST (avertissement) — < 8 h entre deux créneaux (strict). for (const other of existing) { if (overlaps(candidate, other)) continue; let gap: number; let direction: 'BEFORE' | 'AFTER'; if (naiveMinutes(other.ends_at) <= naiveMinutes(candidate.starts_at)) { gap = naiveMinutes(candidate.starts_at) - naiveMinutes(other.ends_at); direction = 'BEFORE'; } else if (naiveMinutes(candidate.ends_at) <= naiveMinutes(other.starts_at)) { gap = naiveMinutes(other.starts_at) - naiveMinutes(candidate.ends_at); direction = 'AFTER'; } else { continue; } if (gap < EIGHT_HOURS_MIN) { violations.push({ code: 'INSUFFICIENT_REST', severity: 'WARNING', params: { other_shift_id: other.id, direction, gap_minutes: gap }, }); } } return violations; } function hasBlocking(violations: Violation[]): boolean { return violations.some((v) => v.severity === 'BLOCKING'); } 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_AVAIL_PENALTY_PER_HALF_HOUR = 5; const SOLVER_REST_PENALTY = 60; 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 windows = state.availability.get(volunteer.id) ?? []; const missed = uncoveredMinutes(windows, shift.starts_at, shift.ends_at); score -= Math.floor(missed / 30) * SOLVER_AVAIL_PENALTY_PER_HALF_HOUR; for (const other of existing) { if (overlaps(shift, other)) continue; let gap: number | null = null; if (naiveMinutes(other.ends_at) <= naiveMinutes(shift.starts_at)) { gap = naiveMinutes(shift.starts_at) - naiveMinutes(other.ends_at); } else if (naiveMinutes(shift.ends_at) <= naiveMinutes(other.starts_at)) { gap = naiveMinutes(other.starts_at) - naiveMinutes(shift.ends_at); } if (gap !== null && gap < EIGHT_HOURS_MIN) { score -= SOLVER_REST_PENALTY; break; } } 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 ?? []), created_at: now, updated_at: now, }; state.volunteers.push(volunteer); state.availability.set(volunteer.id, []); 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) } : {}), 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.availability.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); }, 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); }, }, availability: { list: (_edition, volunteerId) => { if (!state.volunteers.some((v) => v.id === volunteerId)) return notFound(); return delay(sortWindows(state.availability.get(volunteerId) ?? [])); }, listEdition: () => delay( sortWindows( [...state.availability.values()].flat().filter((w) => state.volunteers.some((v) => v.id === w.volunteer_id), ), ), ), replace: (_edition, volunteerId, body: AvailabilitySet) => { if (!state.volunteers.some((v) => v.id === volunteerId)) return notFound(); const seen = new Set(); const windows: AvailabilityRead[] = []; for (const w of body.windows) { if (w.date < state.edition.start_date || w.date > state.edition.end_date) { throw new ApiError(422, 'availability.date_outside_edition'); } const start = hms(w.start_time); const end = hms(w.end_time); if (start === end) throw new ApiError(422, 'availability.empty_window'); const key = `${w.date}|${start}|${end}`; if (seen.has(key)) continue; seen.add(key); const now = touch(); windows.push({ id: `av${state.seq++}`, edition_id: state.edition.id, volunteer_id: volunteerId, date: w.date, start_time: start, end_time: end, created_at: now, updated_at: now, }); } state.availability.set(volunteerId, windows); return delay(sortWindows(windows)); }, }, 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: [], 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; for (const v of working) { if (!state.availability.has(v.id)) state.availability.set(v.id, []); } } 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); generateMissingShifts(position.id); 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()), }, badges: { getTemplate: (): Promise => delay({ edition_id: state.edition.id, has_front_image: state.badgeTemplate.frontImage !== null, has_back_image: state.badgeTemplate.backImage !== null, fields: state.badgeTemplate.fields, }), uploadFrontImage: async (_edition, file: File) => { state.badgeTemplate.frontImage = await fileToDataUrl(file); state.badgeTemplate.frontImageType = file.type; return delay({ edition_id: state.edition.id, has_front_image: true, has_back_image: state.badgeTemplate.backImage !== null, 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, 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); }, 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()); }, }, 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); }