import type { AdapterContext, NotificationMessage, NotificationProvider, } from "@rakazo/adapter-kit"; import type { PrismaClient, PushSubscriptionKind } from "@rakazo/db"; import { forgetPushSubscription, listPushSubscriptions, loadNotificationPolicy, markPushSubscriptionUsed, } from "@rakazo/db"; import { getLogger } from "@rakazo/logging"; /** Lock screens show a line or two; the rest lives in the conversation. */ const PUSH_BODY_MAX_LENGTH = 240; /** What a device shows: the notification plus where a tap leads (a path in the web app). */ export interface PushPayload extends NotificationMessage { url: string; } /** "gone": the push service no longer knows this device, so its registration can be dropped. */ export type PushOutcome = "sent" | "gone"; export interface PushTarget { endpoint: string; p256dh: string | null; auth: string | null; } export interface PushSender { send(target: PushTarget, payload: PushPayload, signal?: AbortSignal): Promise; } function clip(text: string): string { const trimmed = text.trim(); return trimmed.length > PUSH_BODY_MAX_LENGTH ? `${trimmed.slice(0, PUSH_BODY_MAX_LENGTH - 1).trimEnd()}…` : trimmed; } /** * Delivers one notification to every device the user registered (browsers and the mobile app), * except during their quiet hours. Devices the push service reports gone are dropped. Throws only * when every device failed. */ export class PushNotificationProvider implements NotificationProvider { constructor( private readonly prisma: PrismaClient, private readonly senders: Partial>, private readonly now: () => Date = () => new Date(), ) {} describe() { return { id: "push", contractVersion: "1", adapterVersion: "0.1.0", capabilities: { push: true, email: false }, }; } async send(message: NotificationMessage, context: AdapterContext): Promise { const policy = await loadNotificationPolicy(this.prisma, context.userId, this.now()); if (policy.quiet) return; const devices = (await listPushSubscriptions(this.prisma, context.userId)).filter( (device) => this.senders[device.kind], ); if (devices.length === 0) return; const payload: PushPayload = { ...message, title: message.title.trim().slice(0, 120), body: clip(message.body), url: await this.conversationPath(message), }; const results = await Promise.allSettled( devices.map(async (device) => { const outcome = await this.senders[device.kind]!.send(device, payload, context.signal); if (outcome === "gone") await forgetPushSubscription(this.prisma, device.id); else await markPushSubscriptionUsed(this.prisma, device.id, this.now()); }), ); const failures = results.filter((result) => result.status === "rejected"); for (const failure of failures) { // Errors carry a status or a reason, never the endpoint or token. getLogger().error("push delivery failed", failure.reason); } if (failures.length === devices.length) throw new Error("Push delivery failed"); } /** The web app path of the conversation the notification is about. */ private async conversationPath(message: NotificationMessage): Promise { const thread = await this.prisma.thread.findUnique({ where: { id: message.threadId }, select: { groupId: true }, }); return thread?.groupId ? `/app/g/${encodeURIComponent(thread.groupId)}` : `/app/${encodeURIComponent(message.botId)}`; } }