Files
benevoles/frontend/src/api/mock/mock-client.ts
T
AdamThomasandClaude Sonnet 5 b7fc80a7dc Étape 20 : planches de badges bénévoles (PDF)
Recto personnalisable (fond image + champs positionnés : nom, poste(s)
affectés, catégorie, rôle) via un éditeur visuel glisser-déposer ; verso
fixe, la même image pour tous les bénévoles. Nouvel écran « Badges »
(COORDINATION uniquement, /badges).

Backend : modèle BadgeTemplate/BadgeField (migration 0011), génération
PDF isolée (app/badges/pdf.py, ReportLab — planches recto suivies des
planches verso correspondantes pour une impression recto-verso), route
POST /editions/{id}/badges/generate, tests (module pur + API).

Frontend : client HTTP (upload multipart, réponses binaires images/PDF),
client mock (mode démonstration), éditeur de positionnement en
millimètres (coordonnées partagées avec le backend), écran de génération
avec sélection des bénévoles et téléchargement.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 20:05:03 +02:00

1479 lines
54 KiB
TypeScript

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<string, string>();
/** token magique brut -> compte + expiration. */
const magicTokens = new Map<string, { accountId: string; expiresAt: number; used: boolean }>();
/** email (minuscule) -> dernier token magique émis (démo : l'email est stubé). */
const lastMagicLink = new Map<string, string>();
/** 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 = <T>(value: T): Promise<T> =>
new Promise((resolve) => setTimeout(() => resolve(structuredClone(value)), LATENCY_MS));
const notFound = (): never => {
throw new ApiError(404, 'error.not_found');
};
const fileToDataUrl = (file: File): Promise<string> =>
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<</Type/Catalog/Pages 2 0 R>>endobj\n' +
'2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n' +
'3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 595 842]>>endobj\n' +
'trailer<</Root 1 0 R>>\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<T extends { id: string }>(
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<string, string[]> = {
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<string, string> {
const result: Record<string, string> = {};
const used = new Set<string>();
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<string, number>();
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<string> {
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<string, Set<string>> {
const effective = new Map<string, Set<string>>();
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<string, Set<string>> {
const byDay = new Map<string, ShiftRead[]>();
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<string, Set<string>>();
for (const dayShifts of byDay.values()) {
const idsByEnd = new Map<string, string[]>();
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<string>,
effectiveCompanionIds: Set<string>,
): 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<string, number>();
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<string, ShiftRead[]>();
const artistWindowsByVolunteer = new Map<string, TimeWindow[]>();
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<string, ShiftRead[]>();
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<string>();
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<ImportAnalyzeResponse>({
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<string, number>(); // clé `${email}|${nom normalisé}`
const emailSeen = new Map<string, number>();
// 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<AssignmentValidateResult> => {
const violations = runValidation(body.volunteer_id, shiftById(body.shift_id));
return delay({ blocking: hasBlocking(violations), violations });
},
},
matrix: {
get: () => delay(buildMatrix()),
},
badges: {
getTemplate: (): Promise<BadgeTemplateRead> =>
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<BadgeTemplateRead>({
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<BadgeTemplateRead>({
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<string>();
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);
}