Phase 7
This commit is contained in:
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}`,
|
||||
});
|
||||
}
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
@@ -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}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
"<script>alert("x & y's")</script>",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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 & 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("<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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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),
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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()}`,
|
||||
});
|
||||
}
|
||||
@@ -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 } }],
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
|
||||
Reference in new issue
Block a user