258 lines
11 KiB
TypeScript
258 lines
11 KiB
TypeScript
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 = <the status it was in when we
|
|
* read it>`) 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<ProcessNotificationsResult> {
|
|
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<string, number>();
|
|
|
|
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<DeliveryOutcome> {
|
|
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<DeliveryOutcome> {
|
|
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<void> {
|
|
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}`,
|
|
});
|
|
}
|
|
}
|