Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 18 additions & 8 deletions src/app/api/cron/notifications/route.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 = (
Expand Down Expand Up @@ -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 {
Expand All @@ -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,
});
}
24 changes: 17 additions & 7 deletions src/app/api/events/[id]/message/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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);
}
Expand Down
20 changes: 7 additions & 13 deletions src/app/api/meetings/attendance/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
17 changes: 7 additions & 10 deletions src/app/api/signup/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
18 changes: 13 additions & 5 deletions src/app/confidentialite/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,13 @@ export default async function PrivacyPage() {
title: "Données collectées",
content: (
<>
Lors d&apos;une inscription comme bénévole : votre nom, et
l&apos;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&apos;une inscription comme bénévole, ou d&apos;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&apos;elles ne servent pas à la
même chose — l&apos;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.
</>
),
},
Expand All @@ -67,7 +70,12 @@ export default async function PrivacyPage() {
<>
Les inscriptions bénévoles sont conservées le temps de
l&apos;organisation de l&apos;é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&apos;e-mail ne soit demandé et que vous
n&apos;avez reçu aucun message, écrivez ou téléphonez à
l&apos;association
{settings.contactEmail ? ` à ${settings.contactEmail}` : ""} : nous
vous retirons de la liste.
</>
),
},
Expand Down
10 changes: 8 additions & 2 deletions src/components/broadcast-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
);
Expand Down
12 changes: 9 additions & 3 deletions src/components/meeting-attendance-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ export function MeetingAttendanceForm({
id="email"
name="email"
type="email"
required
inputMode="email"
autoComplete="email"
defaultValue={defaultEmail}
Expand All @@ -208,14 +209,19 @@ export function MeetingAttendanceForm({
id="phone"
name="phone"
type="tel"
required
inputMode="tel"
autoComplete="tel"
pattern="[0-9+().\\s-]{9,40}"
/>
</div>
</div>
<p className="text-xs text-slate-500">
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. */}
<p className="text-xs leading-5 text-slate-600">
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.
</p>

{/* Pot de miel anti-robot : invisible pour les humains. */}
Expand Down
22 changes: 18 additions & 4 deletions src/components/slot-manager.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -485,9 +485,23 @@ export function SlotManager({
</span>
</a>
)}
{!signup.phone && !signup.email && (
<span className="mt-0.5 block text-xs font-medium text-slate-600">
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 && (
<span className="mt-0.5 flex items-start gap-1.5 text-xs font-semibold text-sand-800">
<TriangleAlert
className="mt-0.5 h-3.5 w-3.5 shrink-0"
aria-hidden="true"
/>
<span>
{signup.phone
? "Sans e-mail : ne recevra ni rappel, ni lien de désinscription. Prévenez-la par téléphone."
: "Aucune coordonnée laissée : ni rappel, ni moyen de la joindre."}
</span>
</span>
)}
</div>
Expand Down
13 changes: 10 additions & 3 deletions src/components/volunteer-signup-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ export function VolunteerSignupForm({
id="email"
name="email"
type="email"
required
inputMode="email"
autoComplete="email"
defaultValue={defaultEmail}
Expand All @@ -180,14 +181,20 @@ export function VolunteerSignupForm({
id="phone"
name="phone"
type="tel"
required
inputMode="tel"
autoComplete="tel"
pattern="[0-9+().\\s-]{9,40}"
/>
</div>
</div>
<p className="text-xs text-slate-500">
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. */}
<p className="text-xs leading-5 text-slate-600">
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.
</p>

{/* Honeypot anti-bot : invisible pour les humains. */}
Expand Down
22 changes: 14 additions & 8 deletions src/lib/mcp/register-core-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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);
Expand All @@ -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
Expand Down
Loading
Loading