- api : PlanningNotificationRequest/Result dans schema.ts, méthode
notifications.sendPlanning (http + mock), hook useSendPlanningNotification
- mock : compte les destinataires avec/sans email, émet des liens magiques
quand {lien_connexion} est présent
- ConfigMail : plus une maquette. Éditeur objet/corps, variables cliquables
({prenom} {nom} {nb_creneaux} {nb_heures} {creneaux} {lien_connexion}),
boutons « Envoyer le récapitulatif » et « Envoyer avec lien de connexion »,
retour d'envoi (queued / sans adresse) et ErrorNote
- i18n config.mail.* mis à jour ; onglet « MESSAGES » (plus « (MAQUETTE) »)
typecheck + lint + build + smoke : OK
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
511 lines
18 KiB
TypeScript
511 lines
18 KiB
TypeScript
import { hoursBetween, naiveMinutes } from '@/lib/time';
|
|
|
|
import type {
|
|
AccountRead,
|
|
AssignmentRead,
|
|
AssignmentValidateResult,
|
|
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$/, '');
|
|
|
|
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 {
|
|
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);
|
|
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);
|
|
},
|
|
},
|
|
|
|
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,
|
|
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);
|
|
}
|