src/lib/notif/dispatch.ts
Annotation non disponible
Lance npm run annotate (nécessite ANTHROPIC_API_KEY dans .env.local) pour générer une annotation française par Claude Haiku 4.5.
3 exports
dispatchNotificationemitAndDispatchDispatchOptions
Code source· typescript· tronqué à 200 lignes sur 312
import { prisma } from "@/lib/prisma/client";
import type { NotifEvent, NotifEventType, NotifCanal, NotifStatut } from "@prisma/client";
import { sendPushToClient } from "@/lib/push";
import { envoyerNotifGeneric } from "./email-generic";
// Pilier 3 V1 — Dispatcher central Notification Engine.
// 1. Résout les destinataires selon le type d'événement et les abonnements en DB
// 2. Pour chaque destinataire, looke ses canaux configurés (push tokens + email)
// 3. Envoie via chaque canal opt-in, track delivery dans NotifDelivery
// 4. Update compteurs NotifEvent.nbDestinataires / nbEnvoyes / envoyeAt
//
// V1 canaux : PUSH + EMAIL
// V1.5+ : SMS (Vonage), WhatsApp (Cloud API), Snapchat/Insta/Telegram
const V1_CANAUX_SUPPORTES: NotifCanal[] = ["PUSH", "EMAIL"];
// ─── Résolution destinataires ──────────────────────────────────────────────
type Destinataire = {
clientAccountId: string;
abonnementId: string;
// Opt-in canaux pour cet abonnement (override préférence client)
notifPush: boolean;
notifEmail: boolean;
notifSms: boolean;
notifWhatsapp: boolean;
};
async function resoudreDestinataires(event: NotifEvent): Promise<Destinataire[]> {
const tenantId = event.tenantId;
const map = new Map<string, Destinataire>();
// Helper pour merger un abonnement dans la map (1 client = 1 destinataire, OR canaux opt-in)
const merge = (a: { id: string; clientAccountId: string; notifPush: boolean; notifEmail: boolean; notifSms: boolean; notifWhatsapp: boolean }) => {
const existing = map.get(a.clientAccountId);
if (existing) {
// Si déjà présent (via un autre abonnement), on prend l'OR des opt-in (le plus permissif)
existing.notifPush = existing.notifPush || a.notifPush;
existing.notifEmail = existing.notifEmail || a.notifEmail;
existing.notifSms = existing.notifSms || a.notifSms;
existing.notifWhatsapp = existing.notifWhatsapp || a.notifWhatsapp;
} else {
map.set(a.clientAccountId, {
clientAccountId: a.clientAccountId,
abonnementId: a.id,
notifPush: a.notifPush,
notifEmail: a.notifEmail,
notifSms: a.notifSms,
notifWhatsapp: a.notifWhatsapp,
});
}
};
const eventType: NotifEventType = event.type;
// 1. TOUJOURS : abonnés VITRINE du tenant (pertinent pour tous les events liés au tenant)
const abonnesVitrine = await prisma.abonnement.findMany({
where: { type: "VITRINE", tenantId },
select: { id: true, clientAccountId: true, notifPush: true, notifEmail: true, notifSms: true, notifWhatsapp: true },
});
abonnesVitrine.forEach(merge);
// 2. PRODUIT_AJOUTE / PRODUIT_MAJ_X : abonnés directs au produit + abonnés cat wari (V1.5)
if (
(eventType === "PRODUIT_AJOUTE" || eventType === "PRODUIT_MAJ_STOCK" || eventType === "PRODUIT_MAJ_PRIX" || eventType === "PRODUIT_MAJ_PROMO")
&& event.produitId
) {
const abonnesProduit = await prisma.abonnement.findMany({
where: { type: "PRODUIT", produitId: event.produitId },
select: { id: true, clientAccountId: true, notifPush: true, notifEmail: true, notifSms: true, notifWhatsapp: true },
});
abonnesProduit.forEach(merge);
// Catégorie Wari (via tenant principal + secondaires)
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: {
categorieWariId: true,
categoriesWari: { select: { categorieWariId: true } },
},
});
const catWariIds = [
...(tenant?.categorieWariId ? [tenant.categorieWariId] : []),
...(tenant?.categoriesWari.map((c) => c.categorieWariId) ?? []),
];
if (catWariIds.length > 0) {
const abonnesCatWari = await prisma.abonnement.findMany({
where: { type: "CATEGORIE_WARI", categorieWariId: { in: catWariIds } },
select: { id: true, clientAccountId: true, notifPush: true, notifEmail: true, notifSms: true, notifWhatsapp: true },
});
abonnesCatWari.forEach(merge);
}
}
// 3. PRESTATION_AJOUTEE / PRESTATION_MAJ : abonnés directs prestation
if ((eventType === "PRESTATION_AJOUTEE" || eventType === "PRESTATION_MAJ") && event.prestationId) {
const abonnesPrestation = await prisma.abonnement.findMany({
where: { type: "PRESTATION", prestationId: event.prestationId },
select: { id: true, clientAccountId: true, notifPush: true, notifEmail: true, notifSms: true, notifWhatsapp: true },
});
abonnesPrestation.forEach(merge);
}
// 4. PLAT_AJOUTE : juste abonnés vitrine (déjà résolus ci-dessus). V2 : abonnés cat tenant resto.
// 5. VITRINE_ARRIVAGE / VITRINE_MESSAGE : abonnés vitrine (déjà). Si broadcast, segment résolu côté caller.
return Array.from(map.values());
}
// ─── Canaux disponibles pour un client ─────────────────────────────────────
type ClientCanaux = {
email: string | null;
phone: string | null;
hasPushTokens: boolean;
};
async function getCanauxClient(clientAccountId: string): Promise<ClientCanaux> {
const client = await prisma.clientAccount.findUnique({
where: { id: clientAccountId },
select: { email: true, phone: true, pushTokens: { select: { id: true }, take: 1 } },
});
if (!client) return { email: null, phone: null, hasPushTokens: false };
return {
email: client.email,
phone: client.phone,
hasPushTokens: client.pushTokens.length > 0,
};
}
// ─── Senders par canal ─────────────────────────────────────────────────────
async function sendOneViaPush(client: { id: string }, event: NotifEvent): Promise<boolean> {
try {
await sendPushToClient(client.id, {
title: event.titre,
body: event.message ?? "",
data: {
type: event.type,
notifEventId: event.id,
tenantId: event.tenantId,
produitId: event.produitId,
prestationId: event.prestationId,
},
});
return true;
} catch (e) {
console.error("[dispatch] push failed:", e);
return false;
}
}
async function sendOneViaEmail(
client: { id: string; email: string },
event: NotifEvent,
tenant: { nom: string; subdomain: string },
): Promise<boolean> {
const ctaUrl = event.produitId
? `https://${tenant.subdomain}.wari.pro/produit/${event.produitId}`
: event.prestationId
? `https://${tenant.subdomain}.wari.pro/prestation/${event.prestationId}`
: `https://${tenant.subdomain}.wari.pro`;
return envoyerNotifGeneric({
to: client.email,
titre: event.titre,
message: event.message ?? "",
tenantNom: tenant.nom,
tenantSubdomain: tenant.subdomain,
ctaUrl,
ctaLabel: event.produitId || event.prestationId ? "Voir" : "Visiter la vitrine",
});
}
// ─── Orchestrateur ─────────────────────────────────────────────────────────
export type DispatchOptions = {
/** Si fourni, force la liste des destinataires (ex: broadcast avec segment résolu). Sinon résolu via Abonnements. */
destinatairesOverride?: Destinataire[];
/** Canaux à activer pour ce dispatch (subset des canaux supportés). Default V1 = PUSH + EMAIL. */
canaux?: NotifCanal[];
};
export async function dispatchNotification(
notifEventId: string,
opts: DispatchOptions = {},
): Promise<{ nbDestinataires: number; nbEnvoyes: number }> {
const event = await prisma.notifEvent.findUnique({ where: { id: notifEventId } });
if (!event) throw new Error(`NotifEvent ${notifEventId} introuvable`);
const tenant = await prisma.tenant.findUnique({
where: { id: event.tenantId },
select: { nom: true, subdomain: true },
});
if (!tenant) throw new Error(`Tenant ${event.tenantId} introuvable`);
const destinataires = opts.destinatairesOverride ?? (await resoudreDestinataires(event));
const canaux = (opts.canaux ?? V1_CANAUX_SUPPORTES).filter((c) => V1_CANAUX_SUPPORTES.includes(c));
let nbEnvoyes = 0;import { prisma } from "@/lib/prisma/client";
import type { NotifEvent, NotifEventType, NotifCanal, NotifStatut } from "@prisma/client";
import { sendPushToClient } from "@/lib/push";
import { envoyerNotifGeneric } from "./email-generic";
// Pilier 3 V1 — Dispatcher central Notification Engine.
// 1. Résout les destinataires selon le type d'événement et les abonnements en DB
// 2. Pour chaque destinataire, looke ses canaux configurés (push tokens + email)
// 3. Envoie via chaque canal opt-in, track delivery dans NotifDelivery
// 4. Update compteurs NotifEvent.nbDestinataires / nbEnvoyes / envoyeAt
//
// V1 canaux : PUSH + EMAIL
// V1.5+ : SMS (Vonage), WhatsApp (Cloud API), Snapchat/Insta/Telegram
const V1_CANAUX_SUPPORTES: NotifCanal[] = ["PUSH", "EMAIL"];
// ─── Résolution destinataires ──────────────────────────────────────────────
type Destinataire = {
clientAccountId: string;
abonnementId: string;
// Opt-in canaux pour cet abonnement (override préférence client)
notifPush: boolean;
notifEmail: boolean;
notifSms: boolean;
notifWhatsapp: boolean;
};
async function resoudreDestinataires(event: NotifEvent): Promise<Destinataire[]> {
const tenantId = event.tenantId;
const map = new Map<string, Destinataire>();
// Helper pour merger un abonnement dans la map (1 client = 1 destinataire, OR canaux opt-in)
const merge = (a: { id: string; clientAccountId: string; notifPush: boolean; notifEmail: boolean; notifSms: boolean; notifWhatsapp: boolean }) => {
const existing = map.get(a.clientAccountId);
if (existing) {
// Si déjà présent (via un autre abonnement), on prend l'OR des opt-in (le plus permissif)
existing.notifPush = existing.notifPush || a.notifPush;
existing.notifEmail = existing.notifEmail || a.notifEmail;
existing.notifSms = existing.notifSms || a.notifSms;
existing.notifWhatsapp = existing.notifWhatsapp || a.notifWhatsapp;
} else {
map.set(a.clientAccountId, {
clientAccountId: a.clientAccountId,
abonnementId: a.id,
notifPush: a.notifPush,
notifEmail: a.notifEmail,
notifSms: a.notifSms,
notifWhatsapp: a.notifWhatsapp,
});
}
};
const eventType: NotifEventType = event.type;
// 1. TOUJOURS : abonnés VITRINE du tenant (pertinent pour tous les events liés au tenant)
const abonnesVitrine = await prisma.abonnement.findMany({
where: { type: "VITRINE", tenantId },
select: { id: true, clientAccountId: true, notifPush: true, notifEmail: true, notifSms: true, notifWhatsapp: true },
});
abonnesVitrine.forEach(merge);
// 2. PRODUIT_AJOUTE / PRODUIT_MAJ_X : abonnés directs au produit + abonnés cat wari (V1.5)
if (
(eventType === "PRODUIT_AJOUTE" || eventType === "PRODUIT_MAJ_STOCK" || eventType === "PRODUIT_MAJ_PRIX" || eventType === "PRODUIT_MAJ_PROMO")
&& event.produitId
) {
const abonnesProduit = await prisma.abonnement.findMany({
where: { type: "PRODUIT", produitId: event.produitId },
select: { id: true, clientAccountId: true, notifPush: true, notifEmail: true, notifSms: true, notifWhatsapp: true },
});
abonnesProduit.forEach(merge);
// Catégorie Wari (via tenant principal + secondaires)
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: {
categorieWariId: true,
categoriesWari: { select: { categorieWariId: true } },
},
});
const catWariIds = [
...(tenant?.categorieWariId ? [tenant.categorieWariId] : []),
...(tenant?.categoriesWari.map((c) => c.categorieWariId) ?? []),
];
if (catWariIds.length > 0) {
const abonnesCatWari = await prisma.abonnement.findMany({
where: { type: "CATEGORIE_WARI", categorieWariId: { in: catWariIds } },
select: { id: true, clientAccountId: true, notifPush: true, notifEmail: true, notifSms: true, notifWhatsapp: true },
});
abonnesCatWari.forEach(merge);
}
}
// 3. PRESTATION_AJOUTEE / PRESTATION_MAJ : abonnés directs prestation
if ((eventType === "PRESTATION_AJOUTEE" || eventType === "PRESTATION_MAJ") && event.prestationId) {
const abonnesPrestation = await prisma.abonnement.findMany({
where: { type: "PRESTATION", prestationId: event.prestationId },
select: { id: true, clientAccountId: true, notifPush: true, notifEmail: true, notifSms: true, notifWhatsapp: true },
});
abonnesPrestation.forEach(merge);
}
// 4. PLAT_AJOUTE : juste abonnés vitrine (déjà résolus ci-dessus). V2 : abonnés cat tenant resto.
// 5. VITRINE_ARRIVAGE / VITRINE_MESSAGE : abonnés vitrine (déjà). Si broadcast, segment résolu côté caller.
return Array.from(map.values());
}
// ─── Canaux disponibles pour un client ─────────────────────────────────────
type ClientCanaux = {
email: string | null;
phone: string | null;
hasPushTokens: boolean;
};
async function getCanauxClient(clientAccountId: string): Promise<ClientCanaux> {
const client = await prisma.clientAccount.findUnique({
where: { id: clientAccountId },
select: { email: true, phone: true, pushTokens: { select: { id: true }, take: 1 } },
});
if (!client) return { email: null, phone: null, hasPushTokens: false };
return {
email: client.email,
phone: client.phone,
hasPushTokens: client.pushTokens.length > 0,
};
}
// ─── Senders par canal ─────────────────────────────────────────────────────
async function sendOneViaPush(client: { id: string }, event: NotifEvent): Promise<boolean> {
try {
await sendPushToClient(client.id, {
title: event.titre,
body: event.message ?? "",
data: {
type: event.type,
notifEventId: event.id,
tenantId: event.tenantId,
produitId: event.produitId,
prestationId: event.prestationId,
},
});
return true;
} catch (e) {
console.error("[dispatch] push failed:", e);
return false;
}
}
async function sendOneViaEmail(
client: { id: string; email: string },
event: NotifEvent,
tenant: { nom: string; subdomain: string },
): Promise<boolean> {
const ctaUrl = event.produitId
? `https://${tenant.subdomain}.wari.pro/produit/${event.produitId}`
: event.prestationId
? `https://${tenant.subdomain}.wari.pro/prestation/${event.prestationId}`
: `https://${tenant.subdomain}.wari.pro`;
return envoyerNotifGeneric({
to: client.email,
titre: event.titre,
message: event.message ?? "",
tenantNom: tenant.nom,
tenantSubdomain: tenant.subdomain,
ctaUrl,
ctaLabel: event.produitId || event.prestationId ? "Voir" : "Visiter la vitrine",
});
}
// ─── Orchestrateur ─────────────────────────────────────────────────────────
export type DispatchOptions = {
/** Si fourni, force la liste des destinataires (ex: broadcast avec segment résolu). Sinon résolu via Abonnements. */
destinatairesOverride?: Destinataire[];
/** Canaux à activer pour ce dispatch (subset des canaux supportés). Default V1 = PUSH + EMAIL. */
canaux?: NotifCanal[];
};
export async function dispatchNotification(
notifEventId: string,
opts: DispatchOptions = {},
): Promise<{ nbDestinataires: number; nbEnvoyes: number }> {
const event = await prisma.notifEvent.findUnique({ where: { id: notifEventId } });
if (!event) throw new Error(`NotifEvent ${notifEventId} introuvable`);
const tenant = await prisma.tenant.findUnique({
where: { id: event.tenantId },
select: { nom: true, subdomain: true },
});
if (!tenant) throw new Error(`Tenant ${event.tenantId} introuvable`);
const destinataires = opts.destinatairesOverride ?? (await resoudreDestinataires(event));
const canaux = (opts.canaux ?? V1_CANAUX_SUPPORTES).filter((c) => V1_CANAUX_SUPPORTES.includes(c));
let nbEnvoyes = 0;