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
633 lines
22 KiB
TypeScript
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);
|
|
}
|