src/lib/notif/dispatch.ts

function·app·11.5 KB · 312 lignes· Voir l'itinéraire
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;