Recto personnalisable (fond image + champs positionnés : nom, poste(s)
affectés, catégorie, rôle) via un éditeur visuel glisser-déposer ; verso
fixe, la même image pour tous les bénévoles. Nouvel écran « Badges »
(COORDINATION uniquement, /badges).
Backend : modèle BadgeTemplate/BadgeField (migration 0011), génération
PDF isolée (app/badges/pdf.py, ReportLab — planches recto suivies des
planches verso correspondantes pour une impression recto-verso), route
POST /editions/{id}/badges/generate, tests (module pur + API).
Frontend : client HTTP (upload multipart, réponses binaires images/PDF),
client mock (mode démonstration), éditeur de positionnement en
millimètres (coordonnées partagées avec le backend), écran de génération
avec sélection des bénévoles et téléchargement.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1479 lines
54 KiB
TypeScript
1479 lines
54 KiB
TypeScript
import { uncoveredMinutes } from '@/lib/planning';
|
|
import { hoursBetween, naiveMinutes } from '@/lib/time';
|
|
|
|
import type {
|
|
AccountRead,
|
|
ArtistCreate,
|
|
ArtistRead,
|
|
ArtistUpdate,
|
|
AssignmentRead,
|
|
AssignmentValidateResult,
|
|
AvailabilityRead,
|
|
AvailabilitySet,
|
|
BadgeFieldRead,
|
|
BadgeFieldsSet,
|
|
BadgeGenerateRequest,
|
|
BadgeTemplateRead,
|
|
DayPartCreate,
|
|
DayPartRead,
|
|
DayPartUpdate,
|
|
EditionUpdate,
|
|
ImportAnalyzeResponse,
|
|
ImportMapping,
|
|
ImportPreviewRow,
|
|
ImportReport,
|
|
ImportRowReport,
|
|
MatrixResponse,
|
|
PlanningNotificationRequest,
|
|
ShiftRead,
|
|
SolverRunResult,
|
|
SolverSuggestion,
|
|
TokenResponse,
|
|
Violation,
|
|
VolunteerRead,
|
|
} from '../schema';
|
|
import { getAuthToken } from '../session';
|
|
import { ApiError, type PlanningApi } from '../types';
|
|
import { buildMockState, type MockState } from './dataset';
|
|
|
|
// Implémentation en mémoire de `PlanningApi`. Un délai réseau simulé rend le
|
|
// comportement (chargements, invalidations) proche du back-end réel.
|
|
|
|
const LATENCY_MS = 120;
|
|
const EIGHT_HOURS_MIN = 8 * 60;
|
|
const MAGIC_LINK_TTL_MS = 15 * 60 * 1000;
|
|
|
|
let state: MockState = buildMockState();
|
|
|
|
/** token de session -> id de compte. */
|
|
const sessions = new Map<string, string>();
|
|
/** token magique brut -> compte + expiration. */
|
|
const magicTokens = new Map<string, { accountId: string; expiresAt: number; used: boolean }>();
|
|
/** email (minuscule) -> dernier token magique émis (démo : l'email est stubé). */
|
|
const lastMagicLink = new Map<string, string>();
|
|
|
|
/** Réinitialise le jeu de démonstration (utile en test). */
|
|
export function resetMockState(): void {
|
|
state = buildMockState();
|
|
sessions.clear();
|
|
magicTokens.clear();
|
|
lastMagicLink.clear();
|
|
}
|
|
|
|
/**
|
|
* Mode mock uniquement : récupère le dernier lien magique émis pour un email.
|
|
* En mode HTTP le back-end l'envoie par mail (ici stubé dans ses logs).
|
|
*/
|
|
export function peekMockMagicToken(email: string): string | undefined {
|
|
return lastMagicLink.get(email.trim().toLowerCase());
|
|
}
|
|
|
|
function accountByEmail(email: string): AccountRead | undefined {
|
|
const target = email.trim().toLowerCase();
|
|
return state.accounts.find((a) => a.email.toLowerCase() === target);
|
|
}
|
|
|
|
function issue(account: AccountRead): TokenResponse {
|
|
const token = `mock.${account.id}.${Math.random().toString(36).slice(2, 10)}`;
|
|
sessions.set(token, account.id);
|
|
return { access_token: token, token_type: 'bearer', account };
|
|
}
|
|
|
|
const delay = <T>(value: T): Promise<T> =>
|
|
new Promise((resolve) => setTimeout(() => resolve(structuredClone(value)), LATENCY_MS));
|
|
|
|
const notFound = (): never => {
|
|
throw new ApiError(404, 'error.not_found');
|
|
};
|
|
|
|
const fileToDataUrl = (file: File): Promise<string> =>
|
|
new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onload = () => resolve(reader.result as string);
|
|
reader.onerror = () => reject(reader.error);
|
|
reader.readAsDataURL(file);
|
|
});
|
|
|
|
// PDF valide minimal (une page blanche) — remplace la vraie génération
|
|
// (réservée au back-end réel) pour que le téléchargement fonctionne en mode
|
|
// démonstration.
|
|
const MOCK_PDF_BLOB = () =>
|
|
new Blob(
|
|
[
|
|
'%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n' +
|
|
'2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n' +
|
|
'3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 595 842]>>endobj\n' +
|
|
'trailer<</Root 1 0 R>>\n%%EOF',
|
|
],
|
|
{ type: 'application/pdf' },
|
|
);
|
|
|
|
const touch = () => new Date().toISOString().replace(/\.\d+Z$/, '');
|
|
|
|
const addDays = (day: string, n: number): string => {
|
|
const [y, m, d] = day.split('-').map(Number);
|
|
return new Date(Date.UTC(y, m - 1, d + n)).toISOString().slice(0, 10);
|
|
};
|
|
|
|
const slugCode = (label: string): string =>
|
|
label
|
|
.trim()
|
|
.toUpperCase()
|
|
.normalize('NFD')
|
|
.replace(/[^A-Z0-9]+/g, '_')
|
|
.replace(/^_+|_+$/g, '');
|
|
|
|
/** « HH:MM » ou « HH:MM:SS » -> « HH:MM:SS ». */
|
|
const hms = (time: string): string => (time.length === 5 ? `${time}:00` : time);
|
|
|
|
const sortWindows = (ws: AvailabilityRead[]): AvailabilityRead[] =>
|
|
[...ws].sort(
|
|
(a, b) =>
|
|
a.volunteer_id.localeCompare(b.volunteer_id) ||
|
|
a.date.localeCompare(b.date) ||
|
|
a.start_time.localeCompare(b.start_time),
|
|
);
|
|
|
|
/** Vœux du bénévole : dé-doublonne, ordre conservé ; 422 si un id est étranger. */
|
|
function resolveWishes<T extends { id: string }>(
|
|
pool: T[],
|
|
ids: string[],
|
|
code: string,
|
|
): string[] {
|
|
const wanted = [...new Set(ids)];
|
|
const known = new Set(pool.map((x) => x.id));
|
|
if (wanted.some((id) => !known.has(id))) throw new ApiError(422, code);
|
|
return wanted;
|
|
}
|
|
|
|
/** Vœux de compagnon : comme `resolveWishes`, plus le rejet de soi-même. */
|
|
function resolveCompanionWishes(ids: string[], selfId?: string): string[] {
|
|
const resolved = resolveWishes(state.volunteers, ids, 'volunteer.unknown');
|
|
if (selfId != null && resolved.includes(selfId)) {
|
|
throw new ApiError(422, 'volunteer.cannot_wish_self');
|
|
}
|
|
return resolved;
|
|
}
|
|
|
|
/** `lead_account_id` d'un poste : doit désigner un compte référent (LEAD)
|
|
* existant — sinon le poste se retrouve sans référent utilisable. */
|
|
function checkLeadAccount(leadAccountId: string | null | undefined): void {
|
|
if (leadAccountId == null) return;
|
|
const lead = state.accounts.find((a) => a.id === leadAccountId);
|
|
if (!lead || lead.role !== 'LEAD') {
|
|
throw new ApiError(422, 'position.lead_must_be_referent_account');
|
|
}
|
|
}
|
|
|
|
function shiftWindow(day: string, dp: DayPartRead): [string, string] {
|
|
const endDay = dp.end_time <= dp.start_time ? addDays(day, 1) : day;
|
|
return [`${day}T${dp.start_time}`, `${endDay}T${dp.end_time}`];
|
|
}
|
|
|
|
function generateMissingShifts(positionId: string): number {
|
|
const existing = new Set(
|
|
state.shifts
|
|
.filter((s) => s.position_id === positionId)
|
|
.map((s) => `${s.day_part_id}|${s.starts_at.slice(0, 10)}`),
|
|
);
|
|
let created = 0;
|
|
let day = state.edition.start_date;
|
|
while (day <= state.edition.end_date) {
|
|
for (const dp of state.dayParts) {
|
|
if (existing.has(`${dp.id}|${day}`)) continue;
|
|
const [starts_at, ends_at] = shiftWindow(day, dp);
|
|
const now = touch();
|
|
state.shifts.push({
|
|
id: `s${state.seq++}`,
|
|
edition_id: state.edition.id,
|
|
position_id: positionId,
|
|
day_part_id: dp.id,
|
|
starts_at,
|
|
ends_at,
|
|
min_staff: 0,
|
|
ideal_staff: 0,
|
|
created_at: now,
|
|
updated_at: now,
|
|
});
|
|
created += 1;
|
|
}
|
|
day = addDays(day, 1);
|
|
}
|
|
return created;
|
|
}
|
|
|
|
function shiftById(id: string): ShiftRead {
|
|
const shift = state.shifts.find((s) => s.id === id);
|
|
if (!shift) return notFound();
|
|
return shift;
|
|
}
|
|
|
|
// --------------------------------------------------------------------------- //
|
|
// Import de bénévoles (mode démo : fichiers CSV uniquement) //
|
|
// --------------------------------------------------------------------------- //
|
|
const IMPORT_HINTS: Record<string, string[]> = {
|
|
email: ['mail', 'courriel'],
|
|
first_name: ['prenom', 'first name', 'firstname', 'given name'],
|
|
last_name: ['nom', 'last name', 'lastname', 'surname', 'famille'],
|
|
phone: ['tel', 'phone', 'portable', 'mobile', 'gsm'],
|
|
comment: ['commentaire', 'remarque', 'note', 'precision'],
|
|
};
|
|
const IMPORT_EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
|
|
|
function normalizeText(value: string): string {
|
|
return (value ?? '')
|
|
.trim()
|
|
.toLowerCase()
|
|
.normalize('NFD')
|
|
.replace(/[\u0300-\u036f]/g, '')
|
|
.replace(/\s+/g, ' ');
|
|
}
|
|
|
|
function splitCsvLine(line: string, delimiter: string): string[] {
|
|
const cells: string[] = [];
|
|
let cur = '';
|
|
let quoted = false;
|
|
for (let i = 0; i < line.length; i += 1) {
|
|
const ch = line[i];
|
|
if (quoted) {
|
|
if (ch === '"' && line[i + 1] === '"') {
|
|
cur += '"';
|
|
i += 1;
|
|
} else if (ch === '"') {
|
|
quoted = false;
|
|
} else {
|
|
cur += ch;
|
|
}
|
|
} else if (ch === '"') {
|
|
quoted = true;
|
|
} else if (ch === delimiter) {
|
|
cells.push(cur);
|
|
cur = '';
|
|
} else {
|
|
cur += ch;
|
|
}
|
|
}
|
|
cells.push(cur);
|
|
return cells.map((c) => c.trim());
|
|
}
|
|
|
|
async function readImportTable(file: File): Promise<{ headers: string[]; rows: string[][] }> {
|
|
const name = (file.name || '').toLowerCase();
|
|
const buf = new Uint8Array(await file.arrayBuffer());
|
|
const isXlsx =
|
|
name.endsWith('.xlsx') ||
|
|
name.endsWith('.xlsm') ||
|
|
(buf[0] === 0x50 && buf[1] === 0x4b && buf[2] === 0x03 && buf[3] === 0x04);
|
|
if (isXlsx) throw new ApiError(422, 'import.error.xlsx_demo_only');
|
|
const text = new TextDecoder('utf-8')
|
|
.decode(buf)
|
|
.replace(/^\uFEFF/, '')
|
|
.replace(/\r\n?/g, '\n')
|
|
.trim();
|
|
if (!text) throw new ApiError(400, 'import.error.empty_file');
|
|
const lines = text.split('\n').filter((l) => l.trim() !== '');
|
|
const delimiter = [';', '\t', ','].find((d) => lines[0].includes(d)) ?? ',';
|
|
const headers = splitCsvLine(lines[0], delimiter);
|
|
const rows = lines.slice(1).map((l) => splitCsvLine(l, delimiter));
|
|
if (headers.length === 0) throw new ApiError(400, 'import.error.empty_file');
|
|
return { headers, rows };
|
|
}
|
|
|
|
function suggestImportMapping(headers: string[]): Record<string, string> {
|
|
const result: Record<string, string> = {};
|
|
const used = new Set<string>();
|
|
for (const [field, hints] of Object.entries(IMPORT_HINTS)) {
|
|
for (const header of headers) {
|
|
if (used.has(header)) continue;
|
|
if (hints.some((h) => normalizeText(header).includes(h))) {
|
|
result[field] = header;
|
|
used.add(header);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function overlaps(a: ShiftRead, b: ShiftRead): boolean {
|
|
return a.starts_at < b.ends_at && b.starts_at < a.ends_at;
|
|
}
|
|
|
|
/** Portage frontend du moteur (`backend/app/validation/engine.py`). */
|
|
function runValidation(volunteerId: string, candidate: ShiftRead, excludeAssignmentId?: string): Violation[] {
|
|
const volunteer = state.volunteers.find((v) => v.id === volunteerId);
|
|
if (!volunteer) return notFound();
|
|
|
|
const existing = state.assignments
|
|
.filter(
|
|
(a) =>
|
|
a.volunteer_id === volunteerId &&
|
|
a.id !== excludeAssignmentId &&
|
|
a.shift_id !== candidate.id,
|
|
)
|
|
.map((a) => shiftById(a.shift_id));
|
|
|
|
const violations: Violation[] = [];
|
|
|
|
// SHIFT_OVERLAP (bloquante)
|
|
for (const other of existing) {
|
|
if (overlaps(candidate, other)) {
|
|
violations.push({
|
|
code: 'SHIFT_OVERLAP',
|
|
severity: 'BLOCKING',
|
|
params: {
|
|
other_shift_id: other.id,
|
|
other_starts_at: other.starts_at,
|
|
other_ends_at: other.ends_at,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
// OUTSIDE_AVAILABILITY (avertissement) — créneau non couvert par l'union des
|
|
// fenêtres de disponibilité déclarées.
|
|
const gapMin = uncoveredMinutes(
|
|
state.availability.get(volunteerId) ?? [],
|
|
candidate.starts_at,
|
|
candidate.ends_at,
|
|
);
|
|
if (gapMin > 0) {
|
|
violations.push({
|
|
code: 'OUTSIDE_AVAILABILITY',
|
|
severity: 'WARNING',
|
|
params: {
|
|
date: candidate.starts_at.slice(0, 10),
|
|
uncovered_minutes: gapMin,
|
|
},
|
|
});
|
|
}
|
|
|
|
// DAILY_HOURS_EXCEEDED (avertissement) — journée = date de début du créneau.
|
|
const dayKey = candidate.starts_at.slice(0, 10);
|
|
let totalMin = (naiveMinutes(candidate.ends_at) - naiveMinutes(candidate.starts_at));
|
|
for (const other of existing) {
|
|
if (other.starts_at.slice(0, 10) === dayKey) {
|
|
totalMin += naiveMinutes(other.ends_at) - naiveMinutes(other.starts_at);
|
|
}
|
|
}
|
|
if (totalMin > EIGHT_HOURS_MIN) {
|
|
violations.push({
|
|
code: 'DAILY_HOURS_EXCEEDED',
|
|
severity: 'WARNING',
|
|
params: { date: dayKey, total_minutes: totalMin },
|
|
});
|
|
}
|
|
|
|
// INSUFFICIENT_REST (avertissement) — < 8 h entre deux créneaux (strict).
|
|
for (const other of existing) {
|
|
if (overlaps(candidate, other)) continue;
|
|
let gap: number;
|
|
let direction: 'BEFORE' | 'AFTER';
|
|
if (naiveMinutes(other.ends_at) <= naiveMinutes(candidate.starts_at)) {
|
|
gap = naiveMinutes(candidate.starts_at) - naiveMinutes(other.ends_at);
|
|
direction = 'BEFORE';
|
|
} else if (naiveMinutes(candidate.ends_at) <= naiveMinutes(other.starts_at)) {
|
|
gap = naiveMinutes(other.starts_at) - naiveMinutes(candidate.ends_at);
|
|
direction = 'AFTER';
|
|
} else {
|
|
continue;
|
|
}
|
|
if (gap < EIGHT_HOURS_MIN) {
|
|
violations.push({
|
|
code: 'INSUFFICIENT_REST',
|
|
severity: 'WARNING',
|
|
params: { other_shift_id: other.id, direction, gap_minutes: gap },
|
|
});
|
|
}
|
|
}
|
|
|
|
return violations;
|
|
}
|
|
|
|
function hasBlocking(violations: Violation[]): boolean {
|
|
return violations.some((v) => v.severity === 'BLOCKING');
|
|
}
|
|
|
|
function buildMatrix(): MatrixResponse {
|
|
const counts = new Map<string, number>();
|
|
for (const a of state.assignments) {
|
|
counts.set(a.shift_id, (counts.get(a.shift_id) ?? 0) + 1);
|
|
}
|
|
return {
|
|
edition_id: state.positions[0]?.edition_id ?? '',
|
|
positions: [...state.positions]
|
|
.sort((a, b) => a.name.localeCompare(b.name, 'fr'))
|
|
.map((position) => ({
|
|
id: position.id,
|
|
name: position.name,
|
|
category: position.category,
|
|
location: position.location,
|
|
shifts: state.shifts
|
|
.filter((s) => s.position_id === position.id)
|
|
.sort((a, b) => a.starts_at.localeCompare(b.starts_at))
|
|
.map((s) => ({
|
|
shift_id: s.id,
|
|
starts_at: s.starts_at,
|
|
ends_at: s.ends_at,
|
|
min_staff: s.min_staff,
|
|
ideal_staff: s.ideal_staff,
|
|
assigned: counts.get(s.id) ?? 0,
|
|
})),
|
|
})),
|
|
};
|
|
}
|
|
|
|
// --------------------------------------------------------------------------- //
|
|
// Solveur (suggestions) — portage glouton du moteur CP-SAT du back-end //
|
|
// (`backend/app/solver/engine.py`). Approximation : traite les créneaux les //
|
|
// plus urgents en premier et choisit, pour chacun, les meilleurs candidats //
|
|
// libres — pas une optimisation jointe comme CP-SAT, mais un ordre de //
|
|
// grandeur cohérent pour la démonstration. Ne modifie jamais `state`. //
|
|
// --------------------------------------------------------------------------- //
|
|
const SOLVER_URGENT_BASE = 1000;
|
|
const SOLVER_NORMAL_BASE = 20;
|
|
const SOLVER_WISH_BONUS = 30;
|
|
const SOLVER_AVAIL_PENALTY_PER_HALF_HOUR = 5;
|
|
const SOLVER_REST_PENALTY = 60;
|
|
const SOLVER_ARTIST_CONFLICT_PENALTY = 80;
|
|
const SOLVER_FAIRNESS_PENALTY_PER_HOUR = 2;
|
|
// Le bénévole a déjà tenu le créneau immédiatement précédent (même jour,
|
|
// n'importe quel poste) : limite les enchaînements de shifts plutôt que de
|
|
// faire tourner un poste en particulier.
|
|
const SOLVER_CHAIN_PENALTY = 40;
|
|
// Par compagnon souhaité déjà affecté au créneau (0 à N vœux par bénévole),
|
|
// traité comme réciproque : si A souhaite B, B en profite aussi (voir
|
|
// `buildEffectiveCompanions`).
|
|
const SOLVER_COMPANION_BONUS_PER_MATCH = 25;
|
|
|
|
type TimeWindow = { starts_at: string; ends_at: string };
|
|
|
|
function timeRangesOverlap(a: TimeWindow, b: TimeWindow): boolean {
|
|
return a.starts_at < b.ends_at && b.starts_at < a.ends_at;
|
|
}
|
|
|
|
function volunteerExistingShifts(volunteerId: string): ShiftRead[] {
|
|
return state.assignments
|
|
.filter((a) => a.volunteer_id === volunteerId)
|
|
.map((a) => shiftById(a.shift_id));
|
|
}
|
|
|
|
/** Bénévoles déjà affectés (décisions déjà prises) à un créneau — sert au
|
|
* bonus de compagnon de route (voir `solverScore`). */
|
|
function assignedVolunteerIds(shiftId: string): Set<string> {
|
|
return new Set(
|
|
state.assignments.filter((a) => a.shift_id === shiftId).map((a) => a.volunteer_id),
|
|
);
|
|
}
|
|
|
|
/** Vœu de compagnon traité comme réciproque : si A souhaite B, B profite
|
|
* aussi de la présence de A (même s'il ne l'a pas demandé). Indexé par id de
|
|
* bénévole — sert au bonus de compagnon de route (voir `solverScore`). */
|
|
function buildEffectiveCompanions(): Map<string, Set<string>> {
|
|
const effective = new Map<string, Set<string>>();
|
|
for (const v of state.volunteers) {
|
|
effective.set(v.id, new Set(v.wished_companion_ids));
|
|
}
|
|
for (const v of state.volunteers) {
|
|
for (const companionId of v.wished_companion_ids) {
|
|
effective.get(companionId)?.add(v.id);
|
|
}
|
|
}
|
|
return effective;
|
|
}
|
|
|
|
/** Créneau(x) — n'importe quel poste — dont la fin coïncide avec le début de
|
|
* chaque créneau, le même jour (indexé par id de créneau) — sert au malus de
|
|
* chaînage (voir `solverScore`), pour limiter les enchaînements de shifts
|
|
* plutôt que cibler la rotation d'un poste en particulier. */
|
|
function buildPredecessorMap(): Map<string, Set<string>> {
|
|
const byDay = new Map<string, ShiftRead[]>();
|
|
for (const s of state.shifts) {
|
|
const day = s.starts_at.slice(0, 10);
|
|
const list = byDay.get(day) ?? [];
|
|
list.push(s);
|
|
byDay.set(day, list);
|
|
}
|
|
const predecessor = new Map<string, Set<string>>();
|
|
for (const dayShifts of byDay.values()) {
|
|
const idsByEnd = new Map<string, string[]>();
|
|
for (const s of dayShifts) {
|
|
const list = idsByEnd.get(s.ends_at) ?? [];
|
|
list.push(s.id);
|
|
idsByEnd.set(s.ends_at, list);
|
|
}
|
|
const distinctEnds = [...idsByEnd.keys()].sort();
|
|
for (const s of dayShifts) {
|
|
const earlierEnds = distinctEnds.filter((e) => e <= s.starts_at);
|
|
if (earlierEnds.length > 0) {
|
|
const latest = earlierEnds[earlierEnds.length - 1];
|
|
predecessor.set(s.id, new Set(idsByEnd.get(latest)));
|
|
}
|
|
}
|
|
}
|
|
return predecessor;
|
|
}
|
|
|
|
/** Fenêtres datées des passages que le bénévole veut voir (un créneau qui
|
|
* chevauche l'une d'elles lui ferait manquer le passage). */
|
|
function wishedArtistWindows(volunteer: VolunteerRead): TimeWindow[] {
|
|
return volunteer.artist_ids
|
|
.map((id) => state.artists.find((a) => a.id === id))
|
|
.filter((a): a is ArtistRead => Boolean(a))
|
|
.map((a) => {
|
|
const endDate = a.end_time > a.start_time ? a.performance_date : addDays(a.performance_date, 1);
|
|
return { starts_at: `${a.performance_date}T${a.start_time}`, ends_at: `${endDate}T${a.end_time}` };
|
|
});
|
|
}
|
|
|
|
function solverScore(
|
|
volunteer: VolunteerRead,
|
|
shift: ShiftRead,
|
|
assignedCount: number,
|
|
existing: ShiftRead[],
|
|
wishedArtists: TimeWindow[],
|
|
chainedFromPredecessor: boolean,
|
|
alreadyAssignedIds: Set<string>,
|
|
effectiveCompanionIds: Set<string>,
|
|
): number {
|
|
let score = assignedCount < shift.min_staff ? SOLVER_URGENT_BASE : SOLVER_NORMAL_BASE;
|
|
if (volunteer.wished_position_ids.includes(shift.position_id)) score += SOLVER_WISH_BONUS;
|
|
if (wishedArtists.some((w) => timeRangesOverlap(shift, w))) {
|
|
score -= SOLVER_ARTIST_CONFLICT_PENALTY;
|
|
}
|
|
if (chainedFromPredecessor) score -= SOLVER_CHAIN_PENALTY;
|
|
let companionsPresent = 0;
|
|
for (const id of alreadyAssignedIds) {
|
|
if (effectiveCompanionIds.has(id)) companionsPresent += 1;
|
|
}
|
|
score += SOLVER_COMPANION_BONUS_PER_MATCH * companionsPresent;
|
|
|
|
const windows = state.availability.get(volunteer.id) ?? [];
|
|
const missed = uncoveredMinutes(windows, shift.starts_at, shift.ends_at);
|
|
score -= Math.floor(missed / 30) * SOLVER_AVAIL_PENALTY_PER_HALF_HOUR;
|
|
|
|
for (const other of existing) {
|
|
if (overlaps(shift, other)) continue;
|
|
let gap: number | null = null;
|
|
if (naiveMinutes(other.ends_at) <= naiveMinutes(shift.starts_at)) {
|
|
gap = naiveMinutes(shift.starts_at) - naiveMinutes(other.ends_at);
|
|
} else if (naiveMinutes(shift.ends_at) <= naiveMinutes(other.starts_at)) {
|
|
gap = naiveMinutes(other.starts_at) - naiveMinutes(shift.ends_at);
|
|
}
|
|
if (gap !== null && gap < EIGHT_HOURS_MIN) {
|
|
score -= SOLVER_REST_PENALTY;
|
|
break;
|
|
}
|
|
}
|
|
|
|
const existingMinutes = existing.reduce(
|
|
(sum, s) => sum + (naiveMinutes(s.ends_at) - naiveMinutes(s.starts_at)),
|
|
0,
|
|
);
|
|
score -= Math.floor((SOLVER_FAIRNESS_PENALTY_PER_HOUR * existingMinutes) / 60);
|
|
|
|
return score;
|
|
}
|
|
|
|
function runSolver(): SolverRunResult {
|
|
const assignedCount = new Map<string, number>();
|
|
for (const a of state.assignments) {
|
|
assignedCount.set(a.shift_id, (assignedCount.get(a.shift_id) ?? 0) + 1);
|
|
}
|
|
const candidateShifts = state.shifts.filter(
|
|
(s) => (assignedCount.get(s.id) ?? 0) < s.ideal_staff,
|
|
);
|
|
|
|
const existingByVolunteer = new Map<string, ShiftRead[]>();
|
|
const artistWindowsByVolunteer = new Map<string, TimeWindow[]>();
|
|
for (const v of state.volunteers) {
|
|
existingByVolunteer.set(v.id, volunteerExistingShifts(v.id));
|
|
artistWindowsByVolunteer.set(v.id, wishedArtistWindows(v));
|
|
}
|
|
const predecessorByShift = buildPredecessorMap();
|
|
const effectiveCompanions = buildEffectiveCompanions();
|
|
// Suggestions déjà retenues dans ce calcul, pour ne jamais proposer deux
|
|
// fois un même bénévole sur des créneaux qui se chevauchent.
|
|
const takenByVolunteer = new Map<string, ShiftRead[]>();
|
|
|
|
const ordered = [...candidateShifts].sort((a, b) => {
|
|
const urgent = (s: ShiftRead) => ((assignedCount.get(s.id) ?? 0) < s.min_staff ? 0 : 1);
|
|
return urgent(a) - urgent(b);
|
|
});
|
|
|
|
const suggestions: SolverSuggestion[] = [];
|
|
for (const shift of ordered) {
|
|
const gap = shift.ideal_staff - (assignedCount.get(shift.id) ?? 0);
|
|
if (gap <= 0) continue;
|
|
const alreadyAssignedIds = assignedVolunteerIds(shift.id);
|
|
const picked = state.volunteers
|
|
.filter((v) => {
|
|
const busy = [
|
|
...(existingByVolunteer.get(v.id) ?? []),
|
|
...(takenByVolunteer.get(v.id) ?? []),
|
|
];
|
|
return !busy.some((other) => overlaps(shift, other));
|
|
})
|
|
.map((v) => {
|
|
const existing = existingByVolunteer.get(v.id) ?? [];
|
|
const predecessorIds = predecessorByShift.get(shift.id);
|
|
const chained = predecessorIds != null && existing.some((s) => predecessorIds.has(s.id));
|
|
return {
|
|
volunteer: v,
|
|
score: solverScore(
|
|
v,
|
|
shift,
|
|
assignedCount.get(shift.id) ?? 0,
|
|
existing,
|
|
artistWindowsByVolunteer.get(v.id) ?? [],
|
|
chained,
|
|
alreadyAssignedIds,
|
|
effectiveCompanions.get(v.id) ?? new Set(),
|
|
),
|
|
};
|
|
})
|
|
.filter((c) => c.score > 0)
|
|
.sort((a, b) => b.score - a.score)
|
|
.slice(0, gap);
|
|
|
|
if (picked.length > 0) {
|
|
suggestions.push({
|
|
shift_id: shift.id,
|
|
volunteer_ids: picked.map((c) => c.volunteer.id),
|
|
});
|
|
for (const c of picked) {
|
|
const list = takenByVolunteer.get(c.volunteer.id) ?? [];
|
|
list.push(shift);
|
|
takenByVolunteer.set(c.volunteer.id, list);
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
suggestions,
|
|
shifts_considered: candidateShifts.length,
|
|
volunteers_considered: state.volunteers.length,
|
|
};
|
|
}
|
|
|
|
export function createMockApi(): PlanningApi {
|
|
return {
|
|
editions: {
|
|
get: () => delay(state.edition),
|
|
update: (_edition, body: EditionUpdate) => {
|
|
const next = { ...state.edition };
|
|
if ('name' in body) next.name = body.name ?? null;
|
|
if (body.year != null) next.year = body.year;
|
|
if (body.start_date) next.start_date = body.start_date;
|
|
if (body.end_date) next.end_date = body.end_date;
|
|
if ('retention_days' in body) next.retention_days = body.retention_days ?? null;
|
|
if (next.end_date < next.start_date) {
|
|
throw new ApiError(422, 'edition.dates_inconsistent');
|
|
}
|
|
next.updated_at = new Date().toISOString();
|
|
state.edition = next;
|
|
return delay(state.edition);
|
|
},
|
|
},
|
|
dayParts: {
|
|
list: () =>
|
|
delay([...state.dayParts].sort((a, b) => a.order - b.order || a.start_time.localeCompare(b.start_time))),
|
|
create: (_edition, body: DayPartCreate) => {
|
|
const code = (body.code ?? slugCode(body.label)).trim();
|
|
if (!code) throw new ApiError(422, 'day_part.code_required');
|
|
if (state.dayParts.some((d) => d.code === code)) {
|
|
throw new ApiError(409, 'day_part.duplicate_code');
|
|
}
|
|
const now = touch();
|
|
const dp: DayPartRead = {
|
|
id: `dp_${state.seq++}`,
|
|
edition_id: state.edition.id,
|
|
code,
|
|
label: body.label.trim(),
|
|
start_time: body.start_time,
|
|
end_time: body.end_time,
|
|
order: body.order ?? 0,
|
|
created_at: now,
|
|
updated_at: now,
|
|
};
|
|
state.dayParts.push(dp);
|
|
return delay(dp);
|
|
},
|
|
update: (_edition, id, body: DayPartUpdate) => {
|
|
const dp = state.dayParts.find((d) => d.id === id) ?? notFound();
|
|
Object.assign(dp, {
|
|
...('label' in body ? { label: body.label } : {}),
|
|
...('start_time' in body ? { start_time: body.start_time } : {}),
|
|
...('end_time' in body ? { end_time: body.end_time } : {}),
|
|
...('order' in body ? { order: body.order } : {}),
|
|
updated_at: touch(),
|
|
});
|
|
return delay(dp);
|
|
},
|
|
remove: (_edition, id) => {
|
|
state.dayParts = state.dayParts.filter((d) => d.id !== id);
|
|
state.shifts.forEach((s) => {
|
|
if (s.day_part_id === id) s.day_part_id = null;
|
|
});
|
|
return delay(undefined);
|
|
},
|
|
},
|
|
|
|
artists: {
|
|
list: () =>
|
|
delay(
|
|
[...state.artists].sort(
|
|
(a, b) =>
|
|
a.performance_date.localeCompare(b.performance_date) ||
|
|
a.name.localeCompare(b.name, 'fr'),
|
|
),
|
|
),
|
|
create: (_edition, body: ArtistCreate) => {
|
|
const name = body.name.trim();
|
|
if (!name) throw new ApiError(422, 'artist.name_required');
|
|
if (
|
|
state.artists.some(
|
|
(a) => a.name === name && a.performance_date === body.performance_date,
|
|
)
|
|
) {
|
|
throw new ApiError(409, 'artist.duplicate');
|
|
}
|
|
const now = touch();
|
|
const artist: ArtistRead = {
|
|
id: `art_${state.seq++}`,
|
|
edition_id: state.edition.id,
|
|
name,
|
|
performance_date: body.performance_date,
|
|
start_time: hms(body.start_time),
|
|
end_time: hms(body.end_time),
|
|
created_at: now,
|
|
updated_at: now,
|
|
};
|
|
state.artists.push(artist);
|
|
return delay(artist);
|
|
},
|
|
update: (_edition, id, body: ArtistUpdate) => {
|
|
const artist = state.artists.find((a) => a.id === id) ?? notFound();
|
|
const name = 'name' in body ? (body.name ?? '').trim() : artist.name;
|
|
if (!name) throw new ApiError(422, 'artist.name_required');
|
|
const date =
|
|
'performance_date' in body && body.performance_date
|
|
? body.performance_date
|
|
: artist.performance_date;
|
|
if (
|
|
state.artists.some(
|
|
(a) => a.id !== id && a.name === name && a.performance_date === date,
|
|
)
|
|
) {
|
|
throw new ApiError(409, 'artist.duplicate');
|
|
}
|
|
Object.assign(artist, {
|
|
name,
|
|
performance_date: date,
|
|
...(body.start_time ? { start_time: hms(body.start_time) } : {}),
|
|
...(body.end_time ? { end_time: hms(body.end_time) } : {}),
|
|
updated_at: touch(),
|
|
});
|
|
return delay(artist);
|
|
},
|
|
remove: (_edition, id) => {
|
|
state.artists = state.artists.filter((a) => a.id !== id);
|
|
state.volunteers.forEach((v) => {
|
|
v.artist_ids = v.artist_ids.filter((aid) => aid !== id);
|
|
});
|
|
return delay(undefined);
|
|
},
|
|
},
|
|
|
|
volunteers: {
|
|
list: () =>
|
|
delay(
|
|
[...state.volunteers].sort(
|
|
(a, b) =>
|
|
a.last_name.localeCompare(b.last_name, 'fr') ||
|
|
a.first_name.localeCompare(b.first_name, 'fr'),
|
|
),
|
|
),
|
|
get: (_edition, id) => {
|
|
const v = state.volunteers.find((x) => x.id === id) ?? notFound();
|
|
return delay(v);
|
|
},
|
|
create: (_edition, body) => {
|
|
const now = touch();
|
|
const volunteer = {
|
|
id: `v${state.seq++}`,
|
|
edition_id: state.edition.id,
|
|
first_name: body.first_name,
|
|
last_name: body.last_name,
|
|
email: body.email ?? null,
|
|
phone: body.phone ?? null,
|
|
comment: body.comment ?? null,
|
|
artist_ids: resolveWishes(state.artists, body.artist_ids ?? [], 'artist.unknown'),
|
|
wished_position_ids: resolveWishes(
|
|
state.positions,
|
|
body.wished_position_ids ?? [],
|
|
'position.unknown',
|
|
),
|
|
wished_companion_ids: resolveCompanionWishes(body.wished_companion_ids ?? []),
|
|
created_at: now,
|
|
updated_at: now,
|
|
};
|
|
state.volunteers.push(volunteer);
|
|
state.availability.set(volunteer.id, []);
|
|
return delay(volunteer);
|
|
},
|
|
update: (_edition, id, body) => {
|
|
const v = state.volunteers.find((x) => x.id === id) ?? notFound();
|
|
Object.assign(v, {
|
|
...('first_name' in body ? { first_name: body.first_name } : {}),
|
|
...('last_name' in body ? { last_name: body.last_name } : {}),
|
|
...('email' in body ? { email: body.email ?? null } : {}),
|
|
...('phone' in body ? { phone: body.phone ?? null } : {}),
|
|
...('comment' in body ? { comment: body.comment ?? null } : {}),
|
|
...(body.artist_ids != null
|
|
? { artist_ids: resolveWishes(state.artists, body.artist_ids, 'artist.unknown') }
|
|
: {}),
|
|
...(body.wished_position_ids != null
|
|
? {
|
|
wished_position_ids: resolveWishes(
|
|
state.positions,
|
|
body.wished_position_ids,
|
|
'position.unknown',
|
|
),
|
|
}
|
|
: {}),
|
|
...(body.wished_companion_ids != null
|
|
? { wished_companion_ids: resolveCompanionWishes(body.wished_companion_ids, v.id) }
|
|
: {}),
|
|
updated_at: touch(),
|
|
});
|
|
return delay(v);
|
|
},
|
|
remove: (_edition, id) => {
|
|
state.volunteers = state.volunteers.filter((x) => x.id !== id);
|
|
state.assignments = state.assignments.filter((a) => a.volunteer_id !== id);
|
|
state.availability.delete(id);
|
|
// RGPD : effacement complet, y compris le compte de connexion éventuel
|
|
// et les vœux de compagnon dont il était la cible.
|
|
state.accounts = state.accounts.filter((a) => a.volunteer_id !== id);
|
|
state.volunteers.forEach((v) => {
|
|
v.wished_companion_ids = v.wished_companion_ids.filter((cid) => cid !== id);
|
|
});
|
|
return delay(undefined);
|
|
},
|
|
planning: (_edition, id) => {
|
|
if (!state.volunteers.some((v) => v.id === id)) return notFound();
|
|
const entries = state.assignments
|
|
.filter((a) => a.volunteer_id === id)
|
|
.map((a) => {
|
|
const shift = state.shifts.find((s) => s.id === a.shift_id);
|
|
const position = shift ? state.positions.find((p) => p.id === shift.position_id) : undefined;
|
|
const referentAccount = position?.lead_account_id
|
|
? state.accounts.find((acc) => acc.id === position.lead_account_id)
|
|
: undefined;
|
|
const referent = referentAccount?.volunteer_id
|
|
? state.volunteers.find((v) => v.id === referentAccount.volunteer_id)
|
|
: undefined;
|
|
return {
|
|
assignment_id: a.id,
|
|
shift_id: a.shift_id,
|
|
starts_at: shift?.starts_at ?? '',
|
|
ends_at: shift?.ends_at ?? '',
|
|
position_name: position?.name ?? '',
|
|
position_location: position?.location ?? null,
|
|
referent_name: referent ? `${referent.first_name} ${referent.last_name}` : null,
|
|
referent_phone: referent?.phone ?? null,
|
|
};
|
|
})
|
|
.sort((a, b) => a.starts_at.localeCompare(b.starts_at));
|
|
return delay(entries);
|
|
},
|
|
},
|
|
|
|
accounts: {
|
|
list: () =>
|
|
delay([...state.accounts].sort((a, b) => a.email.localeCompare(b.email, 'fr'))),
|
|
create: (_edition, body) => {
|
|
const email = body.email.trim();
|
|
if (state.accounts.some((a) => a.email.toLowerCase() === email.toLowerCase())) {
|
|
throw new ApiError(409, 'account.duplicate');
|
|
}
|
|
if (body.volunteer_id && !state.volunteers.some((v) => v.id === body.volunteer_id)) {
|
|
throw new ApiError(422, 'account.volunteer_wrong_edition');
|
|
}
|
|
if (body.role === 'COORDINATOR') {
|
|
if (!body.password || body.password.length < 10) {
|
|
throw new ApiError(422, 'account.password_required');
|
|
}
|
|
} else if (body.password) {
|
|
throw new ApiError(422, 'account.password_not_allowed');
|
|
}
|
|
const now = touch();
|
|
const account: AccountRead = {
|
|
id: `acc${state.seq++}`,
|
|
edition_id: state.edition.id,
|
|
email,
|
|
role: body.role,
|
|
volunteer_id: body.volunteer_id ?? null,
|
|
auth_method: body.role === 'COORDINATOR' ? 'password' : 'magic_link',
|
|
created_at: now,
|
|
updated_at: now,
|
|
};
|
|
state.accounts.push(account);
|
|
return delay(account);
|
|
},
|
|
update: (_edition, id, body) => {
|
|
const account = state.accounts.find((a) => a.id === id) ?? notFound();
|
|
if (body.role != null && body.role !== account.role) {
|
|
if (account.role === 'COORDINATOR' || body.role === 'COORDINATOR') {
|
|
throw new ApiError(422, 'account.role_change_not_allowed');
|
|
}
|
|
account.role = body.role;
|
|
account.auth_method = 'magic_link';
|
|
}
|
|
account.updated_at = touch();
|
|
return delay(account);
|
|
},
|
|
remove: (_edition, id) => {
|
|
state.accounts = state.accounts.filter((a) => a.id !== id);
|
|
return delay(undefined);
|
|
},
|
|
},
|
|
|
|
availability: {
|
|
list: (_edition, volunteerId) => {
|
|
if (!state.volunteers.some((v) => v.id === volunteerId)) return notFound();
|
|
return delay(sortWindows(state.availability.get(volunteerId) ?? []));
|
|
},
|
|
listEdition: () =>
|
|
delay(
|
|
sortWindows(
|
|
[...state.availability.values()].flat().filter((w) =>
|
|
state.volunteers.some((v) => v.id === w.volunteer_id),
|
|
),
|
|
),
|
|
),
|
|
replace: (_edition, volunteerId, body: AvailabilitySet) => {
|
|
if (!state.volunteers.some((v) => v.id === volunteerId)) return notFound();
|
|
const seen = new Set<string>();
|
|
const windows: AvailabilityRead[] = [];
|
|
for (const w of body.windows) {
|
|
if (w.date < state.edition.start_date || w.date > state.edition.end_date) {
|
|
throw new ApiError(422, 'availability.date_outside_edition');
|
|
}
|
|
const start = hms(w.start_time);
|
|
const end = hms(w.end_time);
|
|
if (start === end) throw new ApiError(422, 'availability.empty_window');
|
|
const key = `${w.date}|${start}|${end}`;
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
const now = touch();
|
|
windows.push({
|
|
id: `av${state.seq++}`,
|
|
edition_id: state.edition.id,
|
|
volunteer_id: volunteerId,
|
|
date: w.date,
|
|
start_time: start,
|
|
end_time: end,
|
|
created_at: now,
|
|
updated_at: now,
|
|
});
|
|
}
|
|
state.availability.set(volunteerId, windows);
|
|
return delay(sortWindows(windows));
|
|
},
|
|
},
|
|
|
|
imports: {
|
|
analyze: async (_edition, file: File) => {
|
|
const { headers, rows } = await readImportTable(file);
|
|
const preview: ImportPreviewRow[] = rows.slice(0, 5).map((cells, i) => ({
|
|
line: i + 2,
|
|
values: Object.fromEntries(headers.map((h, c) => [h, cells[c] ?? ''])),
|
|
}));
|
|
return delay<ImportAnalyzeResponse>({
|
|
filename: file.name || '',
|
|
format: 'csv',
|
|
headers,
|
|
row_count: rows.length,
|
|
preview,
|
|
suggested_mapping: suggestImportMapping(headers),
|
|
});
|
|
},
|
|
execute: async (_edition, file: File, mapping: ImportMapping) => {
|
|
const { headers, rows } = await readImportTable(file);
|
|
const cols = mapping.columns;
|
|
const has = (h?: string | null) => Boolean(h) && headers.includes(h as string);
|
|
const cell = (row: string[], h?: string | null) => {
|
|
const i = h ? headers.indexOf(h) : -1;
|
|
return i >= 0 ? (row[i] ?? '').trim() : '';
|
|
};
|
|
const report: ImportReport = {
|
|
dry_run: mapping.dry_run,
|
|
total_rows: rows.length,
|
|
created: 0,
|
|
updated: 0,
|
|
skipped: 0,
|
|
rows: [],
|
|
};
|
|
const rowSeen = new Map<string, number>(); // clé `${email}|${nom normalisé}`
|
|
const emailSeen = new Map<string, number>();
|
|
// On travaille sur des copies : `state` n'est touché qu'en fin si !dry_run.
|
|
const working = state.volunteers.map((v) => ({ ...v }));
|
|
|
|
rows.forEach((row, i) => {
|
|
const line = i + 2;
|
|
const entry: ImportRowReport = {
|
|
line,
|
|
action: 'SKIPPED',
|
|
volunteer_id: null,
|
|
issues: [],
|
|
};
|
|
const first = cell(row, cols.first_name);
|
|
const last = cell(row, cols.last_name);
|
|
const emailRaw = cell(row, cols.email);
|
|
const email = emailRaw.toLowerCase() || null;
|
|
|
|
if (!first || !last) {
|
|
entry.issues.push({
|
|
code: 'import.row.missing_required_field',
|
|
severity: 'ERROR',
|
|
field: !first ? 'first_name' : 'last_name',
|
|
params: {},
|
|
});
|
|
report.rows.push(entry);
|
|
report.skipped += 1;
|
|
return;
|
|
}
|
|
if (email && !IMPORT_EMAIL_RE.test(email)) {
|
|
entry.issues.push({
|
|
code: 'import.row.invalid_email',
|
|
severity: 'ERROR',
|
|
field: 'email',
|
|
params: { value: emailRaw },
|
|
});
|
|
report.rows.push(entry);
|
|
report.skipped += 1;
|
|
return;
|
|
}
|
|
const nameKey = `${normalizeText(last)}|${normalizeText(first)}`;
|
|
if (email) {
|
|
const rowKey = `${email}|${nameKey}`;
|
|
const rowSeenAt = rowSeen.get(rowKey);
|
|
if (rowSeenAt != null) {
|
|
// Même email ET même nom déjà vus : ligne répétée dans le
|
|
// fichier (copier-coller), pas deux bénévoles distincts.
|
|
entry.issues.push({
|
|
code: 'import.row.duplicate_email_in_file',
|
|
severity: 'ERROR',
|
|
field: 'email',
|
|
params: { value: emailRaw, first_line: rowSeenAt },
|
|
});
|
|
report.rows.push(entry);
|
|
report.skipped += 1;
|
|
return;
|
|
}
|
|
const emailSeenAt = emailSeen.get(email);
|
|
if (emailSeenAt != null) {
|
|
// Même email, nom différent : deux bénévoles peuvent partager
|
|
// une adresse (couple, famille) — on les crée tous les deux.
|
|
entry.issues.push({
|
|
code: 'import.row.shared_email',
|
|
severity: 'WARNING',
|
|
field: 'email',
|
|
params: { value: emailRaw, first_line: emailSeenAt },
|
|
});
|
|
}
|
|
rowSeen.set(rowKey, line);
|
|
if (emailSeenAt == null) emailSeen.set(email, line);
|
|
}
|
|
|
|
let vol = email
|
|
? working.find(
|
|
(v) =>
|
|
(v.email ?? '').toLowerCase() === email &&
|
|
normalizeText(v.last_name) === normalizeText(last) &&
|
|
normalizeText(v.first_name) === normalizeText(first),
|
|
)
|
|
: undefined;
|
|
if (vol) {
|
|
entry.action = 'UPDATED';
|
|
entry.issues.push({
|
|
code: 'import.row.updated_existing',
|
|
severity: 'WARNING',
|
|
params: { volunteer_id: vol.id },
|
|
});
|
|
} else {
|
|
if (!email) {
|
|
const match = working.find(
|
|
(v) =>
|
|
!v.email &&
|
|
normalizeText(v.last_name) === normalizeText(last) &&
|
|
normalizeText(v.first_name) === normalizeText(first),
|
|
);
|
|
if (match) {
|
|
entry.issues.push({
|
|
code: 'import.row.possible_duplicate_name',
|
|
severity: 'WARNING',
|
|
params: { volunteer_id: match.id },
|
|
});
|
|
}
|
|
}
|
|
const now = touch();
|
|
vol = {
|
|
id: `v${state.seq++}`,
|
|
edition_id: state.edition.id,
|
|
first_name: first,
|
|
last_name: last,
|
|
email: null,
|
|
phone: null,
|
|
comment: null,
|
|
artist_ids: [],
|
|
wished_position_ids: [],
|
|
wished_companion_ids: [],
|
|
created_at: now,
|
|
updated_at: now,
|
|
};
|
|
working.push(vol);
|
|
entry.action = 'CREATED';
|
|
}
|
|
|
|
vol.first_name = first;
|
|
vol.last_name = last;
|
|
if (has(cols.email)) vol.email = emailRaw || null;
|
|
if (has(cols.phone)) vol.phone = cell(row, cols.phone) || null;
|
|
if (has(cols.comment)) vol.comment = cell(row, cols.comment) || null;
|
|
|
|
entry.volunteer_id = vol.id;
|
|
report.rows.push(entry);
|
|
if (entry.action === 'CREATED') report.created += 1;
|
|
else report.updated += 1;
|
|
});
|
|
|
|
if (!mapping.dry_run) {
|
|
state.volunteers = working;
|
|
for (const v of working) {
|
|
if (!state.availability.has(v.id)) state.availability.set(v.id, []);
|
|
}
|
|
}
|
|
return delay(report);
|
|
},
|
|
},
|
|
|
|
positions: {
|
|
list: () =>
|
|
delay([...state.positions].sort((a, b) => a.name.localeCompare(b.name, 'fr'))),
|
|
get: (_edition, id) => delay(state.positions.find((x) => x.id === id) ?? notFound()),
|
|
create: (_edition, body) => {
|
|
checkLeadAccount(body.lead_account_id);
|
|
const now = touch();
|
|
const position = {
|
|
id: `p${state.seq++}`,
|
|
edition_id: state.positions[0].edition_id,
|
|
name: body.name,
|
|
location: body.location ?? null,
|
|
category: body.category,
|
|
lead_account_id: body.lead_account_id ?? null,
|
|
created_at: now,
|
|
updated_at: now,
|
|
};
|
|
state.positions.push(position);
|
|
generateMissingShifts(position.id);
|
|
return delay(position);
|
|
},
|
|
update: (_edition, id, body) => {
|
|
const p = state.positions.find((x) => x.id === id) ?? notFound();
|
|
if ('lead_account_id' in body) checkLeadAccount(body.lead_account_id);
|
|
Object.assign(p, {
|
|
...('name' in body ? { name: body.name } : {}),
|
|
...('location' in body ? { location: body.location ?? null } : {}),
|
|
...('category' in body ? { category: body.category } : {}),
|
|
...('lead_account_id' in body ? { lead_account_id: body.lead_account_id ?? null } : {}),
|
|
updated_at: touch(),
|
|
});
|
|
return delay(p);
|
|
},
|
|
remove: (_edition, id) => {
|
|
const shiftIds = new Set(state.shifts.filter((s) => s.position_id === id).map((s) => s.id));
|
|
state.positions = state.positions.filter((x) => x.id !== id);
|
|
state.shifts = state.shifts.filter((s) => s.position_id !== id);
|
|
state.assignments = state.assignments.filter((a) => !shiftIds.has(a.shift_id));
|
|
state.volunteers.forEach((v) => {
|
|
v.wished_position_ids = v.wished_position_ids.filter((pid) => pid !== id);
|
|
});
|
|
return delay(undefined);
|
|
},
|
|
generateShifts: (_edition, id) => {
|
|
if (!state.positions.some((x) => x.id === id)) return notFound();
|
|
return delay({ created: generateMissingShifts(id) });
|
|
},
|
|
},
|
|
|
|
shifts: {
|
|
list: (_edition, params) =>
|
|
delay(
|
|
[...state.shifts]
|
|
.filter((s) => !params?.positionId || s.position_id === params.positionId)
|
|
.sort((a, b) => a.starts_at.localeCompare(b.starts_at)),
|
|
),
|
|
get: (_edition, id) => delay(shiftById(id)),
|
|
create: (_edition, body) => {
|
|
if (naiveMinutes(body.ends_at) <= naiveMinutes(body.starts_at)) {
|
|
throw new ApiError(422, 'shift.ends_not_after_starts');
|
|
}
|
|
if (!state.positions.some((p) => p.id === body.position_id)) {
|
|
throw new ApiError(422, 'shift.position_wrong_edition');
|
|
}
|
|
const now = touch();
|
|
const shift: ShiftRead = {
|
|
id: `s${state.seq++}`,
|
|
edition_id: state.positions[0].edition_id,
|
|
position_id: body.position_id,
|
|
day_part_id: null,
|
|
starts_at: body.starts_at,
|
|
ends_at: body.ends_at,
|
|
min_staff: body.min_staff ?? 0,
|
|
ideal_staff: body.ideal_staff ?? 0,
|
|
created_at: now,
|
|
updated_at: now,
|
|
};
|
|
state.shifts.push(shift);
|
|
return delay(shift);
|
|
},
|
|
update: (_edition, id, body) => {
|
|
const s = shiftById(id);
|
|
Object.assign(s, {
|
|
...('position_id' in body ? { position_id: body.position_id } : {}),
|
|
...('starts_at' in body ? { starts_at: body.starts_at } : {}),
|
|
...('ends_at' in body ? { ends_at: body.ends_at } : {}),
|
|
...('min_staff' in body ? { min_staff: body.min_staff } : {}),
|
|
...('ideal_staff' in body ? { ideal_staff: body.ideal_staff } : {}),
|
|
updated_at: touch(),
|
|
});
|
|
if (naiveMinutes(s.ends_at) <= naiveMinutes(s.starts_at)) {
|
|
throw new ApiError(422, 'shift.ends_not_after_starts');
|
|
}
|
|
return delay(s);
|
|
},
|
|
remove: (_edition, id) => {
|
|
state.shifts = state.shifts.filter((x) => x.id !== id);
|
|
state.assignments = state.assignments.filter((a) => a.shift_id !== id);
|
|
return delay(undefined);
|
|
},
|
|
},
|
|
|
|
assignments: {
|
|
list: (_edition, params) =>
|
|
delay(
|
|
state.assignments.filter(
|
|
(a) =>
|
|
(!params?.volunteerId || a.volunteer_id === params.volunteerId) &&
|
|
(!params?.shiftId || a.shift_id === params.shiftId),
|
|
),
|
|
),
|
|
get: (_edition, id) => delay(state.assignments.find((x) => x.id === id) ?? notFound()),
|
|
create: (_edition, body) => {
|
|
const shift = shiftById(body.shift_id);
|
|
if (!state.volunteers.some((v) => v.id === body.volunteer_id)) {
|
|
throw new ApiError(422, 'assignment.wrong_edition');
|
|
}
|
|
if (
|
|
state.assignments.some(
|
|
(a) => a.volunteer_id === body.volunteer_id && a.shift_id === body.shift_id,
|
|
)
|
|
) {
|
|
throw new ApiError(409, 'assignment.duplicate');
|
|
}
|
|
// Une affectation est toujours validée à la création : une violation
|
|
// bloquante l'interdit, les avertissements sont renvoyés.
|
|
const warnings = runValidation(body.volunteer_id, shift);
|
|
if (hasBlocking(warnings)) {
|
|
throw new ApiError(422, 'error.validation_failed', warnings);
|
|
}
|
|
const now = touch();
|
|
const assignment: AssignmentRead = {
|
|
id: `a${state.seq++}`,
|
|
edition_id: shift.edition_id,
|
|
volunteer_id: body.volunteer_id,
|
|
shift_id: body.shift_id,
|
|
source: body.source ?? 'MANUAL',
|
|
locked: body.locked ?? false,
|
|
created_at: now,
|
|
updated_at: now,
|
|
};
|
|
state.assignments.push(assignment);
|
|
return delay({ assignment, warnings });
|
|
},
|
|
update: (_edition, id, body) => {
|
|
const a = state.assignments.find((x) => x.id === id) ?? notFound();
|
|
Object.assign(a, {
|
|
...('source' in body ? { source: body.source } : {}),
|
|
...('locked' in body ? { locked: body.locked } : {}),
|
|
updated_at: touch(),
|
|
});
|
|
return delay({ assignment: a, warnings: [] as Violation[] });
|
|
},
|
|
remove: (_edition, id) => {
|
|
state.assignments = state.assignments.filter((x) => x.id !== id);
|
|
return delay(undefined);
|
|
},
|
|
validate: (_edition, body): Promise<AssignmentValidateResult> => {
|
|
const violations = runValidation(body.volunteer_id, shiftById(body.shift_id));
|
|
return delay({ blocking: hasBlocking(violations), violations });
|
|
},
|
|
},
|
|
|
|
matrix: {
|
|
get: () => delay(buildMatrix()),
|
|
},
|
|
|
|
badges: {
|
|
getTemplate: (): Promise<BadgeTemplateRead> =>
|
|
delay({
|
|
edition_id: state.edition.id,
|
|
has_front_image: state.badgeTemplate.frontImage !== null,
|
|
has_back_image: state.badgeTemplate.backImage !== null,
|
|
fields: state.badgeTemplate.fields,
|
|
}),
|
|
uploadFrontImage: async (_edition, file: File) => {
|
|
state.badgeTemplate.frontImage = await fileToDataUrl(file);
|
|
state.badgeTemplate.frontImageType = file.type;
|
|
return delay<BadgeTemplateRead>({
|
|
edition_id: state.edition.id,
|
|
has_front_image: true,
|
|
has_back_image: state.badgeTemplate.backImage !== null,
|
|
fields: state.badgeTemplate.fields,
|
|
});
|
|
},
|
|
uploadBackImage: async (_edition, file: File) => {
|
|
state.badgeTemplate.backImage = await fileToDataUrl(file);
|
|
state.badgeTemplate.backImageType = file.type;
|
|
return delay<BadgeTemplateRead>({
|
|
edition_id: state.edition.id,
|
|
has_front_image: state.badgeTemplate.frontImage !== null,
|
|
has_back_image: true,
|
|
fields: state.badgeTemplate.fields,
|
|
});
|
|
},
|
|
frontImageBlob: async () => {
|
|
if (!state.badgeTemplate.frontImage) return null;
|
|
return (await fetch(state.badgeTemplate.frontImage)).blob();
|
|
},
|
|
backImageBlob: async () => {
|
|
if (!state.badgeTemplate.backImage) return null;
|
|
return (await fetch(state.badgeTemplate.backImage)).blob();
|
|
},
|
|
setFields: (_edition, body: BadgeFieldsSet) => {
|
|
const seen = new Set<string>();
|
|
for (const f of body.fields) {
|
|
if (seen.has(f.field_type)) throw new ApiError(422, 'badge.duplicate_field_type');
|
|
seen.add(f.field_type);
|
|
}
|
|
const fields: BadgeFieldRead[] = body.fields.map((f) => ({
|
|
...f,
|
|
id: `bf${state.seq++}`,
|
|
edition_id: state.edition.id,
|
|
}));
|
|
state.badgeTemplate.fields = fields;
|
|
return delay(fields);
|
|
},
|
|
generate: (_edition, body: BadgeGenerateRequest) => {
|
|
if (state.badgeTemplate.fields.length === 0) {
|
|
throw new ApiError(422, 'badge.no_fields');
|
|
}
|
|
const ids = body.volunteer_ids ?? state.volunteers.map((v) => v.id);
|
|
const matching = state.volunteers.filter((v) => ids.includes(v.id));
|
|
if (matching.length === 0) throw new ApiError(422, 'badge.no_volunteers');
|
|
// La composition réelle du PDF (reportlab) est réservée au back-end :
|
|
// le mode démonstration renvoie un PDF minimal valide, téléchargeable.
|
|
return delay(MOCK_PDF_BLOB());
|
|
},
|
|
},
|
|
|
|
solver: {
|
|
run: () => delay(runSolver()),
|
|
},
|
|
|
|
notifications: {
|
|
sendPlanning: (_edition, body: PlanningNotificationRequest) => {
|
|
const ids = body.volunteer_ids ?? null;
|
|
const targets = state.volunteers.filter((v) => ids === null || ids.includes(v.id));
|
|
const withEmail = targets.filter((v) => v.email);
|
|
const wantsLink =
|
|
(body.subject ?? '').includes('{lien_connexion}') ||
|
|
(body.body ?? '').includes('{lien_connexion}');
|
|
for (const volunteer of withEmail) {
|
|
if (!wantsLink) continue;
|
|
const account = state.accounts.find(
|
|
(a) => a.volunteer_id === volunteer.id && a.auth_method === 'magic_link',
|
|
);
|
|
if (!account) continue;
|
|
const raw = `mlt.${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;
|
|
magicTokens.set(raw, {
|
|
accountId: account.id,
|
|
expiresAt: Date.now() + MAGIC_LINK_TTL_MS,
|
|
used: false,
|
|
});
|
|
lastMagicLink.set(account.email.toLowerCase(), raw);
|
|
}
|
|
return delay({
|
|
queued: withEmail.length,
|
|
skipped_no_email: targets.length - withEmail.length,
|
|
recipients: withEmail.map((v) => v.id),
|
|
});
|
|
},
|
|
},
|
|
|
|
auth: {
|
|
login: (_edition, { email, password }) => {
|
|
const account = accountByEmail(email);
|
|
if (!account || account.auth_method !== 'password' || state.passwords.get(account.id) !== password) {
|
|
throw new ApiError(401, 'auth.invalid_credentials');
|
|
}
|
|
return delay(issue(account));
|
|
},
|
|
requestMagicLink: (_edition, { email }) => {
|
|
const account = accountByEmail(email);
|
|
if (account && account.auth_method === 'magic_link') {
|
|
const raw = `mlt.${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;
|
|
magicTokens.set(raw, {
|
|
accountId: account.id,
|
|
expiresAt: Date.now() + MAGIC_LINK_TTL_MS,
|
|
used: false,
|
|
});
|
|
lastMagicLink.set(account.email.toLowerCase(), raw);
|
|
}
|
|
// Réponse identique dans tous les cas (pas de divulgation).
|
|
return delay(undefined);
|
|
},
|
|
verifyMagicLink: (_edition, { token }) => {
|
|
const entry = magicTokens.get(token.trim());
|
|
if (!entry || entry.used || entry.expiresAt < Date.now()) {
|
|
throw new ApiError(401, 'auth.invalid_token');
|
|
}
|
|
entry.used = true;
|
|
const account = state.accounts.find((a) => a.id === entry.accountId);
|
|
if (!account) throw new ApiError(401, 'auth.invalid_token');
|
|
return delay(issue(account));
|
|
},
|
|
me: () => {
|
|
const token = getAuthToken();
|
|
const accountId = token ? sessions.get(token) : undefined;
|
|
const account = accountId ? state.accounts.find((a) => a.id === accountId) : undefined;
|
|
if (!account) throw new ApiError(401, 'error.unauthorized');
|
|
return delay(account);
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Heures affectées d'un bénévole (utilitaire mock, exposé pour les tests). */
|
|
export function mockVolunteerHours(volunteerId: string): number {
|
|
return state.assignments
|
|
.filter((a) => a.volunteer_id === volunteerId)
|
|
.reduce((sum, a) => {
|
|
const s = state.shifts.find((x) => x.id === a.shift_id);
|
|
return s ? sum + hoursBetween(s.starts_at, s.ends_at) : sum;
|
|
}, 0);
|
|
}
|