Frontend : écran Messages branché sur l'envoi d'emails

- 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
This commit is contained in:
AdamThomas
2026-09-10 08:13:28 +02:00
co-authored by Claude Sonnet 5
parent b268e308ab
commit 6b08dcc953
8 changed files with 151 additions and 35 deletions
+8
View File
@@ -10,6 +10,7 @@ import type {
AssignmentCreate,
AssignmentUpdate,
AssignmentValidateResult,
PlanningNotificationRequest,
PositionCreate,
PositionUpdate,
ShiftCreate,
@@ -228,3 +229,10 @@ export function useDeleteAssignment() {
onSuccess: invalidate.planning,
});
}
export function useSendPlanningNotification() {
return useMutation({
mutationFn: (body: PlanningNotificationRequest) =>
api.notifications.sendPlanning(edition, body),
});
}
+8
View File
@@ -4,6 +4,7 @@ import type {
AssignmentValidateRequest,
LoginRequest,
MagicLinkRequest,
PlanningNotificationRequest,
PositionCreate,
PositionUpdate,
ShiftCreate,
@@ -119,6 +120,13 @@ export function createHttpApi(baseUrl: string): PlanningApi {
matrix: {
get: (editionId) => request(`${base(editionId)}/matrix`),
},
notifications: {
sendPlanning: (editionId, body: PlanningNotificationRequest) =>
request(`${base(editionId)}/notifications/planning`, {
method: 'POST',
body: JSON.stringify(body),
}),
},
auth: {
login: (editionId, body: LoginRequest) =>
request(`${base(editionId)}/auth/login`, { method: 'POST', body: JSON.stringify(body) }),
+31
View File
@@ -5,6 +5,7 @@ import type {
AssignmentRead,
AssignmentValidateResult,
MatrixResponse,
PlanningNotificationRequest,
ShiftRead,
TokenResponse,
Violation,
@@ -425,6 +426,36 @@ export function createMockApi(): PlanningApi {
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);
+21
View File
@@ -239,3 +239,24 @@ export interface MagicLinkRequest {
export interface VerifyRequest {
token: string;
}
// --------------------------------------------------------------------------- //
// Notifications par email //
// --------------------------------------------------------------------------- //
/**
* `subject` / `body` sont des modèles. Variables : {prenom} {nom}
* {nb_creneaux} {nb_heures} {creneaux} {lien_connexion} (ce dernier déclenche
* l'émission d'un lien magique personnel).
*/
export interface PlanningNotificationRequest {
subject?: string | null;
body?: string | null;
/** null / absent = tous les bénévoles de l'édition qui ont un email. */
volunteer_ids?: string[] | null;
}
export interface PlanningNotificationResult {
queued: number;
skipped_no_email: number;
recipients: string[];
}
+9
View File
@@ -9,6 +9,8 @@ import type {
LoginRequest,
MagicLinkRequest,
MatrixResponse,
PlanningNotificationRequest,
PlanningNotificationResult,
PositionCreate,
PositionRead,
PositionUpdate,
@@ -66,6 +68,13 @@ export interface PlanningApi {
matrix: {
get(editionId: string): Promise<MatrixResponse>;
};
notifications: {
/** Récapitulatif de planning par email (COORDINATION). */
sendPlanning(
editionId: string,
body: PlanningNotificationRequest,
): Promise<PlanningNotificationResult>;
};
auth: {
login(editionId: string, body: LoginRequest): Promise<TokenResponse>;
/** 202 sans divulgation : ne dit pas si le compte existe. */