src/lib/push.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
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 };
}
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 };
}