Files
benevoles/frontend/src/api/mock/mock-client.ts
T
AdamThomasandClaude Sonnet 5 f58ec00e30 Créneaux types : paramétrage + génération auto des créneaux
Le paramétrage des créneaux se fait dans « Règles & édition » : la coordination
définit des créneaux types (DayPart). À la création d'un poste, un créneau à
besoin 0/0 est généré pour chaque jour de l'édition et chaque créneau type ;
les coordinateurs ajustent ensuite min/idéal.

Back-end :
- DayPart CRUD (GET/POST/PATCH/DELETE /editions/{id}/day-parts). Code figé
  après création (slugifié depuis le libellé si absent), unique par édition.
- Shift.day_part_id (nullable, SET NULL) + migration 0004.
- create_position génère les créneaux ; POST .../positions/{pid}/generate-shifts
  recrée les manquants (idempotent, ne touche pas aux besoins déjà réglés).
- 13 tests (day-parts, génération, extension d'édition, ajout de type).

Front-end :
- api/hooks/mock : dayParts CRUD + positions.generateShifts.
- ConfigRules : panneau « Créneaux types » (libellé + horaires éditables,
  ajout, suppression).
- ConfigGrid : bouton « Générer les créneaux » sur l'en-tête du poste.
- GrillePage : case « à définir » (gris, glyphe ·) quand min=idéal=0, avec
  entrée de légende.

155 tests back OK ; typecheck + lint + build + smoke front OK.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
2026-09-10 20:57:51 +02:00

633 lines
22 KiB
TypeScript

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<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 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<string>();
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<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,
})),
})),
};
}
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<AssignmentValidateResult> => {
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);
}