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>
2003 lines
77 KiB
TypeScript
2003 lines
77 KiB
TypeScript
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);
|
||
}
|