PHP Rebuild
CI / ci (push) Canceled after 0s

Initial PHP rebuild of the app
This commit is contained in:
Stefan Lazic committed 2026-09-05 08:21:33 +01:00
1 parent 8a2b211069
commit 2ef3654841
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}`,
});
}
}