This commit is contained in:
Stefan Lazic committed 2026-08-03 21:15:24 +01:00
1 parent ec5583f972
commit 546613e677
189 files changed
+9873 -655

No files matched your search

@@ -0,0 +1,226 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { NotificationChannel, NotificationType } from "@/generated/prisma/enums";
import {
cleanupTestBusiness,
createTestBusiness,
createTestCustomer,
createTestGuestCustomer,
createTestStaff,
prisma,
} from "@/services/scheduling/__tests__/fixtures";
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
import {
getNotificationPreferences,
getUnreadNotificationCount,
listInAppNotifications,
markAllNotificationsRead,
markNotificationRead,
notify,
setNotificationPreference,
} from "../notification.service";
describe("NotificationService (integration)", () => {
let business: TestBusiness;
let guestProfileIds: string[];
beforeEach(async () => {
business = await createTestBusiness();
guestProfileIds = [];
});
afterEach(async () => {
await cleanupTestBusiness(business);
if (guestProfileIds.length > 0) {
await prisma.customerProfile.deleteMany({ where: { id: { in: guestProfileIds } } }).catch(() => {});
}
});
describe("notify — creation and idempotency", () => {
it("creates a required notification with EMAIL and IN_APP deliveries for a registered customer", async () => {
const { customerId, userId } = await createTestCustomer(business);
const { created, notification } = await notify(prisma, {
businessId: business.businessId,
type: NotificationType.BOOKING_CONFIRMED,
recipientUserId: userId,
recipientCustomerId: customerId,
bodyText: "Your booking is confirmed.",
idempotencyKey: `test:${userId}:booking-confirmed`,
});
expect(created).toBe(true);
expect(notification.deliveries).toHaveLength(2);
const channels = notification.deliveries.map((d) => d.channel).sort();
expect(channels).toEqual([NotificationChannel.EMAIL, NotificationChannel.IN_APP].sort());
const inApp = notification.deliveries.find((d) => d.channel === NotificationChannel.IN_APP);
expect(inApp?.status).toBe("SENT");
const email = notification.deliveries.find((d) => d.channel === NotificationChannel.EMAIL);
expect(email?.status).toBe("PENDING");
});
it("creates only an EMAIL delivery for a guest customer (no account for IN_APP)", async () => {
const guest = await createTestGuestCustomer();
guestProfileIds.push(guest.customerId);
const { notification } = await notify(prisma, {
businessId: business.businessId,
type: NotificationType.BOOKING_CONFIRMED,
recipientCustomerId: guest.customerId,
bodyText: "Your booking is confirmed.",
idempotencyKey: `test:${guest.customerId}:booking-confirmed`,
});
expect(notification.deliveries).toHaveLength(1);
expect(notification.deliveries[0]!.channel).toBe(NotificationChannel.EMAIL);
});
it("a second notify() call with the same idempotencyKey is a no-op, not a duplicate", async () => {
const { customerId, userId } = await createTestCustomer(business);
const idempotencyKey = `test:${userId}:dedupe`;
const first = await notify(prisma, {
businessId: business.businessId,
type: NotificationType.BOOKING_CONFIRMED,
recipientUserId: userId,
recipientCustomerId: customerId,
bodyText: "Your booking is confirmed.",
idempotencyKey,
});
const second = await notify(prisma, {
businessId: business.businessId,
type: NotificationType.BOOKING_CONFIRMED,
recipientUserId: userId,
recipientCustomerId: customerId,
bodyText: "Your booking is confirmed. (retried)",
idempotencyKey,
});
expect(first.created).toBe(true);
expect(second.created).toBe(false);
expect(second.notification.id).toBe(first.notification.id);
// The retried call's different bodyText never overwrote the original.
expect(second.notification.bodyText).toBe("Your booking is confirmed.");
const count = await prisma.notification.count({ where: { idempotencyKey } });
expect(count).toBe(1);
});
it("throws if neither recipientUserId nor recipientCustomerId is supplied", async () => {
await expect(
notify(prisma, {
businessId: business.businessId,
type: NotificationType.OWNER_NEW_BOOKING,
bodyText: "x",
idempotencyKey: "test:no-recipient",
}),
).rejects.toThrow();
});
});
describe("notify — preference-controlled types", () => {
it("skips the EMAIL delivery for a reminder when the user has disabled email reminders", async () => {
const { customerId, userId } = await createTestCustomer(business);
await setNotificationPreference(prisma, {
userId,
type: NotificationType.BOOKING_REMINDER,
channel: NotificationChannel.EMAIL,
isEnabled: false,
});
const { notification } = await notify(prisma, {
businessId: business.businessId,
type: NotificationType.BOOKING_REMINDER,
recipientUserId: userId,
recipientCustomerId: customerId,
bodyText: "Reminder: your appointment is tomorrow.",
idempotencyKey: `test:${userId}:reminder-disabled`,
});
const channels = notification.deliveries.map((d) => d.channel);
expect(channels).not.toContain(NotificationChannel.EMAIL);
expect(channels).toContain(NotificationChannel.IN_APP);
});
it("still sends a required type's EMAIL even if a same-channel reminder preference happens to be disabled", async () => {
const { customerId, userId } = await createTestCustomer(business);
await setNotificationPreference(prisma, {
userId,
type: NotificationType.BOOKING_REMINDER,
channel: NotificationChannel.EMAIL,
isEnabled: false,
});
const { notification } = await notify(prisma, {
businessId: business.businessId,
type: NotificationType.BOOKING_CANCELLED,
recipientUserId: userId,
recipientCustomerId: customerId,
bodyText: "Your appointment was cancelled.",
idempotencyKey: `test:${userId}:cancelled-required`,
});
expect(notification.deliveries.map((d) => d.channel)).toContain(NotificationChannel.EMAIL);
});
it("setNotificationPreference refuses to touch a required (non-preference-controlled) type", async () => {
const { userId } = await createTestCustomer(business);
await expect(
setNotificationPreference(prisma, {
userId,
type: NotificationType.BOOKING_CONFIRMED,
channel: NotificationChannel.EMAIL,
isEnabled: false,
}),
).rejects.toThrow();
});
it("reminders default to enabled when no preference row exists yet", async () => {
const preferences = await getNotificationPreferences(prisma, (await createTestCustomer(business)).userId);
expect(preferences).toHaveLength(0); // no rows yet — resolveChannels() still includes EMAIL by default
});
});
describe("in-app notification centre", () => {
it("lists, counts, and marks a user's own notifications read — scoped so one user can't touch another's", async () => {
const { staffId, userId: staffUserId } = await createTestStaff(business);
const other = await createTestStaff(business);
const { notification: n1 } = await notify(prisma, {
businessId: business.businessId,
type: NotificationType.STAFF_NEW_APPOINTMENT,
recipientUserId: staffUserId,
bodyText: "New appointment booked.",
idempotencyKey: `test:${staffId}:appt-1`,
});
await notify(prisma, {
businessId: business.businessId,
type: NotificationType.STAFF_NEW_APPOINTMENT,
recipientUserId: staffUserId,
bodyText: "Another appointment booked.",
idempotencyKey: `test:${staffId}:appt-2`,
});
expect(await getUnreadNotificationCount(prisma, staffUserId)).toBe(2);
expect(await getUnreadNotificationCount(prisma, other.userId)).toBe(0);
const list = await listInAppNotifications(prisma, { userId: staffUserId });
expect(list).toHaveLength(2);
// Another user can't mark someone else's notification read.
const wrongUserResult = await markNotificationRead(prisma, { notificationId: n1.id, userId: other.userId });
expect(wrongUserResult).toBe(false);
expect(await getUnreadNotificationCount(prisma, staffUserId)).toBe(2);
const rightUserResult = await markNotificationRead(prisma, { notificationId: n1.id, userId: staffUserId });
expect(rightUserResult).toBe(true);
expect(await getUnreadNotificationCount(prisma, staffUserId)).toBe(1);
const markedAll = await markAllNotificationsRead(prisma, staffUserId);
expect(markedAll).toBe(1);
expect(await getUnreadNotificationCount(prisma, staffUserId)).toBe(0);
const unreadOnly = await listInAppNotifications(prisma, { userId: staffUserId, unreadOnly: true });
expect(unreadOnly).toHaveLength(0);
});
});
});
@@ -0,0 +1,88 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
cleanupTestBusiness,
createTestBusiness,
createTestCustomer,
prisma,
} from "@/services/scheduling/__tests__/fixtures";
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
import { listPushSubscriptionsForUser, subscribeToPush, unsubscribeFromPush } from "../push-subscription.service";
describe("PushSubscription service (integration)", () => {
let business: TestBusiness;
beforeEach(async () => {
business = await createTestBusiness();
});
afterEach(async () => {
await cleanupTestBusiness(business);
});
it("subscribing twice with the same endpoint upserts rather than duplicating", async () => {
const { customerId, userId } = await createTestCustomer(business);
const endpoint = `https://push.example.com/${crypto.randomUUID()}`;
await subscribeToPush(prisma, {
businessId: business.businessId,
userId,
customerId,
endpoint,
p256dhKey: "key-1",
authKey: "auth-1",
deviceLabel: "iPhone",
});
await subscribeToPush(prisma, {
businessId: business.businessId,
userId,
customerId,
endpoint,
p256dhKey: "key-2",
authKey: "auth-2",
deviceLabel: "iPhone (updated)",
});
const rows = await prisma.pushSubscription.findMany({ where: { endpoint } });
expect(rows).toHaveLength(1);
expect(rows[0]!.p256dhKey).toBe("key-2");
expect(rows[0]!.deviceLabel).toBe("iPhone (updated)");
});
it("unsubscribe only affects the owning user's own subscription, never someone else's", async () => {
const owner = await createTestCustomer(business);
const other = await createTestCustomer(business);
const endpoint = `https://push.example.com/${crypto.randomUUID()}`;
await subscribeToPush(prisma, {
businessId: business.businessId,
userId: owner.userId,
customerId: owner.customerId,
endpoint,
p256dhKey: "key",
authKey: "auth",
});
const wrongUserResult = await unsubscribeFromPush(prisma, { endpoint, ownerUserId: other.userId });
expect(wrongUserResult).toBe(false);
let row = await prisma.pushSubscription.findUniqueOrThrow({ where: { endpoint } });
expect(row.revokedAt).toBeNull();
const rightUserResult = await unsubscribeFromPush(prisma, { endpoint, ownerUserId: owner.userId });
expect(rightUserResult).toBe(true);
row = await prisma.pushSubscription.findUniqueOrThrow({ where: { endpoint } });
expect(row.revokedAt).not.toBeNull();
});
it("listPushSubscriptionsForUser only returns active (non-revoked) subscriptions for that user", async () => {
const { userId, customerId } = await createTestCustomer(business);
const activeEndpoint = `https://push.example.com/${crypto.randomUUID()}`;
const revokedEndpoint = `https://push.example.com/${crypto.randomUUID()}`;
await subscribeToPush(prisma, { businessId: business.businessId, userId, customerId, endpoint: activeEndpoint, p256dhKey: "k", authKey: "a" });
await subscribeToPush(prisma, { businessId: business.businessId, userId, customerId, endpoint: revokedEndpoint, p256dhKey: "k", authKey: "a" });
await unsubscribeFromPush(prisma, { endpoint: revokedEndpoint, ownerUserId: userId });
const active = await listPushSubscriptionsForUser(prisma, userId);
expect(active.map((s) => s.endpoint)).toEqual([activeEndpoint]);
});
});
@@ -0,0 +1,261 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { NotificationType } from "@/generated/prisma/enums";
import {
cleanupTestBusiness,
createTestBusiness,
createTestCustomer,
prisma,
} from "@/services/scheduling/__tests__/fixtures";
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
import { MockEmailProvider } from "../providers/mock-email-provider";
import { MockPushProvider } from "../providers/mock-push-provider";
import { notify } from "../notification.service";
import { subscribeToPush } from "../push-subscription.service";
import { processDueNotificationDeliveries } from "../reminder-worker";
/**
* Exercises the background processor end to end against a real database,
* with MockEmailProvider standing in for Brevo — no real network calls,
* matching the Phase 7 brief's "no real API calls in automated tests".
*/
describe("processDueNotificationDeliveries (integration)", () => {
let business: TestBusiness;
let provider: MockEmailProvider;
beforeEach(async () => {
business = await createTestBusiness();
provider = new MockEmailProvider();
});
afterEach(async () => {
await cleanupTestBusiness(business);
});
async function createDueEmailNotification(customerId: string, idempotencyKey: string) {
const { notification } = await notify(prisma, {
businessId: business.businessId,
type: NotificationType.LOYALTY_STAMP_ADDED,
recipientCustomerId: customerId,
bodyText: "You've earned a stamp.",
idempotencyKey,
});
return notification;
}
it("sends a due PENDING EMAIL delivery and marks it SENT with the provider message id", async () => {
const { customerId } = await createTestCustomer(business);
const notification = await createDueEmailNotification(customerId, "test:worker:sent");
const result = await processDueNotificationDeliveries(prisma, { emailProvider: provider, businessId: business.businessId });
expect(result.sent).toBe(1);
expect(provider.sentEmails).toHaveLength(1);
const delivery = await prisma.notificationDelivery.findFirstOrThrow({
where: { notificationId: notification.id, channel: "EMAIL" },
});
expect(delivery.status).toBe("SENT");
expect(delivery.providerMessageId).toBe("mock-message-id");
expect(delivery.providerName).toBe("mock");
expect(delivery.attemptCount).toBe(1);
});
it("does not send a delivery scheduled for the future", async () => {
const { customerId } = await createTestCustomer(business);
const { notification } = await notify(prisma, {
businessId: business.businessId,
type: NotificationType.BOOKING_REMINDER,
recipientCustomerId: customerId,
bodyText: "Reminder",
idempotencyKey: "test:worker:future",
scheduledFor: new Date(Date.now() + 60 * 60_000),
});
const result = await processDueNotificationDeliveries(prisma, { emailProvider: provider, businessId: business.businessId });
expect(result.claimed).toBe(0);
expect(provider.sentEmails).toHaveLength(0);
const delivery = await prisma.notificationDelivery.findFirstOrThrow({
where: { notificationId: notification.id, channel: "EMAIL" },
});
expect(delivery.status).toBe("SCHEDULED");
});
it("retries a retryable failure with backoff, then eventually gives up after MAX_ATTEMPTS", async () => {
const { customerId } = await createTestCustomer(business);
const notification = await createDueEmailNotification(customerId, "test:worker:retry");
provider.setDefaultResult({ status: "failed", retryable: true, reason: "Connection timed out" });
// Attempt 1: retried with backoff — scheduledFor pushed into the future, so it won't be "due" again immediately.
const first = await processDueNotificationDeliveries(prisma, { emailProvider: provider, now: new Date(), businessId: business.businessId });
expect(first.retried).toBe(1);
let delivery = await prisma.notificationDelivery.findFirstOrThrow({ where: { notificationId: notification.id, channel: "EMAIL" } });
expect(delivery.status).toBe("PENDING");
expect(delivery.attemptCount).toBe(1);
expect(delivery.scheduledFor).not.toBeNull();
// Simulate time passing past the backoff window on each subsequent attempt.
for (let i = 0; i < 2; i++) {
const future = new Date(delivery.scheduledFor!.getTime() + 1000);
const run = await processDueNotificationDeliveries(prisma, { emailProvider: provider, now: future, businessId: business.businessId });
expect(run.retried).toBe(1);
delivery = await prisma.notificationDelivery.findFirstOrThrow({ where: { notificationId: notification.id, channel: "EMAIL" } });
}
expect(delivery.attemptCount).toBe(3);
// 4th attempt (MAX_ATTEMPTS) exhausts the retry budget — permanent FAILED, not retried again.
const finalRun = await processDueNotificationDeliveries(prisma, {
emailProvider: provider,
now: new Date(delivery.scheduledFor!.getTime() + 1000),
businessId: business.businessId,
});
expect(finalRun.failed).toBe(1);
expect(finalRun.retried).toBe(0);
delivery = await prisma.notificationDelivery.findFirstOrThrow({ where: { notificationId: notification.id, channel: "EMAIL" } });
expect(delivery.status).toBe("FAILED");
expect(delivery.attemptCount).toBe(4);
});
it("never retries a permanent (non-retryable) failure, even on the first attempt", async () => {
const { customerId } = await createTestCustomer(business);
const notification = await createDueEmailNotification(customerId, "test:worker:permanent");
provider.setDefaultResult({ status: "failed", retryable: false, reason: "Mailbox does not exist" });
const result = await processDueNotificationDeliveries(prisma, { emailProvider: provider, businessId: business.businessId });
expect(result.failed).toBe(1);
expect(result.retried).toBe(0);
const delivery = await prisma.notificationDelivery.findFirstOrThrow({ where: { notificationId: notification.id, channel: "EMAIL" } });
expect(delivery.status).toBe("FAILED");
expect(delivery.attemptCount).toBe(1);
expect(delivery.failureReason).toBe("Mailbox does not exist");
});
it("fails a delivery cleanly when the recipient has no email address at all, without calling the provider", async () => {
// NotificationService.notify() never creates an EMAIL delivery for a recipient with no
// resolvable address in the first place (see resolveChannels) — so this scenario is only
// reachable if the address changes between creation and processing (e.g. a customer record
// edited afterward). Constructed directly here to exercise the worker's own defensive guard
// in isolation, bypassing notify()'s channel resolution.
const orphanCustomer = await prisma.customerProfile.create({
data: { firstName: "No", lastName: "Email", email: "", phone: null, isGuest: true },
});
try {
const notification = await prisma.notification.create({
data: {
businessId: business.businessId,
recipientCustomerId: orphanCustomer.id,
type: "LOYALTY_STAMP_ADDED",
bodyText: "You've earned a stamp.",
idempotencyKey: "test:worker:no-email",
deliveries: { create: [{ channel: "EMAIL", status: "PENDING" }] },
},
});
const result = await processDueNotificationDeliveries(prisma, { emailProvider: provider, businessId: business.businessId });
expect(result.failed).toBe(1);
expect(provider.sentEmails).toHaveLength(0);
const delivery = await prisma.notificationDelivery.findFirstOrThrow({ where: { notificationId: notification.id, channel: "EMAIL" } });
expect(delivery.status).toBe("FAILED");
} finally {
await prisma.notification.deleteMany({ where: { recipientCustomerId: orphanCustomer.id } });
await prisma.customerProfile.delete({ where: { id: orphanCustomer.id } });
}
});
it("does not double-send when a delivery has already been claimed (simulated concurrent worker)", async () => {
const { customerId } = await createTestCustomer(business);
const notification = await createDueEmailNotification(customerId, "test:worker:concurrent");
const delivery = await prisma.notificationDelivery.findFirstOrThrow({ where: { notificationId: notification.id, channel: "EMAIL" } });
// Simulate another worker having already claimed this row a moment ago.
await prisma.notificationDelivery.update({ where: { id: delivery.id }, data: { status: "PROCESSING" } });
const result = await processDueNotificationDeliveries(prisma, { emailProvider: provider, businessId: business.businessId });
expect(result.claimed).toBe(0);
expect(provider.sentEmails).toHaveLength(0);
});
it("raises OWNER_REPEATED_DELIVERY_FAILURE once several EMAIL deliveries fail for the same business in one run", async () => {
const role = await prisma.role.create({
data: { businessId: business.businessId, name: "Test Owner", permissions: { create: [{ permission: "VIEW_REPORTING" }] } },
});
const ownerUser = await prisma.user.create({
data: {
email: `${crypto.randomUUID()}@test.local`,
staffProfile: { create: { businessId: business.businessId, roleId: role.id } },
},
});
provider.setDefaultResult({ status: "failed", retryable: false, reason: "Rejected" });
const customers = await Promise.all([createTestCustomer(business), createTestCustomer(business), createTestCustomer(business)]);
for (const [i, c] of customers.entries()) {
await createDueEmailNotification(c.customerId, `test:worker:repeated-failure-${i}`);
}
const result = await processDueNotificationDeliveries(prisma, { emailProvider: provider, businessId: business.businessId });
expect(result.failed).toBe(3);
const ownerNotice = await prisma.notification.findFirst({
where: { recipientUserId: ownerUser.id, type: "OWNER_REPEATED_DELIVERY_FAILURE" },
});
expect(ownerNotice).not.toBeNull();
});
describe("PUSH channel", () => {
let pushProvider: MockPushProvider;
beforeEach(() => {
pushProvider = new MockPushProvider();
});
async function createDuePushNotification(customerId: string, userId: string, idempotencyKey: string) {
const { notification } = await notify(prisma, {
businessId: business.businessId,
type: NotificationType.LOYALTY_STAMP_ADDED,
recipientUserId: userId,
recipientCustomerId: customerId,
bodyText: "You've earned a stamp.",
idempotencyKey,
});
return notification;
}
it("fans out to every active subscription and marks the delivery SENT if at least one succeeds", async () => {
const { customerId, userId } = await createTestCustomer(business);
await subscribeToPush(prisma, { businessId: business.businessId, userId, customerId, endpoint: "https://push.example.com/device-1", p256dhKey: "k1", authKey: "a1" });
await subscribeToPush(prisma, { businessId: business.businessId, userId, customerId, endpoint: "https://push.example.com/device-2", p256dhKey: "k2", authKey: "a2" });
const notification = await createDuePushNotification(customerId, userId, "test:worker:push-fanout");
const result = await processDueNotificationDeliveries(prisma, { emailProvider: provider, pushProvider, businessId: business.businessId });
expect(pushProvider.sentPushes).toHaveLength(2);
const delivery = await prisma.notificationDelivery.findFirstOrThrow({ where: { notificationId: notification.id, channel: "PUSH" } });
expect(delivery.status).toBe("SENT");
expect(result.sent).toBeGreaterThanOrEqual(1);
});
it("revokes a subscription the push service reports as gone (410), independent of the overall delivery outcome", async () => {
const { customerId, userId } = await createTestCustomer(business);
await subscribeToPush(prisma, { businessId: business.businessId, userId, customerId, endpoint: "https://push.example.com/stale-device", p256dhKey: "k1", authKey: "a1" });
pushProvider.setDefaultResult({ status: "failed", retryable: false, subscriptionInvalid: true, reason: "Gone" });
await createDuePushNotification(customerId, userId, "test:worker:push-revoke");
await processDueNotificationDeliveries(prisma, { emailProvider: provider, pushProvider, businessId: business.businessId });
const subscription = await prisma.pushSubscription.findUniqueOrThrow({ where: { endpoint: "https://push.example.com/stale-device" } });
expect(subscription.revokedAt).not.toBeNull();
});
it("fails cleanly with no push provider configured, without touching subscriptions", async () => {
const { customerId, userId } = await createTestCustomer(business);
await subscribeToPush(prisma, { businessId: business.businessId, userId, customerId, endpoint: "https://push.example.com/no-provider", p256dhKey: "k", authKey: "a" });
const notification = await createDuePushNotification(customerId, userId, "test:worker:push-no-provider");
await processDueNotificationDeliveries(prisma, { emailProvider: provider, businessId: business.businessId });
const delivery = await prisma.notificationDelivery.findFirstOrThrow({ where: { notificationId: notification.id, channel: "PUSH" } });
expect(delivery.status).toBe("PENDING"); // retryable — a future run with a configured provider can still succeed
const subscription = await prisma.pushSubscription.findUniqueOrThrow({ where: { endpoint: "https://push.example.com/no-provider" } });
expect(subscription.revokedAt).toBeNull();
});
});
});
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { NotificationType } from "@/generated/prisma/enums";
import { isPreferenceControlled, recipientKindOf } from "../types";
describe("recipientKindOf", () => {
it("classifies every type as exactly one of CUSTOMER/STAFF/OWNER", () => {
for (const type of Object.values(NotificationType)) {
expect(["CUSTOMER", "STAFF", "OWNER"]).toContain(recipientKindOf(type));
}
});
it("classifies a representative type from each group correctly", () => {
expect(recipientKindOf(NotificationType.BOOKING_CONFIRMED)).toBe("CUSTOMER");
expect(recipientKindOf(NotificationType.STAFF_NEW_APPOINTMENT)).toBe("STAFF");
expect(recipientKindOf(NotificationType.OWNER_NEW_BOOKING)).toBe("OWNER");
});
});
describe("isPreferenceControlled", () => {
it("only the two reminder types are preference-controlled", () => {
expect(isPreferenceControlled(NotificationType.BOOKING_REMINDER)).toBe(true);
expect(isPreferenceControlled(NotificationType.STAFF_APPOINTMENT_REMINDER)).toBe(true);
});
it("every required transactional type is not preference-controlled", () => {
const required = Object.values(NotificationType).filter(
(t) => t !== NotificationType.BOOKING_REMINDER && t !== NotificationType.STAFF_APPOINTMENT_REMINDER,
);
for (const type of required) {
expect(isPreferenceControlled(type)).toBe(false);
}
});
});
@@ -0,0 +1,289 @@
import { Prisma } from "@/generated/prisma/client";
import type { PrismaClient } from "@/generated/prisma/client";
import { NotificationChannel, NotificationDeliveryStatus, NotificationType } from "@/generated/prisma/enums";
import { isPreferenceControlled } from "./types";
/**
* The notification domain layer: creates Notification + NotificationDelivery
* rows, resolves which channels actually apply for a given recipient,
* applies preferences, and serves the in-app notification centre.
* Deliberately does NOT decide *when* something is notification-worthy —
* that's every appointment/loyalty workflow's job (see docs/phase-7-summary.md,
* "Notification provider architecture"); this file only owns what happens
* once a workflow has already decided "notify X about Y". Sending
* EMAIL/PUSH deliveries (the Brevo/web-push adapters) and processing the
* PENDING/SCHEDULED queue is a separate concern — see
* src/services/notifications/providers and reminder-worker.ts.
*/
export interface NotifyParams {
businessId: string;
type: NotificationType;
/** Set for staff/owner/registered-customer recipients. At least one of recipientUserId/recipientCustomerId must be set. */
recipientUserId?: string;
/** Set whenever the recipient IS a customer (guest or registered). */
recipientCustomerId?: string;
appointmentId?: string;
loyaltyRewardId?: string;
subject?: string;
bodyText: string;
deepLinkPath?: string;
/** Prevents duplicate creation of the same logical event — see Notification.idempotencyKey in schema.prisma. */
idempotencyKey: string;
/** Omit for "send as soon as the worker picks it up" (every non-reminder type). When set, EMAIL/PUSH deliveries are created directly as SCHEDULED for this time rather than PENDING — see reminder-scheduling.ts, which is the only caller that sets this. */
scheduledFor?: Date;
}
type NotificationWithDeliveries = Prisma.NotificationGetPayload<{ include: { deliveries: true } }>;
export interface NotifyResult {
/** False if a notification with this idempotencyKey already existed — notify() is then a no-op, not a duplicate. */
created: boolean;
notification: NotificationWithDeliveries;
}
function isUniqueConstraintViolation(error: unknown, target: string): boolean {
return (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === "P2002" &&
Array.isArray(error.meta?.target) &&
(error.meta.target as string[]).includes(target)
);
}
/** Exported for reminder-worker.ts, which needs the same "where does this recipient's email actually live" resolution to address the outgoing message — not just to decide whether an EMAIL delivery exists. */
export async function resolveEmailAddress(
prisma: PrismaClient,
params: Pick<NotifyParams, "recipientCustomerId" | "recipientUserId">,
): Promise<string | null> {
if (params.recipientCustomerId) {
const customer = await prisma.customerProfile.findUnique({
where: { id: params.recipientCustomerId },
select: { email: true },
});
if (customer?.email) return customer.email;
}
if (params.recipientUserId) {
const user = await prisma.user.findUnique({ where: { id: params.recipientUserId }, select: { email: true } });
return user?.email ?? null;
}
return null;
}
async function hasActivePushSubscription(
prisma: PrismaClient,
params: Pick<NotifyParams, "recipientCustomerId" | "recipientUserId">,
): Promise<boolean> {
if (!params.recipientUserId && !params.recipientCustomerId) return false;
const count = await prisma.pushSubscription.count({
where: {
revokedAt: null,
OR: [
...(params.recipientUserId ? [{ userId: params.recipientUserId }] : []),
...(params.recipientCustomerId ? [{ customerId: params.recipientCustomerId }] : []),
],
},
});
return count > 0;
}
/** Exported for reminder-worker.ts — every active (non-revoked) subscription for this recipient, since a person can have several devices and a push delivery fans out to all of them. */
export async function resolveActivePushSubscriptions(
prisma: PrismaClient,
params: Pick<NotifyParams, "recipientCustomerId" | "recipientUserId">,
) {
if (!params.recipientUserId && !params.recipientCustomerId) return [];
return prisma.pushSubscription.findMany({
where: {
revokedAt: null,
OR: [
...(params.recipientUserId ? [{ userId: params.recipientUserId }] : []),
...(params.recipientCustomerId ? [{ customerId: params.recipientCustomerId }] : []),
],
},
});
}
async function isChannelEnabled(
prisma: PrismaClient,
params: { userId: string | undefined; type: NotificationType; channel: NotificationChannel },
): Promise<boolean> {
if (!isPreferenceControlled(params.type)) return true; // required — no opt-out, see types.ts
if (!params.userId) return true; // guest, no account to store a preference against — reminders still send by default
const preference = await prisma.notificationPreference.findUnique({
where: { userId_type_channel: { userId: params.userId, type: params.type, channel: params.channel } },
});
return preference?.isEnabled ?? true; // sensible default: on unless the user explicitly turned it off
}
async function resolveChannels(
prisma: PrismaClient,
params: NotifyParams,
): Promise<NotificationChannel[]> {
const channels: NotificationChannel[] = [];
// IN_APP — only meaningful for a recipient with an account to sign into.
if (params.recipientUserId) {
channels.push(NotificationChannel.IN_APP);
}
const emailAddress = await resolveEmailAddress(prisma, params);
if (emailAddress && (await isChannelEnabled(prisma, { userId: params.recipientUserId, type: params.type, channel: NotificationChannel.EMAIL }))) {
channels.push(NotificationChannel.EMAIL);
}
if (
(await hasActivePushSubscription(prisma, params)) &&
(await isChannelEnabled(prisma, { userId: params.recipientUserId, type: params.type, channel: NotificationChannel.PUSH }))
) {
channels.push(NotificationChannel.PUSH);
}
return channels;
}
/**
* Creates a Notification and one NotificationDelivery per applicable
* channel — idempotent on `idempotencyKey`: a second call with the same
* key is a no-op that returns the original row, never a duplicate (see
* the unique index in schema.prisma). IN_APP deliveries are immediately
* SENT (there's no send step, just a DB row); EMAIL/PUSH are left PENDING
* for the background processor (see reminder-worker.ts) to pick up.
*/
export async function notify(prisma: PrismaClient, params: NotifyParams): Promise<NotifyResult> {
if (!params.recipientUserId && !params.recipientCustomerId) {
throw new Error("notify() requires at least one of recipientUserId or recipientCustomerId.");
}
const existing = await prisma.notification.findUnique({
where: { idempotencyKey: params.idempotencyKey },
include: { deliveries: true },
});
if (existing) return { created: false, notification: existing };
const channels = await resolveChannels(prisma, params);
try {
const notification = await prisma.notification.create({
data: {
businessId: params.businessId,
recipientUserId: params.recipientUserId,
recipientCustomerId: params.recipientCustomerId,
type: params.type,
appointmentId: params.appointmentId,
loyaltyRewardId: params.loyaltyRewardId,
subject: params.subject,
bodyText: params.bodyText,
deepLinkPath: params.deepLinkPath,
idempotencyKey: params.idempotencyKey,
deliveries: {
create: channels.map((channel) => {
if (channel === NotificationChannel.IN_APP) {
return { channel, status: NotificationDeliveryStatus.SENT, sentAt: new Date() };
}
return params.scheduledFor
? { channel, status: NotificationDeliveryStatus.SCHEDULED, scheduledFor: params.scheduledFor }
: { channel, status: NotificationDeliveryStatus.PENDING };
}),
},
},
include: { deliveries: true },
});
return { created: true, notification };
} catch (error) {
// A racing duplicate notify() call for the same event hits the unique
// constraint instead of the findUnique above seeing it — same outcome.
if (isUniqueConstraintViolation(error, "idempotencyKey")) {
const raced = await prisma.notification.findUniqueOrThrow({
where: { idempotencyKey: params.idempotencyKey },
include: { deliveries: true },
});
return { created: false, notification: raced };
}
throw error;
}
}
/**
* Same as notify(), except a failure here is caught and logged rather than
* thrown — for every call site in an appointment/loyalty workflow, where a
* notification failing to queue must never roll back or fail an otherwise-
* successful booking/cancellation/reschedule/loyalty transaction (an
* explicit Phase 7 requirement). Always call this, never notify()
* directly, from booking.service.ts/loyalty.service.ts/etc.
*/
export async function notifySafely(prisma: PrismaClient, params: NotifyParams): Promise<void> {
try {
await notify(prisma, params);
} catch (error) {
console.error(
`[NotificationService] notify() failed — type=${params.type} idempotencyKey=${params.idempotencyKey}`,
error,
);
}
}
// ── In-app notification centre ──────────────────────────────────────────
// Every query here is scoped by recipientUserId so one user's notifications
// can never be read or mutated through another user's session.
export async function listInAppNotifications(
prisma: PrismaClient,
params: { userId: string; unreadOnly?: boolean; limit?: number },
) {
return prisma.notification.findMany({
where: {
recipientUserId: params.userId,
deliveries: { some: { channel: NotificationChannel.IN_APP } },
...(params.unreadOnly ? { readAt: null } : {}),
},
orderBy: { createdAt: "desc" },
take: params.limit ?? 50,
});
}
export async function getUnreadNotificationCount(prisma: PrismaClient, userId: string): Promise<number> {
return prisma.notification.count({
where: { recipientUserId: userId, readAt: null, deliveries: { some: { channel: NotificationChannel.IN_APP } } },
});
}
/** Returns false (no-op) if the notification doesn't exist or doesn't belong to this user, rather than throwing — mirrors the rest of this codebase's "not found vs not yours" ambiguity being fine for a mark-read action. */
export async function markNotificationRead(
prisma: PrismaClient,
params: { notificationId: string; userId: string },
): Promise<boolean> {
const result = await prisma.notification.updateMany({
where: { id: params.notificationId, recipientUserId: params.userId },
data: { readAt: new Date() },
});
return result.count > 0;
}
export async function markAllNotificationsRead(prisma: PrismaClient, userId: string): Promise<number> {
const result = await prisma.notification.updateMany({
where: { recipientUserId: userId, readAt: null },
data: { readAt: new Date() },
});
return result.count;
}
// ── Preferences ──────────────────────────────────────────────────────────
export async function getNotificationPreferences(prisma: PrismaClient, userId: string) {
return prisma.notificationPreference.findMany({ where: { userId } });
}
export async function setNotificationPreference(
prisma: PrismaClient,
params: { userId: string; type: NotificationType; channel: NotificationChannel; isEnabled: boolean },
) {
if (!isPreferenceControlled(params.type)) {
throw new Error(`${params.type} is a required notification type and cannot be disabled.`);
}
return prisma.notificationPreference.upsert({
where: { userId_type_channel: { userId: params.userId, type: params.type, channel: params.channel } },
create: { userId: params.userId, type: params.type, channel: params.channel, isEnabled: params.isEnabled },
update: { isEnabled: params.isEnabled },
});
}
@@ -0,0 +1,75 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const sendMail = vi.fn();
vi.mock("nodemailer", () => ({
default: { createTransport: vi.fn(() => ({ sendMail })) },
}));
const { BrevoEmailProvider } = await import("../brevo-email-provider");
/**
* Exercises BrevoEmailProvider's retry classification (see isRetryable in
* brevo-email-provider.ts) with a mocked nodemailer transporter — no real
* SMTP connection is ever made in this test file, matching the Phase 7
* brief's "no real API calls in automated tests" requirement.
*/
describe("BrevoEmailProvider", () => {
const provider = new BrevoEmailProvider({
host: "smtp-relay.brevo.com",
port: 587,
username: "test-user",
password: "test-pass",
fromAddress: "noreply@example.com",
fromName: "Test Business",
});
beforeEach(() => {
sendMail.mockReset();
});
const params = { to: { email: "customer@example.com" }, subject: "Hi", html: "<p>Hi</p>", text: "Hi" };
it("reports success with the provider message id on a clean send", async () => {
sendMail.mockResolvedValueOnce({ messageId: "<abc123@smtp-relay.brevo.com>" });
const result = await provider.sendEmail(params);
expect(result).toEqual({ status: "sent", providerMessageId: "<abc123@smtp-relay.brevo.com>" });
});
it("classifies a connection timeout as retryable", async () => {
sendMail.mockRejectedValueOnce({ code: "ETIMEDOUT", message: "Connection timed out" });
const result = await provider.sendEmail(params);
expect(result).toEqual({ status: "failed", retryable: true, reason: "Connection timed out" });
});
it("classifies a 4xx SMTP response (rate limit / temporary) as retryable", async () => {
sendMail.mockRejectedValueOnce({ responseCode: 421, message: "Too many connections" });
const result = await provider.sendEmail(params);
expect(result).toEqual({ status: "failed", retryable: true, reason: "Too many connections" });
});
it("classifies a 5xx SMTP response (invalid recipient / rejected) as permanent, not retryable", async () => {
sendMail.mockRejectedValueOnce({ responseCode: 550, message: "Mailbox unavailable" });
const result = await provider.sendEmail(params);
expect(result).toEqual({ status: "failed", retryable: false, reason: "Mailbox unavailable" });
});
it("classifies an auth failure (535, permanent per SMTP) as not retryable", async () => {
sendMail.mockRejectedValueOnce({ responseCode: 535, message: "Authentication failed" });
const result = await provider.sendEmail(params);
expect(result.status).toBe("failed");
expect((result as { retryable: boolean }).retryable).toBe(false);
});
it("defaults an unrecognized error to retryable, bounded later by the background processor's max-attempt cap", async () => {
sendMail.mockRejectedValueOnce({ message: "Something unexpected" });
const result = await provider.sendEmail(params);
expect(result).toEqual({ status: "failed", retryable: true, reason: "Something unexpected" });
});
it("never includes the SMTP username/password in a failure result", async () => {
sendMail.mockRejectedValueOnce({ responseCode: 535, message: "Authentication failed" });
const result = await provider.sendEmail(params);
expect(JSON.stringify(result)).not.toContain("test-pass");
expect(JSON.stringify(result)).not.toContain("test-user");
});
});
@@ -0,0 +1,62 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const sendNotification = vi.fn();
const setVapidDetails = vi.fn();
vi.mock("web-push", () => ({
default: { sendNotification, setVapidDetails },
}));
const { WebPushProvider } = await import("../web-push-provider");
describe("WebPushProvider", () => {
const provider = new WebPushProvider({
publicKey: "test-public-key",
privateKey: "test-private-key",
subject: "mailto:test@example.com",
});
beforeEach(() => {
sendNotification.mockReset();
});
const params = {
subscription: { endpoint: "https://push.example.com/abc", p256dhKey: "p256dh", authKey: "auth" },
title: "Hi",
body: "There",
};
it("configures VAPID details once, at construction", () => {
expect(setVapidDetails).toHaveBeenCalledWith("mailto:test@example.com", "test-public-key", "test-private-key");
});
it("reports success on a clean send", async () => {
sendNotification.mockResolvedValueOnce({});
const result = await provider.sendPush(params);
expect(result).toEqual({ status: "sent" });
});
it("flags a 410 (Gone) as a permanently invalid subscription, not retryable", async () => {
sendNotification.mockRejectedValueOnce({ statusCode: 410, message: "Gone" });
const result = await provider.sendPush(params);
expect(result).toEqual({ status: "failed", retryable: false, subscriptionInvalid: true, reason: "Subscription is no longer valid." });
});
it("flags a 404 the same way as a 410", async () => {
sendNotification.mockRejectedValueOnce({ statusCode: 404, message: "Not Found" });
const result = await provider.sendPush(params);
expect(result.status).toBe("failed");
expect((result as { subscriptionInvalid?: boolean }).subscriptionInvalid).toBe(true);
});
it("treats a 5xx push-service error as retryable, not a subscription problem", async () => {
sendNotification.mockRejectedValueOnce({ statusCode: 503, message: "Service unavailable" });
const result = await provider.sendPush(params);
expect(result).toEqual({ status: "failed", retryable: true, reason: "Service unavailable" });
});
it("treats any other error as permanent, not retryable, and not a subscription-invalid signal", async () => {
sendNotification.mockRejectedValueOnce({ statusCode: 400, message: "Bad payload" });
const result = await provider.sendPush(params);
expect(result).toEqual({ status: "failed", retryable: false, reason: "Bad payload" });
});
});
@@ -0,0 +1,131 @@
import nodemailer from "nodemailer";
import type { EmailProvider, EmailSendResult, SendEmailParams } from "./email-provider";
/**
* Brevo transactional email, sent over Brevo's SMTP relay via
* `nodemailer` — not Brevo's REST Transactional Email API
* (`POST /v3/smtp/email`). This was a deliberate choice, not a shortcut:
* the credentials supplied for this project are SMTP relay credentials
* (host smtp-relay.brevo.com, a username/password pair), not a Brevo API
* key. The Phase 7 brief is explicit that this codebase must never invent
* a provider's request/response contract when the real one isn't
* available — SMTP is a standard, RFC-defined protocol (not a
* Brevo-specific shape to guess at), so building against it here doesn't
* require guessing anything Brevo-specific, unlike their REST API's exact
* payload/response/error shape would have.
*
* What this means in practice, documented rather than silently assumed:
* - No Brevo "message ID" is returned the way their REST API would give
* one — `providerMessageId` below is the Message-ID nodemailer/the SMTP
* server generated, which is real and useful for support/log
* correlation, but is not the same identifier Brevo's own dashboard or
* a future webhook would key on.
* - No Brevo webhook integration exists (see reminder-worker.ts) — this
* provider only knows "the relay accepted the message for delivery",
* never "the recipient's inbox actually received it". See
* NotificationDeliveryStatus in schema.prisma: this app deliberately
* has no DELIVERED status for exactly this reason.
* - SMTP has no built-in request-level idempotency the way a REST API
* with a client-reference field would — `idempotencyKey` is attached
* as a custom `X-NextChair-Idempotency-Key` header for
* support/debugging purposes only; the REAL duplicate-send protection
* is app-side, via each NotificationDelivery row's own
* PENDING→PROCESSING→SENT status transition (see
* reminder-worker.ts) — this provider does not and cannot guarantee
* idempotency by itself.
* - If the real Brevo REST API key becomes available later, a second
* adapter implementing the same EmailProvider interface (e.g.
* BrevoApiEmailProvider) can be added without touching
* NotificationService or the background processor — that's the whole
* point of the provider abstraction.
*/
export interface BrevoEmailProviderConfig {
host: string;
port: number;
username: string;
password: string;
fromAddress: string;
fromName: string;
}
export function loadBrevoEmailProviderConfigFromEnv(): BrevoEmailProviderConfig {
const host = process.env.EMAIL_SMTP_HOST;
const username = process.env.EMAIL_SMTP_USERNAME;
const password = process.env.EMAIL_SMTP_PASSWORD;
const fromAddress = process.env.EMAIL_FROM_ADDRESS;
if (!host || !username || !password || !fromAddress) {
throw new Error(
"Brevo SMTP is not configured — EMAIL_SMTP_HOST, EMAIL_SMTP_USERNAME, EMAIL_SMTP_PASSWORD, and EMAIL_FROM_ADDRESS must all be set. See .env.example.",
);
}
return {
host,
port: Number(process.env.EMAIL_SMTP_PORT ?? 587),
username,
password,
fromAddress,
fromName: process.env.EMAIL_FROM_NAME ?? "NextChair",
};
}
interface SmtpError {
code?: string;
responseCode?: number;
message?: string;
}
const RETRYABLE_CONNECTION_CODES = new Set(["ETIMEDOUT", "ECONNECTION", "ECONNREFUSED", "ESOCKET", "EDNS"]);
/** SMTP response codes: 4xx is a temporary failure (retryable), 5xx is permanent (invalid recipient, rejected payload, auth failure) — never retry those. Connection-level errors (no response code at all) are treated as retryable, and so is anything unrecognized — the background processor's own max-attempt cap bounds the cost of guessing wrong. */
function isRetryable(error: SmtpError): boolean {
if (error.code && RETRYABLE_CONNECTION_CODES.has(error.code)) return true;
if (typeof error.responseCode === "number") {
if (error.responseCode >= 500) return false;
if (error.responseCode >= 400) return true;
}
return true;
}
export class BrevoEmailProvider implements EmailProvider {
readonly name = "brevo";
private readonly transporter: nodemailer.Transporter;
private readonly fromAddress: string;
private readonly fromName: string;
constructor(config: BrevoEmailProviderConfig) {
this.fromAddress = config.fromAddress;
this.fromName = config.fromName;
this.transporter = nodemailer.createTransport({
host: config.host,
port: config.port,
secure: config.port === 465, // 587/2525 use STARTTLS, not implicit TLS
auth: { user: config.username, pass: config.password },
connectionTimeout: 10_000,
greetingTimeout: 10_000,
socketTimeout: 20_000,
});
}
async sendEmail(params: SendEmailParams): Promise<EmailSendResult> {
try {
const info = await this.transporter.sendMail({
from: { address: this.fromAddress, name: this.fromName },
to: { address: params.to.email, name: params.to.name },
subject: params.subject,
html: params.html,
text: params.text,
headers: params.idempotencyKey ? { "X-NextChair-Idempotency-Key": params.idempotencyKey } : undefined,
});
return { status: "sent", providerMessageId: info.messageId };
} catch (error) {
const smtpError = error as SmtpError;
// Never log the SMTP username/password (this.transporter carries
// them internally, never passed here) — only a short, safe summary.
return {
status: "failed",
retryable: isRetryable(smtpError),
reason: smtpError.message ?? "Unknown SMTP error",
};
}
}
}
@@ -0,0 +1,28 @@
import type { EmailProvider, EmailSendResult, SendEmailParams } from "./email-provider";
/**
* Dev-only — never actually sends anything, just prints what would have
* been sent so a developer can see confirmation/reminder emails locally
* without Brevo credentials configured. Deliberately logs the full
* subject/text here (unlike production structured logging elsewhere in
* this app — see docs/phase-7-summary.md, "Security hardening") because
* showing the content IS this provider's entire purpose; it must never be
* selected outside development (see providers/index.ts).
*/
export class ConsoleEmailProvider implements EmailProvider {
readonly name = "console";
async sendEmail(params: SendEmailParams): Promise<EmailSendResult> {
console.log(
[
"──── [ConsoleEmailProvider] would send an email (not actually sent) ────",
`To: ${params.to.name ? `${params.to.name} <${params.to.email}>` : params.to.email}`,
`Subject: ${params.subject}`,
"",
params.text,
"──────────────────────────────────────────────────────────────────────",
].join("\n"),
);
return { status: "sent", providerMessageId: `console-${Date.now()}` };
}
}
@@ -0,0 +1,19 @@
import type { PushProvider, PushSendResult, SendPushParams } from "./push-provider";
/** Dev-only — see console-email-provider.ts for the same pattern and reasoning. */
export class ConsolePushProvider implements PushProvider {
readonly name = "console";
async sendPush(params: SendPushParams): Promise<PushSendResult> {
console.log(
[
"──── [ConsolePushProvider] would send a push (not actually sent) ────",
`Endpoint: ${params.subscription.endpoint.slice(0, 60)}...`,
`Title: ${params.title}`,
`Body: ${params.body}`,
"────────────────────────────────────────────────────────────────────",
].join("\n"),
);
return { status: "sent" };
}
}
@@ -0,0 +1,43 @@
/**
* Provider abstraction for sending email — only this file's shape is known
* to NotificationService/reminder-worker.ts; a specific vendor (Brevo,
* dev console, an in-memory test double) is a swappable implementation
* behind it, never something the caller branches on. See
* brevo-email-provider.ts for the one adapter allowed to know
* Brevo-specific details.
*/
export interface EmailAddress {
email: string;
name?: string;
}
export interface SendEmailParams {
to: EmailAddress;
subject: string;
html: string;
/** Plain-text fallback — required, not derived from `html`, so a broken tag can't silently produce an empty fallback. */
text: string;
/**
* The owning NotificationDelivery's id, passed through as the provider's
* client reference where supported — the app-side half of duplicate-send
* protection (see BrevoEmailProvider). Never treat this as a guarantee by
* itself; the background processor's own PENDING→PROCESSING transition
* is what actually prevents two workers from sending the same delivery.
*/
idempotencyKey?: string;
}
export type EmailSendResult =
| { status: "sent"; providerMessageId?: string }
| {
status: "failed";
/** True for a transient failure (timeout, 5xx, rate limit) worth retrying; false for a permanent one (invalid address, auth failure, rejected payload) that never will be. */
retryable: boolean;
reason: string;
};
export interface EmailProvider {
readonly name: string;
sendEmail(params: SendEmailParams): Promise<EmailSendResult>;
}
@@ -0,0 +1,53 @@
import "server-only";
import { BrevoEmailProvider, loadBrevoEmailProviderConfigFromEnv } from "./brevo-email-provider";
import { ConsoleEmailProvider } from "./console-email-provider";
import { ConsolePushProvider } from "./console-push-provider";
import type { EmailProvider } from "./email-provider";
import type { PushProvider } from "./push-provider";
import { WebPushProvider, loadWebPushProviderConfigFromEnv } from "./web-push-provider";
export type { EmailAddress, EmailProvider, EmailSendResult, SendEmailParams } from "./email-provider";
export type { PushProvider, PushSendResult, PushSubscriptionKeys, SendPushParams } from "./push-provider";
export { BrevoEmailProvider } from "./brevo-email-provider";
export { ConsoleEmailProvider } from "./console-email-provider";
export { MockEmailProvider } from "./mock-email-provider";
export { ConsolePushProvider } from "./console-push-provider";
export { MockPushProvider } from "./mock-push-provider";
export { WebPushProvider } from "./web-push-provider";
let cachedEmailProvider: EmailProvider | undefined;
let cachedPushProvider: PushProvider | undefined;
/**
* Selected once via `EMAIL_PROVIDER` and cached — never chosen per-call, so
* a single deployment always sends through exactly one path. Defaults to
* "console" whenever real SMTP credentials aren't present (e.g. local dev
* with no .env values filled in), so the app never accidentally tries a
* real send with empty credentials; this must never be the default in a
* deployed environment (see docs/phase-7-summary.md, "Environment
* variables required" — EMAIL_PROVIDER=brevo is required for production).
*/
export function getEmailProvider(): EmailProvider {
if (cachedEmailProvider) return cachedEmailProvider;
const selected = process.env.EMAIL_PROVIDER ?? "console";
if (selected === "brevo") {
cachedEmailProvider = new BrevoEmailProvider(loadBrevoEmailProviderConfigFromEnv());
} else {
cachedEmailProvider = new ConsoleEmailProvider();
}
return cachedEmailProvider;
}
/** Same selection pattern as getEmailProvider() — set PUSH_PROVIDER="web-push" once real VAPID keys are configured for a deployment; defaults to logging instead of sending. */
export function getPushProvider(): PushProvider {
if (cachedPushProvider) return cachedPushProvider;
const selected = process.env.PUSH_PROVIDER ?? "console";
if (selected === "web-push") {
cachedPushProvider = new WebPushProvider(loadWebPushProviderConfigFromEnv());
} else {
cachedPushProvider = new ConsolePushProvider();
}
return cachedPushProvider;
}
@@ -0,0 +1,36 @@
import type { EmailProvider, EmailSendResult, SendEmailParams } from "./email-provider";
/**
* In-memory test double — captures every call for assertions instead of
* sending anything. Supports scripting a specific result for the next N
* calls (e.g. "the first send fails with a retryable error, the retry
* succeeds"), which is how the retry-classification tests exercise the
* background processor without a real Brevo failure. Automated tests must
* never make a real Brevo API/SMTP call — this is what stands in for it.
*/
export class MockEmailProvider implements EmailProvider {
readonly name = "mock";
readonly sentEmails: SendEmailParams[] = [];
private scriptedResults: EmailSendResult[] = [];
private defaultResult: EmailSendResult = { status: "sent", providerMessageId: "mock-message-id" };
/** Queues results to return in order, one per sendEmail() call, before falling back to the default. */
scriptNextResults(...results: EmailSendResult[]): void {
this.scriptedResults.push(...results);
}
setDefaultResult(result: EmailSendResult): void {
this.defaultResult = result;
}
async sendEmail(params: SendEmailParams): Promise<EmailSendResult> {
this.sentEmails.push(params);
return this.scriptedResults.shift() ?? this.defaultResult;
}
reset(): void {
this.sentEmails.length = 0;
this.scriptedResults = [];
this.defaultResult = { status: "sent", providerMessageId: "mock-message-id" };
}
}
@@ -0,0 +1,28 @@
import type { PushProvider, PushSendResult, SendPushParams } from "./push-provider";
/** In-memory test double — see mock-email-provider.ts for the same pattern. */
export class MockPushProvider implements PushProvider {
readonly name = "mock";
readonly sentPushes: SendPushParams[] = [];
private scriptedResults: PushSendResult[] = [];
private defaultResult: PushSendResult = { status: "sent" };
scriptNextResults(...results: PushSendResult[]): void {
this.scriptedResults.push(...results);
}
setDefaultResult(result: PushSendResult): void {
this.defaultResult = result;
}
async sendPush(params: SendPushParams): Promise<PushSendResult> {
this.sentPushes.push(params);
return this.scriptedResults.shift() ?? this.defaultResult;
}
reset(): void {
this.sentPushes.length = 0;
this.scriptedResults = [];
this.defaultResult = { status: "sent" };
}
}
@@ -0,0 +1,34 @@
/**
* Provider abstraction for Web Push — mirrors email-provider.ts. Only
* web-push-provider.ts is allowed to know the actual Web Push protocol
* details (VAPID signing, the push service's HTTP response shape).
*/
export interface PushSubscriptionKeys {
endpoint: string;
p256dhKey: string;
authKey: string;
}
export interface SendPushParams {
subscription: PushSubscriptionKeys;
title: string;
body: string;
/** Relative path — the service worker's `notificationclick` handler opens this. */
deepLinkPath?: string;
}
export type PushSendResult =
| { status: "sent" }
| {
status: "failed";
retryable: boolean;
reason: string;
/** True for a 404/410 from the push service — this exact subscription is gone and should be revoked, not retried. */
subscriptionInvalid?: boolean;
};
export interface PushProvider {
readonly name: string;
sendPush(params: SendPushParams): Promise<PushSendResult>;
}
@@ -0,0 +1,62 @@
import webpush from "web-push";
import type { PushProvider, PushSendResult, SendPushParams } from "./push-provider";
/**
* Standards-based Web Push (RFC 8030 + VAPID) via the `web-push` package —
* works against any browser's push service (Chrome/FCM, Firefox, Safari)
* without a vendor-specific SDK. Nothing here is Brevo-specific; push and
* email are unrelated delivery channels with unrelated providers.
*/
export interface WebPushProviderConfig {
publicKey: string;
privateKey: string;
subject: string;
}
export function loadWebPushProviderConfigFromEnv(): WebPushProviderConfig {
const publicKey = process.env.VAPID_PUBLIC_KEY;
const privateKey = process.env.VAPID_PRIVATE_KEY;
const subject = process.env.VAPID_SUBJECT;
if (!publicKey || !privateKey || !subject) {
throw new Error("Web Push is not configured — VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, and VAPID_SUBJECT must all be set. See .env.example.");
}
return { publicKey, privateKey, subject };
}
interface WebPushError {
statusCode?: number;
message?: string;
}
export class WebPushProvider implements PushProvider {
readonly name = "web-push";
constructor(config: WebPushProviderConfig) {
webpush.setVapidDetails(config.subject, config.publicKey, config.privateKey);
}
async sendPush(params: SendPushParams): Promise<PushSendResult> {
try {
await webpush.sendNotification(
{
endpoint: params.subscription.endpoint,
keys: { p256dh: params.subscription.p256dhKey, auth: params.subscription.authKey },
},
JSON.stringify({ title: params.title, body: params.body, url: params.deepLinkPath }),
);
return { status: "sent" };
} catch (error) {
const webPushError = error as WebPushError;
// 404/410: the push service itself says this subscription no longer
// exists (browser data cleared, permission revoked, uninstalled) —
// never retryable, and the caller should revoke the row.
if (webPushError.statusCode === 404 || webPushError.statusCode === 410) {
return { status: "failed", retryable: false, subscriptionInvalid: true, reason: "Subscription is no longer valid." };
}
if (typeof webPushError.statusCode === "number" && webPushError.statusCode >= 500) {
return { status: "failed", retryable: true, reason: webPushError.message ?? "Push service error." };
}
return { status: "failed", retryable: false, reason: webPushError.message ?? "Unknown push error." };
}
}
}
@@ -0,0 +1,57 @@
import type { PrismaClient } from "@/generated/prisma/client";
/**
* CRUD for PushSubscription rows — deliberately thin, no business rules
* beyond "one row per endpoint" (see the schema's `@@unique(endpoint)`,
* enforced here via upsert so re-subscribing the same browser/device
* updates the existing row instead of erroring).
*/
export interface SubscribeToPushInput {
businessId: string;
userId?: string;
customerId?: string;
endpoint: string;
p256dhKey: string;
authKey: string;
deviceLabel?: string;
}
export async function subscribeToPush(prisma: PrismaClient, input: SubscribeToPushInput) {
return prisma.pushSubscription.upsert({
where: { endpoint: input.endpoint },
create: {
businessId: input.businessId,
userId: input.userId,
customerId: input.customerId,
endpoint: input.endpoint,
p256dhKey: input.p256dhKey,
authKey: input.authKey,
deviceLabel: input.deviceLabel,
},
update: {
userId: input.userId,
customerId: input.customerId,
p256dhKey: input.p256dhKey,
authKey: input.authKey,
deviceLabel: input.deviceLabel,
revokedAt: null, // re-subscribing un-revokes — the browser just proved this endpoint is live again
},
});
}
/** Scoped by ownerUserId so a user can only ever revoke their own subscription, never guess someone else's endpoint and disable it. */
export async function unsubscribeFromPush(prisma: PrismaClient, params: { endpoint: string; ownerUserId: string }): Promise<boolean> {
const result = await prisma.pushSubscription.updateMany({
where: { endpoint: params.endpoint, userId: params.ownerUserId, revokedAt: null },
data: { revokedAt: new Date() },
});
return result.count > 0;
}
export async function listPushSubscriptionsForUser(prisma: PrismaClient, userId: string) {
return prisma.pushSubscription.findMany({
where: { userId, revokedAt: null },
orderBy: { createdAt: "desc" },
});
}
+20
View File
@@ -0,0 +1,20 @@
import type { PrismaClient } from "@/generated/prisma/client";
import { Permission } from "@/generated/prisma/enums";
/**
* There's no `Business.ownerUserId` column — "owner" is whoever holds a
* role with VIEW_REPORTING (the permission that gates the owner
* dashboard, see schema.prisma), which can be more than one active staff
* member. Every OWNER_* notification goes to all of them, not just one.
*/
export async function getOwnerUserIds(prisma: PrismaClient, businessId: string): Promise<string[]> {
const staff = await prisma.staffProfile.findMany({
where: {
businessId,
status: "ACTIVE",
role: { permissions: { some: { permission: Permission.VIEW_REPORTING } } },
},
select: { userId: true },
});
return staff.map((s) => s.userId);
}
@@ -0,0 +1,248 @@
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";
/**
* 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 },
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;
for (const subscription of subscriptions) {
const sendResult = await pushProvider.sendPush({
subscription: { endpoint: subscription.endpoint, p256dhKey: subscription.p256dhKey, authKey: subscription.authKey },
title: delivery.notification.subject ?? "NextChair",
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}`,
});
}
}
+149
View File
@@ -0,0 +1,149 @@
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
import { NotificationType } from "@/generated/prisma/enums";
import { renderBookingCancelledEmail, renderBookingConfirmedEmail, renderBookingPendingApprovalEmail, renderBookingReminderEmail, renderBookingRescheduledEmail } from "./templates/booking-emails";
import type { AppointmentEmailData } from "./templates/booking-emails";
import { renderGenericNotificationEmail } from "./templates/generic-email";
import { renderLoyaltyRewardEarnedEmail, renderLoyaltyRewardRedeemedEmail } from "./templates/loyalty-emails";
import type { LoyaltyRewardEmailData } from "./templates/loyalty-emails";
import type { RenderedEmail } from "./templates/shared";
/**
* Given a stored Notification row, builds the actual EMAIL to send —
* re-fetching whatever structured data (appointment, loyalty reward,
* business) the matching template needs, rather than storing rendered
* HTML on the row. Called by the background processor right before a
* PENDING EMAIL delivery is attempted (see reminder-worker.ts), never at
* notification-creation time, so it always reflects the current values
* (e.g. a since-updated business name) rather than a stale snapshot. This
* is the file that dispatches by NotificationType; the templates/ folder
* itself is pure and knows nothing about Prisma.
*/
export async function renderEmailForNotification(
prisma: PrismaClient,
notification: { id: string; type: NotificationType; businessId: string; appointmentId: string | null; loyaltyRewardId: string | null; subject: string | null; bodyText: string; deepLinkPath: string | null; recipientUserId: string | null; recipientCustomerId: string | null },
): Promise<RenderedEmail | null> {
const business = await prisma.business.findUnique({
where: { id: notification.businessId },
include: { locations: true, settings: true },
});
if (!business) return null;
const businessInfo = {
name: business.name,
logoUrl: business.logoUrl ?? undefined,
address: business.locations[0]
? `${business.locations[0].addressLine1}, ${business.locations[0].city} ${business.locations[0].postalCode}`
: undefined,
phone: business.phone ?? undefined,
cancellationPolicy: business.settings?.cancellationPolicy ?? undefined,
};
const recipientName = await resolveRecipientName(prisma, notification);
switch (notification.type) {
case NotificationType.BOOKING_CONFIRMED:
case NotificationType.BOOKING_PENDING_APPROVAL:
case NotificationType.BOOKING_RESCHEDULED:
case NotificationType.BOOKING_CANCELLED:
case NotificationType.BOOKING_REMINDER: {
const data = await buildAppointmentEmailData(prisma, notification, {
business: businessInfo,
recipientName,
manageAppointmentPath: notification.deepLinkPath ?? "/appointments",
});
if (!data) return renderFallback();
if (notification.type === NotificationType.BOOKING_CONFIRMED) return renderBookingConfirmedEmail(data);
if (notification.type === NotificationType.BOOKING_PENDING_APPROVAL) return renderBookingPendingApprovalEmail(data);
if (notification.type === NotificationType.BOOKING_RESCHEDULED) return renderBookingRescheduledEmail(data);
if (notification.type === NotificationType.BOOKING_CANCELLED) return renderBookingCancelledEmail(data);
return renderBookingReminderEmail(data);
}
case NotificationType.LOYALTY_REWARD_EARNED:
case NotificationType.LOYALTY_REWARD_REDEEMED: {
const data = await buildLoyaltyRewardEmailData(prisma, notification, { business: businessInfo, recipientName });
if (!data) return renderFallback();
return notification.type === NotificationType.LOYALTY_REWARD_EARNED
? renderLoyaltyRewardEarnedEmail(data)
: renderLoyaltyRewardRedeemedEmail(data);
}
default:
return renderFallback();
}
function renderFallback(): RenderedEmail {
return renderGenericNotificationEmail({
business: businessInfo,
recipientName,
subject: notification.subject ?? businessInfo.name,
bodyText: notification.bodyText,
deepLinkPath: notification.deepLinkPath ?? undefined,
});
}
}
async function resolveRecipientName(
prisma: PrismaClient,
notification: { recipientUserId: string | null; recipientCustomerId: string | null },
): Promise<string> {
if (notification.recipientCustomerId) {
const customer = await prisma.customerProfile.findUnique({
where: { id: notification.recipientCustomerId },
select: { firstName: true, lastName: true },
});
if (customer) return `${customer.firstName} ${customer.lastName}`.trim();
}
if (notification.recipientUserId) {
const user = await prisma.user.findUnique({ where: { id: notification.recipientUserId }, select: { name: true } });
if (user?.name) return user.name;
}
return "there";
}
async function buildAppointmentEmailData(
prisma: PrismaClient,
notification: { appointmentId: string | null },
base: Pick<AppointmentEmailData, "business" | "recipientName" | "manageAppointmentPath">,
): Promise<AppointmentEmailData | null> {
if (!notification.appointmentId) return null;
const appointment = await prisma.appointment.findUnique({
where: { id: notification.appointmentId },
include: { services: { include: { service: true } }, staff: { include: { user: true } } },
});
if (!appointment) return null;
const totalPrice = appointment.services.reduce((sum: number, s: { priceAtBooking: Prisma.Decimal }) => sum + Number(s.priceAtBooking), 0);
const totalMinutes = appointment.services.reduce((sum: number, s: { durationAtBookingMinutes: number }) => sum + s.durationAtBookingMinutes, 0);
return {
...base,
staffName: appointment.staff.user.name ?? "your barber",
serviceNames: appointment.services.map((s: { service: { name: string } }) => s.service.name),
dateLabel: appointment.startAt.toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short" }),
timeLabel: appointment.startAt.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" }),
durationLabel: `${totalMinutes} min`,
totalLabel: `£${totalPrice.toFixed(2)}`,
appointmentRef: appointment.id.slice(-8),
};
}
async function buildLoyaltyRewardEmailData(
prisma: PrismaClient,
notification: { loyaltyRewardId: string | null },
base: Pick<LoyaltyRewardEmailData, "business" | "recipientName">,
): Promise<LoyaltyRewardEmailData | null> {
if (!notification.loyaltyRewardId) return null;
const reward = await prisma.loyaltyReward.findUnique({ where: { id: notification.loyaltyRewardId } });
if (!reward) return null;
return {
...base,
rewardLabel: reward.label,
rewardDescription: reward.label,
expiresLabel: reward.expiresAt
? reward.expiresAt.toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" })
: undefined,
loyaltyCardPath: "/home",
};
}
@@ -0,0 +1,150 @@
import { describe, expect, it } from "vitest";
import { escapeHtml } from "../layout";
import {
renderBookingCancelledEmail,
renderBookingConfirmedEmail,
renderBookingPendingApprovalEmail,
renderBookingReminderEmail,
renderBookingRescheduledEmail,
} from "../booking-emails";
import type { AppointmentEmailData } from "../booking-emails";
import { renderGenericNotificationEmail } from "../generic-email";
import { renderLoyaltyRewardEarnedEmail, renderLoyaltyRewardRedeemedEmail } from "../loyalty-emails";
import type { LoyaltyRewardEmailData } from "../loyalty-emails";
import { renderStaffInvitationEmail, renderStaffScheduleChangedEmail } from "../staff-emails";
describe("escapeHtml", () => {
it("escapes all five HTML-significant characters", () => {
expect(escapeHtml(`<script>alert("x & y's")</script>`)).toBe(
"&lt;script&gt;alert(&quot;x &amp; y&#39;s&quot;)&lt;/script&gt;",
);
});
});
const business = { name: "Blake Heritage Barbershop", address: "1 High St, London", phone: "020 1234 5678" };
const appointmentData: AppointmentEmailData = {
business,
recipientName: "Alex Chen",
staffName: "Marcus Reed",
serviceNames: ["Haircut & Style"],
dateLabel: "Mon 3 Aug",
timeLabel: "16:30",
durationLabel: "30 min",
totalLabel: "£22.00",
appointmentRef: "abc12345",
manageAppointmentPath: "/appointments/abc123?token=raw-token-value",
};
describe("booking email templates", () => {
it("renderBookingConfirmedEmail includes appointment details and a working action link", () => {
const email = renderBookingConfirmedEmail(appointmentData);
expect(email.subject).toContain("Booking confirmed");
expect(email.html).toContain("Marcus Reed");
expect(email.html).toContain("Haircut &amp; Style");
expect(email.html).toContain("/appointments/abc123?token=raw-token-value");
expect(email.text).toContain("Marcus Reed");
expect(email.text).not.toMatch(/<[a-z]/i); // no stray HTML tags in the plain-text fallback
});
it("renderBookingPendingApprovalEmail mentions approval is still needed", () => {
const email = renderBookingPendingApprovalEmail(appointmentData);
expect(email.html.toLowerCase()).toContain("approved");
});
it("renderBookingRescheduledEmail mentions the updated time", () => {
const email = renderBookingRescheduledEmail(appointmentData);
expect(email.html.toLowerCase()).toContain("rescheduled");
});
it("renderBookingCancelledEmail includes a late-cancellation note only when flagged", () => {
const late = renderBookingCancelledEmail(appointmentData, { wasLateCancellation: true });
expect(late.html.toLowerCase()).toContain("late cancellation");
const notLate = renderBookingCancelledEmail(appointmentData);
expect(notLate.html.toLowerCase()).not.toContain("late cancellation");
});
it("renderBookingCancelledEmail escapes a user-supplied cancellation reason", () => {
const email = renderBookingCancelledEmail(appointmentData, { reason: `<img src=x onerror="alert(1)">` });
expect(email.html).not.toContain("<img src=x");
expect(email.html).toContain("&lt;img");
});
it("renderBookingReminderEmail has a reminder-appropriate subject", () => {
const email = renderBookingReminderEmail(appointmentData);
expect(email.subject.toLowerCase()).toContain("reminder");
});
});
describe("loyalty email templates", () => {
const rewardData: LoyaltyRewardEmailData = {
business,
recipientName: "Sam Osei",
rewardLabel: "50% Off Any Service",
rewardDescription: "50% off your next visit",
loyaltyCardPath: "/home",
};
it("renderLoyaltyRewardEarnedEmail announces the new reward", () => {
const email = renderLoyaltyRewardEarnedEmail(rewardData);
expect(email.html).toContain("50% Off Any Service");
expect(email.subject.toLowerCase()).toContain("reward");
});
it("renderLoyaltyRewardRedeemedEmail confirms it was applied", () => {
const email = renderLoyaltyRewardRedeemedEmail(rewardData);
expect(email.html.toLowerCase()).toContain("applied");
});
});
describe("staff email templates", () => {
it("renderStaffInvitationEmail includes the accept link and role", () => {
const email = renderStaffInvitationEmail({
business,
staffName: "Jamie Wallace",
roleName: "Barber",
invitedByName: "Marcus Reed",
acceptInvitePath: "/invite/accept?token=abc",
});
expect(email.html).toContain("Barber");
expect(email.html).toContain("/invite/accept?token=abc");
});
it("renderStaffScheduleChangedEmail names who made the change", () => {
const email = renderStaffScheduleChangedEmail({
business,
staffName: "Leo Vance",
changedByName: "Marcus Reed",
whatChanged: "your working hours",
viewSchedulePath: "/staff/settings/working-hours",
});
expect(email.html).toContain("Marcus Reed");
expect(email.html).toContain("your working hours");
});
});
describe("generic fallback template", () => {
it("renders the notification's own subject/bodyText when no bespoke template exists", () => {
const email = renderGenericNotificationEmail({
business,
recipientName: "Marcus Reed",
subject: "New appointment booked",
bodyText: "A new appointment was booked for you.",
deepLinkPath: "/staff/appointments/xyz",
});
expect(email.subject).toBe("New appointment booked");
expect(email.html).toContain("A new appointment was booked for you.");
expect(email.html).toContain("/staff/appointments/xyz");
});
it("omits the action button when there's no deep link", () => {
const email = renderGenericNotificationEmail({
business,
recipientName: "Marcus Reed",
subject: "Heads up",
bodyText: "Something happened.",
});
expect(email.html).not.toContain("View Details");
});
});
@@ -0,0 +1,125 @@
import { detailRow, paragraph, renderEmailLayout, renderPlainTextLayout } from "./layout";
import { absoluteUrl, footerNoteHtml, footerNoteText } from "./shared";
import type { BusinessEmailInfo, RenderedEmail } from "./shared";
export interface AppointmentEmailData {
business: BusinessEmailInfo;
recipientName: string;
staffName: string;
serviceNames: string[];
dateLabel: string;
timeLabel: string;
durationLabel: string;
totalLabel: string;
appointmentRef: string;
/** Relative path — e.g. "/appointments/xyz?token=..." for a guest, "/appointments" for a registered customer. */
manageAppointmentPath: string;
}
function renderAppointmentEmail(
data: AppointmentEmailData,
copy: { subject: string; headline: string; intro: string; extraNote?: string; actionLabel?: string },
): RenderedEmail {
const actionUrl = absoluteUrl(data.manageAppointmentPath);
const actionLabel = copy.actionLabel ?? "View Appointment";
const detailsHtml = [
detailRow("Service", data.serviceNames.join(", ")),
detailRow("Barber", data.staffName),
detailRow("Date", data.dateLabel),
detailRow("Time", data.timeLabel),
detailRow("Duration", data.durationLabel),
detailRow("Total", data.totalLabel),
detailRow("Reference", data.appointmentRef),
].join("");
const bodyHtml = [
paragraph(`Hi ${data.recipientName},`),
paragraph(copy.intro),
detailsHtml,
copy.extraNote ? paragraph(copy.extraNote) : "",
].join("");
const html = renderEmailLayout({
businessName: data.business.name,
businessLogoUrl: data.business.logoUrl,
headline: copy.headline,
bodyHtml,
actionLabel,
actionUrl,
footerNote: footerNoteHtml(data.business),
});
const text = renderPlainTextLayout({
businessName: data.business.name,
headline: copy.headline,
bodyLines: [
`Hi ${data.recipientName},`,
"",
copy.intro,
"",
`Service: ${data.serviceNames.join(", ")}`,
`Barber: ${data.staffName}`,
`Date: ${data.dateLabel}`,
`Time: ${data.timeLabel}`,
`Duration: ${data.durationLabel}`,
`Total: ${data.totalLabel}`,
`Reference: ${data.appointmentRef}`,
...(copy.extraNote ? ["", copy.extraNote] : []),
],
actionLabel,
actionUrl,
footerNote: footerNoteText(data.business),
});
return { subject: copy.subject, html, text };
}
export function renderBookingConfirmedEmail(data: AppointmentEmailData): RenderedEmail {
return renderAppointmentEmail(data, {
subject: `Booking confirmed — ${data.business.name}`,
headline: "Your booking is confirmed",
intro: `We've confirmed your appointment with ${data.staffName}. Here are the details:`,
});
}
export function renderBookingPendingApprovalEmail(data: AppointmentEmailData): RenderedEmail {
return renderAppointmentEmail(data, {
subject: `Booking received — ${data.business.name}`,
headline: "Your booking request has been received",
intro: `We've received your appointment request with ${data.staffName}. It needs to be approved before it's confirmed — we'll let you know as soon as it is.`,
});
}
export function renderBookingRescheduledEmail(data: AppointmentEmailData): RenderedEmail {
return renderAppointmentEmail(data, {
subject: `Booking rescheduled — ${data.business.name}`,
headline: "Your appointment has been rescheduled",
intro: "Here's the updated time for your appointment:",
});
}
export function renderBookingCancelledEmail(
data: AppointmentEmailData,
options: { wasLateCancellation?: boolean; reason?: string } = {},
): RenderedEmail {
return renderAppointmentEmail(data, {
subject: `Booking cancelled — ${data.business.name}`,
headline: "Your appointment has been cancelled",
intro: "This appointment has been cancelled. It's kept below for your records.",
extraNote: options.wasLateCancellation
? "This was a late cancellation, outside the free-cancellation window. The business's cancellation policy may apply."
: options.reason
? `Reason: ${options.reason}`
: undefined,
actionLabel: "Book Another Appointment",
});
}
export function renderBookingReminderEmail(data: AppointmentEmailData): RenderedEmail {
return renderAppointmentEmail(data, {
subject: `Reminder: your appointment is coming up — ${data.business.name}`,
headline: "See you soon!",
intro: "This is a reminder about your upcoming appointment:",
});
}
@@ -0,0 +1,47 @@
import { paragraph, renderEmailLayout, renderPlainTextLayout } from "./layout";
import { absoluteUrl, footerNoteHtml, footerNoteText } from "./shared";
import type { BusinessEmailInfo, RenderedEmail } from "./shared";
export interface GenericNotificationEmailData {
business: BusinessEmailInfo;
recipientName: string;
subject: string;
/** The Notification's own canonical bodyText (see Notification.bodyText in schema.prisma) — this template is the fallback for any type without a bespoke renderer, so it has no more structured data than that. */
bodyText: string;
deepLinkPath?: string;
}
/**
* Fallback for every NotificationType that doesn't have one of the nine
* bespoke templates the Phase 7 brief named explicitly (booking
* confirmed/pending/rescheduled/cancelled/reminder, loyalty reward
* earned/redeemed, staff invitation, staff schedule changed) — mainly the
* barber/owner operational types (new appointment, no-show, etc.). Still
* branded, still escaped, still has a working action link; just without a
* bespoke layout for data this generic renderer was never given.
*/
export function renderGenericNotificationEmail(data: GenericNotificationEmailData): RenderedEmail {
const actionUrl = data.deepLinkPath ? absoluteUrl(data.deepLinkPath) : undefined;
const bodyHtml = [paragraph(`Hi ${data.recipientName},`), paragraph(data.bodyText)].join("");
return {
subject: data.subject,
html: renderEmailLayout({
businessName: data.business.name,
businessLogoUrl: data.business.logoUrl,
headline: data.subject,
bodyHtml,
actionLabel: actionUrl ? "View Details" : undefined,
actionUrl,
footerNote: footerNoteHtml(data.business),
}),
text: renderPlainTextLayout({
businessName: data.business.name,
headline: data.subject,
bodyLines: [`Hi ${data.recipientName},`, "", data.bodyText],
actionLabel: actionUrl ? "View Details" : undefined,
actionUrl,
footerNote: footerNoteText(data.business),
}),
};
}
@@ -0,0 +1,104 @@
/**
* Shared HTML/plain-text shell every email template renders through — the
* one place that owns escaping, inline-CSS email-client compatibility,
* and "still readable with images blocked". No template in this folder
* builds raw HTML strings on its own outside this file's helpers; that's
* the whole point of "reusable typed renderer functions... not
* concatenated HTML strings in Server Actions" from the Phase 7 brief.
*/
/** Escapes the five HTML-significant characters — every piece of user/business-supplied text (names, notes, addresses) must go through this before landing in a template's HTML output. */
export function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
export interface EmailLayoutInput {
businessName: string;
/** Absolute URL — email clients can't resolve a relative path the way a browser can. Omitted entirely (not a broken `<img>`) if not supplied. */
businessLogoUrl?: string;
headline: string;
/** Pre-escaped/pre-built HTML fragment for the main content — see the per-type renderers, which build this from escaped pieces. */
bodyHtml: string;
actionLabel?: string;
/** Absolute URL — see NEXT_PUBLIC_APP_URL / renderAbsoluteLink in index.ts. */
actionUrl?: string;
footerNote?: string;
}
/**
* A deliberately plain, table-based, inline-styled layout — no external
* stylesheet (most email clients strip it), no background images (the
* business name is always rendered as real text right next to/instead of
* the logo, so the email is still identifiable and readable with images
* blocked, which is the default in most mail clients).
*/
export function renderEmailLayout(input: EmailLayoutInput): string {
const logo = input.businessLogoUrl
? `<img src="${escapeHtml(input.businessLogoUrl)}" alt="${escapeHtml(input.businessName)}" height="32" style="height:32px;vertical-align:middle;margin-right:8px;" />`
: "";
const action =
input.actionLabel && input.actionUrl
? `<tr><td style="padding:24px 0 0;">
<a href="${escapeHtml(input.actionUrl)}" style="display:inline-block;background:#171717;color:#ffffff;text-decoration:none;padding:12px 24px;border-radius:9999px;font-weight:bold;font-size:14px;">${escapeHtml(input.actionLabel)}</a>
</td></tr>`
: "";
const footer = input.footerNote
? `<tr><td style="padding-top:24px;border-top:1px solid #e5e5e5;color:#737373;font-size:12px;line-height:1.5;">${input.footerNote}</td></tr>`
: "";
return `<!doctype html>
<html>
<body style="margin:0;padding:0;background:#f5f5f4;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f5f5f4;padding:24px 0;">
<tr>
<td align="center">
<table role="presentation" width="480" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:16px;padding:32px;max-width:480px;width:100%;">
<tr><td style="padding-bottom:16px;font-size:16px;font-weight:bold;color:#171717;">${logo}${escapeHtml(input.businessName)}</td></tr>
<tr><td style="font-size:20px;font-weight:bold;color:#171717;padding-bottom:12px;">${escapeHtml(input.headline)}</td></tr>
<tr><td style="font-size:14px;line-height:1.6;color:#404040;">${input.bodyHtml}</td></tr>
${action}
${footer}
</table>
</td>
</tr>
</table>
</body>
</html>`;
}
export interface PlainTextLayoutInput {
businessName: string;
headline: string;
bodyLines: string[];
actionLabel?: string;
actionUrl?: string;
footerNote?: string;
}
export function renderPlainTextLayout(input: PlainTextLayoutInput): string {
const lines = [input.businessName, "", input.headline, "", ...input.bodyLines];
if (input.actionLabel && input.actionUrl) {
lines.push("", `${input.actionLabel}: ${input.actionUrl}`);
}
if (input.footerNote) {
lines.push("", input.footerNote);
}
return lines.join("\n");
}
/** A `<p>`-wrapped, escaped line — the smallest building block the per-type renderers compose bodyHtml from. */
export function paragraph(text: string): string {
return `<p style="margin:0 0 12px;">${escapeHtml(text)}</p>`;
}
/** A label/value row — "Date: Mon 3 Aug", "Barber: Marcus Reed", etc. */
export function detailRow(label: string, value: string): string {
return `<p style="margin:0 0 4px;"><strong>${escapeHtml(label)}:</strong> ${escapeHtml(value)}</p>`;
}
@@ -0,0 +1,92 @@
import { detailRow, paragraph, renderEmailLayout, renderPlainTextLayout } from "./layout";
import { absoluteUrl, footerNoteHtml, footerNoteText } from "./shared";
import type { BusinessEmailInfo, RenderedEmail } from "./shared";
export interface LoyaltyRewardEmailData {
business: BusinessEmailInfo;
recipientName: string;
rewardLabel: string;
/** e.g. "50% off your next visit", already formatted for display. */
rewardDescription: string;
expiresLabel?: string;
/** Relative path to the customer's loyalty card. */
loyaltyCardPath: string;
}
export function renderLoyaltyRewardEarnedEmail(data: LoyaltyRewardEmailData): RenderedEmail {
const actionUrl = absoluteUrl(data.loyaltyCardPath);
const bodyHtml = [
paragraph(`Hi ${data.recipientName},`),
paragraph(`You've earned a reward: ${data.rewardLabel}!`),
detailRow("Reward", data.rewardDescription),
data.expiresLabel ? detailRow("Expires", data.expiresLabel) : "",
paragraph("Show this to your barber on your next visit to redeem it."),
].join("");
return {
subject: `You've earned a reward — ${data.business.name}`,
html: renderEmailLayout({
businessName: data.business.name,
businessLogoUrl: data.business.logoUrl,
headline: "You've earned a reward!",
bodyHtml,
actionLabel: "View Loyalty Card",
actionUrl,
footerNote: footerNoteHtml(data.business),
}),
text: renderPlainTextLayout({
businessName: data.business.name,
headline: "You've earned a reward!",
bodyLines: [
`Hi ${data.recipientName},`,
"",
`You've earned a reward: ${data.rewardLabel}!`,
`Reward: ${data.rewardDescription}`,
...(data.expiresLabel ? [`Expires: ${data.expiresLabel}`] : []),
"",
"Show this to your barber on your next visit to redeem it.",
],
actionLabel: "View Loyalty Card",
actionUrl,
footerNote: footerNoteText(data.business),
}),
};
}
export function renderLoyaltyRewardRedeemedEmail(data: LoyaltyRewardEmailData): RenderedEmail {
const actionUrl = absoluteUrl(data.loyaltyCardPath);
const bodyHtml = [
paragraph(`Hi ${data.recipientName},`),
paragraph(`Your reward has been applied: ${data.rewardLabel}.`),
detailRow("Reward", data.rewardDescription),
paragraph("Thanks for being a loyal customer!"),
].join("");
return {
subject: `Reward applied — ${data.business.name}`,
html: renderEmailLayout({
businessName: data.business.name,
businessLogoUrl: data.business.logoUrl,
headline: "Your reward has been applied",
bodyHtml,
actionLabel: "View Loyalty Card",
actionUrl,
footerNote: footerNoteHtml(data.business),
}),
text: renderPlainTextLayout({
businessName: data.business.name,
headline: "Your reward has been applied",
bodyLines: [
`Hi ${data.recipientName},`,
"",
`Your reward has been applied: ${data.rewardLabel}.`,
`Reward: ${data.rewardDescription}`,
"",
"Thanks for being a loyal customer!",
],
actionLabel: "View Loyalty Card",
actionUrl,
footerNote: footerNoteText(data.business),
}),
};
}
@@ -0,0 +1,37 @@
import { escapeHtml } from "./layout";
export interface BusinessEmailInfo {
name: string;
logoUrl?: string;
address?: string;
phone?: string;
cancellationPolicy?: string;
}
/** Absolute URL — email clients can't resolve a relative path, unlike a browser looking at the current origin. */
export function absoluteUrl(path: string): string {
const base = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
return path.startsWith("http") ? path : `${base}${path.startsWith("/") ? "" : "/"}${path}`;
}
export function footerNoteHtml(business: BusinessEmailInfo): string | undefined {
const parts: string[] = [];
if (business.address) parts.push(business.address);
if (business.phone) parts.push(business.phone);
if (business.cancellationPolicy) parts.push(business.cancellationPolicy);
return parts.length > 0 ? parts.map((p) => `<span>${escapeHtml(p)}</span>`).join("<br/>") : undefined;
}
export function footerNoteText(business: BusinessEmailInfo): string | undefined {
const parts: string[] = [];
if (business.address) parts.push(business.address);
if (business.phone) parts.push(business.phone);
if (business.cancellationPolicy) parts.push(business.cancellationPolicy);
return parts.length > 0 ? parts.join("\n") : undefined;
}
export interface RenderedEmail {
subject: string;
html: string;
text: string;
}
@@ -0,0 +1,90 @@
import { paragraph, renderEmailLayout, renderPlainTextLayout } from "./layout";
import { absoluteUrl, footerNoteHtml, footerNoteText } from "./shared";
import type { BusinessEmailInfo, RenderedEmail } from "./shared";
export interface StaffInvitationEmailData {
business: BusinessEmailInfo;
staffName: string;
roleName: string;
invitedByName: string;
/** Relative path — /invite/accept?token=... */
acceptInvitePath: string;
}
export function renderStaffInvitationEmail(data: StaffInvitationEmailData): RenderedEmail {
const actionUrl = absoluteUrl(data.acceptInvitePath);
const bodyHtml = [
paragraph(`Hi ${data.staffName},`),
paragraph(
`${data.invitedByName} has added you as ${data.roleName === "Owner" ? "an" : "a"} ${data.roleName} at ${data.business.name} on NextChair. Set your password to get started.`,
),
].join("");
return {
subject: `You've been invited to join ${data.business.name}`,
html: renderEmailLayout({
businessName: data.business.name,
businessLogoUrl: data.business.logoUrl,
headline: "You've been invited",
bodyHtml,
actionLabel: "Set Your Password",
actionUrl,
footerNote: footerNoteHtml(data.business),
}),
text: renderPlainTextLayout({
businessName: data.business.name,
headline: "You've been invited",
bodyLines: [
`Hi ${data.staffName},`,
"",
`${data.invitedByName} has added you as a ${data.roleName} at ${data.business.name} on NextChair. Set your password to get started.`,
],
actionLabel: "Set Your Password",
actionUrl,
footerNote: footerNoteText(data.business),
}),
};
}
export interface StaffScheduleChangedEmailData {
business: BusinessEmailInfo;
staffName: string;
changedByName: string;
/** e.g. "your working hours", "your breaks", "your time off" */
whatChanged: string;
/** Relative path — e.g. /staff/settings/working-hours */
viewSchedulePath: string;
}
export function renderStaffScheduleChangedEmail(data: StaffScheduleChangedEmailData): RenderedEmail {
const actionUrl = absoluteUrl(data.viewSchedulePath);
const bodyHtml = [
paragraph(`Hi ${data.staffName},`),
paragraph(`${data.changedByName} has updated ${data.whatChanged} at ${data.business.name}. Take a look to see what changed.`),
].join("");
return {
subject: `Your schedule was updated — ${data.business.name}`,
html: renderEmailLayout({
businessName: data.business.name,
businessLogoUrl: data.business.logoUrl,
headline: "Your schedule was updated",
bodyHtml,
actionLabel: "View Schedule",
actionUrl,
footerNote: footerNoteHtml(data.business),
}),
text: renderPlainTextLayout({
businessName: data.business.name,
headline: "Your schedule was updated",
bodyLines: [
`Hi ${data.staffName},`,
"",
`${data.changedByName} has updated ${data.whatChanged} at ${data.business.name}. Take a look to see what changed.`,
],
actionLabel: "View Schedule",
actionUrl,
footerNote: footerNoteText(data.business),
}),
};
}
+58
View File
@@ -0,0 +1,58 @@
import { NotificationType } from "@/generated/prisma/enums";
/**
* Static metadata about each NotificationType — who it's for, and whether
* it's the kind of thing a user gets to turn off. Kept as plain lookup
* tables here rather than columns on the enum/schema, so policy can change
* without a migration (see the NotificationPreference model comment in
* schema.prisma).
*/
export type NotificationRecipientKind = "CUSTOMER" | "STAFF" | "OWNER";
const RECIPIENT_KIND: Record<NotificationType, NotificationRecipientKind> = {
BOOKING_CONFIRMED: "CUSTOMER",
BOOKING_PENDING_APPROVAL: "CUSTOMER",
BOOKING_APPROVED: "CUSTOMER",
BOOKING_REMINDER: "CUSTOMER",
BOOKING_RESCHEDULED: "CUSTOMER",
BOOKING_CANCELLED: "CUSTOMER",
BOOKING_BARBER_CHANGED: "CUSTOMER",
BOOKING_COMPLETED: "CUSTOMER",
LOYALTY_STAMP_ADDED: "CUSTOMER",
LOYALTY_REWARD_EARNED: "CUSTOMER",
LOYALTY_REWARD_REDEEMED: "CUSTOMER",
LOYALTY_REWARD_EXPIRING: "CUSTOMER",
STAFF_NEW_APPOINTMENT: "STAFF",
STAFF_APPOINTMENT_RESCHEDULED: "STAFF",
STAFF_APPOINTMENT_CANCELLED: "STAFF",
STAFF_APPOINTMENT_CHECKED_IN: "STAFF",
STAFF_APPOINTMENT_REMINDER: "STAFF",
STAFF_SCHEDULE_CHANGED: "STAFF",
STAFF_INVITATION_RECEIVED: "STAFF",
OWNER_NEW_BOOKING: "OWNER",
OWNER_BOOKING_CANCELLED: "OWNER",
OWNER_NO_SHOW: "OWNER",
OWNER_STAFF_INVITATION_ACCEPTED: "OWNER",
OWNER_REPEATED_DELIVERY_FAILURE: "OWNER",
};
export function recipientKindOf(type: NotificationType): NotificationRecipientKind {
return RECIPIENT_KIND[type];
}
/**
* Every other type is REQUIRED — always sent on every channel it has a
* valid target for, with no user-facing toggle. Only these two are ever
* looked up in NotificationPreference (see NotificationService.notify).
* Deliberately a short, explicit list rather than a "category" column:
* the set of optional types is a product decision, not a schema fact.
*/
const PREFERENCE_CONTROLLED_TYPES = new Set<NotificationType>([
NotificationType.BOOKING_REMINDER,
NotificationType.STAFF_APPOINTMENT_REMINDER,
]);
export function isPreferenceControlled(type: NotificationType): boolean {
return PREFERENCE_CONTROLLED_TYPES.has(type);
}