import type { Prisma, PrismaClient } from "@/generated/prisma/client"; import { NotificationChannel, NotificationDeliveryStatus, NotificationType } from "@/generated/prisma/enums"; import { notifySafely, resolveActivePushSubscriptions, resolveEmailAddress } from "./notification.service"; import type { EmailProvider } from "./providers/email-provider"; import type { PushProvider } from "./providers/push-provider"; import { getOwnerUserIds } from "./recipients"; import { renderEmailForNotification } from "./render-email"; import { getBranding } from "@/lib/business/branding"; /** * The background notification processor — the one place EMAIL and PUSH * deliveries actually get sent. Deliberately NOT a Next.js server * timer/interval (the brief's explicit "do not rely on in-memory Next.js * server timers" — a serverless/multi-instance deployment has no single * long-lived process to host one): this is a plain function meant to be * invoked by an external scheduler hitting the authenticated route * handler at src/app/api/cron/process-notifications/route.ts. See that * file's header comment for the recommended trigger frequency and * deployment notes. * * Locking: each due delivery is claimed with a single conditional * `updateMany` (`WHERE id = ? AND status = `) before doing any work. If two workers race for the same row, * exactly one's claim affects a row (MySQL row-level locking during the * UPDATE), and the loser sees `count: 0` and moves on — no separate lock * table, no SELECT ... FOR UPDATE transaction held open across a slow * network call to Brevo/a push service. * * Retry: only a `retryable` failure (timeout/rate-limit/5xx — see * BrevoEmailProvider.isRetryable / WebPushProvider.sendPush) is retried, * with a short exponential backoff (1 min, 5 min, 30 min) up to * MAX_ATTEMPTS; a permanent failure (invalid recipient, rejected payload, * auth failure, expired push subscription) or an exhausted retry budget * both end at FAILED, never retried again. */ const MAX_ATTEMPTS = 4; const BACKOFF_MINUTES = [1, 5, 30] as const; /** Failed deliveries for one business within a single processing run at or above this count raises OWNER_REPEATED_DELIVERY_FAILURE — see NotificationType in schema.prisma. */ const REPEATED_FAILURE_THRESHOLD = 3; export interface ProcessNotificationsResult { claimed: number; sent: number; retried: number; failed: number; /** Another worker already claimed the row between our read and our claim attempt — not an error. */ skippedAlreadyClaimed: number; } type DueDelivery = Prisma.NotificationDeliveryGetPayload<{ include: { notification: true } }>; function backoffMinutesFor(attemptNumber: number): number { return BACKOFF_MINUTES[attemptNumber - 1] ?? BACKOFF_MINUTES[BACKOFF_MINUTES.length - 1] ?? 30; } export async function processDueNotificationDeliveries( prisma: PrismaClient, params: { emailProvider: EmailProvider; pushProvider?: PushProvider; now?: Date; batchSize?: number; businessId?: string }, ): Promise { const now = params.now ?? new Date(); const batchSize = params.batchSize ?? 25; const due = await prisma.notificationDelivery.findMany({ where: { channel: { in: [NotificationChannel.EMAIL, NotificationChannel.PUSH] }, status: { in: [NotificationDeliveryStatus.PENDING, NotificationDeliveryStatus.SCHEDULED] }, OR: [{ scheduledFor: null }, { scheduledFor: { lte: now } }], ...(params.businessId ? { notification: { businessId: params.businessId } } : {}), }, orderBy: [{ scheduledFor: "asc" }, { createdAt: "asc" }], take: batchSize, include: { notification: true }, }); const result: ProcessNotificationsResult = { claimed: 0, sent: 0, retried: 0, failed: 0, skippedAlreadyClaimed: 0 }; const failuresByBusiness = new Map(); for (const delivery of due) { const claim = await prisma.notificationDelivery.updateMany({ where: { id: delivery.id, status: delivery.status }, data: { status: NotificationDeliveryStatus.PROCESSING, lastAttemptedAt: now, attemptCount: { increment: 1 } }, }); if (claim.count === 0) { result.skippedAlreadyClaimed++; continue; } result.claimed++; const attemptNumber = delivery.attemptCount + 1; const outcome = delivery.channel === NotificationChannel.EMAIL ? await processEmailDelivery(prisma, delivery, params.emailProvider) : await processPushDelivery(prisma, delivery, params.pushProvider); if (outcome.status === "sent") { await prisma.notificationDelivery.update({ where: { id: delivery.id }, data: { status: NotificationDeliveryStatus.SENT, sentAt: new Date(), providerName: outcome.providerName, providerMessageId: outcome.providerMessageId, }, }); result.sent++; continue; } if (outcome.retryable && attemptNumber < MAX_ATTEMPTS) { await prisma.notificationDelivery.update({ where: { id: delivery.id }, data: { status: NotificationDeliveryStatus.PENDING, scheduledFor: new Date(now.getTime() + backoffMinutesFor(attemptNumber) * 60_000), failureReason: outcome.reason.slice(0, 500), }, }); result.retried++; } else { await prisma.notificationDelivery.update({ where: { id: delivery.id }, data: { status: NotificationDeliveryStatus.FAILED, failureReason: outcome.reason.slice(0, 500) }, }); result.failed++; failuresByBusiness.set( delivery.notification.businessId, (failuresByBusiness.get(delivery.notification.businessId) ?? 0) + 1, ); } } for (const [businessId, count] of failuresByBusiness) { if (count >= REPEATED_FAILURE_THRESHOLD) { await raiseRepeatedFailureNotice(prisma, businessId, now); } } return result; } type DeliveryOutcome = | { status: "sent"; providerName: string; providerMessageId?: string } | { status: "failed"; retryable: boolean; reason: string }; async function processEmailDelivery(prisma: PrismaClient, delivery: DueDelivery, emailProvider: EmailProvider): Promise { try { const recipientEmail = await resolveEmailAddress(prisma, { recipientUserId: delivery.notification.recipientUserId ?? undefined, recipientCustomerId: delivery.notification.recipientCustomerId ?? undefined, }); if (!recipientEmail) return { status: "failed", retryable: false, reason: "No email address on file for this recipient." }; const email = await renderEmailForNotification(prisma, delivery.notification); if (!email) { return { status: "failed", retryable: false, reason: "Could not render email content — the business or referenced appointment/reward may have been deleted.", }; } const sendResult = await emailProvider.sendEmail({ to: { email: recipientEmail }, sender: email.sender, subject: email.subject, html: email.html, text: email.text, idempotencyKey: delivery.id, }); if (sendResult.status === "sent") { return { status: "sent", providerName: emailProvider.name, providerMessageId: sendResult.providerMessageId }; } return { status: "failed", retryable: sendResult.retryable, reason: sendResult.reason }; } catch (error) { return { status: "failed", retryable: true, reason: error instanceof Error ? error.message : "Unknown error while sending email." }; } } /** * A PUSH delivery fans out to every active subscription this recipient * has (see PushSubscription in schema.prisma — a person can have several * devices, but there's only one PUSH NotificationDelivery row per * notification). Counted "sent" overall if at least one subscription * received it; any individual subscription the push service reports as * gone (404/410) is revoked immediately, regardless of the others' * outcome — that's real signal about that specific device, not about * this notification. */ async function processPushDelivery(prisma: PrismaClient, delivery: DueDelivery, pushProvider: PushProvider | undefined): Promise { if (!pushProvider) return { status: "failed", retryable: true, reason: "No push provider configured." }; try { const subscriptions = await resolveActivePushSubscriptions(prisma, { recipientUserId: delivery.notification.recipientUserId ?? undefined, recipientCustomerId: delivery.notification.recipientCustomerId ?? undefined, }); if (subscriptions.length === 0) { return { status: "failed", retryable: false, reason: "No active push subscription for this recipient." }; } let anySent = false; let lastFailureReason = ""; let anyRetryable = false; // Every notify() call sets a subject (see notification.service.ts), // so this fallback is defensive rather than a normal path — but a // push notification's title is genuinely user-visible (shown by the // OS), so it still needs the business name, not the internal product // name, when reached. const fallbackTitle = delivery.notification.subject ?? (await getBranding()).name; for (const subscription of subscriptions) { const sendResult = await pushProvider.sendPush({ subscription: { endpoint: subscription.endpoint, p256dhKey: subscription.p256dhKey, authKey: subscription.authKey }, title: fallbackTitle, body: delivery.notification.bodyText, deepLinkPath: delivery.notification.deepLinkPath ?? undefined, }); if (sendResult.status === "sent") { anySent = true; await prisma.pushSubscription.update({ where: { id: subscription.id }, data: { lastUsedAt: new Date() } }).catch(() => {}); continue; } lastFailureReason = sendResult.reason; if (sendResult.subscriptionInvalid) { await prisma.pushSubscription.update({ where: { id: subscription.id }, data: { revokedAt: new Date() } }).catch(() => {}); } else if (sendResult.retryable) { anyRetryable = true; } } if (anySent) return { status: "sent", providerName: pushProvider.name }; return { status: "failed", retryable: anyRetryable, reason: lastFailureReason || "All subscriptions failed." }; } catch (error) { return { status: "failed", retryable: true, reason: error instanceof Error ? error.message : "Unknown error while sending push." }; } } async function raiseRepeatedFailureNotice(prisma: PrismaClient, businessId: string, now: Date): Promise { const dayStamp = now.toISOString().slice(0, 10); // one notice per business per day, not one per processor run const ownerUserIds = await getOwnerUserIds(prisma, businessId); for (const ownerUserId of ownerUserIds) { await notifySafely(prisma, { businessId, type: NotificationType.OWNER_REPEATED_DELIVERY_FAILURE, recipientUserId: ownerUserId, subject: "Some notifications failed to send", bodyText: "Several notifications failed to send today. Check your notification settings, Brevo configuration, and push setup.", deepLinkPath: "/owner/notifications", idempotencyKey: `notify:owner-repeated-delivery-failure:${businessId}:${dayStamp}`, }); } }