Initial PHP rebuild of the app
This commit is contained in:
690 files changed
+33944
-12046
No files matched your search
@@ -1,257 +0,0 @@
|
||||
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}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user