From 28dc3c9c0f811e7fcd5ed00a24aa4272f69e1a5b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 19 Sep 2026 07:51:07 +0000 Subject: [PATCH] =?UTF-8?q?E-mail=20et=20t=C3=A9l=C3=A9phone=20deviennent?= =?UTF-8?q?=20obligatoires,=20et=20les=20silences=20parlent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'un des deux suffisait, et cela fabriquait trois promesses non tenues. Un inscrit sans e-mail n'était jamais rappelé : le cron filtrait sur isNotNull(email) sans rien dire, pendant que le réglage promettait de « prévenir les inscrits ». « Écrire aux bénévoles » ne joignait que les porteurs d'adresse, sans compter ceux qu'il laissait de côté. Et la page Confidentialité annonçait « vous désinscrire via le lien reçu par e-mail » à des gens qui n'avaient donné qu'un numéro : ceux-là n'avaient aucun moyen de se retirer seuls. Les deux coordonnées sont désormais exigées, et chacune pour une raison qui lui est propre : l'e-mail porte la confirmation, le rappel et le lien de retrait ; le téléphone sert le jour même, quand personne ne lit ses courriels. Les formulaires le disent plutôt que d'exiger sans expliquer. La règle s'applique partout où une inscription naît : formulaire bénévole public, réponse publique à une réunion, et l'outil MCP qui inscrit de vive voix — sinon un organisateur rouvrait le trou d'un appel. Le téléphone est validé en comptant ses chiffres, pas en imposant un format : 06 12 34 56 78, 0612345678 et +33 6 98 76 54 32 sont le même numéro, et refuser l'un des trois ferait abandonner le formulaire. La fourchette 9–15 couvre le plan français comme la norme E.164. Restaient les inscriptions antérieures à cette règle, qui n'ont pas toujours d'adresse. Aucune migration ne peut leur en inventer une ; on cesse donc de les traiter en silence : - sous le créneau, elles portent « Sans e-mail : ne recevra ni rappel, ni lien de désinscription. Prévenez-la par téléphone. » — le numéro est juste au-dessus, la panne devient un coup de fil ; - la diffusion répond désormais « Message envoyé à 5 destinataire(s). 2 inscrit(s) sans adresse e-mail n'ont pas pu être joints : appelez-les. » au lieu de laisser croire qu'elle a écrit à tout le monde ; - le cron rend volunteersSansEmail et l'écrit au journal ; - la page Confidentialité dit quoi faire quand on n'a reçu aucun message. Vérifié sur une instance réelle. API : les quatre saisies incomplètes sont refusées champ par champ, les trois formats de numéro acceptés, rien n'entre en base sur un refus. Formulaire public : les cinq champs sont requis, la validation native bloque avant tout envoi, et une soumission complète arrive en base avec ses deux coordonnées. Présences : mêmes règles, mêmes refus. Diffusion : 5 envoyés, 2 signalés. Cron : 5 rappels, 2 injoignables annoncés. Bandeaux à 8,04:1, aucun débordement de 320 à 1280 px. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014SfQYBU4xXTeSEHKhHQXdD --- src/app/api/cron/notifications/route.ts | 26 ++++++--- src/app/api/events/[id]/message/route.ts | 24 ++++++--- src/app/api/meetings/attendance/route.ts | 20 +++---- src/app/api/signup/route.ts | 17 +++--- src/app/confidentialite/page.tsx | 18 +++++-- src/components/broadcast-form.tsx | 10 +++- src/components/meeting-attendance-form.tsx | 12 +++-- src/components/slot-manager.tsx | 22 ++++++-- src/components/volunteer-signup-form.tsx | 13 +++-- src/lib/mcp/register-core-tools.ts | 22 +++++--- src/lib/validation.ts | 62 ++++++++++++++++------ 11 files changed, 167 insertions(+), 79 deletions(-) diff --git a/src/app/api/cron/notifications/route.ts b/src/app/api/cron/notifications/route.ts index 81ed2a0..707b792 100644 --- a/src/app/api/cron/notifications/route.ts +++ b/src/app/api/cron/notifications/route.ts @@ -1,6 +1,6 @@ import { timingSafeEqual } from "node:crypto"; -import { and, inArray, isNotNull, isNull, lte, ne } from "drizzle-orm"; +import { and, inArray, isNull, lte, ne } from "drizzle-orm"; import { NextResponse } from "next/server"; import { formatDateTime } from "@/lib/dates"; @@ -156,25 +156,28 @@ export async function GET(req: Request) { association.volunteerReminderWindowDays * 24 * 60 * 60 * 1000, ); // Sans URL publique configurée, les liens des e-mails seraient cassés : on saute. + // On charge aussi les inscrits SANS e-mail, uniquement pour les compter : + // l'adresse est désormais exigée à l'inscription, mais les inscriptions + // antérieures à cette règle n'en ont pas toujours. Sans ce décompte, le + // rappel les sautait en silence, et le réglage « prévenir les inscrits » + // promettait ce qu'il ne tenait pas. const signups = appUrl ? await db.query.volunteerSignups.findMany({ - where: and( - isNull(volunteerSignups.remindedAt), - isNotNull(volunteerSignups.email), - ), + where: isNull(volunteerSignups.remindedAt), with: { slot: { with: { event: true } } }, }) : []; - const eligibleSignups = signups.filter((s) => { + const dansLaFenetre = signups.filter((s) => { const ev = s.slot.event; return ( ev.status === "published" && ev.startAt > now && - ev.startAt <= volunteerHorizon && - !!s.email + ev.startAt <= volunteerHorizon ); }); + const eligibleSignups = dansLaFenetre.filter((s) => !!s.email); + const volunteersSansEmail = dansLaFenetre.length - eligibleSignups.length; // Envois en parallèle puis un seul UPDATE groupé (au lieu de N en série). const remindedIds = ( @@ -205,6 +208,11 @@ export async function GET(req: Request) { .where(inArray(volunteerSignups.id, remindedIds)); } const volunteerReminders = remindedIds.length; + if (volunteersSansEmail > 0) { + console.warn( + `[cron] ${volunteersSansEmail} bénévole(s) à rappeler n'ont pas d'adresse e-mail : aucun rappel ne peut leur être envoyé.`, + ); + } let orphanedUploadsRemoved = 0; try { @@ -227,6 +235,8 @@ export async function GET(req: Request) { skipped, failed: results.length - succeeded.length, volunteerReminders, + /** Inscrits que le rappel ne peut pas atteindre, faute d'adresse. */ + volunteersSansEmail, orphanedUploadsRemoved, }); } diff --git a/src/app/api/events/[id]/message/route.ts b/src/app/api/events/[id]/message/route.ts index bd4627c..d757ed9 100644 --- a/src/app/api/events/[id]/message/route.ts +++ b/src/app/api/events/[id]/message/route.ts @@ -18,13 +18,23 @@ export async function POST(req: Request, { params }: Params) { const event = await getEventWithDetails(id); if (!event) throw new HttpError(404, "Événement introuvable."); - const recipients = uniqueRecipients( - event.volunteerSlots - .flatMap((slot) => slot.signups) - .map((signup) => signup.email), - ); + const inscrits = event.volunteerSlots.flatMap((slot) => slot.signups); + const recipients = uniqueRecipients(inscrits.map((signup) => signup.email)); + + // L'e-mail est désormais exigé à l'inscription, mais les inscriptions + // antérieures à cette règle n'en ont pas toujours un. Le bouton promettait + // d'écrire « aux bénévoles » et en oubliait silencieusement : on compte + // ceux qu'on laisse de côté et on le dit à l'expéditeur, qui a leur numéro + // sous les yeux sur le même écran. + const sansEmail = inscrits.filter((signup) => !signup.email).length; + if (recipients.length === 0) { - throw new HttpError(400, "Aucun bénévole avec une adresse e-mail."); + throw new HttpError( + 400, + sansEmail > 0 + ? `Aucun des ${sansEmail} bénévole${sansEmail > 1 ? "s" : ""} inscrit${sansEmail > 1 ? "s" : ""} n’a laissé d’adresse e-mail. Leurs numéros figurent sous chaque créneau.` + : "Aucun bénévole inscrit à cet événement.", + ); } const association = await getAssociationSettings(); @@ -40,7 +50,7 @@ export async function POST(req: Request, { params }: Params) { }); const sent = await sendBulkEmail(recipients, mail); - return NextResponse.json({ ok: true, sent }); + return NextResponse.json({ ok: true, sent, sansEmail }); } catch (error) { return handleApiError(error); } diff --git a/src/app/api/meetings/attendance/route.ts b/src/app/api/meetings/attendance/route.ts index eb69078..39dc278 100644 --- a/src/app/api/meetings/attendance/route.ts +++ b/src/app/api/meetings/attendance/route.ts @@ -30,19 +30,13 @@ import { publicMeetingAttendanceSchema } from "@/lib/validation"; * Un membre connecté qui répond depuis cette page est reconnu : sa réponse est * rattachée à son compte plutôt qu'ajoutée en double sous son nom saisi. */ -const schemaPublic = publicMeetingAttendanceSchema - .extend({ - token: z.string().min(8, "Lien invalide"), - // Pot de miel anti-robot : champ caché qui doit rester vide. - website: z.string().optional(), - }) - .refine( - (d) => (d.email && d.email.length > 0) || (d.phone && d.phone.trim().length > 0), - { - message: "Indiquez au moins un e-mail ou un téléphone.", - path: ["email"], - }, - ); +// Les deux coordonnées sont exigées par `publicMeetingAttendanceSchema` : +// plus de règle « au moins l'un des deux » à poser ici. +const schemaPublic = publicMeetingAttendanceSchema.extend({ + token: z.string().min(8, "Lien invalide"), + // Pot de miel anti-robot : champ caché qui doit rester vide. + website: z.string().optional(), +}); export async function POST(req: Request) { try { diff --git a/src/app/api/signup/route.ts b/src/app/api/signup/route.ts index 1b68623..3d4b81c 100644 --- a/src/app/api/signup/route.ts +++ b/src/app/api/signup/route.ts @@ -19,16 +19,13 @@ import { generateToken } from "@/lib/tokens"; import { emptyToNull } from "@/lib/utils"; import { signupSchema } from "@/lib/validation"; -const publicSignupSchema = signupSchema - .extend({ - token: z.string().min(8, "Lien invalide"), - // Honeypot anti-bot : champ caché qui doit rester vide. - website: z.string().optional(), - }) - .refine((d) => (d.email && d.email.length > 0) || (d.phone && d.phone.trim().length > 0), { - message: "Indiquez au moins un e-mail ou un téléphone.", - path: ["email"], - }); +// Les deux coordonnées sont exigées par `signupSchema` lui-même : il n'y a plus +// de règle « au moins l'un des deux » à poser ici. +const publicSignupSchema = signupSchema.extend({ + token: z.string().min(8, "Lien invalide"), + // Honeypot anti-bot : champ caché qui doit rester vide. + website: z.string().optional(), +}); export async function POST(req: Request) { try { diff --git a/src/app/confidentialite/page.tsx b/src/app/confidentialite/page.tsx index d7634a8..95f5c55 100644 --- a/src/app/confidentialite/page.tsx +++ b/src/app/confidentialite/page.tsx @@ -43,10 +43,13 @@ export default async function PrivacyPage() { title: "Données collectées", content: ( <> - Lors d'une inscription comme bénévole : votre nom, et - l'e-mail et/ou le téléphone que vous indiquez. Pour les membres - connectés : nom, e-mail et, si vous le renseignez, votre identifiant - Telegram. + Lors d'une inscription comme bénévole, ou d'une réponse à + une réunion : votre nom, votre e-mail et votre téléphone. Les deux + coordonnées sont demandées parce qu'elles ne servent pas à la + même chose — l'e-mail porte la confirmation, le rappel et le lien + qui vous permet de vous retirer ; le téléphone sert à vous joindre le + jour même. Pour les membres connectés : nom, e-mail et, si vous le + renseignez, votre identifiant Telegram. ), }, @@ -67,7 +70,12 @@ export default async function PrivacyPage() { <> Les inscriptions bénévoles sont conservées le temps de l'organisation de l'événement puis supprimées. Vous pouvez - vous désinscrire à tout moment via le lien reçu par e-mail. + vous désinscrire à tout moment via le lien reçu par e-mail. Si vous + vous êtes inscrit avant que l'e-mail ne soit demandé et que vous + n'avez reçu aucun message, écrivez ou téléphonez à + l'association + {settings.contactEmail ? ` à ${settings.contactEmail}` : ""} : nous + vous retirons de la liste. ), }, diff --git a/src/components/broadcast-form.tsx b/src/components/broadcast-form.tsx index 2412dc8..ce3431c 100644 --- a/src/components/broadcast-form.tsx +++ b/src/components/broadcast-form.tsx @@ -25,12 +25,18 @@ export function BroadcastForm({ setLoading(true); const form = new FormData(e.currentTarget); try { - const res = await api<{ sent: number }>(endpoint, { + const res = await api<{ sent: number; sansEmail?: number }>(endpoint, { body: { subject: form.get("subject"), message: form.get("message") }, }); + // Qui n'a pas été joint compte autant que qui l'a été : sans cette + // mention, on croit avoir écrit à tout le monde. + const oublies = res.sansEmail ?? 0; toast( res.sent > 0 - ? `Message envoyé à ${res.sent} destinataire(s).` + ? `Message envoyé à ${res.sent} destinataire(s).` + + (oublies > 0 + ? ` ${oublies} inscrit(s) sans adresse e-mail n’ont pas pu être joints : appelez-les.` + : "") : "Aucun e-mail n'a pu être envoyé (vérifiez la configuration e-mail).", res.sent > 0 ? "success" : "error", ); diff --git a/src/components/meeting-attendance-form.tsx b/src/components/meeting-attendance-form.tsx index 29a1cfa..24c27bd 100644 --- a/src/components/meeting-attendance-form.tsx +++ b/src/components/meeting-attendance-form.tsx @@ -197,6 +197,7 @@ export function MeetingAttendanceForm({ id="email" name="email" type="email" + required inputMode="email" autoComplete="email" defaultValue={defaultEmail} @@ -208,14 +209,19 @@ export function MeetingAttendanceForm({ id="phone" name="phone" type="tel" + required inputMode="tel" autoComplete="tel" + pattern="[0-9+().\\s-]{9,40}" /> -

- Indiquez au moins un e-mail ou un téléphone. L’e-mail permet de recevoir - la confirmation et d’être prévenu si la réunion est déplacée. + {/* Même raison que pour une inscription bénévole : le lien de retrait ne + voyage que par e-mail. */} +

+ Les deux sont nécessaires. L’e-mail vous apporte la confirmation, vous + prévient si la réunion est déplacée, et porte le lien qui retire votre + réponse. Le téléphone sert à vous joindre le jour même.

{/* Pot de miel anti-robot : invisible pour les humains. */} diff --git a/src/components/slot-manager.tsx b/src/components/slot-manager.tsx index 1044df2..09be7a2 100644 --- a/src/components/slot-manager.tsx +++ b/src/components/slot-manager.tsx @@ -1,6 +1,6 @@ "use client"; -import { Mail, Phone } from "lucide-react"; +import { Mail, Phone, TriangleAlert } from "lucide-react"; import { useRouter } from "next/navigation"; import { useState } from "react"; @@ -485,9 +485,23 @@ export function SlotManager({ )} - {!signup.phone && !signup.email && ( - - Aucune coordonnée laissée + {/* L'e-mail est désormais exigé à l'inscription. Les + inscriptions antérieures à cette règle n'en ont + pas toujours, et celles-là ne reçoivent ni + rappel, ni lien de désinscription : le dire ici + transforme une panne silencieuse en coup de + fil, puisque le numéro est juste au-dessus. */} + {!signup.email && ( + + )} diff --git a/src/components/volunteer-signup-form.tsx b/src/components/volunteer-signup-form.tsx index 0812d00..73fa897 100644 --- a/src/components/volunteer-signup-form.tsx +++ b/src/components/volunteer-signup-form.tsx @@ -169,6 +169,7 @@ export function VolunteerSignupForm({ id="email" name="email" type="email" + required inputMode="email" autoComplete="email" defaultValue={defaultEmail} @@ -180,14 +181,20 @@ export function VolunteerSignupForm({ id="phone" name="phone" type="tel" + required inputMode="tel" autoComplete="tel" + pattern="[0-9+().\\s-]{9,40}" /> -

- Indiquez au moins un e-mail ou un téléphone. L’e-mail permet de recevoir - une confirmation et un rappel. + {/* Dire à quoi sert chaque champ, plutôt que d'exiger sans expliquer : + le lien de désinscription ne voyage que par e-mail, et le jour même + on téléphone. */} +

+ Les deux sont nécessaires. L’e-mail vous apporte la confirmation, un + rappel avant le jour J, et le lien qui vous permet de vous désinscrire + quand vous voulez. Le téléphone sert à vous joindre le jour même.

{/* Honeypot anti-bot : invisible pour les humains. */} diff --git a/src/lib/mcp/register-core-tools.ts b/src/lib/mcp/register-core-tools.ts index 2487545..1094a9f 100644 --- a/src/lib/mcp/register-core-tools.ts +++ b/src/lib/mcp/register-core-tools.ts @@ -1224,12 +1224,21 @@ export function registerCoreTools( { title: "Inscrire un bénévole", description: - "Inscrit manuellement un bénévole sur un créneau, par exemple après une réponse reçue de vive voix. Refuse un créneau complet ou un doublon d’e-mail, et envoie la confirmation si une adresse est fournie.", + "Inscrit manuellement un bénévole sur un créneau, par exemple après une réponse reçue de vive voix. E-mail et téléphone sont tous deux obligatoires, comme sur le formulaire public. Refuse un créneau complet ou un doublon d’e-mail, et envoie la confirmation.", inputSchema: z.object({ slotId: z.string().uuid(), name: z.string().min(2).max(120), - email: z.string().email().nullable().optional(), - phone: z.string().max(40).nullable().optional(), + // Exigés, comme sur le formulaire public : une inscription créée ici + // sans e-mail donnerait à quelqu'un un engagement dont il ne pourrait + // pas se retirer, et que ni le rappel ni la diffusion n'atteindraient. + email: z + .string() + .email() + .describe("Obligatoire : porte la confirmation, le rappel et le lien de désinscription."), + phone: z + .string() + .max(40) + .describe("Obligatoire : pour joindre la personne le jour même."), notify: z .boolean() .default(true) @@ -1244,11 +1253,8 @@ export function registerCoreTools( with: { event: true }, }); if (!slot) throw new Error("Créneau introuvable."); - if (!email && !phone) { - throw new Error("Indiquez au moins un e-mail ou un téléphone."); - } - const normalizedEmail = emptyToNull(email ?? null)?.toLowerCase() ?? null; + const normalizedEmail = emptyToNull(email)?.toLowerCase() ?? null; // Même longueur que l'inscription publique : ce jeton protège le lien de // désinscription envoyé au bénévole. const cancelToken = generateToken(18); @@ -1263,7 +1269,7 @@ export function registerCoreTools( ${slot.id}::uuid, ${name}, ${normalizedEmail}, - ${emptyToNull(phone ?? null)}, + ${emptyToNull(phone)}, ${cancelToken} WHERE ( SELECT count(*) FROM volunteer_signups WHERE slot_id = ${slot.id}::uuid diff --git a/src/lib/validation.ts b/src/lib/validation.ts index 538d986..6f2a243 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -135,17 +135,51 @@ export const slotSchema = z.object({ endAt: localDateTime.nullable().optional(), }); +/** + * Les deux coordonnées d'une personne qui s'engage sur un rendez-vous. + * + * Les deux sont exigées, et chacune pour une raison distincte. L'e-mail porte + * la confirmation, le rappel, et surtout le lien de désinscription : sans lui, + * la personne n'a aucun moyen de se retirer seule, alors que la page + * Confidentialité le lui promet. Le téléphone sert le jour même, quand il faut + * joindre quelqu'un qui ne lira pas ses courriels avant le lendemain. + * + * Auparavant l'un des deux suffisait, et cela fabriquait deux silences : un + * inscrit sans e-mail n'était jamais rappelé ni joignable par la diffusion, et + * il ne pouvait pas se désinscrire. + */ +export const emailRequis = z + .string() + .trim() + .toLowerCase() + .min(1, "E-mail requis") + .email("Adresse e-mail invalide") + .max(200); + +/** + * On compte les chiffres plutôt que d'imposer un format : 06 12 34 56 78, + * 0612345678 et +33 6 12 34 56 78 sont le même numéro, et refuser l'un des + * trois ferait abandonner le formulaire. La fourchette 9–15 couvre le plan de + * numérotation français comme la norme E.164. + */ +export const telephoneRequis = z + .string() + .trim() + .min(1, "Téléphone requis") + .max(40) + .refine( + (v) => { + const chiffres = (v.match(/\d/g) ?? []).length; + return chiffres >= 9 && chiffres <= 15; + }, + "Numéro de téléphone incomplet", + ); + export const signupSchema = z.object({ slotId: z.string().uuid("Créneau invalide"), name: z.string().trim().min(2, "Nom requis").max(120), - email: z - .string() - .trim() - .toLowerCase() - .email("Adresse e-mail invalide") - .optional() - .or(z.literal("")), - phone: z.string().trim().max(40).optional(), + email: emailRequis, + phone: telephoneRequis, consent: z.boolean().refine((v) => v === true, { message: "Vous devez accepter la politique de confidentialité.", }), @@ -249,14 +283,10 @@ export const meetingAttendanceSchema = z.object({ export const publicMeetingAttendanceSchema = z.object({ status: z.enum(["yes", "maybe", "no"]), name: z.string().trim().min(2, "Nom requis").max(120), - email: z - .string() - .trim() - .toLowerCase() - .email("Adresse e-mail invalide") - .optional() - .or(z.literal("")), - phone: z.string().trim().max(40).optional(), + // Même exigence que pour une inscription bénévole, et pour les mêmes + // raisons : une présence annoncée porte aussi un lien de retrait. + email: emailRequis, + phone: telephoneRequis, consent: z.boolean().refine((v) => v === true, { message: "Vous devez accepter la politique de confidentialité.", }),