Broadcasts multi-canaux
Envoyer une annonce à un segment d'utilisateurs sur email + push + in-app simultanément.
Les broadcasts permettent à un admin d'envoyer un message à un segment
d'users sur plusieurs canaux en même temps : email (Resend), push
(OneSignal), notification in-app (table Notification).
Page admin : /admin/broadcasts (raccourcie sous /broadcasts).
Workflow admin
- Composer — éditeur rich-text multi-locale (
subject/bodypar locale dispo dans la base users), avec preview. - Cibler une audience — voir tableau plus bas.
- Choisir les canaux —
inapp,email,push(au moins un). - (Optionnel) Court titre/corps — override pour push (≤120 chars) et in-app (≤180 chars). Sinon le subject/body email est tronqué auto.
- Test — bouton "Envoyer un test" envoie le broadcast à l'admin uniquement (Gmail, iOS push truncation, etc.).
- Envoyer — POST avec
sendNow=trueou save en draft (envoyer plus tard).
Audiences
| Audience | Filtre |
|---|---|
all | Tous les users vérifiés non-bloqués |
paid | Subscription.status ∈ ["active", "trialing"] |
free | Pas de subscription ou status != active/trialing |
active30d | Au moins une UserSession.lastActiveAt ≥ J-30 |
inactive60d | Aucune session active depuis 60 jours |
country | UserSession.country = <code> (filtre audienceFilter.country) |
Logique dans lib/broadcasts.ts → audienceWhere(). Étends-le pour
ajouter des segments custom (par tag user, par cohorte, etc.).
Race & atomicité
executeBroadcast(broadcastId) claim atomique :
const claimed = await prisma.broadcast.updateMany({
where: { id: broadcastId, status: "draft" },
data: { status: "sending" },
});
if (claimed.count === 0) throw new Error("broadcast_not_draft");Un double-clic ou une exécution cron concurrente ne déclenche pas
deux envois — seul le premier appelant flip draft → sending, les
autres bail out.
Sanitization HTML
Subject + body sont passés par sanitizeLocalized() à la création/PATCH —
voir Sanitization. Pas de risque XSS dans les
emails / in-app.
Email logging
Chaque envoi email d'un broadcast crée une row EmailLog avec
templateKey="broadcast". Consultable dans
Email logs. Si l'envoi throw (réseau Resend),
on log quand même avec errorMsg.
Push (OneSignal)
Voir Push notifications. En résumé :
import { sendPushNotification } from "@/lib/onesignal";
await sendPushNotification({
userIds: [u.id],
title: shortTitle,
message: pushBody,
}).catch((e) => console.error("[broadcast] push failed:", e));Push échoué = log + on continue (pas de retry, pas de fail du broadcast).
Configure ONESIGNAL_APP_ID + ONESIGNAL_REST_API_KEY en env pour activer.
In-app
Crée une row Notification(userId, title, message). Le composant
<NotificationBell> (header) la lit en temps réel et affiche un badge.
Modèle de données
model Broadcast {
id String @id @default(cuid())
subject Json // Record<locale, string>
body Json // Record<locale, string> (rich text HTML)
shortTitle Json? // Override push/in-app
shortBody Json?
channels String[] // ["inapp"|"email"|"push"]
audience String // "all" | "paid" | …
audienceFilter Json?
status String @default("draft") // draft | sending | sent | failed
totalRecipients Int @default(0)
sentCount Int @default(0)
errorCount Int @default(0)
sentAt DateTime?
createdById String
createdAt DateTime @default(now())
}Routes API
GET /api/admin/broadcasts → liste paginée
POST /api/admin/broadcasts → create + sendNow optionnel
?preview=1 → renvoie juste le count audience
GET /api/admin/broadcasts/[id]
PATCH /api/admin/broadcasts/[id] → édit draft + sendNow optionnel
DELETE /api/admin/broadcasts/[id] → drop draft (sent broadcasts conservés)
POST /api/admin/broadcasts/test → test à l'admin onlyToutes admin-only (requireApiAuth({ admin: true })) avec audit
(broadcast.create, broadcast.send_test, etc.).
Allez plus loin
- Email logs — tracker la réception
- Push notifications — config OneSignal
- Sanitization — ce qui est nettoyé du HTML