Files
benevoles/frontend/src/api/mock/mock-client.ts
T
AdamThomasandClaude Opus 5.5 d722a81a87
ci-cd / build-backend (push) Successful in 31s
ci-cd / build-frontend (push) Successful in 19s
ci-cd / deploy (push) Successful in 12s
Retrait des disponibilités et des limites de temps dans la journée
Disponibilités déclarées supprimées (table `availability`, migration 0022,
API, éditeur de la fiche bénévole, badge de couverture du panneau
d'affectation, colonnes des statistiques) ainsi que les règles
OUTSIDE_AVAILABILITY, DAILY_HOURS_EXCEEDED et INSUFFICIENT_REST, les malus
de disponibilité et de repos du solveur et le filtre « Surchargés » de la
vue bénévoles. Seul le chevauchement reste contrôlé. La case « je suis
disponible » du formulaire public est conservée.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 21:29:58 +02:00

2003 lines
77 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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');
};
// 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<string> =>
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<</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' },
);
// 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<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;
}
/** 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<T extends { id: string }>(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<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,
},
});
}
}
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<string, number>();
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<string, VolunteerRead[]>();
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<string, Row>();
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<string, number>();
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<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_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 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 ?? []),
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<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: [],
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<AssignmentValidateResult> => {
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<BadgeTemplateRead> =>
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<BadgeTemplateRead>({
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<BadgeTemplateRead>({
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<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);
},
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<BadgeTemplateRead>({
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<FormTemplateRead> => delay(formTemplateRead()),
setFields: (_edition, body: FormFieldsSet) => {
state.formTemplate.fields = [...new Set(body.fields)];
return delay<FormTemplateRead>(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>(formTemplateRead());
},
uploadLogo: async (_edition, file: File) => {
state.formTemplate.logo = await fileToDataUrl(file);
state.formTemplate.logoType = file.type;
return delay<FormTemplateRead>(formTemplateRead());
},
regenerateToken: () => {
state.formTemplate.accessToken = `demo-access-token-${state.seq++}`;
return delay<FormTemplateRead>(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<PublicFormConfig> => {
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<FormPaymentOptionRead[]> => 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<FormPaymentOptionRead>(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<FormPaymentOptionRead>(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<FormSubmissionResult>({
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<FormSubmissionResult>({
volunteer_id: null,
pending_token: pending.id,
requires_mecenat_info: true,
sumup_checkout_url: null,
});
}
const checkoutUrl = createPendingCheckout(pending);
return delay<FormSubmissionResult>({
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<FormSubmissionResult>({
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<PendingSubmissionStatus>({
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<FormSubmissionResult>({
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);
}