src/lib/push.ts

function·app·4.4 KB · 149 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

sendPushToTenantAdminsendPushToClientsendPushToAbonnesTenant

Code source· typescript

import { Expo, type ExpoPushMessage } from "expo-server-sdk";
import { prisma } from "@/lib/prisma/client";

const expo = new Expo({
  accessToken: process.env.EXPO_ACCESS_TOKEN,
});

type PushPayload = {
  title: string;
  body: string;
  data?: Record<string, unknown>;
  sound?: "default" | null;
};

async function sendToTokens(rawTokens: string[], payload: PushPayload): Promise<void> {
  const messages: ExpoPushMessage[] = rawTokens
    .filter((t) => Expo.isExpoPushToken(t))
    .map((t) => ({
      to: t,
      sound: payload.sound ?? "default",
      title: payload.title,
      body: payload.body,
      data: payload.data ?? {},
      priority: "high",
    }));

  if (!messages.length) return;

  const chunks = expo.chunkPushNotifications(messages);
  const invalidTokens: string[] = [];

  for (const chunk of chunks) {
    try {
      const tickets = await expo.sendPushNotificationsAsync(chunk);
      tickets.forEach((ticket, idx) => {
        if (ticket.status === "error") {
          const message = chunk[idx];
          const errCode = ticket.details?.error;
          if (errCode === "DeviceNotRegistered" && typeof message.to === "string") {
            invalidTokens.push(message.to);
          }
          console.error("[push] error ticket:", ticket.message, errCode);
        }
      });
    } catch (e) {
      console.error("[push] sendPushNotificationsAsync error:", e);
    }
  }

  if (invalidTokens.length) {
    await prisma.pushToken.deleteMany({
      where: { token: { in: invalidTokens } },
    }).catch((e) => console.error("[push] cleanup error:", e));
  }
}

/**
 * Envoie une notification push à tous les TENANT_ADMIN d'un tenant.
 * Utilisé pour notifier le gérant d'une nouvelle commande, message client, etc.
 */
export async function sendPushToTenantAdmin(
  tenantId: string,
  payload: PushPayload
): Promise<void> {
  const adminUsers = await prisma.user.findMany({
    where: { tenantId, role: "TENANT_ADMIN" },
    select: { id: true },
  });
  if (!adminUsers.length) return;

  const tokens = await prisma.pushToken.findMany({
    where: { userId: { in: adminUsers.map((u) => u.id) } },
    select: { token: true },
  });
  await sendToTokens(tokens.map((t) => t.token), payload);
}

/**
 * Envoie une notification push à un ClientAccount précis.
 * Utilisé pour les rappels RDV (WP-221), accusés de commande, etc.
 */
export async function sendPushToClient(
  clientAccountId: string,
  payload: PushPayload
): Promise<void> {
  const tokens = await prisma.pushToken.findMany({
    where: { clientAccountId },
    select: { token: true },
  });
  await sendToTokens(tokens.map((t) => t.token), payload);
}

/**
 * Envoie une notification push à tous les abonnés actifs d'un tenant (Stories V1).
 *
 * Modèle `Abonnement` polymorphique (Pilier 3 Notification Engine) : on filtre
 * `type='VITRINE'` + `notifPush=true`. Compat legacy : `tenantId` peut stocker
 * soit l'UUID Tenant.id soit le subdomain (cf. /feed-vitrines-suivies) — on
 * accepte les deux et on dédoublonne par clientAccountId.
 *
 * Best-effort : chaque envoi est wrappé en try/catch individuel ; un client
 * mal-tokenisé ne bloque pas les autres. Retourne les compteurs pour log.
 */
export async function sendPushToAbonnesTenant(
  tenantId: string,
  payload: PushPayload
): Promise<{ sent: number; failed: number }> {
  // Lookup subdomain pour matcher les abonnements legacy stockés par subdomain
  let subdomain: string | null = null;
  try {
    const tenant = await prisma.tenant.findUnique({
      where: { id: tenantId },
      select: { subdomain: true },
    });
    subdomain = tenant?.subdomain ?? null;
  } catch (e) {
    console.error("[sendPushToAbonnesTenant] tenant lookup failed:", e);
  }

  const abonnements = await prisma.abonnement.findMany({
    where: {
      type: "VITRINE",
      notifPush: true,
      OR: subdomain
        ? [{ tenantId }, { tenantId: subdomain }]
        : [{ tenantId }],
    },
    select: { clientAccountId: true },
    take: 1000,
  });

  // Dédoublonnage (tenantId peut stocker id OR subdomain — legacy)
  const uniqueClients = [...new Set(abonnements.map((a) => a.clientAccountId))];
  if (!uniqueClients.length) return { sent: 0, failed: 0 };

  let sent = 0;
  let failed = 0;

  const results = await Promise.allSettled(
    uniqueClients.map((cid) => sendPushToClient(cid, payload)),
  );
  for (const r of results) {
    if (r.status === "fulfilled") sent++;
    else failed++;
  }

  return { sent, failed };
}