import { hoursBetween, naiveMinutes } from '@/lib/time'; import type { AccountRead, AssignmentRead, AssignmentValidateResult, DayPartCreate, DayPartRead, DayPartUpdate, EditionUpdate, MatrixResponse, PlanningNotificationRequest, ShiftRead, TokenResponse, Violation, } from '../schema'; import { getAuthToken } from '../session'; import { ApiError, type PlanningApi } from '../types'; import { buildMockState, type MockState } from './dataset'; // Implémentation en mémoire de `PlanningApi`. Un délai réseau simulé rend le // comportement (chargements, invalidations) proche du back-end réel. const LATENCY_MS = 120; const EIGHT_HOURS_MIN = 8 * 60; const MAGIC_LINK_TTL_MS = 15 * 60 * 1000; let state: MockState = buildMockState(); /** token de session -> id de compte. */ const sessions = new Map(); /** token magique brut -> compte + expiration. */ const magicTokens = new Map(); /** email (minuscule) -> dernier token magique émis (démo : l'email est stubé). */ const lastMagicLink = new Map(); /** Réinitialise le jeu de démonstration (utile en test). */ export function resetMockState(): void { state = buildMockState(); sessions.clear(); magicTokens.clear(); lastMagicLink.clear(); } /** * Mode mock uniquement : récupère le dernier lien magique émis pour un email. * En mode HTTP le back-end l'envoie par mail (ici stubé dans ses logs). */ export function peekMockMagicToken(email: string): string | undefined { return lastMagicLink.get(email.trim().toLowerCase()); } function accountByEmail(email: string): AccountRead | undefined { const target = email.trim().toLowerCase(); return state.accounts.find((a) => a.email.toLowerCase() === target); } function issue(account: AccountRead): TokenResponse { const token = `mock.${account.id}.${Math.random().toString(36).slice(2, 10)}`; sessions.set(token, account.id); return { access_token: token, token_type: 'bearer', account }; } const delay = (value: T): Promise => new Promise((resolve) => setTimeout(() => resolve(structuredClone(value)), LATENCY_MS)); const notFound = (): never => { throw new ApiError(404, 'error.not_found'); }; const 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, ''); 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, required_skill_codes: [], 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; } 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) — le mock ne connaît que AVAILABLE / non déclaré. const declared = state.availability.get(volunteerId) ?? new Set(); const availKey = `${candidate.starts_at.slice(0, 10)}|${candidate.starts_at.slice(11, 16)}`; if (!declared.has(availKey)) { violations.push({ code: 'OUTSIDE_AVAILABILITY', severity: 'WARNING', params: { date: candidate.starts_at.slice(0, 10), day_part_code: candidate.starts_at.slice(11, 16), status: 'UNDECLARED', }, }); } // 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 }, }); } } // MISSING_REQUIRED_SKILL (avertissement) for (const code of [...candidate.required_skill_codes].sort()) { if (!volunteer.skill_codes.includes(code)) { violations.push({ code: 'MISSING_REQUIRED_SKILL', severity: 'WARNING', params: { skill_code: code }, }); } } return violations; } function hasBlocking(violations: Violation[]): boolean { return violations.some((v) => v.severity === 'BLOCKING'); } function buildMatrix(): MatrixResponse { const counts = new Map(); for (const a of state.assignments) { counts.set(a.shift_id, (counts.get(a.shift_id) ?? 0) + 1); } return { edition_id: state.positions[0]?.edition_id ?? '', positions: [...state.positions] .sort((a, b) => a.name.localeCompare(b.name, 'fr')) .map((position) => ({ id: position.id, name: position.name, category: position.category, location: position.location, shifts: state.shifts .filter((s) => s.position_id === position.id) .sort((a, b) => a.starts_at.localeCompare(b.starts_at)) .map((s) => ({ shift_id: s.id, starts_at: s.starts_at, ends_at: s.ends_at, min_staff: s.min_staff, ideal_staff: s.ideal_staff, assigned: counts.get(s.id) ?? 0, })), })), }; } 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); }, }, 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.positions[0].edition_id, first_name: body.first_name, last_name: body.last_name, email: body.email ?? null, phone: body.phone ?? null, tshirt_size: body.tshirt_size ?? null, diet: body.diet ?? null, comment: body.comment ?? null, skill_codes: body.skill_codes ?? [], created_at: now, updated_at: now, }; state.volunteers.push(volunteer); state.availability.set(volunteer.id, new Set()); 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 } : {}), ...('tshirt_size' in body ? { tshirt_size: body.tshirt_size ?? null } : {}), ...('diet' in body ? { diet: body.diet ?? null } : {}), ...('comment' in body ? { comment: body.comment ?? null } : {}), ...(body.skill_codes != null ? { skill_codes: body.skill_codes } : {}), 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); return delay(undefined); }, }, 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) => { 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(); 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)); 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, required_skill_codes: body.required_skill_codes ?? [], 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 } : {}), ...(body.required_skill_codes != null ? { required_skill_codes: body.required_skill_codes } : {}), updated_at: touch(), }); if (naiveMinutes(s.ends_at) <= naiveMinutes(s.starts_at)) { throw new ApiError(422, 'shift.ends_not_after_starts'); } return delay(s); }, remove: (_edition, id) => { state.shifts = state.shifts.filter((x) => x.id !== id); state.assignments = state.assignments.filter((a) => a.shift_id !== id); return delay(undefined); }, }, assignments: { list: (_edition, params) => delay( state.assignments.filter( (a) => (!params?.volunteerId || a.volunteer_id === params.volunteerId) && (!params?.shiftId || a.shift_id === params.shiftId), ), ), get: (_edition, id) => delay(state.assignments.find((x) => x.id === id) ?? notFound()), create: (_edition, body) => { const shift = shiftById(body.shift_id); if (!state.volunteers.some((v) => v.id === body.volunteer_id)) { throw new ApiError(422, 'assignment.wrong_edition'); } if ( state.assignments.some( (a) => a.volunteer_id === body.volunteer_id && a.shift_id === body.shift_id, ) ) { throw new ApiError(409, 'assignment.duplicate'); } // Une affectation est toujours validée à la création : une violation // bloquante l'interdit, les avertissements sont renvoyés. const warnings = runValidation(body.volunteer_id, shift); if (hasBlocking(warnings)) { throw new ApiError(422, 'error.validation_failed', warnings); } const now = touch(); const assignment: AssignmentRead = { id: `a${state.seq++}`, edition_id: shift.edition_id, volunteer_id: body.volunteer_id, shift_id: body.shift_id, source: body.source ?? 'MANUAL', locked: body.locked ?? false, created_at: now, updated_at: now, }; state.assignments.push(assignment); return delay({ assignment, warnings }); }, update: (_edition, id, body) => { const a = state.assignments.find((x) => x.id === id) ?? notFound(); Object.assign(a, { ...('source' in body ? { source: body.source } : {}), ...('locked' in body ? { locked: body.locked } : {}), updated_at: touch(), }); return delay({ assignment: a, warnings: [] as Violation[] }); }, remove: (_edition, id) => { state.assignments = state.assignments.filter((x) => x.id !== id); return delay(undefined); }, validate: (_edition, body): Promise => { const violations = runValidation(body.volunteer_id, shiftById(body.shift_id)); return delay({ blocking: hasBlocking(violations), violations }); }, }, matrix: { get: () => delay(buildMatrix()), }, 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); }