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

@@ -189,7 +189,9 @@ export async function listServices(
where: {
businessId: params.businessId,
...(params.categoryId ? { categoryId: params.categoryId } : {}),
...(params.q ? { name: { contains: params.q, mode: "insensitive" } } : {}),
// MySQL's default collation is already case-insensitive — see staff.service.ts's
// listStaffForBusiness for the full note on why `mode: "insensitive"` was removed.
...(params.q ? { name: { contains: params.q } } : {}),
},
include: { category: true },
orderBy: [{ categoryId: "asc" }, { displayOrder: "asc" }],
@@ -0,0 +1,148 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { RewardType } from "@/generated/prisma/enums";
import { addStamp, awardStampForCompletedAppointment, redeemReward } from "../loyalty.service";
import { cleanupTestBusiness, createTestBusiness, createTestCustomer, createTestStaff, prisma } from "@/services/scheduling/__tests__/fixtures";
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
/**
* Verifies loyalty.service.ts queues the right Notification rows on
* stamp-add/reward-earn/redemption — see loyalty-notifications.ts. Full
* channel/preference logic is covered elsewhere; this file is "does the
* loyalty workflow call notify() correctly, exactly once per real event".
*/
describe("loyalty notifications (integration)", () => {
let business: TestBusiness;
async function enableLoyalty(stampsRequired = 2) {
return prisma.loyaltyProgramme.create({
data: {
businessId: business.businessId,
isEnabled: true,
stampsRequired,
rewardType: RewardType.PERCENTAGE,
rewardValue: 50,
rewardLabel: "50% Off",
},
});
}
async function completedAppointment(staffId: string, customerId: string) {
return prisma.appointment.create({
data: {
businessId: business.businessId,
staffId,
customerId,
status: "COMPLETED",
startAt: new Date(),
endAt: new Date(Date.now() + 30 * 60_000),
services: { create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }] },
},
});
}
beforeEach(async () => {
business = await createTestBusiness();
});
afterEach(async () => {
await prisma.loyaltyTransaction.deleteMany({ where: { loyaltyAccount: { loyaltyProgramme: { businessId: business.businessId } } } }).catch(() => {});
await prisma.loyaltyReward.deleteMany({ where: { loyaltyAccount: { loyaltyProgramme: { businessId: business.businessId } } } }).catch(() => {});
await prisma.loyaltyAccount.deleteMany({ where: { loyaltyProgramme: { businessId: business.businessId } } }).catch(() => {});
await prisma.loyaltyProgramme.deleteMany({ where: { businessId: business.businessId } }).catch(() => {});
await cleanupTestBusiness(business);
});
it("a completed appointment below the reward threshold only notifies LOYALTY_STAMP_ADDED", async () => {
await enableLoyalty(5);
const { staffId } = await createTestStaff(business);
const { customerId, userId } = await createTestCustomer(business);
const appointment = await completedAppointment(staffId, customerId);
const result = await awardStampForCompletedAppointment(prisma, { appointmentId: appointment.id, staffId });
expect(result.success).toBe(true);
expect(result.rewardCreated).toBe(false);
const notifications = await prisma.notification.findMany({ where: { recipientUserId: userId } });
expect(notifications.map((n) => n.type)).toEqual(["LOYALTY_STAMP_ADDED"]);
});
it("crossing the reward threshold notifies both LOYALTY_STAMP_ADDED and LOYALTY_REWARD_EARNED, exactly once each", async () => {
await enableLoyalty(1);
const { staffId } = await createTestStaff(business);
const { customerId, userId } = await createTestCustomer(business);
const appointment = await completedAppointment(staffId, customerId);
const result = await awardStampForCompletedAppointment(prisma, { appointmentId: appointment.id, staffId });
expect(result.rewardCreated).toBe(true);
const notifications = await prisma.notification.findMany({ where: { recipientUserId: userId } });
expect(notifications.map((n) => n.type).sort()).toEqual(["LOYALTY_REWARD_EARNED", "LOYALTY_STAMP_ADDED"]);
const rewardNotification = notifications.find((n) => n.type === "LOYALTY_REWARD_EARNED");
expect(rewardNotification?.loyaltyRewardId).toBeTruthy();
// Calling award again for the same appointment is a no-op (see awardStampForCompletedAppointment's
// own alreadyAwarded check) — no duplicate notifications either.
const again = await awardStampForCompletedAppointment(prisma, { appointmentId: appointment.id, staffId });
expect(again.alreadyAwarded).toBe(true);
const stillJustTwo = await prisma.notification.count({ where: { recipientUserId: userId } });
expect(stillJustTwo).toBe(2);
});
it("a manual addStamp that crosses the threshold also notifies both types", async () => {
await enableLoyalty(3);
const { staffId } = await createTestStaff(business);
const { customerId, userId } = await createTestCustomer(business);
const result = await addStamp(prisma, { customerId, businessId: business.businessId, amount: 3, staffId });
expect(result.rewardCreated).toBe(true);
const notifications = await prisma.notification.findMany({ where: { recipientUserId: userId } });
expect(notifications.map((n) => n.type).sort()).toEqual(["LOYALTY_REWARD_EARNED", "LOYALTY_STAMP_ADDED"]);
});
it("redeeming a reward notifies LOYALTY_REWARD_REDEEMED exactly once, even if redemption is attempted twice", async () => {
await enableLoyalty(1);
const { staffId } = await createTestStaff(business);
const { customerId, userId } = await createTestCustomer(business);
const earningAppointment = await completedAppointment(staffId, customerId);
await awardStampForCompletedAppointment(prisma, { appointmentId: earningAppointment.id, staffId });
const reward = await prisma.loyaltyReward.findFirstOrThrow({
where: { loyaltyAccount: { customerId }, status: "AVAILABLE" },
});
const redemptionAppointment = await prisma.appointment.create({
data: {
businessId: business.businessId,
staffId,
customerId,
status: "CONFIRMED",
startAt: new Date(Date.now() + 86_400_000),
endAt: new Date(Date.now() + 86_400_000 + 30 * 60_000),
services: { create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }] },
},
});
const redeemed = await redeemReward(prisma, {
rewardId: reward.id,
appointmentId: redemptionAppointment.id,
staffId,
businessId: business.businessId,
});
expect(redeemed.success).toBe(true);
const redeemedAgain = await redeemReward(prisma, {
rewardId: reward.id,
appointmentId: redemptionAppointment.id,
staffId,
businessId: business.businessId,
});
expect(redeemedAgain.success).toBe(false); // already REDEEMED, not AVAILABLE
const redeemedNotifications = await prisma.notification.findMany({
where: { recipientUserId: userId, type: "LOYALTY_REWARD_REDEEMED" },
});
expect(redeemedNotifications).toHaveLength(1);
expect(redeemedNotifications[0]!.loyaltyRewardId).toBe(reward.id);
});
});
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
expireOverdueRewards,
redeemReward,
setEligibleServices,
updateProgrammeConfig,
@@ -218,6 +219,28 @@ describe("LoyaltyService — owner configuration and redemption (integration)",
expect(result.validation.issues[0]?.code).toBe("APPOINTMENT_NOT_ELIGIBLE");
});
it("rejects a reward past its own expiresAt even though its stored status still says AVAILABLE", async () => {
// Defense-in-depth against the periodic expiry sweep (expireOverdueRewards)
// not having run yet — see redeemReward's own expiresAt check.
const { staffId, reward, appointment } = await setUpRedeemableReward();
await prisma.loyaltyReward.update({
where: { id: reward.id },
data: { expiresAt: new Date(Date.now() - 60_000) },
});
const result = await redeemReward(prisma, {
rewardId: reward.id,
appointmentId: appointment.id,
staffId,
businessId: business.businessId,
});
expect(result.success).toBe(false);
expect(result.validation.issues[0]?.code).toBe("REWARD_NOT_AVAILABLE");
const unaffected = await prisma.appointment.findUniqueOrThrow({ where: { id: appointment.id } });
expect(unaffected.discountAmount).toBeNull();
});
it("rejects applying a reward to a different customer's appointment", async () => {
const { staffId, reward } = await setUpRedeemableReward();
const { customerId: otherCustomerId } = await createTestCustomer(business);
@@ -245,4 +268,68 @@ describe("LoyaltyService — owner configuration and redemption (integration)",
expect(result.validation.issues[0]?.code).toBe("REWARD_CUSTOMER_MISMATCH");
});
});
describe("expireOverdueRewards", () => {
async function setUpAccountWithReward(expiresAt: Date | null) {
const programme = await prisma.loyaltyProgramme.upsert({
where: { businessId: business.businessId },
update: {},
create: { businessId: business.businessId, stampsRequired: 10, rewardType: "PERCENTAGE", rewardValue: 50 },
});
const { customerId } = await createTestCustomer(business);
const account = await prisma.loyaltyAccount.create({
data: { customerId, loyaltyProgrammeId: programme.id, currentStamps: 10 },
});
const reward = await prisma.loyaltyReward.create({
data: { loyaltyAccountId: account.id, rewardType: "PERCENTAGE", value: 50, label: "50% Off", status: "AVAILABLE", expiresAt },
});
return { account, reward };
}
it("flips an AVAILABLE reward past its expiresAt to EXPIRED", async () => {
const { reward } = await setUpAccountWithReward(new Date(Date.now() - 60_000));
await expireOverdueRewards(prisma);
const updated = await prisma.loyaltyReward.findUniqueOrThrow({ where: { id: reward.id } });
expect(updated.status).toBe("EXPIRED");
});
it("leaves a reward with no expiry, or one not yet due, untouched", async () => {
const { reward: noExpiry } = await setUpAccountWithReward(null);
const { reward: futureExpiry } = await setUpAccountWithReward(new Date(Date.now() + 30 * 24 * 60 * 60 * 1000));
await expireOverdueRewards(prisma);
expect((await prisma.loyaltyReward.findUniqueOrThrow({ where: { id: noExpiry.id } })).status).toBe("AVAILABLE");
expect((await prisma.loyaltyReward.findUniqueOrThrow({ where: { id: futureExpiry.id } })).status).toBe("AVAILABLE");
});
it("creates a LOYALTY_REWARD_EXPIRING notification for a reward expiring within the next 7 days", async () => {
const { account, reward } = await setUpAccountWithReward(new Date(Date.now() + 3 * 24 * 60 * 60 * 1000));
await expireOverdueRewards(prisma);
const notification = await prisma.notification.findFirst({
where: { type: "LOYALTY_REWARD_EXPIRING", loyaltyRewardId: reward.id },
});
expect(notification).not.toBeNull();
expect(notification?.recipientCustomerId).toBe(account.customerId);
// Still AVAILABLE — only actually-overdue rewards get flipped to EXPIRED.
expect((await prisma.loyaltyReward.findUniqueOrThrow({ where: { id: reward.id } })).status).toBe("AVAILABLE");
});
it("is idempotent — a second run doesn't create a duplicate expiring-soon notification the same day", async () => {
const { reward } = await setUpAccountWithReward(new Date(Date.now() + 3 * 24 * 60 * 60 * 1000));
await expireOverdueRewards(prisma);
await expireOverdueRewards(prisma);
const count = await prisma.notification.count({
where: { type: "LOYALTY_REWARD_EXPIRING", loyaltyRewardId: reward.id },
});
expect(count).toBe(1);
});
});
});
@@ -0,0 +1,94 @@
import type { PrismaClient } from "@/generated/prisma/client";
import { NotificationType } from "@/generated/prisma/enums";
import { notifySafely } from "@/services/notifications/notification.service";
/**
* Queues the Notification rows a loyalty-domain event produces — called
* by loyalty.service.ts strictly after the ledger transaction has already
* committed, using notifySafely so a notification failure can never roll
* back or fail an otherwise-successful stamp/redemption. See
* booking-notifications.ts for the same pattern on the booking side.
*/
async function resolveCustomerRecipient(prisma: PrismaClient, customerId: string) {
return prisma.customerProfile.findUnique({
where: { id: customerId },
select: { userId: true },
});
}
export async function notifyLoyaltyStampAdded(
prisma: PrismaClient,
params: { businessId: string; customerId: string; transactionId: string; appointmentId?: string },
): Promise<void> {
const customer = await resolveCustomerRecipient(prisma, params.customerId);
await notifySafely(prisma, {
businessId: params.businessId,
type: NotificationType.LOYALTY_STAMP_ADDED,
recipientUserId: customer?.userId ?? undefined,
recipientCustomerId: params.customerId,
appointmentId: params.appointmentId,
subject: "You've earned a loyalty stamp",
bodyText: "You've earned a loyalty stamp. Keep it up!",
deepLinkPath: "/loyalty",
idempotencyKey: `notify:loyalty-stamp-added:${params.transactionId}`,
});
}
export async function notifyLoyaltyRewardEarned(
prisma: PrismaClient,
params: { businessId: string; customerId: string; rewardId: string; rewardLabel: string },
): Promise<void> {
const customer = await resolveCustomerRecipient(prisma, params.customerId);
await notifySafely(prisma, {
businessId: params.businessId,
type: NotificationType.LOYALTY_REWARD_EARNED,
recipientUserId: customer?.userId ?? undefined,
recipientCustomerId: params.customerId,
loyaltyRewardId: params.rewardId,
subject: "You've earned a reward",
bodyText: `You've earned a reward: ${params.rewardLabel}. Show this to your barber on your next visit.`,
deepLinkPath: "/loyalty",
idempotencyKey: `notify:loyalty-reward-earned:${params.rewardId}`,
});
}
/** Idempotency includes the day, not just the reward — a reward within the expiring-soon window keeps getting swept every cron run, but this should only ever notify once per calendar day it's found. */
export async function notifyLoyaltyRewardExpiring(
prisma: PrismaClient,
params: { businessId: string; customerId: string; rewardId: string; rewardLabel: string; expiresAt: Date; now: Date },
): Promise<void> {
const customer = await resolveCustomerRecipient(prisma, params.customerId);
const dayStamp = params.now.toISOString().slice(0, 10);
const expiresLabel = params.expiresAt.toLocaleDateString("en-GB", { day: "numeric", month: "short" });
await notifySafely(prisma, {
businessId: params.businessId,
type: NotificationType.LOYALTY_REWARD_EXPIRING,
recipientUserId: customer?.userId ?? undefined,
recipientCustomerId: params.customerId,
loyaltyRewardId: params.rewardId,
subject: "Your reward is expiring soon",
bodyText: `Your reward (${params.rewardLabel}) expires on ${expiresLabel}. Redeem it on your next visit before then.`,
deepLinkPath: "/loyalty",
idempotencyKey: `notify:loyalty-reward-expiring:${params.rewardId}:${dayStamp}`,
});
}
export async function notifyLoyaltyRewardRedeemed(
prisma: PrismaClient,
params: { businessId: string; customerId: string; rewardId: string; rewardLabel: string; appointmentId: string },
): Promise<void> {
const customer = await resolveCustomerRecipient(prisma, params.customerId);
await notifySafely(prisma, {
businessId: params.businessId,
type: NotificationType.LOYALTY_REWARD_REDEEMED,
recipientUserId: customer?.userId ?? undefined,
recipientCustomerId: params.customerId,
loyaltyRewardId: params.rewardId,
appointmentId: params.appointmentId,
subject: "Reward applied",
bodyText: `Your reward has been applied: ${params.rewardLabel}.`,
deepLinkPath: "/loyalty",
idempotencyKey: `notify:loyalty-reward-redeemed:${params.rewardId}`,
});
}
+103 -7
View File
@@ -1,6 +1,12 @@
import type { LoyaltyProgramme, PrismaClient } from "@/generated/prisma/client";
import { fail, ok } from "@/lib/validation-result";
import type { ValidationResult } from "@/lib/validation-result";
import {
notifyLoyaltyRewardEarned,
notifyLoyaltyRewardExpiring,
notifyLoyaltyRewardRedeemed,
notifyLoyaltyStampAdded,
} from "./loyalty-notifications";
/**
* Ledger-based loyalty, per the brief: the cached `LoyaltyAccount.currentStamps`
@@ -39,7 +45,7 @@ export interface LoyaltySummary {
accountId: string | null;
currentStamps: number;
stampsRequired: number;
availableReward: { id: string; label: string; createdAt: Date } | null;
availableReward: { id: string; label: string; createdAt: Date; expiresAt: Date | null } | null;
rewardLabel: string | null;
stampIcon: string | null;
cardBackgroundColor: string | null;
@@ -77,6 +83,7 @@ export async function getLoyaltySummary(
id: account.rewards[0].id,
label: account.rewards[0].label,
createdAt: account.rewards[0].createdAt,
expiresAt: account.rewards[0].expiresAt,
}
: null,
rewardLabel: programme.rewardLabel,
@@ -275,7 +282,7 @@ export async function awardStampForCompletedAppointment(
});
if (alreadyAwarded) return { success: true, alreadyAwarded: true, validation: ok() };
const { rewardCreated } = await addStampTransaction(prisma, {
const { rewardCreated, rewardId, transactionId } = await addStampTransaction(prisma, {
loyaltyAccountId: account.id,
amount: 1, // one stamp per completed visit — see file header; not a per-service count
appointmentId: appointment.id,
@@ -284,6 +291,21 @@ export async function awardStampForCompletedAppointment(
programme,
});
await notifyLoyaltyStampAdded(prisma, {
businessId: appointment.businessId,
customerId: appointment.customerId,
transactionId,
appointmentId: appointment.id,
});
if (rewardCreated && rewardId) {
await notifyLoyaltyRewardEarned(prisma, {
businessId: appointment.businessId,
customerId: appointment.customerId,
rewardId,
rewardLabel: programme.rewardLabel ?? "Reward",
});
}
return { success: true, rewardCreated, validation: ok() };
}
@@ -324,7 +346,7 @@ export async function addStamp(
loyaltyProgrammeId: programme.id,
});
const { rewardCreated } = await addStampTransaction(prisma, {
const { rewardCreated, rewardId, transactionId } = await addStampTransaction(prisma, {
loyaltyAccountId: account.id,
amount: params.amount,
appointmentId: params.appointmentId,
@@ -333,6 +355,21 @@ export async function addStamp(
programme,
});
await notifyLoyaltyStampAdded(prisma, {
businessId: params.businessId,
customerId: params.customerId,
transactionId,
appointmentId: params.appointmentId,
});
if (rewardCreated && rewardId) {
await notifyLoyaltyRewardEarned(prisma, {
businessId: params.businessId,
customerId: params.customerId,
rewardId,
rewardLabel: programme.rewardLabel ?? "Reward",
});
}
return { success: true, rewardCreated, validation: ok() };
}
@@ -346,13 +383,13 @@ async function addStampTransaction(
reason: string | null;
programme: LoyaltyProgramme;
},
): Promise<{ rewardCreated: boolean }> {
): Promise<{ rewardCreated: boolean; rewardId?: string; transactionId: string }> {
return prisma.$transaction(async (tx) => {
const account = await tx.loyaltyAccount.update({
where: { id: params.loyaltyAccountId },
data: { currentStamps: { increment: params.amount } },
});
await tx.loyaltyTransaction.create({
const transaction = await tx.loyaltyTransaction.create({
data: {
loyaltyAccountId: params.loyaltyAccountId,
type: "STAMP_ADDED",
@@ -364,6 +401,7 @@ async function addStampTransaction(
});
let rewardCreated = false;
let rewardId: string | undefined;
if (account.currentStamps >= params.programme.stampsRequired) {
const existingAvailable = await tx.loyaltyReward.findFirst({
where: { loyaltyAccountId: account.id, status: "AVAILABLE" },
@@ -371,7 +409,7 @@ async function addStampTransaction(
// Crossing the threshold again while a reward is already waiting to be
// redeemed doesn't stack a second one.
if (!existingAvailable) {
await tx.loyaltyReward.create({
const reward = await tx.loyaltyReward.create({
data: {
loyaltyAccountId: account.id,
rewardType: params.programme.rewardType,
@@ -384,10 +422,11 @@ async function addStampTransaction(
},
});
rewardCreated = true;
rewardId = reward.id;
}
}
return { rewardCreated };
return { rewardCreated, rewardId, transactionId: transaction.id };
});
}
@@ -645,6 +684,16 @@ export async function redeemReward(
validation: fail({ code: "REWARD_NOT_AVAILABLE", message: "This reward has already been redeemed or has expired." }),
};
}
// Belt-and-braces against the periodic expiry sweep (see
// expireOverdueRewards, run from the notification cron) not having run
// yet — a reward past its own expiresAt is never redeemable even if its
// stored status still says AVAILABLE.
if (reward.expiresAt && reward.expiresAt < new Date()) {
return {
success: false,
validation: fail({ code: "REWARD_NOT_AVAILABLE", message: "This reward has expired." }),
};
}
const appointment = await prisma.appointment.findFirst({
where: { id: params.appointmentId, businessId: params.businessId },
@@ -738,5 +787,52 @@ export async function redeemReward(
};
}
await notifyLoyaltyRewardRedeemed(prisma, {
businessId: params.businessId,
customerId: reward.loyaltyAccount.customerId,
rewardId: reward.id,
rewardLabel: reward.label,
appointmentId: params.appointmentId,
});
return { success: true, validation: ok(), discountAmount: discountAmountString };
}
const REWARD_EXPIRING_SOON_WINDOW_DAYS = 7;
/**
* Run from the notification cron (see /api/cron/process-notifications)
* alongside processDueNotificationDeliveries — one scheduled trigger covers
* both. Two independent jobs in one pass: flip any AVAILABLE reward whose
* expiresAt has passed to EXPIRED (redeemReward also defends against this
* directly, but the sweep is what makes "Expired" actually show up in
* history/UI instead of staying stuck at AVAILABLE forever), then notify
* once per day for rewards still AVAILABLE and expiring within the next
* week.
*/
export async function expireOverdueRewards(prisma: PrismaClient, now: Date = new Date()): Promise<void> {
await prisma.loyaltyReward.updateMany({
where: { status: "AVAILABLE", expiresAt: { lt: now } },
data: { status: "EXPIRED" },
});
const expiringSoon = await prisma.loyaltyReward.findMany({
where: {
status: "AVAILABLE",
expiresAt: { gte: now, lte: new Date(now.getTime() + REWARD_EXPIRING_SOON_WINDOW_DAYS * 86_400_000) },
},
include: { loyaltyAccount: { include: { loyaltyProgramme: true } } },
});
for (const reward of expiringSoon) {
if (!reward.expiresAt) continue;
await notifyLoyaltyRewardExpiring(prisma, {
businessId: reward.loyaltyAccount.loyaltyProgramme.businessId,
customerId: reward.loyaltyAccount.customerId,
rewardId: reward.id,
rewardLabel: reward.label,
expiresAt: reward.expiresAt,
now,
});
}
}
@@ -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);
}
@@ -0,0 +1,207 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
cleanupTestBusiness,
createTestBusiness,
createTestCustomer,
createTestStaff,
prisma,
} from "@/services/scheduling/__tests__/fixtures";
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
import {
getAppointmentReport,
getCustomerReport,
getLoyaltyReport,
getNotificationReport,
getServiceReport,
getStaffReport,
} from "../reports.service";
import { notify } from "@/services/notifications/notification.service";
import { NotificationType } from "@/generated/prisma/enums";
describe("reports.service (integration)", () => {
let business: TestBusiness;
let staffId: string;
let customerId: string;
let customerUserId: string;
const range = { from: new Date(Date.now() - 24 * 60 * 60 * 1000), to: new Date(Date.now() + 60 * 60 * 1000) };
beforeEach(async () => {
business = await createTestBusiness();
const staff = await createTestStaff(business);
staffId = staff.staffId;
const customer = await createTestCustomer(business);
customerId = customer.customerId;
customerUserId = customer.userId;
});
afterEach(async () => {
await cleanupTestBusiness(business);
});
async function createAppointment(overrides: {
status: "COMPLETED" | "CANCELLED" | "NO_SHOW" | "CONFIRMED";
discountAmount?: string;
}) {
return prisma.appointment.create({
data: {
businessId: business.businessId,
staffId,
customerId,
status: overrides.status,
startAt: new Date(),
endAt: new Date(Date.now() + 30 * 60_000),
discountAmount: overrides.discountAmount,
services: {
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
},
},
});
}
describe("getAppointmentReport", () => {
it("totals status counts and computes gross/discount/net revenue from completed appointments only", async () => {
await createAppointment({ status: "COMPLETED" });
await createAppointment({ status: "COMPLETED", discountAmount: "5.00" });
await createAppointment({ status: "CANCELLED" });
await createAppointment({ status: "NO_SHOW" });
const report = await getAppointmentReport(prisma, { businessId: business.businessId, range });
expect(report.totalAppointments).toBe(4);
expect(report.completed).toBe(2);
expect(report.cancelled).toBe(1);
expect(report.noShow).toBe(1);
// £20 + £20 = £40 gross across the two completed appointments; only one had a £5 discount.
expect(report.grossRevenue).toBe("40.00");
expect(report.discountsGiven).toBe("5.00");
expect(report.netRevenue).toBe("35.00");
});
it("returns all-zero figures when there are no appointments in range", async () => {
const report = await getAppointmentReport(prisma, { businessId: business.businessId, range });
expect(report.totalAppointments).toBe(0);
expect(report.grossRevenue).toBe("0.00");
});
});
describe("getServiceReport", () => {
it("aggregates revenue and completed count per service, ignoring non-completed appointments", async () => {
await createAppointment({ status: "COMPLETED" });
await createAppointment({ status: "COMPLETED" });
await createAppointment({ status: "CANCELLED" });
const rows = await getServiceReport(prisma, { businessId: business.businessId, range });
expect(rows).toHaveLength(1);
expect(rows[0]?.timesCompleted).toBe(2);
expect(rows[0]?.revenue).toBe("40.00");
});
});
describe("getStaffReport", () => {
it("attributes net revenue (subtotal minus discount) to the assigned staff member", async () => {
await createAppointment({ status: "COMPLETED", discountAmount: "5.00" });
await createAppointment({ status: "NO_SHOW" });
const rows = await getStaffReport(prisma, { businessId: business.businessId, range });
expect(rows).toHaveLength(1);
expect(rows[0]?.staffId).toBe(staffId);
expect(rows[0]?.completed).toBe(1);
expect(rows[0]?.noShow).toBe(1);
expect(rows[0]?.revenue).toBe("15.00");
});
});
describe("getCustomerReport", () => {
it("counts a customer with no prior visit as new, and lists them in topCustomers", async () => {
await createAppointment({ status: "COMPLETED" });
const report = await getCustomerReport(prisma, { businessId: business.businessId, range });
expect(report.newCustomers).toBe(1);
expect(report.returningCustomers).toBe(0);
expect(report.topCustomers).toHaveLength(1);
expect(report.topCustomers[0]?.customerId).toBe(customerId);
expect(report.topCustomers[0]?.spend).toBe("20.00");
});
it("counts a customer with a visit before the range as returning", async () => {
// A completed visit well before `range.from`.
await prisma.appointment.create({
data: {
businessId: business.businessId,
staffId,
customerId,
status: "COMPLETED",
startAt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
endAt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000 + 30 * 60_000),
services: { create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }] },
},
});
await createAppointment({ status: "COMPLETED" });
const report = await getCustomerReport(prisma, { businessId: business.businessId, range });
expect(report.newCustomers).toBe(0);
expect(report.returningCustomers).toBe(1);
});
});
describe("getLoyaltyReport", () => {
it("sums stamp transactions and counts rewards earned/redeemed within range", async () => {
const programme = await prisma.loyaltyProgramme.create({
data: { businessId: business.businessId, isEnabled: true, stampsRequired: 5, rewardLabel: "Free cut" },
});
const account = await prisma.loyaltyAccount.create({
data: { customerId, loyaltyProgrammeId: programme.id, currentStamps: 5 },
});
await prisma.loyaltyTransaction.createMany({
data: [
{ loyaltyAccountId: account.id, type: "STAMP_ADDED", amount: 1 },
{ loyaltyAccountId: account.id, type: "STAMP_ADDED", amount: 1 },
{ loyaltyAccountId: account.id, type: "STAMP_REMOVED", amount: -1 },
],
});
const reward = await prisma.loyaltyReward.create({
data: { loyaltyAccountId: account.id, rewardType: "FIXED_AMOUNT", value: 5, label: "Free cut", status: "REDEEMED", redeemedAt: new Date() },
});
const report = await getLoyaltyReport(prisma, { businessId: business.businessId, range });
expect(report.stampsIssued).toBe(2);
expect(report.stampsRemoved).toBe(1);
expect(report.rewardsEarned).toBe(1);
expect(report.rewardsRedeemed).toBe(1);
// Cleanup isn't handled by cleanupTestBusiness for loyalty rows — delete explicitly.
await prisma.loyaltyReward.delete({ where: { id: reward.id } });
await prisma.loyaltyTransaction.deleteMany({ where: { loyaltyAccountId: account.id } });
await prisma.loyaltyAccount.delete({ where: { id: account.id } });
await prisma.loyaltyProgramme.delete({ where: { id: programme.id } });
});
});
describe("getNotificationReport", () => {
it("splits sent/failed counts by notification type and channel", async () => {
const { notification } = await notify(prisma, {
businessId: business.businessId,
type: NotificationType.BOOKING_CONFIRMED,
recipientUserId: customerUserId,
recipientCustomerId: customerId,
subject: "Booking confirmed",
bodyText: "Your booking is confirmed.",
idempotencyKey: `test-notify-${customerId}`,
});
await prisma.notificationDelivery.updateMany({
where: { notificationId: notification.id },
data: { status: "SENT", sentAt: new Date() },
});
const report = await getNotificationReport(prisma, { businessId: business.businessId, range });
expect(report.totalSent).toBeGreaterThanOrEqual(1);
expect(report.byType.some((t) => t.type === "BOOKING_CONFIRMED" && t.sent >= 1)).toBe(true);
});
});
});
@@ -252,15 +252,33 @@ async function findUpcomingTimeOffConflicts(prisma: PrismaClient, businessId: st
});
if (upcomingTimeOff.length === 0) return [];
// One query for every affected staff member's upcoming appointments,
// then the per-time-off overlap check happens in memory — rather than
// one `count()` round-trip per time-off row (see the Phase 7
// performance pass). Each time-off row has its own [startDate, endDate)
// window, so the ranges can't be folded into a single SQL WHERE; the
// one shared lower bound below still lets MySQL use the
// (staffId, startAt) index to avoid scanning appointments already in
// the past.
const staffIds = [...new Set(upcomingTimeOff.map((t) => t.staffId))];
const earliestStart = upcomingTimeOff.reduce(
(min, t) => (t.startDate < min ? t.startDate : min),
upcomingTimeOff[0]!.startDate,
);
const candidateAppointments = await prisma.appointment.findMany({
where: {
staffId: { in: staffIds },
status: { notIn: ["CANCELLED", "NO_SHOW", "COMPLETED"] },
startAt: { gte: earliestStart },
},
select: { staffId: true, startAt: true },
});
const warnings: ConfigWarning[] = [];
for (const timeOff of upcomingTimeOff) {
const conflictCount = await prisma.appointment.count({
where: {
staffId: timeOff.staffId,
status: { notIn: ["CANCELLED", "NO_SHOW", "COMPLETED"] },
startAt: { lt: timeOff.endDate, gte: timeOff.startDate },
},
});
const conflictCount = candidateAppointments.filter(
(a) => a.staffId === timeOff.staffId && a.startAt >= timeOff.startDate && a.startAt < timeOff.endDate,
).length;
if (conflictCount > 0) {
warnings.push({
code: "TIME_OFF_CONFLICT",
+322
View File
@@ -0,0 +1,322 @@
import type { PrismaClient } from "@/generated/prisma/client";
/**
* Date-range operational reports for the owner — distinct from
* owner-reporting.service.ts's single-day dashboard snapshot. Every
* function here is read-only and scoped to one businessId + [from, to]
* window; nothing writes anything.
*/
export interface ReportRange {
from: Date;
to: Date;
}
export interface AppointmentReport {
totalAppointments: number;
completed: number;
cancelled: number;
noShow: number;
completionRate: number;
cancellationRate: number;
noShowRate: number;
grossRevenue: string;
discountsGiven: string;
netRevenue: string;
}
export async function getAppointmentReport(
prisma: PrismaClient,
params: { businessId: string; range: ReportRange },
): Promise<AppointmentReport> {
const appointments = await prisma.appointment.findMany({
where: { businessId: params.businessId, startAt: { gte: params.range.from, lte: params.range.to } },
include: { services: true },
});
const completed = appointments.filter((a) => a.status === "COMPLETED");
const cancelled = appointments.filter((a) => a.status === "CANCELLED");
const noShow = appointments.filter((a) => a.status === "NO_SHOW");
const total = appointments.length;
const grossRevenue = completed.reduce(
(sum, a) => sum + a.services.reduce((s, line) => s + Number(line.priceAtBooking), 0),
0,
);
const discountsGiven = completed.reduce((sum, a) => sum + (a.discountAmount ? Number(a.discountAmount) : 0), 0);
return {
totalAppointments: total,
completed: completed.length,
cancelled: cancelled.length,
noShow: noShow.length,
completionRate: total > 0 ? Math.round((completed.length / total) * 100) : 0,
cancellationRate: total > 0 ? Math.round((cancelled.length / total) * 100) : 0,
noShowRate: total > 0 ? Math.round((noShow.length / total) * 100) : 0,
grossRevenue: grossRevenue.toFixed(2),
discountsGiven: discountsGiven.toFixed(2),
netRevenue: (grossRevenue - discountsGiven).toFixed(2),
};
}
export interface ServiceReportRow {
serviceId: string;
name: string;
timesCompleted: number;
revenue: string;
}
/** Only counts COMPLETED appointments — a cancelled booking never earned revenue for a service. */
export async function getServiceReport(
prisma: PrismaClient,
params: { businessId: string; range: ReportRange },
): Promise<ServiceReportRow[]> {
const lines = await prisma.appointmentService.findMany({
where: {
appointment: {
businessId: params.businessId,
status: "COMPLETED",
startAt: { gte: params.range.from, lte: params.range.to },
},
},
include: { service: { select: { id: true, name: true } } },
});
const byService = new Map<string, ServiceReportRow>();
for (const line of lines) {
const existing = byService.get(line.serviceId);
const revenue = Number(line.priceAtBooking);
if (existing) {
existing.timesCompleted += 1;
existing.revenue = (Number(existing.revenue) + revenue).toFixed(2);
} else {
byService.set(line.serviceId, {
serviceId: line.serviceId,
name: line.service.name,
timesCompleted: 1,
revenue: revenue.toFixed(2),
});
}
}
return [...byService.values()].sort((a, b) => Number(b.revenue) - Number(a.revenue));
}
export interface StaffReportRow {
staffId: string;
name: string;
completed: number;
cancelled: number;
noShow: number;
revenue: string;
}
/** Revenue attributed to a staff member is their completed appointments' net total (subtotal minus any redeemed reward), same figure the appointment detail pages show as "Total". */
export async function getStaffReport(
prisma: PrismaClient,
params: { businessId: string; range: ReportRange },
): Promise<StaffReportRow[]> {
const appointments = await prisma.appointment.findMany({
where: { businessId: params.businessId, startAt: { gte: params.range.from, lte: params.range.to } },
include: { services: true, staff: { include: { user: { select: { name: true } } } } },
});
const byStaff = new Map<string, StaffReportRow>();
for (const a of appointments) {
const row = byStaff.get(a.staffId) ?? {
staffId: a.staffId,
name: a.staff.user.name ?? "Staff",
completed: 0,
cancelled: 0,
noShow: 0,
revenue: "0.00",
};
if (a.status === "COMPLETED") {
row.completed += 1;
const subtotal = a.services.reduce((s, line) => s + Number(line.priceAtBooking), 0);
const discount = a.discountAmount ? Number(a.discountAmount) : 0;
row.revenue = (Number(row.revenue) + (subtotal - discount)).toFixed(2);
} else if (a.status === "CANCELLED") {
row.cancelled += 1;
} else if (a.status === "NO_SHOW") {
row.noShow += 1;
}
byStaff.set(a.staffId, row);
}
return [...byStaff.values()].sort((a, b) => Number(b.revenue) - Number(a.revenue));
}
export interface CustomerReport {
newCustomers: number;
returningCustomers: number;
topCustomers: { customerId: string; name: string; visits: number; spend: string }[];
}
/**
* "New" = a customer whose first-ever appointment with this business falls
* inside the range; "returning" = a customer who visited inside the range
* but already had an appointment before the range started. A customer with
* no appointment in the range at all counts as neither.
*/
export async function getCustomerReport(
prisma: PrismaClient,
params: { businessId: string; range: ReportRange },
): Promise<CustomerReport> {
const appointmentsInRange = await prisma.appointment.findMany({
where: {
businessId: params.businessId,
status: { notIn: ["CANCELLED"] },
startAt: { gte: params.range.from, lte: params.range.to },
},
include: { services: true, customer: { select: { firstName: true, lastName: true } } },
});
const customerIds = [...new Set(appointmentsInRange.map((a) => a.customerId))];
if (customerIds.length === 0) {
return { newCustomers: 0, returningCustomers: 0, topCustomers: [] };
}
const priorAppointments = await prisma.appointment.groupBy({
by: ["customerId"],
where: {
businessId: params.businessId,
customerId: { in: customerIds },
status: { notIn: ["CANCELLED"] },
startAt: { lt: params.range.from },
},
_count: true,
});
const hadPriorVisit = new Set(priorAppointments.map((p) => p.customerId));
let newCustomers = 0;
let returningCustomers = 0;
for (const customerId of customerIds) {
if (hadPriorVisit.has(customerId)) returningCustomers += 1;
else newCustomers += 1;
}
const byCustomer = new Map<string, { customerId: string; name: string; visits: number; spend: string }>();
for (const a of appointmentsInRange) {
const row = byCustomer.get(a.customerId) ?? {
customerId: a.customerId,
name: `${a.customer.firstName} ${a.customer.lastName}`,
visits: 0,
spend: "0.00",
};
row.visits += 1;
if (a.status === "COMPLETED") {
const subtotal = a.services.reduce((s, line) => s + Number(line.priceAtBooking), 0);
const discount = a.discountAmount ? Number(a.discountAmount) : 0;
row.spend = (Number(row.spend) + (subtotal - discount)).toFixed(2);
}
byCustomer.set(a.customerId, row);
}
const topCustomers = [...byCustomer.values()].sort((a, b) => Number(b.spend) - Number(a.spend)).slice(0, 10);
return { newCustomers, returningCustomers, topCustomers };
}
export interface LoyaltyReport {
stampsIssued: number;
stampsRemoved: number;
rewardsEarned: number;
rewardsRedeemed: number;
rewardsExpired: number;
}
export async function getLoyaltyReport(
prisma: PrismaClient,
params: { businessId: string; range: ReportRange },
): Promise<LoyaltyReport> {
const accountScope = { loyaltyAccount: { loyaltyProgramme: { businessId: params.businessId } } };
const inRange = { createdAt: { gte: params.range.from, lte: params.range.to } };
const [stampTransactions, rewardsEarned, rewardsRedeemed, rewardsExpired] = await Promise.all([
prisma.loyaltyTransaction.findMany({
where: { ...accountScope, ...inRange, type: { in: ["STAMP_ADDED", "STAMP_REMOVED"] } },
select: { amount: true },
}),
prisma.loyaltyReward.count({
where: { loyaltyAccount: { loyaltyProgramme: { businessId: params.businessId } }, ...inRange },
}),
prisma.loyaltyReward.count({
where: {
loyaltyAccount: { loyaltyProgramme: { businessId: params.businessId } },
redeemedAt: { gte: params.range.from, lte: params.range.to },
},
}),
// No expiredAt column exists — the periodic sweep only flips status, so
// this approximates "expired in range" by expiresAt falling in range
// for a reward that is now EXPIRED. Good enough for a trend, not exact
// to the second the sweep ran.
prisma.loyaltyReward.count({
where: {
loyaltyAccount: { loyaltyProgramme: { businessId: params.businessId } },
status: "EXPIRED",
expiresAt: { gte: params.range.from, lte: params.range.to },
},
}),
]);
return {
stampsIssued: stampTransactions.filter((t) => t.amount > 0).reduce((sum, t) => sum + t.amount, 0),
stampsRemoved: Math.abs(stampTransactions.filter((t) => t.amount < 0).reduce((sum, t) => sum + t.amount, 0)),
rewardsEarned,
rewardsRedeemed,
rewardsExpired,
};
}
export interface NotificationReportRow {
type: string;
sent: number;
failed: number;
}
export interface NotificationReport {
totalSent: number;
totalFailed: number;
byType: NotificationReportRow[];
byChannel: { channel: string; sent: number; failed: number }[];
}
export async function getNotificationReport(
prisma: PrismaClient,
params: { businessId: string; range: ReportRange },
): Promise<NotificationReport> {
const deliveries = await prisma.notificationDelivery.findMany({
where: {
notification: { businessId: params.businessId },
createdAt: { gte: params.range.from, lte: params.range.to },
},
select: { status: true, channel: true, notification: { select: { type: true } } },
});
const byType = new Map<string, NotificationReportRow>();
const byChannel = new Map<string, { channel: string; sent: number; failed: number }>();
let totalSent = 0;
let totalFailed = 0;
for (const d of deliveries) {
const isSent = d.status === "SENT";
const isFailed = d.status === "FAILED";
if (isSent) totalSent += 1;
if (isFailed) totalFailed += 1;
const typeRow = byType.get(d.notification.type) ?? { type: d.notification.type, sent: 0, failed: 0 };
if (isSent) typeRow.sent += 1;
if (isFailed) typeRow.failed += 1;
byType.set(d.notification.type, typeRow);
const channelRow = byChannel.get(d.channel) ?? { channel: d.channel, sent: 0, failed: 0 };
if (isSent) channelRow.sent += 1;
if (isFailed) channelRow.failed += 1;
byChannel.set(d.channel, channelRow);
}
return {
totalSent,
totalFailed,
byType: [...byType.values()].sort((a, b) => b.sent + b.failed - (a.sent + a.failed)),
byChannel: [...byChannel.values()].sort((a, b) => b.sent + b.failed - (a.sent + a.failed)),
};
}
@@ -360,6 +360,50 @@ describe("booking engine (integration)", () => {
});
expect(laterValidation.valid).toBe(true);
});
// Regression test for a real bug found via a MySQL E2E run (see
// docs/phase-7-summary.md): with an asymmetric buffer
// (bufferBeforeMinutes 0, bufferAfterMinutes > 0 — the seeded
// production default), assertNoConflict in scheduling.service.ts was
// applying the wrong side's buffer to the candidate appointment,
// wrongly rejecting a slot placed immediately BEFORE an existing
// appointment using that appointment's *after* buffer instead of its
// (zero) *before* buffer. The test above only exercises
// validateBookingSlot (AvailabilityService) for a candidate placed
// AFTER an existing appointment; createBooking's actual DB-level
// conflict check (assertNoConflict) was never exercised for the
// "before" direction until now.
it("does not apply a later appointment's after-buffer to a candidate slot placed immediately before it", async () => {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
await prisma.bookingRule.update({
where: { businessId: business.businessId },
data: { bufferBeforeMinutes: 0, bufferAfterMinutes: 5 },
});
// Book the later appointment first: 10:30–11:00.
const later = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId], // 30 min
startAt: wallClockToInstant(monday, 10 * 60 + 30, business.timezone),
});
expect(later.success).toBe(true);
// A candidate ending exactly when the later appointment starts (10:00–10:30) needs no gap —
// only the LATER appointment's own trailing (after) buffer applies once IT finishes, not
// before it starts, since bufferBeforeMinutes is 0.
const earlier = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
expect(earlier.success).toBe(true);
});
});
describe("booking validation", () => {
@@ -0,0 +1,208 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { Weekday } from "@/generated/prisma/enums";
import { addDays, todayIn, wallClockToInstant, weekdayOf } from "@/lib/time/business-time";
import type { CalendarDate } from "@/lib/time/business-time";
import { cancelBooking, createBooking, rescheduleBooking } from "../booking.service";
import { cleanupTestBusiness, createTestBusiness, createTestCustomer, createTestGuestCustomer, createTestStaff, prisma } from "./fixtures";
import type { TestBusiness } from "./fixtures";
function nextWeekday(timezone: string, target: Weekday, minDaysAhead = 7): CalendarDate {
let date = addDays(todayIn(timezone), minDaysAhead);
for (let i = 0; i < 8; i++) {
if (weekdayOf(date) === target) return date;
date = addDays(date, 1);
}
throw new Error("unreachable");
}
/**
* Verifies booking.service.ts actually queues the right Notification rows
* for the right recipients on create/reschedule/cancel — see
* booking-notifications.ts. The full channel-resolution/preference logic
* is covered by notification.service.integration.test.ts; this file is
* specifically "does the booking workflow call notify() correctly".
*/
describe("booking notifications (integration)", () => {
let business: TestBusiness;
let monday: CalendarDate;
let guestProfileIds: string[];
beforeEach(async () => {
business = await createTestBusiness();
monday = nextWeekday(business.timezone, Weekday.MONDAY);
guestProfileIds = [];
});
afterEach(async () => {
await cleanupTestBusiness(business);
if (guestProfileIds.length > 0) {
await prisma.customerProfile.deleteMany({ where: { id: { in: guestProfileIds } } }).catch(() => {});
}
});
async function grantOwnerPermission(userId: string) {
const role = await prisma.role.create({
data: {
businessId: business.businessId,
name: "Test Owner",
permissions: { create: [{ permission: "VIEW_REPORTING" }] },
},
});
await prisma.staffProfile.update({ where: { userId }, data: { roleId: role.id } });
}
it("creating a booking notifies the customer, the staff member, and every owner", async () => {
const { staffId, userId: staffUserId } = await createTestStaff(business);
const { customerId, userId: customerUserId } = await createTestCustomer(business);
await grantOwnerPermission(staffUserId); // this staff member is also the "owner" for this test
const result = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
expect(result.success).toBe(true);
if (!result.success) return;
const notifications = await prisma.notification.findMany({ where: { appointmentId: result.appointment.id } });
const types = notifications.map((n) => n.type).sort();
// A booking far enough out (monday is >= 7 days ahead — see nextWeekday) also schedules both
// 24h/2h reminders for the customer and the staff member — see reminder-scheduling.ts.
expect(types).toEqual(
[
"BOOKING_CONFIRMED",
"BOOKING_REMINDER",
"BOOKING_REMINDER",
"OWNER_NEW_BOOKING",
"STAFF_APPOINTMENT_REMINDER",
"STAFF_APPOINTMENT_REMINDER",
"STAFF_NEW_APPOINTMENT",
].sort(),
);
const customerNotification = notifications.find((n) => n.type === "BOOKING_CONFIRMED");
expect(customerNotification?.recipientUserId).toBe(customerUserId);
expect(customerNotification?.recipientCustomerId).toBe(customerId);
const staffNotification = notifications.find((n) => n.type === "STAFF_NEW_APPOINTMENT");
expect(staffNotification?.recipientUserId).toBe(staffUserId);
// Same staff member holds both roles in this test, so OWNER_NEW_BOOKING also targets them.
const ownerNotification = notifications.find((n) => n.type === "OWNER_NEW_BOOKING");
expect(ownerNotification?.recipientUserId).toBe(staffUserId);
});
it("a guest booking's customer notification has no recipientUserId but does have a deep link with the guest token", async () => {
const { staffId } = await createTestStaff(business);
const guest = await createTestGuestCustomer();
guestProfileIds.push(guest.customerId);
const result = await createBooking(prisma, {
businessId: business.businessId,
customerId: guest.customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
expect(result.success).toBe(true);
if (!result.success) return;
expect(result.guestAccessToken).toBeTruthy();
const notification = await prisma.notification.findFirst({
where: { appointmentId: result.appointment.id, type: "BOOKING_CONFIRMED" },
});
expect(notification?.recipientUserId).toBeNull();
expect(notification?.recipientCustomerId).toBe(guest.customerId);
expect(notification?.deepLinkPath).toContain(result.guestAccessToken);
});
it("requireApproval creates a BOOKING_PENDING_APPROVAL notification instead of BOOKING_CONFIRMED", async () => {
await prisma.bookingRule.update({ where: { businessId: business.businessId }, data: { requireApproval: true } });
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const result = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
expect(result.success).toBe(true);
if (!result.success) return;
const notification = await prisma.notification.findFirst({
where: { appointmentId: result.appointment.id, recipientCustomerId: customerId },
});
expect(notification?.type).toBe("BOOKING_PENDING_APPROVAL");
});
it("rescheduling notifies the customer and staff, and a second reschedule creates a fresh notification rather than deduping", async () => {
const { staffId, userId: staffUserId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const created = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
if (!created.success) throw new Error("setup failed");
const first = await rescheduleBooking(prisma, {
appointmentId: created.appointment.id,
startAt: wallClockToInstant(monday, 11 * 60, business.timezone),
});
expect(first.success).toBe(true);
const second = await rescheduleBooking(prisma, {
appointmentId: created.appointment.id,
startAt: wallClockToInstant(monday, 13 * 60, business.timezone),
});
expect(second.success).toBe(true);
const rescheduledNotifications = await prisma.notification.findMany({
where: { appointmentId: created.appointment.id, type: "BOOKING_RESCHEDULED" },
});
expect(rescheduledNotifications).toHaveLength(2);
const staffNotifications = await prisma.notification.findMany({
where: { appointmentId: created.appointment.id, type: "STAFF_APPOINTMENT_RESCHEDULED", recipientUserId: staffUserId },
});
expect(staffNotifications).toHaveLength(2);
});
it("cancelling notifies the customer, staff, and every owner, and only once even if called again on an already-cancelled appointment", async () => {
const { staffId, userId: staffUserId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
await grantOwnerPermission(staffUserId);
const created = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
if (!created.success) throw new Error("setup failed");
const cancelled = await cancelBooking(prisma, { appointmentId: created.appointment.id });
expect(cancelled.success).toBe(true);
const cancelNotifications = await prisma.notification.findMany({
where: { appointmentId: created.appointment.id, type: { in: ["BOOKING_CANCELLED", "STAFF_APPOINTMENT_CANCELLED", "OWNER_BOOKING_CANCELLED"] } },
});
expect(cancelNotifications).toHaveLength(3);
// Cancelling again is rejected by cancelBooking itself (already CANCELLED) — never reaches notify() a second time.
const secondAttempt = await cancelBooking(prisma, { appointmentId: created.appointment.id });
expect(secondAttempt.success).toBe(false);
const stillOnlyOnce = await prisma.notification.count({
where: { appointmentId: created.appointment.id, type: "BOOKING_CANCELLED" },
});
expect(stillOnlyOnce).toBe(1);
});
});
+16 -4
View File
@@ -1,15 +1,17 @@
import "dotenv/config";
import { PrismaClient } from "@/generated/prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
import { buildMysqlPoolConfig } from "@/lib/db/mysql-pool-config";
import { Weekday } from "@/generated/prisma/enums";
/**
* Integration-test fixtures — real Postgres, real transactions. Not used
* Integration-test fixtures — real MySQL, real transactions. Not used
* by the unit test suite (`npm test`); only by `npm run test:integration`.
* See docs/phase-3-summary.md for how to point this at a database.
* See docs/phase-7-summary.md for how to point this at a database
* (migrated from PostgreSQL to MySQL in Phase 7).
*/
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
const adapter = new PrismaMariaDb(buildMysqlPoolConfig(process.env.DATABASE_URL));
export const prisma = new PrismaClient({ adapter });
let counter = 0;
@@ -176,6 +178,16 @@ export async function cleanupTestBusiness(business: TestBusiness): Promise<void>
// appointments first lets that cascade complete instead of the business
// delete silently failing and leaking a "Test Barbershop" row per run.
await prisma.appointment.deleteMany({ where: { businessId: business.businessId } }).catch(() => {});
// StaffProfile.roleId is ALSO onDelete: Restrict, and Business cascades
// to both Role and StaffProfile. On Postgres, the database's own native
// FK cascade resolver orders this correctly; empirically (Phase 7, see
// docs/phase-7-summary.md), Prisma 7's client-engine-runtime interprets
// cascading deletes itself against MySQL and does NOT reliably delete
// StaffProfile before Role, leaving a P2003 "Foreign key constraint
// violated on (roleId)" that this function's own .catch(() => {}) was
// silently swallowing — leaking a "Test Barbershop" business per run.
// Deleting StaffProfile explicitly first removes the ordering ambiguity.
await prisma.staffProfile.deleteMany({ where: { businessId: business.businessId } }).catch(() => {});
await prisma.business.delete({ where: { id: business.businessId } }).catch(() => {});
if (business.createdUserIds.length > 0) {
await prisma.user.deleteMany({ where: { id: { in: business.createdUserIds } } }).catch(() => {});
@@ -0,0 +1,158 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { Weekday } from "@/generated/prisma/enums";
import { addDays, todayIn, wallClockToInstant, weekdayOf } from "@/lib/time/business-time";
import type { CalendarDate } from "@/lib/time/business-time";
import { cancelBooking, createBooking, rescheduleBooking } from "../booking.service";
import { cleanupTestBusiness, createTestBusiness, createTestCustomer, createTestStaff, prisma } from "./fixtures";
import type { TestBusiness } from "./fixtures";
function nextWeekday(timezone: string, target: Weekday, minDaysAhead = 7): CalendarDate {
let date = addDays(todayIn(timezone), minDaysAhead);
for (let i = 0; i < 8; i++) {
if (weekdayOf(date) === target) return date;
date = addDays(date, 1);
}
throw new Error("unreachable");
}
async function reminderDeliveries(appointmentId: string) {
return prisma.notificationDelivery.findMany({
where: {
notification: { appointmentId, type: { in: ["BOOKING_REMINDER", "STAFF_APPOINTMENT_REMINDER"] } },
channel: { in: ["EMAIL", "PUSH"] },
},
include: { notification: true },
});
}
describe("reminder scheduling (integration)", () => {
let business: TestBusiness;
let monday: CalendarDate;
beforeEach(async () => {
business = await createTestBusiness();
monday = nextWeekday(business.timezone, Weekday.MONDAY);
});
afterEach(async () => {
await cleanupTestBusiness(business);
});
it("schedules 24h and 2h EMAIL reminders for both customer and staff, as SCHEDULED (not PENDING)", async () => {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const result = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
if (!result.success) throw new Error("setup failed");
const deliveries = await reminderDeliveries(result.appointment.id);
expect(deliveries).toHaveLength(4); // 2 offsets × (customer + staff)
for (const d of deliveries) {
expect(d.status).toBe("SCHEDULED");
expect(d.scheduledFor).not.toBeNull();
expect(d.scheduledFor!.getTime()).toBeLessThan(result.appointment.startAt.getTime());
}
});
it("does not schedule reminders for a PENDING (requireApproval) booking", async () => {
await prisma.bookingRule.update({ where: { businessId: business.businessId }, data: { requireApproval: true } });
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const result = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
if (!result.success) throw new Error("setup failed");
const deliveries = await reminderDeliveries(result.appointment.id);
expect(deliveries).toHaveLength(0);
});
it("does not schedule a reminder offset that would already be in the past", async () => {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
// Book for 1 hour from now — the 24h and 2h-before offsets are both already past.
const result = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: new Date(Date.now() + 3 * 60 * 60_000), // 3h out, staff working hours permitting — see note below
}).catch(() => null);
// This may fail validation depending on min-notice/working-hours fixtures; only assert the
// reminder-skipping behaviour when the booking itself actually succeeded.
if (!result || !result.success) return;
const deliveries = await reminderDeliveries(result.appointment.id);
// 3h out: the 2h-before offset is still in the future, the 24h-before offset is in the past.
expect(deliveries.length).toBeLessThanOrEqual(2);
expect(deliveries.every((d) => d.notification.idempotencyKey.includes("reminder-2h"))).toBe(true);
});
it("rescheduling cancels the old reminders and schedules fresh ones for the new time", async () => {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const created = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
if (!created.success) throw new Error("setup failed");
const originalDeliveries = await reminderDeliveries(created.appointment.id);
expect(originalDeliveries).toHaveLength(4);
const rescheduled = await rescheduleBooking(prisma, {
appointmentId: created.appointment.id,
startAt: wallClockToInstant(monday, 14 * 60, business.timezone),
});
expect(rescheduled.success).toBe(true);
if (!rescheduled.success) return;
const allDeliveries = await reminderDeliveries(created.appointment.id);
const cancelledCount = allDeliveries.filter((d) => d.status === "CANCELLED").length;
const scheduledCount = allDeliveries.filter((d) => d.status === "SCHEDULED").length;
expect(cancelledCount).toBe(4); // the original 4, now superseded
expect(scheduledCount).toBe(4); // 4 fresh ones for the new time
const freshOnes = allDeliveries.filter((d) => d.status === "SCHEDULED");
for (const d of freshOnes) {
expect(d.scheduledFor!.getTime()).toBeLessThan(rescheduled.appointment.startAt.getTime());
}
});
it("cancelling the appointment cancels its scheduled reminders", async () => {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const created = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
if (!created.success) throw new Error("setup failed");
const cancelled = await cancelBooking(prisma, { appointmentId: created.appointment.id });
expect(cancelled.success).toBe(true);
const deliveries = await reminderDeliveries(created.appointment.id);
expect(deliveries.every((d) => d.status === "CANCELLED")).toBe(true);
expect(deliveries).toHaveLength(4);
});
});
@@ -0,0 +1,235 @@
import type { PrismaClient } from "@/generated/prisma/client";
import { NotificationType } from "@/generated/prisma/enums";
import { notifySafely } from "@/services/notifications/notification.service";
import { getOwnerUserIds } from "@/services/notifications/recipients";
import type { Appointment } from "./booking.service";
import { scheduleAppointmentReminders } from "./reminder-scheduling";
/**
* Builds and queues the Notification rows a booking-domain event
* produces — called by booking.service.ts strictly AFTER the appointment
* transaction has already committed, using notifySafely so a notification
* failure can never roll back or fail an otherwise-successful booking.
* Owns only "who gets told what, in what words" — the actual email/push
* send happens later, out of process (see reminder-worker.ts).
*/
function formatDateLabel(date: Date, timezone: string): string {
return date.toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short", timeZone: timezone });
}
function formatTimeLabel(date: Date, timezone: string): string {
return date.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", timeZone: timezone });
}
async function getBusinessTimezone(prisma: PrismaClient, businessId: string): Promise<string> {
const business = await prisma.business.findUnique({ where: { id: businessId }, select: { timezone: true } });
return business?.timezone ?? "Europe/London";
}
function serviceNames(appointment: Appointment): string {
return appointment.services.map((s) => s.service.name).join(", ");
}
export async function notifyBookingCreated(
prisma: PrismaClient,
appointment: Appointment,
options: { guestAccessToken?: string } = {},
): Promise<void> {
const timezone = await getBusinessTimezone(prisma, appointment.businessId);
const dateLabel = formatDateLabel(appointment.startAt, timezone);
const timeLabel = formatTimeLabel(appointment.startAt, timezone);
const services = serviceNames(appointment);
const isPending = appointment.status === "PENDING";
const customerDeepLink = `/appointments/${appointment.id}${options.guestAccessToken ? `?token=${options.guestAccessToken}` : ""}`;
await notifySafely(prisma, {
businessId: appointment.businessId,
type: isPending ? NotificationType.BOOKING_PENDING_APPROVAL : NotificationType.BOOKING_CONFIRMED,
recipientUserId: appointment.customer.userId ?? undefined,
recipientCustomerId: appointment.customer.id,
appointmentId: appointment.id,
subject: isPending ? "Booking request received" : "Booking confirmed",
bodyText: isPending
? `Your request for ${services} with ${appointment.staff.user.name} on ${dateLabel} at ${timeLabel} is awaiting approval.`
: `Your appointment for ${services} with ${appointment.staff.user.name} on ${dateLabel} at ${timeLabel} is confirmed.`,
deepLinkPath: customerDeepLink,
idempotencyKey: `notify:booking-created:${appointment.id}`,
});
await notifySafely(prisma, {
businessId: appointment.businessId,
type: NotificationType.STAFF_NEW_APPOINTMENT,
recipientUserId: appointment.staff.userId,
appointmentId: appointment.id,
subject: "New appointment",
bodyText: `New appointment: ${services} with ${appointment.customer.firstName} ${appointment.customer.lastName} on ${dateLabel} at ${timeLabel}.`,
deepLinkPath: `/staff/appointments/${appointment.id}`,
idempotencyKey: `notify:staff-new-appointment:${appointment.id}`,
});
const ownerUserIds = await getOwnerUserIds(prisma, appointment.businessId);
for (const ownerUserId of ownerUserIds) {
await notifySafely(prisma, {
businessId: appointment.businessId,
type: NotificationType.OWNER_NEW_BOOKING,
recipientUserId: ownerUserId,
appointmentId: appointment.id,
subject: "New booking",
bodyText: `${appointment.customer.firstName} ${appointment.customer.lastName} booked ${services} with ${appointment.staff.user.name} on ${dateLabel} at ${timeLabel}.`,
deepLinkPath: `/owner/appointments/${appointment.id}`,
idempotencyKey: `notify:owner-new-booking:${appointment.id}:${ownerUserId}`,
});
}
}
export async function notifyBookingRescheduled(prisma: PrismaClient, appointment: Appointment): Promise<void> {
const timezone = await getBusinessTimezone(prisma, appointment.businessId);
const dateLabel = formatDateLabel(appointment.startAt, timezone);
const timeLabel = formatTimeLabel(appointment.startAt, timezone);
const services = serviceNames(appointment);
// Included in the key so a second reschedule creates a fresh notification
// rather than being deduped against the first one's key.
const versionSuffix = appointment.startAt.getTime();
await notifySafely(prisma, {
businessId: appointment.businessId,
type: NotificationType.BOOKING_RESCHEDULED,
recipientUserId: appointment.customer.userId ?? undefined,
recipientCustomerId: appointment.customer.id,
appointmentId: appointment.id,
subject: "Booking rescheduled",
bodyText: `Your appointment for ${services} with ${appointment.staff.user.name} has been moved to ${dateLabel} at ${timeLabel}.`,
deepLinkPath: `/appointments/${appointment.id}`,
idempotencyKey: `notify:booking-rescheduled:${appointment.id}:${versionSuffix}`,
});
await notifySafely(prisma, {
businessId: appointment.businessId,
type: NotificationType.STAFF_APPOINTMENT_RESCHEDULED,
recipientUserId: appointment.staff.userId,
appointmentId: appointment.id,
subject: "Appointment rescheduled",
bodyText: `${appointment.customer.firstName} ${appointment.customer.lastName}'s appointment (${services}) has moved to ${dateLabel} at ${timeLabel}.`,
deepLinkPath: `/staff/appointments/${appointment.id}`,
idempotencyKey: `notify:staff-appointment-rescheduled:${appointment.id}:${versionSuffix}`,
});
}
export async function notifyBookingCancelled(
prisma: PrismaClient,
appointment: Appointment,
options: { wasLateCancellation?: boolean; reason?: string } = {},
): Promise<void> {
const timezone = await getBusinessTimezone(prisma, appointment.businessId);
const dateLabel = formatDateLabel(appointment.startAt, timezone);
const timeLabel = formatTimeLabel(appointment.startAt, timezone);
const services = serviceNames(appointment);
const lateNote = options.wasLateCancellation ? " This was a late cancellation." : "";
await notifySafely(prisma, {
businessId: appointment.businessId,
type: NotificationType.BOOKING_CANCELLED,
recipientUserId: appointment.customer.userId ?? undefined,
recipientCustomerId: appointment.customer.id,
appointmentId: appointment.id,
subject: "Booking cancelled",
bodyText: `Your appointment for ${services} with ${appointment.staff.user.name} on ${dateLabel} at ${timeLabel} has been cancelled.${lateNote}`,
deepLinkPath: `/appointments/${appointment.id}`,
idempotencyKey: `notify:booking-cancelled:${appointment.id}`,
});
await notifySafely(prisma, {
businessId: appointment.businessId,
type: NotificationType.STAFF_APPOINTMENT_CANCELLED,
recipientUserId: appointment.staff.userId,
appointmentId: appointment.id,
subject: "Appointment cancelled",
bodyText: `${appointment.customer.firstName} ${appointment.customer.lastName}'s appointment (${services}) on ${dateLabel} at ${timeLabel} was cancelled.`,
deepLinkPath: `/staff/appointments/${appointment.id}`,
idempotencyKey: `notify:staff-appointment-cancelled:${appointment.id}`,
});
const ownerUserIds = await getOwnerUserIds(prisma, appointment.businessId);
for (const ownerUserId of ownerUserIds) {
await notifySafely(prisma, {
businessId: appointment.businessId,
type: NotificationType.OWNER_BOOKING_CANCELLED,
recipientUserId: ownerUserId,
appointmentId: appointment.id,
subject: "Booking cancelled",
bodyText: `${appointment.customer.firstName} ${appointment.customer.lastName}'s appointment with ${appointment.staff.user.name} on ${dateLabel} at ${timeLabel} was cancelled.${lateNote}`,
deepLinkPath: `/owner/appointments/${appointment.id}`,
idempotencyKey: `notify:owner-booking-cancelled:${appointment.id}:${ownerUserId}`,
});
}
}
/** A PENDING booking (requireApproval) is approved — see AppointmentService.confirm, only ever reachable from PENDING. */
export async function notifyBookingApproved(prisma: PrismaClient, appointment: Appointment): Promise<void> {
const timezone = await getBusinessTimezone(prisma, appointment.businessId);
await notifySafely(prisma, {
businessId: appointment.businessId,
type: NotificationType.BOOKING_APPROVED,
recipientUserId: appointment.customer.userId ?? undefined,
recipientCustomerId: appointment.customer.id,
appointmentId: appointment.id,
subject: "Booking approved",
bodyText: `Your appointment for ${serviceNames(appointment)} with ${appointment.staff.user.name} on ${formatDateLabel(appointment.startAt, timezone)} at ${formatTimeLabel(appointment.startAt, timezone)} has been approved.`,
deepLinkPath: `/appointments/${appointment.id}`,
idempotencyKey: `notify:booking-approved:${appointment.id}`,
});
// Approval is the first point a PENDING (requireApproval) booking becomes
// CONFIRMED — reminders were never scheduled at creation time for it (see
// scheduleAppointmentReminders' own CONFIRMED-only guard), so this is
// where that has to happen instead.
await scheduleAppointmentReminders(prisma, appointment);
}
/** The owner reassigns an appointment to a different staff member — distinct from a plain time reschedule (see notifyBookingRescheduled, which still also fires for a reassignment since the underlying rescheduleBooking() call can't distinguish "why", see ownerRescheduleAppointmentAction). This tells the customer specifically who they're now booked with. */
export async function notifyBookingBarberChanged(prisma: PrismaClient, appointment: Appointment): Promise<void> {
await notifySafely(prisma, {
businessId: appointment.businessId,
type: NotificationType.BOOKING_BARBER_CHANGED,
recipientUserId: appointment.customer.userId ?? undefined,
recipientCustomerId: appointment.customer.id,
appointmentId: appointment.id,
subject: "Your barber has changed",
bodyText: `Your appointment for ${serviceNames(appointment)} is now with ${appointment.staff.user.name}.`,
deepLinkPath: `/appointments/${appointment.id}`,
idempotencyKey: `notify:booking-barber-changed:${appointment.id}:${appointment.staffId}`,
});
}
/** The appointment reaches COMPLETED — a distinct, non-loyalty "thanks for visiting" notice (see LoyaltyService for the separate stamp/reward notifications, which fire independently). */
export async function notifyBookingCompleted(prisma: PrismaClient, appointment: Appointment): Promise<void> {
await notifySafely(prisma, {
businessId: appointment.businessId,
type: NotificationType.BOOKING_COMPLETED,
recipientUserId: appointment.customer.userId ?? undefined,
recipientCustomerId: appointment.customer.id,
appointmentId: appointment.id,
subject: "Thanks for visiting",
bodyText: `Thanks for visiting ${appointment.staff.user.name ? `and seeing ${appointment.staff.user.name}` : ""} — hope you loved your ${serviceNames(appointment)}!`,
deepLinkPath: `/appointments/${appointment.id}`,
idempotencyKey: `notify:booking-completed:${appointment.id}`,
});
}
/** Customer marked NO_SHOW — owner-facing only (the barber already knows, they're the one who marked it). */
export async function notifyAppointmentNoShow(prisma: PrismaClient, appointment: Appointment): Promise<void> {
const timezone = await getBusinessTimezone(prisma, appointment.businessId);
const ownerUserIds = await getOwnerUserIds(prisma, appointment.businessId);
for (const ownerUserId of ownerUserIds) {
await notifySafely(prisma, {
businessId: appointment.businessId,
type: NotificationType.OWNER_NO_SHOW,
recipientUserId: ownerUserId,
appointmentId: appointment.id,
subject: "No-show recorded",
bodyText: `${appointment.customer.firstName} ${appointment.customer.lastName} didn't show up for their appointment with ${appointment.staff.user.name} on ${formatDateLabel(appointment.startAt, timezone)} at ${formatTimeLabel(appointment.startAt, timezone)}.`,
deepLinkPath: `/owner/appointments/${appointment.id}`,
idempotencyKey: `notify:owner-no-show:${appointment.id}:${ownerUserId}`,
});
}
}
+24 -2
View File
@@ -7,7 +7,10 @@ import {
resolveBookingContext,
validateBookingSlot,
} from "./availability.service";
import { notifyBookingCancelled, notifyBookingCreated, notifyBookingRescheduled } from "./booking-notifications";
import { isLateCancellation, isLateReschedule } from "./business-rules.service";
import { issueGuestAccessToken } from "./guest-access.service";
import { cancelScheduledReminders, scheduleAppointmentReminders } from "./reminder-scheduling";
import { DoubleBookingError, createAppointment, rescheduleAppointment } from "./scheduling.service";
import { countStaffAppointmentsOnDate } from "./staff-availability.service";
import { fail, ok } from "./types";
@@ -35,7 +38,13 @@ export interface CreateBookingInput {
export type Appointment = NonNullable<Awaited<ReturnType<typeof getAppointmentById>>>;
export type BookingResult =
{ success: true; appointment: Appointment } | { success: false; validation: ValidationResult };
| {
success: true;
appointment: Appointment;
/** Raw guest access token — set only when the appointment's customer has no linked User. See guest-access.service.ts; never logged, never persisted beyond its hash. */
guestAccessToken?: string;
}
| { success: false; validation: ValidationResult };
/** Among staff free at this slot, prefer whoever has the fewest appointments that day — spreads "any barber" bookings out instead of always picking the same one. */
async function pickLeastBusyStaff(
@@ -144,7 +153,12 @@ export async function createBooking(
// Re-fetch through the shared include shape so callers get the same
// appointment shape regardless of which workflow produced it.
const appointment = await getAppointmentById(prisma, created.id);
return { success: true, appointment: appointment! };
const guestAccessToken = created.customer.userId
? undefined
: await issueGuestAccessToken(prisma, { appointmentId: created.id, appointmentEndAt: endAt });
await notifyBookingCreated(prisma, appointment!, { guestAccessToken });
await scheduleAppointmentReminders(prisma, appointment!);
return { success: true, appointment: appointment!, guestAccessToken };
} catch (error) {
if (error instanceof DoubleBookingError) {
return {
@@ -213,6 +227,9 @@ export async function rescheduleBooking(
effectiveBuffer: context.effectiveBuffer,
});
const appointment = await getAppointmentById(prisma, input.appointmentId);
await notifyBookingRescheduled(prisma, appointment!);
await cancelScheduledReminders(prisma, input.appointmentId);
await scheduleAppointmentReminders(prisma, appointment!);
return { success: true, appointment: appointment! };
} catch (error) {
if (error instanceof DoubleBookingError) {
@@ -302,6 +319,11 @@ export async function cancelBooking(
},
});
await notifyBookingCancelled(prisma, appointment, {
wasLateCancellation,
reason: input.reason,
});
await cancelScheduledReminders(prisma, input.appointmentId);
return { success: true, appointment, wasLateCancellation, validation: ok() };
}
@@ -0,0 +1,89 @@
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import type { PrismaClient } from "@/generated/prisma/client";
/**
* Single-purpose access tokens for guest (no linked User) appointments —
* closes the Phase 4 gap where a guest's only credential was the
* appointment's own id (see docs/phase-4-summary.md, "Security", and
* GuestAppointmentAccessToken in schema.prisma). A registered customer's
* access is still session-based and untouched by this file — see
* src/features/booking/authorization.ts.
*
* Only the SHA-256 hash of the token is ever persisted. The raw token
* exists solely in this module's return values, for a caller to embed in
* a link (the booking confirmation page today; a future notification
* email) and never log or store elsewhere.
*/
const TOKEN_BYTES = 32;
const VALID_FOR_DAYS_AFTER_APPOINTMENT = 90;
function hashToken(rawToken: string): string {
return createHash("sha256").update(rawToken).digest("hex");
}
/**
* Issues this appointment's guest access token, generating one if none
* exists or overwriting the previous hash if one does — deliberately a
* single row per appointment (see the model's `appointmentId @unique`),
* so re-issuing invalidates any earlier copy of the raw token rather than
* leaving multiple valid links outstanding. Reschedule/cancel do not
* currently re-issue — the same link a guest already has keeps working
* across those, which is the more useful default until Phase 7's
* notification emails (not yet built) give a concrete reason to rotate on
* a specific event.
*/
export async function issueGuestAccessToken(
prisma: PrismaClient,
params: { appointmentId: string; appointmentEndAt: Date },
): Promise<string> {
const rawToken = randomBytes(TOKEN_BYTES).toString("base64url");
const expiresAt = new Date(
params.appointmentEndAt.getTime() + VALID_FOR_DAYS_AFTER_APPOINTMENT * 24 * 60 * 60 * 1000,
);
await prisma.guestAppointmentAccessToken.upsert({
where: { appointmentId: params.appointmentId },
create: { appointmentId: params.appointmentId, tokenHash: hashToken(rawToken), expiresAt },
update: { tokenHash: hashToken(rawToken), expiresAt, revokedAt: null, lastUsedAt: null },
});
return rawToken;
}
/**
* True only if `rawToken` is the CURRENT token for exactly this
* appointment, not expired, and not revoked. Compares hashes with
* `timingSafeEqual` (matching the convention in
* src/lib/booking-draft/sign.ts) since, unlike a lookup keyed by the hash
* itself, this comparison happens in application code after fetching the
* row by appointmentId.
*/
export async function validateGuestAccessToken(
prisma: PrismaClient,
params: { appointmentId: string; rawToken: string },
): Promise<boolean> {
const record = await prisma.guestAppointmentAccessToken.findUnique({
where: { appointmentId: params.appointmentId },
});
if (!record || record.revokedAt || record.expiresAt < new Date()) return false;
const provided = Buffer.from(hashToken(params.rawToken), "hex");
const stored = Buffer.from(record.tokenHash, "hex");
if (provided.length !== stored.length || !timingSafeEqual(provided, stored)) return false;
// Best-effort — a stale lastUsedAt from a lost race isn't a security concern.
await prisma.guestAppointmentAccessToken
.update({ where: { appointmentId: params.appointmentId }, data: { lastUsedAt: new Date() } })
.catch(() => {});
return true;
}
/** Immediately invalidates a guest appointment's current access link — e.g. if it was sent to the wrong address. */
export async function revokeGuestAccessToken(prisma: PrismaClient, appointmentId: string): Promise<void> {
await prisma.guestAppointmentAccessToken.updateMany({
where: { appointmentId, revokedAt: null },
data: { revokedAt: new Date() },
});
}
@@ -0,0 +1,98 @@
import type { PrismaClient } from "@/generated/prisma/client";
import { NotificationDeliveryStatus, NotificationType } from "@/generated/prisma/enums";
import { notifySafely } from "@/services/notifications/notification.service";
import type { Appointment } from "./booking.service";
/**
* Reminder timing — hardcoded, not yet a BookingRule column: there's no
* owner-facing UI to change it in Phase 7, so storing an unreachable
* setting felt like the wrong kind of "configurable" (see
* docs/phase-7-summary.md, "Known limitations"). A natural home for this
* would be two new BookingRule fields feeding the existing Booking Rules
* editor — not built this phase.
*/
const REMINDER_HOURS_BEFORE = [24, 2] as const;
function formatDateLabel(date: Date, timezone: string): string {
return date.toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short", timeZone: timezone });
}
function formatTimeLabel(date: Date, timezone: string): string {
return date.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", timeZone: timezone });
}
async function getBusinessTimezone(prisma: PrismaClient, businessId: string): Promise<string> {
const business = await prisma.business.findUnique({ where: { id: businessId }, select: { timezone: true } });
return business?.timezone ?? "Europe/London";
}
/**
* Cancels every not-yet-sent reminder delivery tied to this appointment —
* called before rescheduling to a new time and before cancelling, so a
* customer/barber never gets a reminder for a time that's no longer
* correct. Only touches PENDING/SCHEDULED rows; a reminder that already
* sent stays as a true historical record.
*/
export async function cancelScheduledReminders(prisma: PrismaClient, appointmentId: string): Promise<void> {
await prisma.notificationDelivery.updateMany({
where: {
status: { in: [NotificationDeliveryStatus.PENDING, NotificationDeliveryStatus.SCHEDULED] },
notification: {
appointmentId,
type: { in: [NotificationType.BOOKING_REMINDER, NotificationType.STAFF_APPOINTMENT_REMINDER] },
},
},
data: { status: NotificationDeliveryStatus.CANCELLED },
});
}
/**
* Schedules the customer's and barber's reminders for a CONFIRMED
* appointment at each offset in REMINDER_HOURS_BEFORE that's still in the
* future — an offset that's already passed (e.g. booked 1 hour out) is
* silently skipped, not scheduled in the past. Each reminder's
* idempotencyKey includes the exact startAt, so a reschedule (which calls
* cancelScheduledReminders then this again) always produces fresh rows
* rather than colliding with a cancelled one — see
* booking-notifications.ts for the same "version-suffixed key" pattern.
*/
export async function scheduleAppointmentReminders(prisma: PrismaClient, appointment: Appointment): Promise<void> {
if (appointment.status !== "CONFIRMED") return;
const timezone = await getBusinessTimezone(prisma, appointment.businessId);
const dateLabel = formatDateLabel(appointment.startAt, timezone);
const timeLabel = formatTimeLabel(appointment.startAt, timezone);
const services = appointment.services.map((s) => s.service.name).join(", ");
const now = new Date();
const versionSuffix = appointment.startAt.getTime();
for (const hoursBefore of REMINDER_HOURS_BEFORE) {
const scheduledFor = new Date(appointment.startAt.getTime() - hoursBefore * 60 * 60 * 1000);
if (scheduledFor <= now) continue;
await notifySafely(prisma, {
businessId: appointment.businessId,
type: NotificationType.BOOKING_REMINDER,
recipientUserId: appointment.customer.userId ?? undefined,
recipientCustomerId: appointment.customer.id,
appointmentId: appointment.id,
subject: "Upcoming appointment reminder",
bodyText: `Reminder: your appointment for ${services} with ${appointment.staff.user.name} is on ${dateLabel} at ${timeLabel}.`,
deepLinkPath: `/appointments/${appointment.id}`,
idempotencyKey: `notify:booking-reminder-${hoursBefore}h:${appointment.id}:${versionSuffix}`,
scheduledFor,
});
await notifySafely(prisma, {
businessId: appointment.businessId,
type: NotificationType.STAFF_APPOINTMENT_REMINDER,
recipientUserId: appointment.staff.userId,
appointmentId: appointment.id,
subject: "Upcoming appointment reminder",
bodyText: `Reminder: ${appointment.customer.firstName} ${appointment.customer.lastName} (${services}) is booked for ${dateLabel} at ${timeLabel}.`,
deepLinkPath: `/staff/appointments/${appointment.id}`,
idempotencyKey: `notify:staff-appointment-reminder-${hoursBefore}h:${appointment.id}:${versionSuffix}`,
scheduledFor,
});
}
}
+45 -12
View File
@@ -21,6 +21,21 @@ import type { AppointmentStatus } from "@/generated/prisma/enums";
* translate into the same DoubleBookingError. Serializable isolation
* is standard SQL, supported by both Postgres and MySQL — this stays
* portable, unlike a Postgres-only exclusion constraint would be.
*
* Re-verified against MySQL 8.0 in Phase 7 (see docs/phase-7-summary.md):
* a 25-way concurrent-write stress test against the same slot produced 1
* commit, 15 rejections caught by the explicit `assertNoConflict` check,
* and 9 raw database write-conflict errors — all surfaced as Prisma's
* `P2034` (`PrismaClientKnownRequestError`), the SAME code the classic
* query-engine used for Postgres. This was not a given: Prisma 7's
* TypeScript-native client-engine-runtime (used with driver adapters, see
* src/lib/db/prisma.ts) propagates a raw `DriverAdapterError` for a
* Postgres serialization failure instead of wrapping it as P2034 (see the
* check below) — empirically, MySQL's write conflicts take the OTHER path
* and already arrive as P2034, so `isSerializationFailure`'s first check
* already covers MySQL correctly with no new branch needed. Found by
* running the concurrency test repeatedly and a dedicated high-concurrency
* probe, not assumed from the Postgres behaviour.
*/
export class DoubleBookingError extends Error {
@@ -70,11 +85,28 @@ async function assertNoConflict(
excludeAppointmentId?: string;
},
): Promise<void> {
// AvailabilityService pads each EXISTING appointment by
// [start - beforeMinutes, end + afterMinutes] and checks the candidate's
// raw window against that (slot-calculator.ts expandWithBuffer). Doing
// the equivalent check here — with a single query, without fetching and
// padding every row in application code — means padding the CANDIDATE
// instead, but that flips which side each buffer applies to: a conflict
// exists when `existing.startAt < candidateEnd + beforeMinutes` (an
// existing appointment starting soon after the candidate ends still
// needs its own leading buffer) `&& existing.endAt > candidateStart -
// afterMinutes` (an existing appointment that just finished still needs
// its trailing buffer before the candidate can start). Using
// beforeMinutes/afterMinutes the other way round (as an earlier version
// of this function did) silently swaps which buffer guards which side —
// found via a real MySQL E2E failure where a candidate slot placed
// immediately BEFORE an existing appointment was wrongly rejected using
// that appointment's bufferAfterMinutes instead of its
// bufferBeforeMinutes (see docs/phase-7-summary.md).
const bufferedStart = new Date(
params.startAt.getTime() - params.effectiveBuffer.beforeMinutes * 60_000,
params.startAt.getTime() - params.effectiveBuffer.afterMinutes * 60_000,
);
const bufferedEnd = new Date(
params.endAt.getTime() + params.effectiveBuffer.afterMinutes * 60_000,
params.endAt.getTime() + params.effectiveBuffer.beforeMinutes * 60_000,
);
const conflict = await tx.appointment.findFirst({
@@ -82,10 +114,6 @@ async function assertNoConflict(
staffId: params.staffId,
status: { notIn: NON_BLOCKING_STATUSES },
...(params.excludeAppointmentId ? { id: { not: params.excludeAppointmentId } } : {}),
// Overlap test: the candidate's buffered span against existing appointments' raw spans — mirrors
// exactly how AvailabilityService treats existing appointments as buffered "busy" time (see
// slot-calculator.ts expandWithBuffer), so a slot the availability search offered can't be
// rejected here for a different reason than why it wouldn't have been offered in the first place.
startAt: { lt: bufferedEnd },
endAt: { gt: bufferedStart },
},
@@ -97,15 +125,20 @@ async function assertNoConflict(
function isSerializationFailure(error: unknown): boolean {
// P2034: "Transaction failed due to a write conflict or a deadlock. Please retry your transaction."
// This is the error shape Prisma's classic query-engine binary produces.
// The error shape Prisma's classic query-engine binary produces — and, found
// empirically in Phase 7, also what MySQL 8.0's write-conflict errors arrive as
// through the TS-native client-engine-runtime (see the file header for the
// concurrency-stress-test results). This one check covers both databases.
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2034") return true;
// Prisma 7's TypeScript-native client-engine-runtime (used with driver
// adapters, see src/lib/db/prisma.ts) instead propagates the adapter's
// own DriverAdapterError for a Postgres serialization failure (SQLSTATE
// 40001) rather than wrapping it as P2034 — found empirically by
// actually running the concurrency test below, not from documentation.
// Duck-typed rather than importing @prisma/driver-adapter-utils
// adapters, see src/lib/db/prisma.ts) can instead propagate the adapter's
// own DriverAdapterError for a serialization failure rather than wrapping
// it as P2034 — found empirically for Postgres (SQLSTATE 40001) in Phase
// 3. MySQL's write conflicts did NOT take this path in Phase 7's testing
// (they all arrived as P2034 above); this fallback is kept as defence in
// depth for whichever database is in use, not because MySQL is known to
// need it. Duck-typed rather than importing @prisma/driver-adapter-utils
// directly, since that's a transitive dependency, not one of ours.
if (
error instanceof Error &&
@@ -53,6 +53,7 @@ describe("StaffService (integration)", () => {
const { staffId } = await createTestStaff(business);
const result = await setWorkingHours(prisma, {
staffId,
businessTimezone: business.timezone,
days: [
{ dayOfWeek: Weekday.MONDAY, isOff: false, startMinutes: 9 * 60, endMinutes: 17 * 60 },
{ dayOfWeek: Weekday.SUNDAY, isOff: true },
@@ -67,6 +68,78 @@ describe("StaffService (integration)", () => {
const sunday = hours.find((h) => h.dayOfWeek === Weekday.SUNDAY);
expect(sunday?.isOff).toBe(true);
});
it("blocks marking a day off that has a future appointment, and Save Anyway bypasses it without cancelling the appointment", async () => {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const monday = nextWeekday(business.timezone, Weekday.MONDAY);
const appointment = await prisma.appointment.create({
data: {
businessId: business.businessId,
staffId,
customerId,
status: "CONFIRMED",
startAt: wallClockToInstant(monday, 12 * 60, business.timezone),
endAt: wallClockToInstant(monday, 12 * 60 + 30, business.timezone),
services: {
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
},
},
});
const days = [
{ dayOfWeek: Weekday.MONDAY, isOff: true },
{ dayOfWeek: Weekday.TUESDAY, isOff: false, startMinutes: 9 * 60, endMinutes: 17 * 60 },
];
const blocked = await setWorkingHours(prisma, { staffId, businessTimezone: business.timezone, days });
expect(blocked.success).toBe(false);
if (blocked.success) throw new Error("unreachable");
expect(blocked.conflicts.map((c) => c.id)).toContain(appointment.id);
const stillConfirmed = await prisma.appointment.findUniqueOrThrow({ where: { id: appointment.id } });
expect(stillConfirmed.status).toBe("CONFIRMED");
const acknowledged = await setWorkingHours(prisma, {
staffId,
businessTimezone: business.timezone,
days,
acknowledgeConflicts: true,
});
expect(acknowledged.success).toBe(true);
const afterSave = await prisma.appointment.findUniqueOrThrow({ where: { id: appointment.id } });
expect(afterSave.status).toBe("CONFIRMED");
expect(afterSave.staffId).toBe(staffId);
});
it("does not flag a conflict for an appointment that falls inside the new working window", async () => {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const monday = nextWeekday(business.timezone, Weekday.MONDAY);
await prisma.appointment.create({
data: {
businessId: business.businessId,
staffId,
customerId,
status: "CONFIRMED",
startAt: wallClockToInstant(monday, 12 * 60, business.timezone),
endAt: wallClockToInstant(monday, 12 * 60 + 30, business.timezone),
services: {
create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }],
},
},
});
const result = await setWorkingHours(prisma, {
staffId,
businessTimezone: business.timezone,
days: [{ dayOfWeek: Weekday.MONDAY, isOff: false, startMinutes: 9 * 60, endMinutes: 17 * 60 }],
});
expect(result.success).toBe(true);
});
});
describe("breaks", () => {
+47 -7
View File
@@ -1,6 +1,9 @@
import { randomBytes } from "node:crypto";
import bcrypt from "bcryptjs";
import type { PrismaClient } from "@/generated/prisma/client";
import { NotificationType } from "@/generated/prisma/enums";
import { notifySafely } from "@/services/notifications/notification.service";
import { getOwnerUserIds } from "@/services/notifications/recipients";
/**
* The "invite a new staff member" flow, deliberately split out of
@@ -10,12 +13,13 @@ import type { PrismaClient } from "@/generated/prisma/client";
* immediately; this file only tracks the separate "has this person set
* their own password yet" signal via StaffInvitation.acceptedAt.
*
* No real email delivery this phase — `token` is a plain opaque value
* (crypto-random, not HMAC-signed like the booking-draft cookie, because
* it's validated against a database row rather than needing to be
* self-contained) surfaced to the owner as a copy-link
* (`/invite/accept?token=...`) to send however they like. This is a
* documented development-safe shortcut, not a production email system.
* `token` is a plain opaque value (crypto-random, not HMAC-signed like the
* booking-draft cookie, because it's validated against a database row
* rather than needing to be self-contained) — it's still also surfaced to
* the owner as a copy-link (`/invite/accept?token=...`) as a fallback in
* case email delivery is unavailable/unconfigured, but as of Phase 7 a
* real STAFF_INVITATION_RECEIVED email is queued too (see
* NotificationService/templates/staff-emails.ts).
*/
const INVITATION_TTL_DAYS = 7;
@@ -30,7 +34,7 @@ export async function createInvitation(
prisma: PrismaClient,
params: { businessId: string; staffId: string; email: string; invitedByUserId?: string },
) {
return prisma.staffInvitation.create({
const invitation = await prisma.staffInvitation.create({
data: {
businessId: params.businessId,
staffId: params.staffId,
@@ -40,6 +44,28 @@ export async function createInvitation(
expiresAt: new Date(Date.now() + INVITATION_TTL_DAYS * 24 * 60 * 60 * 1000),
},
});
const staff = await prisma.staffProfile.findUnique({
where: { id: params.staffId },
include: { user: { select: { id: true, name: true } }, role: { select: { name: true } }, business: { select: { name: true } } },
});
const invitedBy = params.invitedByUserId
? await prisma.user.findUnique({ where: { id: params.invitedByUserId }, select: { name: true } })
: null;
if (staff) {
await notifySafely(prisma, {
businessId: params.businessId,
type: NotificationType.STAFF_INVITATION_RECEIVED,
recipientUserId: staff.user.id,
subject: `You've been invited to join ${staff.business.name}`,
bodyText: `${invitedBy?.name ?? "The owner"} has added you as ${staff.role.name} at ${staff.business.name}. Set your password to get started.`,
deepLinkPath: `/invite/accept?token=${invitation.token}`,
idempotencyKey: `notify:staff-invitation-received:${invitation.id}`,
});
}
return invitation;
}
/** An expired or already-viewed invitation gets a fresh token/expiry rather than a second row — one StaffInvitation per StaffProfile (see the schema's `staffId @unique`). */
@@ -99,5 +125,19 @@ export async function acceptInvitation(
data: { acceptedAt: new Date() },
}),
]);
const ownerUserIds = await getOwnerUserIds(prisma, check.invitation.businessId);
for (const ownerUserId of ownerUserIds) {
await notifySafely(prisma, {
businessId: check.invitation.businessId,
type: NotificationType.OWNER_STAFF_INVITATION_ACCEPTED,
recipientUserId: ownerUserId,
subject: "Invitation accepted",
bodyText: `${check.invitation.staff.user.name ?? "A new team member"} has set their password and joined ${check.invitation.staff.business.name}.`,
deepLinkPath: `/owner/team/${check.invitation.staff.id}`,
idempotencyKey: `notify:owner-staff-invitation-accepted:${check.invitation.id}:${ownerUserId}`,
});
}
return { success: true };
}
+32
View File
@@ -0,0 +1,32 @@
import { randomUUID } from "node:crypto";
import type { PrismaClient } from "@/generated/prisma/client";
import { NotificationType } from "@/generated/prisma/enums";
import { notifySafely } from "@/services/notifications/notification.service";
/**
* STAFF_SCHEDULE_CHANGED — fired only when an OWNER changes a staff
* member's working hours/breaks/time off on their behalf (see
* src/features/owner/actions/breaks.ts, time-off.ts, staff.ts). Never
* called from the staff self-service editors — notifying someone about
* their own action would just be noise, and StaffService itself has no
* "who initiated this" context to gate on, since owner-on-behalf and
* self-service both call the exact same StaffService functions by design
* (see docs/phase-6-summary.md, "explicitly NOT creating separate
* owner-only availability logic"). A fresh idempotencyKey every call —
* unlike a booking, there's no natural "one instance" to dedupe against;
* every distinct owner edit is its own notification-worthy event.
*/
export async function notifyStaffScheduleChanged(
prisma: PrismaClient,
params: { businessId: string; staffUserId: string; changedByName: string; whatChanged: string; viewSchedulePath: string },
): Promise<void> {
await notifySafely(prisma, {
businessId: params.businessId,
type: NotificationType.STAFF_SCHEDULE_CHANGED,
recipientUserId: params.staffUserId,
subject: "Your schedule was updated",
bodyText: `${params.changedByName} has updated ${params.whatChanged}.`,
deepLinkPath: params.viewSchedulePath,
idempotencyKey: `notify:staff-schedule-changed:${randomUUID()}`,
});
}
+69 -56
View File
@@ -1,5 +1,5 @@
import type { PrismaClient } from "@/generated/prisma/client";
import { NotificationCategory, NotificationChannel, Weekday } from "@/generated/prisma/enums";
import { Weekday } from "@/generated/prisma/enums";
import {
calendarDateOf,
calendarDateToUtcMidnight,
@@ -152,12 +152,72 @@ export async function getWorkingHours(prisma: PrismaClient, staffId: string) {
return prisma.workingHours.findMany({ where: { staffId }, orderBy: { dayOfWeek: "asc" } });
}
/**
* Future, non-cancelled appointments that would fall outside the proposed
* working hours — either on a day newly marked off, or before/after the
* new start/end window on a day that stays working. Same pattern as
* findBreakConflicts/findTimeOffConflicts/BusinessService's
* findOpeningHoursConflicts (see docs/phase-7-summary.md, "Working-hours
* conflict review"): this never cancels anything itself, only reports what
* the caller should show before asking for acknowledgement.
*/
export async function findWorkingHoursConflicts(
prisma: PrismaClient,
params: { staffId: string; businessTimezone: string; days: WorkingHoursDayInput[] },
) {
const appointments = await prisma.appointment.findMany({
where: {
staffId: params.staffId,
status: { notIn: ["CANCELLED", "NO_SHOW"] },
startAt: { gte: new Date() },
},
include: APPOINTMENT_CONFLICT_INCLUDE,
orderBy: { startAt: "asc" },
});
const byDay = new Map(params.days.map((d) => [d.dayOfWeek, d]));
return appointments.filter((a) => {
const weekday = weekdayOf(calendarDateOf(a.startAt, params.businessTimezone));
const proposed = byDay.get(weekday);
if (!proposed) return false;
if (proposed.isOff) return true;
const aStart = minutesFromMidnight(a.startAt, params.businessTimezone);
const aEnd = minutesFromMidnight(a.endAt, params.businessTimezone);
return aStart < (proposed.startMinutes ?? 0) || aEnd > (proposed.endMinutes ?? 24 * 60);
});
}
export async function setWorkingHours(
prisma: PrismaClient,
params: { staffId: string; days: WorkingHoursDayInput[] },
): Promise<{ success: boolean; validation: ValidationResult<StaffServiceErrorCode> }> {
params: {
staffId: string;
businessTimezone: string;
days: WorkingHoursDayInput[];
acknowledgeConflicts?: boolean;
},
) {
const validation = validateWorkingHoursInput(params.days);
if (!validation.valid) return { success: false, validation };
if (!validation.valid) return { success: false as const, validation, conflicts: [] };
if (!params.acknowledgeConflicts) {
const conflicts = await findWorkingHoursConflicts(prisma, {
staffId: params.staffId,
businessTimezone: params.businessTimezone,
days: params.days,
});
if (conflicts.length > 0) {
return {
success: false as const,
conflicts,
validation: fail({
code: "APPOINTMENT_CONFLICTS",
message: "These hours would leave existing appointments outside your working hours.",
}),
};
}
}
await prisma.$transaction(
params.days.map((day) =>
@@ -179,7 +239,7 @@ export async function setWorkingHours(
),
);
return { success: true, validation: ok() };
return { success: true as const, conflicts: [], validation: ok() };
}
// ── Breaks ──────────────────────────────────────────────────────────────
@@ -582,53 +642,6 @@ export async function deleteTimeOff(
return { success: true };
}
// ── Notification preferences ───────────────────────────────────────────
// UI only this phase — no real delivery. Every (category, channel) pair
// defaults to enabled (matches the schema's `isEnabled @default(true)`),
// so a user with no rows yet still sees a fully-populated, all-on grid.
const NOTIFICATION_CATEGORIES = Object.values(NotificationCategory);
const NOTIFICATION_CHANNELS = Object.values(NotificationChannel);
export interface NotificationPreferenceCell {
category: NotificationCategory;
channel: NotificationChannel;
isEnabled: boolean;
}
export async function getNotificationPreferences(
prisma: PrismaClient,
userId: string,
): Promise<NotificationPreferenceCell[]> {
const rows = await prisma.notificationPreference.findMany({ where: { userId } });
const byKey = new Map(rows.map((r) => [`${r.category}:${r.channel}`, r.isEnabled]));
const cells: NotificationPreferenceCell[] = [];
for (const category of NOTIFICATION_CATEGORIES) {
for (const channel of NOTIFICATION_CHANNELS) {
cells.push({ category, channel, isEnabled: byKey.get(`${category}:${channel}`) ?? true });
}
}
return cells;
}
export async function setNotificationPreferences(
prisma: PrismaClient,
userId: string,
updates: NotificationPreferenceCell[],
): Promise<{ success: true }> {
await prisma.$transaction(
updates.map((u) =>
prisma.notificationPreference.upsert({
where: { userId_category_channel: { userId, category: u.category, channel: u.channel } },
create: { userId, category: u.category, channel: u.channel, isEnabled: u.isEnabled },
update: { isEnabled: u.isEnabled },
}),
),
);
return { success: true };
}
// ── Owner: team management ─────────────────────────────────────────────
// Everything below acts on staff *other than* the caller — the owner's own
// business-membership must already be verified by the caller (see
@@ -643,13 +656,13 @@ export async function listStaffForBusiness(
where: {
businessId: params.businessId,
...(params.includeInactive ? {} : { status: { not: "INACTIVE" } }),
// No `mode: "insensitive"` — that's a Postgres-only Prisma option (removed in the
// Phase 7 MySQL migration). MySQL's default collation (utf8mb4_0900_ai_ci — "ai" =
// accent-insensitive, "ci" = case-insensitive) already makes `contains` case-insensitive.
...(params.q
? {
user: {
OR: [
{ name: { contains: params.q, mode: "insensitive" } },
{ email: { contains: params.q, mode: "insensitive" } },
],
OR: [{ name: { contains: params.q } }, { email: { contains: params.q } }],
},
}
: {}),