Guest merge Guest history now joins the account when the email is proven, in three places: OTP sign-in, Google sign-in, and staff adding a client. Unverified emails are never matched. What moves: appointments, notes, push subscriptions and notifications go onto the account's profile, and the guest profile is deleted. One email, several guests: if the email has several guest profiles (one per phone number), all are merged. No profile yet: the oldest guest profile becomes the account's profile. Account wins: it keeps its own name and phone, and only gaps get filled. Stamps: summed, with the ledger entries moved across. If the combined total reaches the threshold, the reward is earned and the customer is notified. Safety: it runs in one transaction and is audit-logged. A failure never blocks sign-in; the next sign-in retries. Reminders Staff: one reminder, 2h before; the 24h one is gone. Push and in-app are on by default and email is opt-in. Defaults are now per channel, and the settings page shows email off, push and in-app on. Customer: one reminder at 24h. If booked too late for that, they get one at 2h instead, and none if the appointment is under 2h away. In-app reminders: held back and released by the worker at the reminder time, so they no longer appear the moment you book. Cancelling or rescheduling clears them, and "mark all read" no longer marks unseen ones. Guest links: every guest email now carries a working link. The booking-time token is unchanged, and later emails use one derived from the appointment id and AUTH_SECRET. Revoking a guest appointment's access kills both. Reward expiry: two emails per reward at most, at 7 days and 1 day before. Stale guard: the worker drops a reminder, without counting a failure, if the appointment has started or isn't confirmed. OTP rate-limit tests (2): the failures came from my scratch environment, not the app. I had E2E_TEST_MODE=true in the scratch .env, which turns off the OTP rate limits, so the tests had nothing to assert on. I'd also used MariaDB, which throws error 1020 on concurrent upserts, while your real stack runs mysql:8.0. On MySQL 8 without the flag they pass every time. I also pinned that flag off in the integration config, so a .env with it set can't break them again. I tested that by turning it on in .env, and the OTP tests still passed. Authorization and nav-role suites (2): this was a real bug. Both files import @/lib/auth/permissions, which builds the database connection when it's imported, before the test fixtures load .env. The suites crashed before running a single test, so the owner-permission checks (barbers can't do owner actions, nothing leaks across businesses) hadn't been running at all. I now load dotenv once in the integration config, so no test depends on import order. They run and pass (13 tests).
This commit is contained in:
25 files changed
+1246
-302
No files matched your search
@@ -26,7 +26,7 @@ export default async function CustomerNotificationSettingsPage() {
|
||||
CHANNELS.map((channel) => ({
|
||||
type: group.type,
|
||||
channel,
|
||||
isEnabled: rows.find((r) => r.type === group.type && r.channel === channel)?.isEnabled ?? defaultPreferenceIsEnabled(group.type),
|
||||
isEnabled: rows.find((r) => r.type === group.type && r.channel === channel)?.isEnabled ?? defaultPreferenceIsEnabled(group.type, channel),
|
||||
})),
|
||||
);
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ export default async function StaffNotificationsPage() {
|
||||
CHANNELS.map((channel) => ({
|
||||
type: group.type,
|
||||
channel,
|
||||
isEnabled: rows.find((r) => r.type === group.type && r.channel === channel)?.isEnabled ?? defaultPreferenceIsEnabled(group.type),
|
||||
isEnabled: rows.find((r) => r.type === group.type && r.channel === channel)?.isEnabled ?? defaultPreferenceIsEnabled(group.type, channel),
|
||||
})),
|
||||
);
|
||||
|
||||
|
||||
+10
@@ -5,6 +5,7 @@ import Google from "next-auth/providers/google";
|
||||
import { authConfig } from "@/auth.config";
|
||||
import { authorizeCredentials } from "@/lib/auth/authorize-credentials";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { claimGuestProfilesSafely } from "@/services/customer/guest-merge.service";
|
||||
|
||||
export { authorizeCredentials } from "@/lib/auth/authorize-credentials";
|
||||
|
||||
@@ -37,6 +38,15 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
authorize: authorizeCredentials,
|
||||
}),
|
||||
],
|
||||
events: {
|
||||
// Email OTP sign-ins claim guest history inside authorizeCredentials
|
||||
// itself; Google never passes through that, so it's done here. Google
|
||||
// only ever hands back a verified email, which the merge itself
|
||||
// re-checks via User.emailVerified before touching anything.
|
||||
async signIn({ user, account }) {
|
||||
if (account?.provider === "google" && user.id) await claimGuestProfilesSafely(prisma, user.id);
|
||||
},
|
||||
},
|
||||
callbacks: {
|
||||
async jwt({ token, user, trigger, session }) {
|
||||
if (user?.id) {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { prisma } from "@/services/scheduling/__tests__/fixtures";
|
||||
import { authorizeCredentials } from "@/lib/auth/authorize-credentials";
|
||||
import { requestOtp } from "@/lib/auth/otp.service";
|
||||
import { MockEmailProvider } from "@/services/notifications/providers/mock-email-provider";
|
||||
|
||||
/**
|
||||
* Signing in with an emailed code proves the person controls that inbox,
|
||||
* so it's the moment guest bookings made under the same address join their
|
||||
* account (see services/customer/guest-merge.service.ts).
|
||||
*/
|
||||
describe("OTP sign-in claims guest history (integration)", () => {
|
||||
const email = `signin-merge-${Date.now()}@test.local`;
|
||||
let userId: string;
|
||||
let guestId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
name: "Returning Guest",
|
||||
emailVerified: new Date(),
|
||||
customerProfile: { create: { firstName: "Returning", lastName: "Guest", email, isGuest: false } },
|
||||
},
|
||||
});
|
||||
userId = user.id;
|
||||
const guest = await prisma.customerProfile.create({
|
||||
data: { firstName: "Returning", lastName: "Guest", email, phone: "+44 7700 900100", isGuest: true },
|
||||
});
|
||||
guestId = guest.id;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.customerProfile.deleteMany({ where: { id: guestId } });
|
||||
await prisma.emailOtp.deleteMany({ where: { email } }).catch(() => {});
|
||||
await prisma.auditLog.deleteMany({ where: { actorUserId: userId } });
|
||||
await prisma.user.deleteMany({ where: { id: userId } });
|
||||
});
|
||||
|
||||
it("merges the guest profile when the code is correct", async () => {
|
||||
const provider = new MockEmailProvider();
|
||||
await requestOtp(prisma, { email, purpose: "login", onlyIfUserExists: true, emailProvider: provider });
|
||||
const code = /verification code is (\d{6})/.exec(provider.sentEmails[0]?.text ?? "")?.[1];
|
||||
if (!code) throw new Error("Test setup failed: couldn't read the OTP code.");
|
||||
|
||||
const signedIn = await authorizeCredentials({ email, code });
|
||||
|
||||
expect(signedIn?.id).toBe(userId);
|
||||
expect(await prisma.customerProfile.findUnique({ where: { id: guestId } })).toBeNull();
|
||||
});
|
||||
|
||||
it("does not merge anything when the code is wrong", async () => {
|
||||
const provider = new MockEmailProvider();
|
||||
await requestOtp(prisma, { email, purpose: "login", onlyIfUserExists: true, emailProvider: provider });
|
||||
|
||||
expect(await authorizeCredentials({ email, code: "000000" })).toBeNull();
|
||||
expect(await prisma.customerProfile.findUnique({ where: { id: guestId } })).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { finalizeOtp, verifyOtp } from "@/lib/auth/otp.service";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { checkRateLimit } from "@/lib/security/rate-limit";
|
||||
import { claimGuestProfilesSafely } from "@/services/customer/guest-merge.service";
|
||||
import { secretsMatch } from "@/lib/security/secrets-match";
|
||||
|
||||
/**
|
||||
@@ -77,5 +78,11 @@ export async function authorizeCredentials(
|
||||
const user = await prisma.user.findUnique({ where: { email } });
|
||||
if (!user) return null;
|
||||
|
||||
// The code just proved they control this inbox, so any guest bookings
|
||||
// made under it can now safely join their account — see
|
||||
// guest-merge.service.ts. Skipped for the installer's finalize path
|
||||
// (no guest history exists before the business is even installed).
|
||||
if (raw?.finalize !== "true") await claimGuestProfilesSafely(prisma, user.id);
|
||||
|
||||
return { id: user.id, email: user.email, name: user.name, image: user.image };
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
cleanupTestBusiness,
|
||||
createTestAppointment,
|
||||
createTestBusiness,
|
||||
createTestStaff,
|
||||
prisma,
|
||||
} from "@/services/scheduling/__tests__/fixtures";
|
||||
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
|
||||
import { createCustomerByStaff } from "../customer.service";
|
||||
import { claimGuestProfilesForUser } from "../guest-merge.service";
|
||||
|
||||
let counter = 0;
|
||||
const uniqueEmail = () => `merge-${Date.now()}-${++counter}@test.local`;
|
||||
|
||||
describe("claimGuestProfilesForUser (integration)", () => {
|
||||
let business: TestBusiness;
|
||||
let leftoverProfileIds: string[];
|
||||
|
||||
beforeEach(async () => {
|
||||
business = await createTestBusiness();
|
||||
leftoverProfileIds = [];
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupTestBusiness(business);
|
||||
await prisma.customerProfile.deleteMany({ where: { id: { in: leftoverProfileIds } } });
|
||||
});
|
||||
|
||||
async function createUser(email: string, opts: { verified?: boolean; withProfile?: boolean } = {}) {
|
||||
const { verified = true, withProfile = true } = opts;
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
name: "Real Person",
|
||||
emailVerified: verified ? new Date() : null,
|
||||
...(withProfile ? { customerProfile: { create: { firstName: "Real", lastName: "Person", email, isGuest: false } } } : {}),
|
||||
},
|
||||
include: { customerProfile: true },
|
||||
});
|
||||
business.createdUserIds.push(user.id);
|
||||
return user;
|
||||
}
|
||||
|
||||
async function createGuest(email: string, extra: { phone?: string; firstName?: string } = {}) {
|
||||
const guest = await prisma.customerProfile.create({
|
||||
data: { firstName: extra.firstName ?? "Guest", lastName: "Visitor", email, phone: extra.phone ?? null, isGuest: true },
|
||||
});
|
||||
leftoverProfileIds.push(guest.id);
|
||||
return guest;
|
||||
}
|
||||
|
||||
async function bookFor(customerId: string) {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
return (await createTestAppointment(business, { customerId, staffId })).appointmentId;
|
||||
}
|
||||
|
||||
it("moves a guest's appointments and notes onto the registered profile, then removes the guest profile", async () => {
|
||||
const email = uniqueEmail();
|
||||
const user = await createUser(email);
|
||||
const guest = await createGuest(email);
|
||||
const appointmentId = await bookFor(guest.id);
|
||||
const staff = await prisma.staffProfile.findFirstOrThrow({ where: { businessId: business.businessId } });
|
||||
await prisma.customerNote.create({ data: { customerId: guest.id, authorStaffId: staff.id, body: "Prefers a skin fade" } });
|
||||
|
||||
const result = await claimGuestProfilesForUser(prisma, user.id);
|
||||
|
||||
expect(result).toEqual({ mergedProfileCount: 1, appointmentsMoved: 1 });
|
||||
const appointment = await prisma.appointment.findUniqueOrThrow({ where: { id: appointmentId } });
|
||||
expect(appointment.customerId).toBe(user.customerProfile!.id);
|
||||
expect(await prisma.customerNote.count({ where: { customerId: user.customerProfile!.id } })).toBe(1);
|
||||
expect(await prisma.customerProfile.findUnique({ where: { id: guest.id } })).toBeNull();
|
||||
});
|
||||
|
||||
it("merges every guest profile for the email — one email can have several, one per phone number", async () => {
|
||||
const email = uniqueEmail();
|
||||
const user = await createUser(email);
|
||||
const a = await createGuest(email, { phone: "+44 7700 900001" });
|
||||
const b = await createGuest(email, { phone: "+44 7700 900002" });
|
||||
await bookFor(a.id);
|
||||
await bookFor(b.id);
|
||||
|
||||
const result = await claimGuestProfilesForUser(prisma, user.id);
|
||||
|
||||
expect(result).toEqual({ mergedProfileCount: 2, appointmentsMoved: 2 });
|
||||
expect(await prisma.appointment.count({ where: { customerId: user.customerProfile!.id } })).toBe(2);
|
||||
});
|
||||
|
||||
it("matches the email case-insensitively", async () => {
|
||||
const email = uniqueEmail();
|
||||
const user = await createUser(email.toUpperCase());
|
||||
await createGuest(email);
|
||||
|
||||
expect((await claimGuestProfilesForUser(prisma, user.id)).mergedProfileCount).toBe(1);
|
||||
});
|
||||
|
||||
it("leaves guests under a different email alone", async () => {
|
||||
const user = await createUser(uniqueEmail());
|
||||
const other = await createGuest(uniqueEmail());
|
||||
await bookFor(other.id);
|
||||
|
||||
const result = await claimGuestProfilesForUser(prisma, user.id);
|
||||
|
||||
expect(result.mergedProfileCount).toBe(0);
|
||||
expect(await prisma.customerProfile.findUnique({ where: { id: other.id } })).not.toBeNull();
|
||||
});
|
||||
|
||||
it("does nothing until the account's email is verified — an unproven address must not inherit someone's history", async () => {
|
||||
const email = uniqueEmail();
|
||||
const user = await createUser(email, { verified: false });
|
||||
const guest = await createGuest(email);
|
||||
await bookFor(guest.id);
|
||||
|
||||
expect((await claimGuestProfilesForUser(prisma, user.id)).mergedProfileCount).toBe(0);
|
||||
expect(await prisma.customerProfile.findUnique({ where: { id: guest.id } })).not.toBeNull();
|
||||
});
|
||||
|
||||
it("promotes the oldest guest profile when the account has no profile yet, and folds the rest into it", async () => {
|
||||
const email = uniqueEmail();
|
||||
const user = await createUser(email, { withProfile: false });
|
||||
const older = await createGuest(email, { phone: "+44 7700 900003", firstName: "Older" });
|
||||
const newer = await createGuest(email, { phone: "+44 7700 900004", firstName: "Newer" });
|
||||
await bookFor(older.id);
|
||||
await bookFor(newer.id);
|
||||
|
||||
const result = await claimGuestProfilesForUser(prisma, user.id);
|
||||
|
||||
expect(result.mergedProfileCount).toBe(2);
|
||||
const profile = await prisma.customerProfile.findUniqueOrThrow({ where: { userId: user.id } });
|
||||
expect(profile.id).toBe(older.id);
|
||||
expect(profile.isGuest).toBe(false);
|
||||
expect(await prisma.appointment.count({ where: { customerId: profile.id } })).toBe(2);
|
||||
expect(await prisma.customerProfile.findUnique({ where: { id: newer.id } })).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the account's own details and only fills in what's missing", async () => {
|
||||
const email = uniqueEmail();
|
||||
const user = await createUser(email);
|
||||
await createGuest(email, { phone: "+44 7700 900005", firstName: "Different" });
|
||||
|
||||
await claimGuestProfilesForUser(prisma, user.id);
|
||||
|
||||
const profile = await prisma.customerProfile.findUniqueOrThrow({ where: { userId: user.id } });
|
||||
expect(profile.firstName).toBe("Real"); // account name wins
|
||||
expect(profile.phone).toBe("+44 7700 900005"); // gap filled
|
||||
});
|
||||
|
||||
it("is a no-op the second time", async () => {
|
||||
const email = uniqueEmail();
|
||||
const user = await createUser(email);
|
||||
await createGuest(email);
|
||||
|
||||
await claimGuestProfilesForUser(prisma, user.id);
|
||||
expect(await claimGuestProfilesForUser(prisma, user.id)).toEqual({ mergedProfileCount: 0, appointmentsMoved: 0 });
|
||||
});
|
||||
|
||||
it("writes an audit log entry for the merge", async () => {
|
||||
const email = uniqueEmail();
|
||||
const user = await createUser(email);
|
||||
const guest = await createGuest(email);
|
||||
|
||||
await claimGuestProfilesForUser(prisma, user.id);
|
||||
|
||||
const entry = await prisma.auditLog.findFirst({ where: { action: "GUEST_PROFILES_MERGED", entityId: user.customerProfile!.id } });
|
||||
expect(entry).not.toBeNull();
|
||||
expect(JSON.stringify(entry?.metadata)).toContain(guest.id);
|
||||
await prisma.auditLog.deleteMany({ where: { entityId: user.customerProfile!.id } });
|
||||
});
|
||||
|
||||
describe("loyalty stamps", () => {
|
||||
async function programme(stampsRequired = 10) {
|
||||
return prisma.loyaltyProgramme.upsert({
|
||||
where: { businessId: business.businessId },
|
||||
update: { stampsRequired, isEnabled: true },
|
||||
create: { businessId: business.businessId, isEnabled: true, stampsRequired, rewardType: "PERCENTAGE", rewardValue: 50, rewardLabel: "50% Off" },
|
||||
});
|
||||
}
|
||||
|
||||
it("adds the guest's stamps to the account's, moving the ledger entries along with them", async () => {
|
||||
const prog = await programme();
|
||||
const email = uniqueEmail();
|
||||
const user = await createUser(email);
|
||||
const guest = await createGuest(email);
|
||||
const staff = await createTestStaff(business);
|
||||
const registered = await prisma.loyaltyAccount.create({ data: { customerId: user.customerProfile!.id, loyaltyProgrammeId: prog.id, currentStamps: 3 } });
|
||||
const guestAccount = await prisma.loyaltyAccount.create({ data: { customerId: guest.id, loyaltyProgrammeId: prog.id, currentStamps: 4 } });
|
||||
await prisma.loyaltyTransaction.create({ data: { loyaltyAccountId: guestAccount.id, type: "STAMP_ADDED", amount: 4, authoredByStaffId: staff.staffId } });
|
||||
|
||||
await claimGuestProfilesForUser(prisma, user.id);
|
||||
|
||||
const merged = await prisma.loyaltyAccount.findUniqueOrThrow({ where: { id: registered.id }, include: { transactions: true } });
|
||||
expect(merged.currentStamps).toBe(7);
|
||||
expect(merged.transactions).toHaveLength(1);
|
||||
expect(await prisma.loyaltyAccount.findUnique({ where: { id: guestAccount.id } })).toBeNull();
|
||||
});
|
||||
|
||||
it("hands over the guest's account whole when the registered profile has none", async () => {
|
||||
const prog = await programme();
|
||||
const email = uniqueEmail();
|
||||
const user = await createUser(email);
|
||||
const guest = await createGuest(email);
|
||||
const guestAccount = await prisma.loyaltyAccount.create({ data: { customerId: guest.id, loyaltyProgrammeId: prog.id, currentStamps: 5 } });
|
||||
|
||||
await claimGuestProfilesForUser(prisma, user.id);
|
||||
|
||||
const moved = await prisma.loyaltyAccount.findUniqueOrThrow({ where: { id: guestAccount.id } });
|
||||
expect(moved.customerId).toBe(user.customerProfile!.id);
|
||||
expect(moved.currentStamps).toBe(5);
|
||||
});
|
||||
|
||||
it("earns the reward when the combined total reaches the threshold even though neither half did", async () => {
|
||||
const prog = await programme(10);
|
||||
const email = uniqueEmail();
|
||||
const user = await createUser(email);
|
||||
const guest = await createGuest(email);
|
||||
const registered = await prisma.loyaltyAccount.create({ data: { customerId: user.customerProfile!.id, loyaltyProgrammeId: prog.id, currentStamps: 6 } });
|
||||
await prisma.loyaltyAccount.create({ data: { customerId: guest.id, loyaltyProgrammeId: prog.id, currentStamps: 5 } });
|
||||
|
||||
await claimGuestProfilesForUser(prisma, user.id);
|
||||
|
||||
const rewards = await prisma.loyaltyReward.findMany({ where: { loyaltyAccountId: registered.id, status: "AVAILABLE" } });
|
||||
expect(rewards).toHaveLength(1);
|
||||
expect((await prisma.loyaltyAccount.findUniqueOrThrow({ where: { id: registered.id } })).currentStamps).toBe(11);
|
||||
});
|
||||
|
||||
it("does not stack a second reward when one is already waiting", async () => {
|
||||
const prog = await programme(10);
|
||||
const email = uniqueEmail();
|
||||
const user = await createUser(email);
|
||||
const guest = await createGuest(email);
|
||||
const registered = await prisma.loyaltyAccount.create({ data: { customerId: user.customerProfile!.id, loyaltyProgrammeId: prog.id, currentStamps: 10 } });
|
||||
await prisma.loyaltyReward.create({ data: { loyaltyAccountId: registered.id, rewardType: "PERCENTAGE", value: 50, label: "50% Off", status: "AVAILABLE" } });
|
||||
await prisma.loyaltyAccount.create({ data: { customerId: guest.id, loyaltyProgrammeId: prog.id, currentStamps: 2 } });
|
||||
|
||||
await claimGuestProfilesForUser(prisma, user.id);
|
||||
|
||||
expect(await prisma.loyaltyReward.count({ where: { loyaltyAccountId: registered.id, status: "AVAILABLE" } })).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createCustomerByStaff", () => {
|
||||
it("folds in an existing guest's history when staff add the same email", async () => {
|
||||
const email = uniqueEmail();
|
||||
const guest = await createGuest(email);
|
||||
const appointmentId = await bookFor(guest.id);
|
||||
|
||||
const result = await createCustomerByStaff(prisma, { firstName: "Sam", lastName: "Walker", email });
|
||||
expect(result.success).toBe(true);
|
||||
const profile = await prisma.customerProfile.findUniqueOrThrow({ where: { id: result.customerId } });
|
||||
business.createdUserIds.push(profile.userId!);
|
||||
|
||||
expect((await prisma.appointment.findUniqueOrThrow({ where: { id: appointmentId } })).customerId).toBe(result.customerId);
|
||||
expect(await prisma.customerProfile.findUnique({ where: { id: guest.id } })).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import type { GuestDetails } from "@/lib/booking-draft/schema";
|
||||
import { claimGuestProfilesSafely } from "./guest-merge.service";
|
||||
|
||||
/**
|
||||
* New in Phase 4 — anticipated by the Phase 3 brief's list of future
|
||||
@@ -38,9 +39,9 @@ import type { GuestDetails } from "@/lib/booking-draft/schema";
|
||||
* later phase, not something this function silently "fixes" by
|
||||
* guessing which version is correct.
|
||||
*
|
||||
* Full account claiming (a guest later creating an account and having
|
||||
* their past guest bookings attached to it) is explicitly out of scope
|
||||
* for this phase — see docs/phase-4-summary.md.
|
||||
* A guest later creating an account has their past guest bookings
|
||||
* attached to it — see guest-merge.service.ts (originally out of scope,
|
||||
* docs/phase-4-summary.md).
|
||||
*/
|
||||
|
||||
export interface ResolveCustomerParams {
|
||||
@@ -151,12 +152,9 @@ export interface CreateCustomerByStaffResult {
|
||||
* not a partial/pending account the way a staff invitation is.
|
||||
*
|
||||
* Deliberately does not call notify()/requestOtp() at all — the customer
|
||||
* hears nothing until they themselves ask to sign in later. Does not
|
||||
* attempt to match/attach an existing guest CustomerProfile with the same
|
||||
* email/phone either — full guest-to-registered account claiming is an
|
||||
* explicit out-of-scope case for this service (see this file's own
|
||||
* header); a plain email-uniqueness check against User is enough here,
|
||||
* same as register.ts.
|
||||
* hears nothing until they themselves ask to sign in later. Does attach
|
||||
* any existing guest history under the same email — see
|
||||
* guest-merge.service.ts.
|
||||
*/
|
||||
export async function createCustomerByStaff(
|
||||
prisma: PrismaClient,
|
||||
@@ -182,10 +180,15 @@ export async function createCustomerByStaff(
|
||||
create: { firstName, lastName, email, phone, isGuest: false },
|
||||
},
|
||||
},
|
||||
include: { customerProfile: true },
|
||||
});
|
||||
|
||||
return { success: true, customerId: user.customerProfile!.id };
|
||||
// Staff typed this email themselves, so it's as trusted as the guest
|
||||
// booking they may have taken for the same person earlier — fold any
|
||||
// such history in now rather than leaving a duplicate until first sign-in.
|
||||
await claimGuestProfilesSafely(prisma, user.id);
|
||||
const profile = await prisma.customerProfile.findUniqueOrThrow({ where: { userId: user.id }, select: { id: true } });
|
||||
|
||||
return { success: true, customerId: profile.id };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { writeAuditLog } from "@/lib/audit/write-audit-log";
|
||||
import { notifyLoyaltyRewardEarned } from "@/services/loyalty/loyalty-notifications";
|
||||
import { awardRewardIfEarned } from "@/services/loyalty/loyalty.service";
|
||||
|
||||
/**
|
||||
* Guest-to-account merge. A guest booking creates a CustomerProfile with no
|
||||
* linked User (see resolveGuestCustomer in customer.service.ts); if that
|
||||
* person later signs up with the same email they'd otherwise end up with a
|
||||
* second, empty profile while their booking history, loyalty stamps and
|
||||
* notes stayed stranded on the guest one.
|
||||
*
|
||||
* Only ever runs once the account's email is PROVEN (`User.emailVerified`
|
||||
* set — an OTP sign-in, a Google sign-in, or staff adding the client
|
||||
* themselves), never off an email someone merely typed: matching guest
|
||||
* history against an unverified address would hand a stranger another
|
||||
* person's bookings. Matches on email alone — guest profiles are keyed by
|
||||
* email + phone, so one email can legitimately have several, and all of
|
||||
* them belong to whoever controls that inbox.
|
||||
*
|
||||
* Everything happens in one transaction: either every guest profile for
|
||||
* this email is folded into the account's profile, or nothing changes.
|
||||
*/
|
||||
|
||||
export interface ClaimGuestProfilesResult {
|
||||
mergedProfileCount: number;
|
||||
appointmentsMoved: number;
|
||||
}
|
||||
|
||||
const NOTHING_TO_MERGE: ClaimGuestProfilesResult = { mergedProfileCount: 0, appointmentsMoved: 0 };
|
||||
|
||||
export async function claimGuestProfilesForUser(prisma: PrismaClient, userId: string): Promise<ClaimGuestProfilesResult> {
|
||||
const user = await prisma.user.findUnique({ where: { id: userId }, select: { email: true, emailVerified: true } });
|
||||
if (!user?.email || !user.emailVerified) return NOTHING_TO_MERGE;
|
||||
const email = user.email.trim().toLowerCase();
|
||||
|
||||
// Cheap early-out — this runs on every sign-in, and nearly all of them
|
||||
// have nothing to merge. The transaction below re-reads, so a concurrent
|
||||
// sign-in can't merge the same profile twice.
|
||||
const anyGuest = await prisma.customerProfile.findFirst({ where: { userId: null, isGuest: true, email }, select: { id: true } });
|
||||
if (!anyGuest) return NOTHING_TO_MERGE;
|
||||
|
||||
const outcome = await prisma.$transaction(async (tx) => {
|
||||
const guests = await tx.customerProfile.findMany({
|
||||
where: { userId: null, isGuest: true, email },
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
if (guests.length === 0) return null;
|
||||
|
||||
let target = await tx.customerProfile.findUnique({ where: { userId } });
|
||||
let sources = guests;
|
||||
if (!target) {
|
||||
// No account profile yet (they've never booked while signed in) —
|
||||
// the oldest guest profile simply becomes it, rather than creating
|
||||
// an empty one only to merge into it.
|
||||
const [first, ...rest] = guests;
|
||||
if (!first) return null;
|
||||
target = await tx.customerProfile.update({ where: { id: first.id }, data: { userId, isGuest: false } });
|
||||
sources = rest;
|
||||
}
|
||||
|
||||
let appointmentsMoved = 0;
|
||||
const earnedRewards: { rewardId: string; businessId: string; label: string }[] = [];
|
||||
|
||||
for (const source of sources) {
|
||||
const moved = await tx.appointment.updateMany({ where: { customerId: source.id }, data: { customerId: target.id } });
|
||||
appointmentsMoved += moved.count;
|
||||
await tx.customerNote.updateMany({ where: { customerId: source.id }, data: { customerId: target.id } });
|
||||
await tx.pushSubscription.updateMany({ where: { customerId: source.id }, data: { customerId: target.id } });
|
||||
await tx.notification.updateMany({ where: { recipientCustomerId: source.id }, data: { recipientCustomerId: target.id } });
|
||||
|
||||
const accounts = await tx.loyaltyAccount.findMany({ where: { customerId: source.id }, include: { loyaltyProgramme: true } });
|
||||
for (const account of accounts) {
|
||||
const existing = await tx.loyaltyAccount.findUnique({
|
||||
where: { customerId_loyaltyProgrammeId: { customerId: target.id, loyaltyProgrammeId: account.loyaltyProgrammeId } },
|
||||
});
|
||||
if (!existing) {
|
||||
await tx.loyaltyAccount.update({ where: { id: account.id }, data: { customerId: target.id } });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Both sides already hold an account in this programme (unique per
|
||||
// customer) — fold the guest's into the registered one. Moving the
|
||||
// ledger rows along with the cached total keeps "currentStamps is
|
||||
// the sum of its transactions" true on the surviving account.
|
||||
await tx.loyaltyTransaction.updateMany({ where: { loyaltyAccountId: account.id }, data: { loyaltyAccountId: existing.id } });
|
||||
await tx.loyaltyReward.updateMany({ where: { loyaltyAccountId: account.id }, data: { loyaltyAccountId: existing.id } });
|
||||
const merged = await tx.loyaltyAccount.update({
|
||||
where: { id: existing.id },
|
||||
data: { currentStamps: { increment: account.currentStamps } },
|
||||
});
|
||||
await tx.loyaltyAccount.delete({ where: { id: account.id } });
|
||||
|
||||
// The combined total may now clear the reward threshold even though
|
||||
// neither half did on its own.
|
||||
const award = await awardRewardIfEarned(tx, {
|
||||
loyaltyAccountId: merged.id,
|
||||
currentStamps: merged.currentStamps,
|
||||
programme: account.loyaltyProgramme,
|
||||
});
|
||||
if (award.rewardCreated && award.rewardId) {
|
||||
earnedRewards.push({
|
||||
rewardId: award.rewardId,
|
||||
businessId: account.loyaltyProgramme.businessId,
|
||||
label: account.loyaltyProgramme.rewardLabel ?? "Reward",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Fill gaps only — the account's own details always win.
|
||||
const fill: { phone?: string; firstName?: string; lastName?: string; preferredBarberId?: string } = {};
|
||||
if (!target.phone && source.phone) fill.phone = source.phone;
|
||||
if (!target.preferredBarberId && source.preferredBarberId) fill.preferredBarberId = source.preferredBarberId;
|
||||
// resolveAuthenticatedCustomer's placeholder when a User has no name.
|
||||
if (target.firstName === "Customer" && !target.lastName) {
|
||||
fill.firstName = source.firstName;
|
||||
fill.lastName = source.lastName;
|
||||
}
|
||||
if (Object.keys(fill).length > 0) target = await tx.customerProfile.update({ where: { id: target.id }, data: fill });
|
||||
|
||||
await tx.customerProfile.delete({ where: { id: source.id } });
|
||||
}
|
||||
|
||||
return {
|
||||
targetId: target.id,
|
||||
mergedProfileIds: guests.map((g) => g.id),
|
||||
appointmentsMoved,
|
||||
earnedRewards,
|
||||
};
|
||||
});
|
||||
|
||||
if (!outcome) return NOTHING_TO_MERGE;
|
||||
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: userId,
|
||||
action: "GUEST_PROFILES_MERGED",
|
||||
entityType: "CustomerProfile",
|
||||
entityId: outcome.targetId,
|
||||
metadata: { mergedProfileIds: outcome.mergedProfileIds, appointmentsMoved: outcome.appointmentsMoved },
|
||||
});
|
||||
for (const reward of outcome.earnedRewards) {
|
||||
await notifyLoyaltyRewardEarned(prisma, {
|
||||
businessId: reward.businessId,
|
||||
customerId: outcome.targetId,
|
||||
rewardId: reward.rewardId,
|
||||
rewardLabel: reward.label,
|
||||
});
|
||||
}
|
||||
|
||||
return { mergedProfileCount: outcome.mergedProfileIds.length, appointmentsMoved: outcome.appointmentsMoved };
|
||||
}
|
||||
|
||||
/**
|
||||
* For sign-in/registration paths: a merge problem must never stop someone
|
||||
* from signing in or adding a client. The merge is a transaction, so a
|
||||
* failure leaves everything as it was and the next sign-in simply retries.
|
||||
*/
|
||||
export async function claimGuestProfilesSafely(prisma: PrismaClient, userId: string): Promise<void> {
|
||||
try {
|
||||
await claimGuestProfilesForUser(prisma, userId);
|
||||
} catch (error) {
|
||||
console.error(`[GuestMerge] could not merge guest profiles for user ${userId}`, error);
|
||||
}
|
||||
}
|
||||
@@ -330,16 +330,33 @@ describe("LoyaltyService — owner configuration and redemption (integration)",
|
||||
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));
|
||||
it("sends the expiring warning once when it enters the 7-day window, however often the sweep runs", async () => {
|
||||
const { reward } = await setUpAccountWithReward(new Date(Date.now() + 5 * 24 * 60 * 60 * 1000));
|
||||
|
||||
await expireOverdueRewards(prisma);
|
||||
await expireOverdueRewards(prisma);
|
||||
// The cron sweep runs every couple of minutes and finds the same reward each time.
|
||||
for (const daysLater of [0, 0, 1, 2, 3]) {
|
||||
await expireOverdueRewards(prisma, new Date(Date.now() + daysLater * 24 * 60 * 60 * 1000));
|
||||
}
|
||||
|
||||
const count = await prisma.notification.count({
|
||||
where: { type: "LOYALTY_REWARD_EXPIRING", loyaltyRewardId: reward.id },
|
||||
});
|
||||
const count = await prisma.notification.count({ where: { type: "LOYALTY_REWARD_EXPIRING", loyaltyRewardId: reward.id } });
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
it("sends exactly one more warning when the reward is down to its last day — two in total", async () => {
|
||||
const { reward } = await setUpAccountWithReward(new Date(Date.now() + 5 * 24 * 60 * 60 * 1000));
|
||||
|
||||
await expireOverdueRewards(prisma); // 5 days out → the "7d" warning
|
||||
await expireOverdueRewards(prisma, new Date(Date.now() + 4.5 * 24 * 60 * 60 * 1000)); // 12h left → the "1d" warning
|
||||
await expireOverdueRewards(prisma, new Date(Date.now() + 4.8 * 24 * 60 * 60 * 1000)); // still the last day — no third
|
||||
|
||||
const notifications = await prisma.notification.findMany({
|
||||
where: { type: "LOYALTY_REWARD_EXPIRING", loyaltyRewardId: reward.id },
|
||||
select: { idempotencyKey: true },
|
||||
});
|
||||
expect(notifications.map((n) => n.idempotencyKey).sort()).toEqual([
|
||||
`notify:loyalty-reward-expiring:${reward.id}:1d`,
|
||||
`notify:loyalty-reward-expiring:${reward.id}:7d`,
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -55,13 +55,20 @@ export async function notifyLoyaltyRewardEarned(
|
||||
});
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
/**
|
||||
* Fires at most twice per reward: once when it first comes within the
|
||||
* 7-day window, and once more when it's down to its last day. The
|
||||
* idempotency key is the bucket, not the day — the sweep runs every cron
|
||||
* tick and finds the same reward repeatedly, and the old per-day key
|
||||
* meant up to seven emails for one reward.
|
||||
*/
|
||||
export type RewardExpiryBucket = "7d" | "1d";
|
||||
|
||||
export async function notifyLoyaltyRewardExpiring(
|
||||
prisma: PrismaClient,
|
||||
params: { businessId: string; customerId: string; rewardId: string; rewardLabel: string; expiresAt: Date; now: Date },
|
||||
params: { businessId: string; customerId: string; rewardId: string; rewardLabel: string; expiresAt: Date; bucket: RewardExpiryBucket },
|
||||
): 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,
|
||||
@@ -72,7 +79,7 @@ export async function notifyLoyaltyRewardExpiring(
|
||||
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}`,
|
||||
idempotencyKey: `notify:loyalty-reward-expiring:${params.rewardId}:${params.bucket}`,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { LoyaltyProgramme, PrismaClient } from "@/generated/prisma/client";
|
||||
import type { LoyaltyProgramme, Prisma, PrismaClient } from "@/generated/prisma/client";
|
||||
import { LoyaltyCardFont, LoyaltyCardTextSize, RewardPositionMode } from "@/generated/prisma/enums";
|
||||
import { fail, ok } from "@/lib/validation-result";
|
||||
import type { ValidationResult } from "@/lib/validation-result";
|
||||
@@ -397,6 +397,39 @@ export async function addStamp(
|
||||
return { success: true, rewardCreated, validation: ok() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the account's reward if its stamp total has reached the
|
||||
* programme's threshold and none is already waiting. Crossing the
|
||||
* threshold again while a reward is already AVAILABLE doesn't stack a
|
||||
* second one. Shared by addStamp and the guest-profile merge (see
|
||||
* services/customer/guest-merge.service.ts), where two stamp totals
|
||||
* summing past the threshold is the same event as one more stamp doing it.
|
||||
*/
|
||||
export async function awardRewardIfEarned(
|
||||
tx: Prisma.TransactionClient,
|
||||
params: { loyaltyAccountId: string; currentStamps: number; programme: LoyaltyProgramme },
|
||||
): Promise<{ rewardCreated: boolean; rewardId?: string }> {
|
||||
if (params.currentStamps < resolveRewardThreshold(params.programme)) return { rewardCreated: false };
|
||||
const existingAvailable = await tx.loyaltyReward.findFirst({
|
||||
where: { loyaltyAccountId: params.loyaltyAccountId, status: "AVAILABLE" },
|
||||
});
|
||||
if (existingAvailable) return { rewardCreated: false };
|
||||
|
||||
const reward = await tx.loyaltyReward.create({
|
||||
data: {
|
||||
loyaltyAccountId: params.loyaltyAccountId,
|
||||
rewardType: params.programme.rewardType,
|
||||
value: params.programme.rewardValue,
|
||||
label: params.programme.rewardLabel ?? "Reward",
|
||||
status: "AVAILABLE",
|
||||
expiresAt: params.programme.rewardExpiresAfterDays
|
||||
? new Date(Date.now() + params.programme.rewardExpiresAfterDays * 86_400_000)
|
||||
: null,
|
||||
},
|
||||
});
|
||||
return { rewardCreated: true, rewardId: reward.id };
|
||||
}
|
||||
|
||||
async function addStampTransaction(
|
||||
prisma: PrismaClient,
|
||||
params: {
|
||||
@@ -424,31 +457,11 @@ async function addStampTransaction(
|
||||
},
|
||||
});
|
||||
|
||||
let rewardCreated = false;
|
||||
let rewardId: string | undefined;
|
||||
if (account.currentStamps >= resolveRewardThreshold(params.programme)) {
|
||||
const existingAvailable = await tx.loyaltyReward.findFirst({
|
||||
where: { loyaltyAccountId: account.id, status: "AVAILABLE" },
|
||||
});
|
||||
// Crossing the threshold again while a reward is already waiting to be
|
||||
// redeemed doesn't stack a second one.
|
||||
if (!existingAvailable) {
|
||||
const reward = await tx.loyaltyReward.create({
|
||||
data: {
|
||||
loyaltyAccountId: account.id,
|
||||
rewardType: params.programme.rewardType,
|
||||
value: params.programme.rewardValue,
|
||||
label: params.programme.rewardLabel ?? "Reward",
|
||||
status: "AVAILABLE",
|
||||
expiresAt: params.programme.rewardExpiresAfterDays
|
||||
? new Date(Date.now() + params.programme.rewardExpiresAfterDays * 86_400_000)
|
||||
: null,
|
||||
},
|
||||
});
|
||||
rewardCreated = true;
|
||||
rewardId = reward.id;
|
||||
}
|
||||
}
|
||||
const { rewardCreated, rewardId } = await awardRewardIfEarned(tx, {
|
||||
loyaltyAccountId: account.id,
|
||||
currentStamps: account.currentStamps,
|
||||
programme: params.programme,
|
||||
});
|
||||
|
||||
return { rewardCreated, rewardId, transactionId: transaction.id };
|
||||
});
|
||||
@@ -850,9 +863,10 @@ const REWARD_EXPIRING_SOON_WINDOW_DAYS = 7;
|
||||
* 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.
|
||||
* history/UI instead of staying stuck at AVAILABLE forever), then warn
|
||||
* about rewards still AVAILABLE and expiring within the next week — twice
|
||||
* per reward at most (7 days out, then the last day; see
|
||||
* notifyLoyaltyRewardExpiring), not on every sweep.
|
||||
*/
|
||||
export async function expireOverdueRewards(prisma: PrismaClient, now: Date = new Date()): Promise<void> {
|
||||
await prisma.loyaltyReward.updateMany({
|
||||
@@ -876,7 +890,7 @@ export async function expireOverdueRewards(prisma: PrismaClient, now: Date = new
|
||||
rewardId: reward.id,
|
||||
rewardLabel: reward.label,
|
||||
expiresAt: reward.expiresAt,
|
||||
now,
|
||||
bucket: reward.expiresAt.getTime() - now.getTime() <= 86_400_000 ? "1d" : "7d",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -227,12 +227,11 @@ describe("NotificationService (integration)", () => {
|
||||
expect(staffNotification.deliveries.map((d) => d.channel)).toContain(NotificationChannel.IN_APP);
|
||||
});
|
||||
|
||||
it("STAFF_NEW_APPOINTMENT, STAFF_APPOINTMENT_RESCHEDULED, STAFF_APPOINTMENT_REMINDER, OWNER_NEW_BOOKING, and OWNER_NO_SHOW default to off with no preference row", async () => {
|
||||
it("STAFF_NEW_APPOINTMENT, STAFF_APPOINTMENT_RESCHEDULED, OWNER_NEW_BOOKING, and OWNER_NO_SHOW default to off with no preference row", async () => {
|
||||
const { userId } = await createTestCustomer(business);
|
||||
const disabledByDefault = [
|
||||
NotificationType.STAFF_NEW_APPOINTMENT,
|
||||
NotificationType.STAFF_APPOINTMENT_RESCHEDULED,
|
||||
NotificationType.STAFF_APPOINTMENT_REMINDER,
|
||||
NotificationType.OWNER_NEW_BOOKING,
|
||||
NotificationType.OWNER_NO_SHOW,
|
||||
];
|
||||
@@ -248,6 +247,30 @@ describe("NotificationService (integration)", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("STAFF_APPOINTMENT_REMINDER defaults to IN_APP on and EMAIL off, and a stored EMAIL preference opts in", async () => {
|
||||
const { userId } = await createTestCustomer(business);
|
||||
const params = {
|
||||
businessId: business.businessId,
|
||||
type: NotificationType.STAFF_APPOINTMENT_REMINDER,
|
||||
recipientUserId: userId,
|
||||
bodyText: "Reminder.",
|
||||
};
|
||||
|
||||
const { notification: defaults } = await notify(prisma, { ...params, idempotencyKey: `test:${userId}:staff-reminder-default` });
|
||||
const defaultChannels = defaults.deliveries.map((d) => d.channel);
|
||||
expect(defaultChannels).toContain(NotificationChannel.IN_APP);
|
||||
expect(defaultChannels).not.toContain(NotificationChannel.EMAIL);
|
||||
|
||||
await setNotificationPreference(prisma, {
|
||||
userId,
|
||||
type: NotificationType.STAFF_APPOINTMENT_REMINDER,
|
||||
channel: NotificationChannel.EMAIL,
|
||||
isEnabled: true,
|
||||
});
|
||||
const { notification: optedIn } = await notify(prisma, { ...params, idempotencyKey: `test:${userId}:staff-reminder-optin` });
|
||||
expect(optedIn.deliveries.map((d) => d.channel)).toContain(NotificationChannel.EMAIL);
|
||||
});
|
||||
|
||||
it("STAFF_APPOINTMENT_CANCELLED, STAFF_SCHEDULE_CHANGED, STAFF_INVITATION_RECEIVED, OWNER_BOOKING_CANCELLED, OWNER_STAFF_INVITATION_ACCEPTED, and OWNER_REPEATED_DELIVERY_FAILURE still default to on with no preference row", async () => {
|
||||
const { userId } = await createTestCustomer(business);
|
||||
const stillEnabledByDefault = [
|
||||
|
||||
@@ -2,8 +2,10 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { NotificationType } from "@/generated/prisma/enums";
|
||||
import {
|
||||
cleanupTestBusiness,
|
||||
createTestAppointment,
|
||||
createTestBusiness,
|
||||
createTestCustomer,
|
||||
createTestStaff,
|
||||
prisma,
|
||||
} from "@/services/scheduling/__tests__/fixtures";
|
||||
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
|
||||
@@ -258,4 +260,54 @@ describe("processDueNotificationDeliveries (integration)", () => {
|
||||
expect(subscription.revokedAt).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("stale reminder guard", () => {
|
||||
async function dueReminderFor(appointmentStartAt: Date, status: "CONFIRMED" | "CANCELLED") {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
const { appointmentId } = await createTestAppointment(business, { customerId, staffId, startAt: appointmentStartAt });
|
||||
await prisma.appointment.update({ where: { id: appointmentId }, data: { status } });
|
||||
await notify(prisma, {
|
||||
businessId: business.businessId,
|
||||
type: NotificationType.BOOKING_REMINDER,
|
||||
recipientCustomerId: customerId,
|
||||
appointmentId,
|
||||
bodyText: "Reminder",
|
||||
idempotencyKey: `test:worker:stale:${appointmentId}`,
|
||||
scheduledFor: new Date(Date.now() - 60_000),
|
||||
});
|
||||
return appointmentId;
|
||||
}
|
||||
|
||||
it("still sends a due reminder for an upcoming confirmed appointment", async () => {
|
||||
await dueReminderFor(new Date(Date.now() + 3 * 60 * 60_000), "CONFIRMED");
|
||||
|
||||
const result = await processDueNotificationDeliveries(prisma, { emailProvider: provider, businessId: business.businessId });
|
||||
expect(result.sent).toBe(1);
|
||||
expect(result.skippedStale).toBe(0);
|
||||
});
|
||||
|
||||
it("drops — doesn't send, doesn't fail — a reminder whose appointment has already started", async () => {
|
||||
// e.g. the processor was down long enough that a 24h reminder came due after the appointment itself.
|
||||
const appointmentId = await dueReminderFor(new Date(Date.now() - 30 * 60_000), "CONFIRMED");
|
||||
|
||||
const result = await processDueNotificationDeliveries(prisma, { emailProvider: provider, businessId: business.businessId });
|
||||
expect(result.sent).toBe(0);
|
||||
expect(result.failed).toBe(0);
|
||||
expect(result.skippedStale).toBe(1);
|
||||
expect(provider.sentEmails).toHaveLength(0);
|
||||
|
||||
const delivery = await prisma.notificationDelivery.findFirstOrThrow({ where: { notification: { appointmentId } } });
|
||||
expect(delivery.status).toBe("CANCELLED");
|
||||
});
|
||||
|
||||
it("drops a reminder whose appointment is no longer confirmed", async () => {
|
||||
await dueReminderFor(new Date(Date.now() + 3 * 60 * 60_000), "CANCELLED");
|
||||
|
||||
const result = await processDueNotificationDeliveries(prisma, { emailProvider: provider, businessId: business.businessId });
|
||||
expect(result.sent).toBe(0);
|
||||
expect(result.skippedStale).toBe(1);
|
||||
expect(provider.sentEmails).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { NotificationType } from "@/generated/prisma/enums";
|
||||
import { isPreferenceControlled, recipientKindOf } from "../types";
|
||||
import { NotificationChannel, NotificationType } from "@/generated/prisma/enums";
|
||||
import { defaultPreferenceIsEnabled, isPreferenceControlled, recipientKindOf } from "../types";
|
||||
|
||||
describe("recipientKindOf", () => {
|
||||
it("classifies every type as exactly one of CUSTOMER/STAFF/OWNER", () => {
|
||||
@@ -42,3 +42,31 @@ describe("isPreferenceControlled", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultPreferenceIsEnabled", () => {
|
||||
const channels = Object.values(NotificationChannel);
|
||||
|
||||
it("STAFF_APPOINTMENT_REMINDER defaults on for IN_APP and PUSH but off for EMAIL", () => {
|
||||
const type = NotificationType.STAFF_APPOINTMENT_REMINDER;
|
||||
expect(defaultPreferenceIsEnabled(type, NotificationChannel.IN_APP)).toBe(true);
|
||||
expect(defaultPreferenceIsEnabled(type, NotificationChannel.PUSH)).toBe(true);
|
||||
expect(defaultPreferenceIsEnabled(type, NotificationChannel.EMAIL)).toBe(false);
|
||||
});
|
||||
|
||||
it("types that default off do so on every channel", () => {
|
||||
for (const type of [
|
||||
NotificationType.STAFF_NEW_APPOINTMENT,
|
||||
NotificationType.STAFF_APPOINTMENT_RESCHEDULED,
|
||||
NotificationType.OWNER_NEW_BOOKING,
|
||||
NotificationType.OWNER_NO_SHOW,
|
||||
]) {
|
||||
for (const channel of channels) expect(defaultPreferenceIsEnabled(type, channel)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("everything else defaults on for every channel", () => {
|
||||
for (const type of [NotificationType.BOOKING_REMINDER, NotificationType.STAFF_APPOINTMENT_CANCELLED]) {
|
||||
for (const channel of channels) expect(defaultPreferenceIsEnabled(type, channel)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -126,8 +126,8 @@ async function isChannelEnabled(
|
||||
});
|
||||
// A stored row always wins, whichever way it's set — only the fallback
|
||||
// for "nobody has ever touched this" varies by type (see types.ts's
|
||||
// defaultPreferenceIsEnabled).
|
||||
return preference?.isEnabled ?? defaultPreferenceIsEnabled(params.type);
|
||||
// defaultPreferenceIsEnabled, which is per type AND channel).
|
||||
return preference?.isEnabled ?? defaultPreferenceIsEnabled(params.type, params.channel);
|
||||
}
|
||||
|
||||
async function resolveChannels(
|
||||
@@ -249,8 +249,10 @@ async function checkEquivalenceGroup(
|
||||
* 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.
|
||||
* SENT (there's no send step, just a DB row) unless `scheduledFor` is set,
|
||||
* in which case the worker releases them at that time; EMAIL/PUSH are left
|
||||
* PENDING (or SCHEDULED) 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) {
|
||||
@@ -295,7 +297,16 @@ export async function notify(prisma: PrismaClient, params: NotifyParams): Promis
|
||||
deliveries: {
|
||||
create: channels.map((channel) => {
|
||||
if (channel === NotificationChannel.IN_APP) {
|
||||
return { channel, status: NotificationDeliveryStatus.SENT, sentAt: new Date() };
|
||||
// A scheduled notification (a reminder) must not show up in
|
||||
// the bell the moment it's queued — it'd read "Upcoming
|
||||
// appointment reminder" the instant someone books, and
|
||||
// outlive a later cancel/reschedule since only
|
||||
// PENDING/SCHEDULED rows get cancelled. Left SCHEDULED, it
|
||||
// becomes visible only when the worker releases it (see
|
||||
// reminder-worker.ts's releaseDueInAppReminders).
|
||||
return params.scheduledFor
|
||||
? { channel, status: NotificationDeliveryStatus.SCHEDULED, scheduledFor: params.scheduledFor }
|
||||
: { channel, status: NotificationDeliveryStatus.SENT, sentAt: new Date() };
|
||||
}
|
||||
return params.scheduledFor
|
||||
? { channel, status: NotificationDeliveryStatus.SCHEDULED, scheduledFor: params.scheduledFor }
|
||||
@@ -350,7 +361,7 @@ export async function listInAppNotifications(
|
||||
return prisma.notification.findMany({
|
||||
where: {
|
||||
recipientUserId: params.userId,
|
||||
deliveries: { some: { channel: NotificationChannel.IN_APP } },
|
||||
deliveries: { some: { channel: NotificationChannel.IN_APP, status: NotificationDeliveryStatus.SENT } },
|
||||
...(params.unreadOnly ? { readAt: null } : {}),
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
@@ -360,7 +371,7 @@ export async function listInAppNotifications(
|
||||
|
||||
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 } } },
|
||||
where: { recipientUserId: userId, readAt: null, deliveries: { some: { channel: NotificationChannel.IN_APP, status: NotificationDeliveryStatus.SENT } } },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -378,7 +389,16 @@ export async function markNotificationRead(
|
||||
|
||||
export async function markAllNotificationsRead(prisma: PrismaClient, userId: string): Promise<number> {
|
||||
const result = await prisma.notification.updateMany({
|
||||
where: { recipientUserId: userId, readAt: null },
|
||||
// Only what's actually visible in the bell — a still-SCHEDULED reminder
|
||||
// hasn't been shown yet, so "mark all read" mustn't swallow it unseen.
|
||||
where: {
|
||||
recipientUserId: userId,
|
||||
readAt: null,
|
||||
OR: [
|
||||
{ deliveries: { none: { channel: NotificationChannel.IN_APP } } },
|
||||
{ deliveries: { some: { channel: NotificationChannel.IN_APP, status: NotificationDeliveryStatus.SENT } } },
|
||||
],
|
||||
},
|
||||
data: { readAt: new Date() },
|
||||
});
|
||||
return result.count;
|
||||
|
||||
@@ -46,10 +46,79 @@ export interface ProcessNotificationsResult {
|
||||
failed: number;
|
||||
/** Another worker already claimed the row between our read and our claim attempt — not an error. */
|
||||
skippedAlreadyClaimed: number;
|
||||
/** Reminder deliveries dropped instead of sent because the appointment had since passed or was no longer confirmed — see isStaleReminder. */
|
||||
skippedStale: number;
|
||||
/** Scheduled IN_APP reminders that became visible in the notification bell this run. */
|
||||
inAppReleased: number;
|
||||
}
|
||||
|
||||
type DueDelivery = Prisma.NotificationDeliveryGetPayload<{ include: { notification: true } }>;
|
||||
|
||||
const REMINDER_TYPES: NotificationType[] = [NotificationType.BOOKING_REMINDER, NotificationType.STAFF_APPOINTMENT_REMINDER];
|
||||
|
||||
type AppointmentSnapshot = { status: string; startAt: Date };
|
||||
|
||||
/**
|
||||
* A reminder is only worth sending while its appointment is still coming
|
||||
* up and confirmed. Cancel/reschedule already cancel queued reminders
|
||||
* (see reminder-scheduling.ts), so this is a backstop for everything that
|
||||
* can slip past that — chiefly the processor being down long enough that a
|
||||
* "24 hours before" reminder would otherwise go out after the appointment
|
||||
* itself, or an appointment that left CONFIRMED by a route that doesn't
|
||||
* call cancelScheduledReminders.
|
||||
*/
|
||||
function isStaleReminder(appointment: AppointmentSnapshot | undefined, now: Date): boolean {
|
||||
return !appointment || appointment.status !== "CONFIRMED" || appointment.startAt <= now;
|
||||
}
|
||||
|
||||
async function loadAppointmentSnapshots(prisma: PrismaClient, ids: string[]): Promise<Map<string, AppointmentSnapshot>> {
|
||||
if (ids.length === 0) return new Map();
|
||||
const rows = await prisma.appointment.findMany({ where: { id: { in: ids } }, select: { id: true, status: true, startAt: true } });
|
||||
return new Map(rows.map((r) => [r.id, { status: r.status, startAt: r.startAt }]));
|
||||
}
|
||||
|
||||
/**
|
||||
* IN_APP deliveries have no send step, but a scheduled one (a reminder)
|
||||
* is created SCHEDULED so it stays invisible until it's actually due —
|
||||
* see notify()'s IN_APP branch. This makes the due ones visible, and
|
||||
* bumps the notification's createdAt to now: the bell sorts by createdAt,
|
||||
* so without that a reminder would land buried among entries from the day
|
||||
* it was booked rather than at the top where it just arrived.
|
||||
*/
|
||||
async function releaseDueInAppReminders(prisma: PrismaClient, now: Date): Promise<{ released: number; stale: number }> {
|
||||
const due = await prisma.notificationDelivery.findMany({
|
||||
where: { channel: NotificationChannel.IN_APP, status: NotificationDeliveryStatus.SCHEDULED, scheduledFor: { lte: now } },
|
||||
include: { notification: { select: { id: true, appointmentId: true } } },
|
||||
take: 200,
|
||||
});
|
||||
const snapshots = await loadAppointmentSnapshots(
|
||||
prisma,
|
||||
due.flatMap((d) => (d.notification.appointmentId ? [d.notification.appointmentId] : [])),
|
||||
);
|
||||
|
||||
let released = 0;
|
||||
let stale = 0;
|
||||
for (const delivery of due) {
|
||||
const appointmentId = delivery.notification.appointmentId;
|
||||
if (!appointmentId || isStaleReminder(snapshots.get(appointmentId), now)) {
|
||||
await prisma.notificationDelivery.updateMany({
|
||||
where: { id: delivery.id, status: NotificationDeliveryStatus.SCHEDULED },
|
||||
data: { status: NotificationDeliveryStatus.CANCELLED, failureReason: "Skipped: appointment is no longer upcoming and confirmed." },
|
||||
});
|
||||
stale++;
|
||||
continue;
|
||||
}
|
||||
const claimed = await prisma.notificationDelivery.updateMany({
|
||||
where: { id: delivery.id, status: NotificationDeliveryStatus.SCHEDULED },
|
||||
data: { status: NotificationDeliveryStatus.SENT, sentAt: now },
|
||||
});
|
||||
if (claimed.count === 0) continue;
|
||||
await prisma.notification.update({ where: { id: delivery.notification.id }, data: { createdAt: now } });
|
||||
released++;
|
||||
}
|
||||
return { released, stale };
|
||||
}
|
||||
|
||||
function backoffMinutesFor(attemptNumber: number): number {
|
||||
return BACKOFF_MINUTES[attemptNumber - 1] ?? BACKOFF_MINUTES[BACKOFF_MINUTES.length - 1] ?? 30;
|
||||
}
|
||||
@@ -73,10 +142,44 @@ export async function processDueNotificationDeliveries(
|
||||
include: { notification: true },
|
||||
});
|
||||
|
||||
const result: ProcessNotificationsResult = { claimed: 0, sent: 0, retried: 0, failed: 0, skippedAlreadyClaimed: 0 };
|
||||
const result: ProcessNotificationsResult = {
|
||||
claimed: 0,
|
||||
sent: 0,
|
||||
retried: 0,
|
||||
failed: 0,
|
||||
skippedAlreadyClaimed: 0,
|
||||
skippedStale: 0,
|
||||
inAppReleased: 0,
|
||||
};
|
||||
const failuresByBusiness = new Map<string, number>();
|
||||
|
||||
const inApp = await releaseDueInAppReminders(prisma, now);
|
||||
result.inAppReleased = inApp.released;
|
||||
result.skippedStale += inApp.stale;
|
||||
|
||||
const reminderSnapshots = await loadAppointmentSnapshots(
|
||||
prisma,
|
||||
due.flatMap((d) =>
|
||||
REMINDER_TYPES.includes(d.notification.type) && d.notification.appointmentId ? [d.notification.appointmentId] : [],
|
||||
),
|
||||
);
|
||||
|
||||
for (const delivery of due) {
|
||||
if (REMINDER_TYPES.includes(delivery.notification.type)) {
|
||||
const appointmentId = delivery.notification.appointmentId;
|
||||
if (!appointmentId || isStaleReminder(reminderSnapshots.get(appointmentId), now)) {
|
||||
// Not FAILED: nothing went wrong, the reminder just stopped being
|
||||
// true — and a failure here would count toward the owner's
|
||||
// "repeated delivery failure" alert.
|
||||
await prisma.notificationDelivery.updateMany({
|
||||
where: { id: delivery.id, status: delivery.status },
|
||||
data: { status: NotificationDeliveryStatus.CANCELLED, failureReason: "Skipped: appointment is no longer upcoming and confirmed." },
|
||||
});
|
||||
result.skippedStale++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const claim = await prisma.notificationDelivery.updateMany({
|
||||
where: { id: delivery.id, status: delivery.status },
|
||||
data: { status: NotificationDeliveryStatus.PROCESSING, lastAttemptedAt: now, attemptCount: { increment: 1 } },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NotificationType } from "@/generated/prisma/enums";
|
||||
import { NotificationChannel, NotificationType } from "@/generated/prisma/enums";
|
||||
|
||||
/**
|
||||
* Static metadata about each NotificationType — who it's for, and whether
|
||||
@@ -101,19 +101,29 @@ export function isPreferenceControlled(type: NotificationType): boolean {
|
||||
const DEFAULT_DISABLED_TYPES = new Set<NotificationType>([
|
||||
NotificationType.STAFF_NEW_APPOINTMENT,
|
||||
NotificationType.STAFF_APPOINTMENT_RESCHEDULED,
|
||||
NotificationType.STAFF_APPOINTMENT_REMINDER,
|
||||
NotificationType.OWNER_NEW_BOOKING,
|
||||
NotificationType.OWNER_NO_SHOW,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Types that are on by default overall but have specific channels that
|
||||
* default to OFF — e.g. a barber's 2-hour heads-up is worth a push/in-app
|
||||
* nudge out of the box, but a second email for something that's about to
|
||||
* happen anyway is opt-in. Same "stored row always wins" rule as above.
|
||||
*/
|
||||
const DEFAULT_DISABLED_CHANNELS: Partial<Record<NotificationType, ReadonlySet<NotificationChannel>>> = {
|
||||
[NotificationType.STAFF_APPOINTMENT_REMINDER]: new Set([NotificationChannel.EMAIL]),
|
||||
};
|
||||
|
||||
/**
|
||||
* The single source of truth for "what does this preference-controlled
|
||||
* type do when nobody has ever saved a row for it" — used both by the
|
||||
* type do, on this channel, when nobody has ever saved a row for it" — used both by the
|
||||
* actual send decision (isChannelEnabled) and by the Settings pages that
|
||||
* render each toggle's initial on/off state, so the UI can never show a
|
||||
* toggle as on for something that actually defaults to off (or vice
|
||||
* versa). Meaningless for a required (non-preference-controlled) type.
|
||||
*/
|
||||
export function defaultPreferenceIsEnabled(type: NotificationType): boolean {
|
||||
return !DEFAULT_DISABLED_TYPES.has(type);
|
||||
export function defaultPreferenceIsEnabled(type: NotificationType, channel: NotificationChannel): boolean {
|
||||
if (DEFAULT_DISABLED_TYPES.has(type)) return false;
|
||||
return !DEFAULT_DISABLED_CHANNELS[type]?.has(channel);
|
||||
}
|
||||
@@ -75,16 +75,14 @@ describe("booking notifications (integration)", () => {
|
||||
});
|
||||
const types = notifications.map((n) => n.type).sort();
|
||||
// A booking far enough out (monday is >= 7 days ahead — see
|
||||
// nextWeekday) also schedules the customer's 24h reminder (only one —
|
||||
// the 2h leg was removed, see reminder-scheduling.ts) and the staff
|
||||
// member's 24h+2h reminders.
|
||||
// nextWeekday) also schedules the customer's single 24h reminder and the
|
||||
// staff member's single 2h reminder (see reminder-scheduling.ts).
|
||||
expect(types).toEqual(
|
||||
[
|
||||
"BOOKING_CONFIRMED",
|
||||
"BOOKING_REMINDER",
|
||||
"OWNER_NEW_BOOKING",
|
||||
"STAFF_APPOINTMENT_REMINDER",
|
||||
"STAFF_APPOINTMENT_REMINDER",
|
||||
"STAFF_NEW_APPOINTMENT",
|
||||
].sort(),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
cleanupTestBusiness,
|
||||
createTestAppointment,
|
||||
createTestBusiness,
|
||||
createTestCustomer,
|
||||
createTestGuestCustomer,
|
||||
createTestStaff,
|
||||
prisma,
|
||||
} from "./fixtures";
|
||||
import type { TestBusiness } from "./fixtures";
|
||||
import {
|
||||
customerAppointmentLink,
|
||||
deriveGuestAccessToken,
|
||||
issueGuestAccessToken,
|
||||
revokeGuestAccessToken,
|
||||
validateGuestAccessToken,
|
||||
} from "../guest-access.service";
|
||||
|
||||
describe("guest access links (integration)", () => {
|
||||
let business: TestBusiness;
|
||||
let guestIds: string[];
|
||||
|
||||
beforeEach(async () => {
|
||||
process.env.AUTH_SECRET ??= "test-secret-for-guest-links";
|
||||
business = await createTestBusiness();
|
||||
guestIds = [];
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupTestBusiness(business);
|
||||
await prisma.customerProfile.deleteMany({ where: { id: { in: guestIds } } });
|
||||
});
|
||||
|
||||
async function guestAppointment() {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const { customerId } = await createTestGuestCustomer();
|
||||
guestIds.push(customerId);
|
||||
const { appointmentId } = await createTestAppointment(business, { customerId, staffId });
|
||||
const appointment = await prisma.appointment.findUniqueOrThrow({ where: { id: appointmentId } });
|
||||
// The booking-time token — random, only its hash is kept.
|
||||
const bookingToken = await issueGuestAccessToken(prisma, { appointmentId, appointmentEndAt: appointment.endAt });
|
||||
return { appointmentId, bookingToken, customerId };
|
||||
}
|
||||
|
||||
it("derives the same token every time for an appointment, and different ones for different appointments", () => {
|
||||
expect(deriveGuestAccessToken("appt-1")).toBe(deriveGuestAccessToken("appt-1"));
|
||||
expect(deriveGuestAccessToken("appt-1")).not.toBe(deriveGuestAccessToken("appt-2"));
|
||||
});
|
||||
|
||||
it("accepts the derived token as well as the booking-time one — so follow-up emails can carry a working link", async () => {
|
||||
const { appointmentId, bookingToken } = await guestAppointment();
|
||||
|
||||
expect(await validateGuestAccessToken(prisma, { appointmentId, rawToken: bookingToken })).toBe(true);
|
||||
expect(await validateGuestAccessToken(prisma, { appointmentId, rawToken: deriveGuestAccessToken(appointmentId) })).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a guessed token, and a token derived for a different appointment", async () => {
|
||||
const first = await guestAppointment();
|
||||
const second = await guestAppointment();
|
||||
|
||||
expect(await validateGuestAccessToken(prisma, { appointmentId: first.appointmentId, rawToken: "not-a-token" })).toBe(false);
|
||||
expect(
|
||||
await validateGuestAccessToken(prisma, { appointmentId: first.appointmentId, rawToken: deriveGuestAccessToken(second.appointmentId) }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("revoking the appointment's access also kills the derived link", async () => {
|
||||
const { appointmentId } = await guestAppointment();
|
||||
await revokeGuestAccessToken(prisma, appointmentId);
|
||||
|
||||
expect(await validateGuestAccessToken(prisma, { appointmentId, rawToken: deriveGuestAccessToken(appointmentId) })).toBe(false);
|
||||
});
|
||||
|
||||
it("customerAppointmentLink appends the token for a guest, and not for a registered customer", async () => {
|
||||
const { appointmentId } = await guestAppointment();
|
||||
|
||||
const guestLink = customerAppointmentLink({ id: appointmentId, customer: { userId: null } });
|
||||
expect(guestLink).toBe(`/appointments/${appointmentId}?token=${deriveGuestAccessToken(appointmentId)}`);
|
||||
|
||||
const { userId } = await createTestCustomer(business);
|
||||
expect(customerAppointmentLink({ id: appointmentId, customer: { userId } })).toBe(`/appointments/${appointmentId}`);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,18 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { cleanupTestBusiness, createTestBusiness, createTestCustomer, createTestStaff, prisma } from "./fixtures";
|
||||
import type { TestBusiness } from "./fixtures";
|
||||
import { NotificationChannel, NotificationType, Weekday } from "@/generated/prisma/enums";
|
||||
import { addDays, todayIn, wallClockToInstant, weekdayOf } from "@/lib/time/business-time";
|
||||
import type { CalendarDate } from "@/lib/time/business-time";
|
||||
import { setNotificationPreference } from "@/services/notifications/notification.service";
|
||||
import {
|
||||
getUnreadNotificationCount,
|
||||
listInAppNotifications,
|
||||
setNotificationPreference,
|
||||
} from "@/services/notifications/notification.service";
|
||||
import { processDueNotificationDeliveries } from "@/services/notifications/reminder-worker";
|
||||
import { MockEmailProvider } from "@/services/notifications/providers/mock-email-provider";
|
||||
import { cancelBooking, createBooking, rescheduleBooking } from "../booking.service";
|
||||
import { cleanupTestBusiness, createTestBusiness, createTestCustomer, createTestStaff, prisma } from "./fixtures";
|
||||
import type { TestBusiness } from "./fixtures";
|
||||
import { scheduleAppointmentReminders } from "../reminder-scheduling";
|
||||
|
||||
function nextWeekday(timezone: string, target: Weekday, minDaysAhead = 7): CalendarDate {
|
||||
let date = addDays(todayIn(timezone), minDaysAhead);
|
||||
@@ -16,26 +23,26 @@ function nextWeekday(timezone: string, target: Weekday, minDaysAhead = 7): Calen
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
|
||||
async function reminderDeliveries(appointmentId: string) {
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
|
||||
async function reminderDeliveries(appointmentId: string, channels: ("EMAIL" | "PUSH" | "IN_APP")[] = ["EMAIL", "PUSH"]) {
|
||||
return prisma.notificationDelivery.findMany({
|
||||
where: {
|
||||
notification: { appointmentId, type: { in: ["BOOKING_REMINDER", "STAFF_APPOINTMENT_REMINDER"] } },
|
||||
channel: { in: ["EMAIL", "PUSH"] },
|
||||
channel: { in: channels },
|
||||
},
|
||||
include: { notification: true },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer and staff reminders now have independently-controllable
|
||||
* defaults (see reminder-scheduling.ts's split CUSTOMER_/STAFF_
|
||||
* REMINDER_HOURS_BEFORE, and types.ts's DEFAULT_DISABLED_TYPES): a
|
||||
* customer's reminder is 24h-before only and defaults on; a staff
|
||||
* member's is still 24h+2h-before but defaults OFF (an explicit
|
||||
* NotificationPreference row is needed to see it). Most tests below
|
||||
* enable the staff preference up front specifically to exercise that
|
||||
* still-working mechanism end to end — see "with the staff reminder
|
||||
* preference enabled" for why.
|
||||
* Reminder rules (see reminder-scheduling.ts):
|
||||
* - Customer: one reminder 24h before; if the booking was made too late for
|
||||
* that, one 2h before instead — never both, never none (unless under 2h).
|
||||
* - Staff: one reminder 2h before only. IN_APP and PUSH default on, EMAIL
|
||||
* is opt-in (types.ts's DEFAULT_DISABLED_CHANNELS).
|
||||
* Every scheduled IN_APP copy is created SCHEDULED, not SENT, so it stays
|
||||
* out of the notification bell until the worker releases it.
|
||||
*/
|
||||
describe("reminder scheduling (integration)", () => {
|
||||
let business: TestBusiness;
|
||||
@@ -50,38 +57,55 @@ describe("reminder scheduling (integration)", () => {
|
||||
await cleanupTestBusiness(business);
|
||||
});
|
||||
|
||||
it("by default, only schedules the customer's 24h EMAIL reminder — the staff member gets none (disabled by default)", async () => {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
|
||||
async function book(startAt = wallClockToInstant(monday, 10 * 60, business.timezone)) {
|
||||
const { staffId, userId: staffUserId } = await createTestStaff(business);
|
||||
const { customerId, userId: customerUserId } = await createTestCustomer(business);
|
||||
const result = await createBooking(prisma, {
|
||||
businessId: business.businessId,
|
||||
customerId,
|
||||
staffId,
|
||||
serviceIds: [business.haircutServiceId],
|
||||
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
|
||||
startAt,
|
||||
});
|
||||
if (!result.success) throw new Error("setup failed");
|
||||
return { appointment: result.appointment, staffUserId, customerUserId };
|
||||
}
|
||||
|
||||
const deliveries = await reminderDeliveries(result.appointment.id);
|
||||
expect(deliveries).toHaveLength(1);
|
||||
expect(deliveries[0]!.notification.type).toBe("BOOKING_REMINDER");
|
||||
expect(deliveries[0]!.status).toBe("SCHEDULED");
|
||||
expect(deliveries[0]!.scheduledFor!.getTime()).toBe(
|
||||
result.appointment.startAt.getTime() - 24 * 60 * 60 * 1000,
|
||||
);
|
||||
});
|
||||
|
||||
it("never schedules a 2-hour reminder for the customer, even with the staff preference enabled", async () => {
|
||||
const { staffId, userId: staffUserId } = await createTestStaff(business);
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
async function enableStaffReminderEmail(staffUserId: string) {
|
||||
await setNotificationPreference(prisma, {
|
||||
userId: staffUserId,
|
||||
type: NotificationType.STAFF_APPOINTMENT_REMINDER,
|
||||
channel: NotificationChannel.EMAIL,
|
||||
isEnabled: true,
|
||||
});
|
||||
}
|
||||
|
||||
it("by default, the customer gets a 24h EMAIL reminder and the staff member gets no EMAIL one (opt-in)", async () => {
|
||||
const { appointment } = await book();
|
||||
|
||||
const deliveries = await reminderDeliveries(appointment.id);
|
||||
expect(deliveries).toHaveLength(1);
|
||||
expect(deliveries[0]!.notification.type).toBe("BOOKING_REMINDER");
|
||||
expect(deliveries[0]!.status).toBe("SCHEDULED");
|
||||
expect(deliveries[0]!.scheduledFor!.getTime()).toBe(appointment.startAt.getTime() - 24 * HOUR);
|
||||
});
|
||||
|
||||
it("by default, the staff member gets exactly one IN_APP reminder, 2 hours before — never 24h", async () => {
|
||||
const { appointment } = await book();
|
||||
|
||||
const staff = (await reminderDeliveries(appointment.id, ["EMAIL", "PUSH", "IN_APP"])).filter(
|
||||
(d) => d.notification.type === "STAFF_APPOINTMENT_REMINDER",
|
||||
);
|
||||
expect(staff).toHaveLength(1);
|
||||
expect(staff[0]!.channel).toBe("IN_APP");
|
||||
expect(staff[0]!.scheduledFor!.getTime()).toBe(appointment.startAt.getTime() - 2 * HOUR);
|
||||
});
|
||||
|
||||
it("with the staff EMAIL preference enabled, adds a single 2h EMAIL reminder for the staff member", async () => {
|
||||
// Preferences are read when the booking is made, so enable it first.
|
||||
const { staffId, userId: staffUserId } = await createTestStaff(business);
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
await enableStaffReminderEmail(staffUserId);
|
||||
const result = await createBooking(prisma, {
|
||||
businessId: business.businessId,
|
||||
customerId,
|
||||
@@ -92,144 +116,110 @@ describe("reminder scheduling (integration)", () => {
|
||||
if (!result.success) throw new Error("setup failed");
|
||||
|
||||
const deliveries = await reminderDeliveries(result.appointment.id);
|
||||
const customerReminders = deliveries.filter((d) => d.notification.type === "BOOKING_REMINDER");
|
||||
expect(customerReminders).toHaveLength(1); // 24h only, never a second one at 2h
|
||||
expect(deliveries).toHaveLength(2); // customer 24h + staff 2h
|
||||
for (const d of deliveries) {
|
||||
expect(d.status).toBe("SCHEDULED");
|
||||
expect(d.scheduledFor!.getTime()).toBeLessThan(result.appointment.startAt.getTime());
|
||||
}
|
||||
const staffEmail = deliveries.filter((d) => d.notification.type === "STAFF_APPOINTMENT_REMINDER");
|
||||
expect(staffEmail).toHaveLength(1);
|
||||
expect(staffEmail[0]!.scheduledFor!.getTime()).toBe(result.appointment.startAt.getTime() - 2 * HOUR);
|
||||
});
|
||||
|
||||
describe("with the staff reminder preference enabled", () => {
|
||||
async function enableStaffReminderEmail(staffUserId: string) {
|
||||
await setNotificationPreference(prisma, {
|
||||
userId: staffUserId,
|
||||
type: NotificationType.STAFF_APPOINTMENT_REMINDER,
|
||||
channel: NotificationChannel.EMAIL,
|
||||
isEnabled: true,
|
||||
describe("late bookings", () => {
|
||||
it("falls back to a 2h customer reminder when the 24h point has already passed", async () => {
|
||||
const { appointment } = await book();
|
||||
const startAt = new Date(Date.now() + 5 * HOUR);
|
||||
await scheduleAppointmentReminders(prisma, { ...appointment, startAt });
|
||||
|
||||
const reminders = (await reminderDeliveries(appointment.id)).filter(
|
||||
(d) => d.notification.type === "BOOKING_REMINDER" && d.notification.idempotencyKey.endsWith(`:${startAt.getTime()}`),
|
||||
);
|
||||
expect(reminders).toHaveLength(1);
|
||||
expect(reminders[0]!.scheduledFor!.getTime()).toBe(startAt.getTime() - 2 * HOUR);
|
||||
expect(reminders[0]!.notification.idempotencyKey).toContain("booking-reminder-2h");
|
||||
});
|
||||
|
||||
it("schedules no customer reminder when the appointment is under 2 hours away", async () => {
|
||||
const { appointment } = await book();
|
||||
const startAt = new Date(Date.now() + 1 * HOUR);
|
||||
await scheduleAppointmentReminders(prisma, { ...appointment, startAt });
|
||||
|
||||
const forThisStart = (await reminderDeliveries(appointment.id, ["EMAIL", "PUSH", "IN_APP"])).filter((d) =>
|
||||
d.notification.idempotencyKey.endsWith(`:${startAt.getTime()}`),
|
||||
);
|
||||
expect(forThisStart).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("in-app reminders", () => {
|
||||
it("stay out of the bell and the unread count until the worker releases them", async () => {
|
||||
const { appointment, customerUserId } = await book();
|
||||
|
||||
expect(await getUnreadNotificationCount(prisma, customerUserId)).toBe(1); // only the booking confirmation
|
||||
const before = await listInAppNotifications(prisma, { userId: customerUserId });
|
||||
expect(before.map((n) => n.type)).not.toContain("BOOKING_REMINDER");
|
||||
|
||||
// Not due yet — nothing is released.
|
||||
const early = await processDueNotificationDeliveries(prisma, { emailProvider: new MockEmailProvider(), businessId: business.businessId });
|
||||
expect(early.inAppReleased).toBe(0);
|
||||
|
||||
const due = new Date(appointment.startAt.getTime() - 24 * HOUR + 60_000);
|
||||
const result = await processDueNotificationDeliveries(prisma, {
|
||||
emailProvider: new MockEmailProvider(),
|
||||
businessId: business.businessId,
|
||||
now: due,
|
||||
});
|
||||
expect(result.inAppReleased).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const after = await listInAppNotifications(prisma, { userId: customerUserId });
|
||||
expect(after.map((n) => n.type)).toContain("BOOKING_REMINDER");
|
||||
expect(await getUnreadNotificationCount(prisma, customerUserId)).toBe(2);
|
||||
});
|
||||
|
||||
it("are cancelled with the appointment, so they never show a stale reminder", async () => {
|
||||
const { appointment, customerUserId } = await book();
|
||||
await cancelBooking(prisma, { appointmentId: appointment.id });
|
||||
|
||||
await processDueNotificationDeliveries(prisma, {
|
||||
emailProvider: new MockEmailProvider(),
|
||||
businessId: business.businessId,
|
||||
now: new Date(appointment.startAt.getTime() - HOUR),
|
||||
});
|
||||
|
||||
const bell = await listInAppNotifications(prisma, { userId: customerUserId });
|
||||
expect(bell.map((n) => n.type)).not.toContain("BOOKING_REMINDER");
|
||||
const all = await reminderDeliveries(appointment.id, ["EMAIL", "PUSH", "IN_APP"]);
|
||||
expect(all.every((d) => d.status === "CANCELLED")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("rescheduling cancels the old reminders and schedules fresh ones for the new time", async () => {
|
||||
const { appointment } = await book();
|
||||
const original = await reminderDeliveries(appointment.id, ["EMAIL", "PUSH", "IN_APP"]);
|
||||
expect(original).toHaveLength(3); // customer 24h EMAIL + customer 24h IN_APP + staff 2h IN_APP
|
||||
|
||||
const rescheduled = await rescheduleBooking(prisma, {
|
||||
appointmentId: appointment.id,
|
||||
startAt: wallClockToInstant(monday, 14 * 60, business.timezone),
|
||||
});
|
||||
expect(rescheduled.success).toBe(true);
|
||||
if (!rescheduled.success) return;
|
||||
|
||||
const all = await reminderDeliveries(appointment.id, ["EMAIL", "PUSH", "IN_APP"]);
|
||||
expect(all.filter((d) => d.status === "CANCELLED")).toHaveLength(3); // the originals, superseded
|
||||
const fresh = all.filter((d) => d.status === "SCHEDULED");
|
||||
expect(fresh).toHaveLength(3);
|
||||
for (const d of fresh) {
|
||||
expect(d.scheduledFor!.getTime()).toBeLessThan(rescheduled.appointment.startAt.getTime());
|
||||
}
|
||||
|
||||
it("schedules 24h and 2h EMAIL reminders for the staff member, alongside the customer's 24h one, as SCHEDULED (not PENDING)", async () => {
|
||||
const { staffId, userId: staffUserId } = await createTestStaff(business);
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
await enableStaffReminderEmail(staffUserId);
|
||||
|
||||
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(3); // customer 24h + staff 24h + staff 2h
|
||||
for (const d of deliveries) {
|
||||
expect(d.status).toBe("SCHEDULED");
|
||||
expect(d.scheduledFor).not.toBeNull();
|
||||
expect(d.scheduledFor!.getTime()).toBeLessThan(result.appointment.startAt.getTime());
|
||||
}
|
||||
const staffReminders = deliveries.filter((d) => d.notification.type === "STAFF_APPOINTMENT_REMINDER");
|
||||
expect(staffReminders).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("does not schedule a reminder offset that would already be in the past", async () => {
|
||||
const { staffId, userId: staffUserId } = await createTestStaff(business);
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
await enableStaffReminderEmail(staffUserId);
|
||||
|
||||
// Book for 3 hours from now — the customer's only offset (24h) and
|
||||
// the staff's 24h offset are both already past; the staff's 2h
|
||||
// offset is still in the future.
|
||||
const result = await createBooking(prisma, {
|
||||
businessId: business.businessId,
|
||||
customerId,
|
||||
staffId,
|
||||
serviceIds: [business.haircutServiceId],
|
||||
startAt: new Date(Date.now() + 3 * 60 * 60_000),
|
||||
}).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);
|
||||
expect(deliveries).toHaveLength(1);
|
||||
expect(deliveries[0]!.notification.type).toBe("STAFF_APPOINTMENT_REMINDER");
|
||||
expect(deliveries[0]!.notification.idempotencyKey).toContain("reminder-2h");
|
||||
});
|
||||
|
||||
it("rescheduling cancels the old reminders and schedules fresh ones for the new time", async () => {
|
||||
const { staffId, userId: staffUserId } = await createTestStaff(business);
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
await enableStaffReminderEmail(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 originalDeliveries = await reminderDeliveries(created.appointment.id);
|
||||
expect(originalDeliveries).toHaveLength(3);
|
||||
|
||||
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(3); // the original 3, now superseded
|
||||
expect(scheduledCount).toBe(3); // 3 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, userId: staffUserId } = await createTestStaff(business);
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
await enableStaffReminderEmail(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 deliveries = await reminderDeliveries(created.appointment.id);
|
||||
expect(deliveries.every((d) => d.status === "CANCELLED")).toBe(true);
|
||||
expect(deliveries).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
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 { appointment } = await book();
|
||||
|
||||
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);
|
||||
const deliveries = await reminderDeliveries(appointment.id, ["EMAIL", "PUSH", "IN_APP"]);
|
||||
expect(deliveries).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ 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 { customerAppointmentLink } from "./guest-access.service";
|
||||
import { scheduleAppointmentReminders } from "./reminder-scheduling";
|
||||
|
||||
/**
|
||||
@@ -100,7 +101,7 @@ export async function notifyBookingRescheduled(prisma: PrismaClient, appointment
|
||||
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}`,
|
||||
deepLinkPath: customerAppointmentLink(appointment),
|
||||
idempotencyKey: `notify:booking-rescheduled:${appointment.id}:${versionSuffix}`,
|
||||
});
|
||||
|
||||
@@ -135,7 +136,7 @@ export async function notifyBookingCancelled(
|
||||
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}`,
|
||||
deepLinkPath: customerAppointmentLink(appointment),
|
||||
idempotencyKey: `notify:booking-cancelled:${appointment.id}`,
|
||||
});
|
||||
|
||||
@@ -176,7 +177,7 @@ export async function notifyBookingApproved(prisma: PrismaClient, appointment: A
|
||||
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}`,
|
||||
deepLinkPath: customerAppointmentLink(appointment),
|
||||
idempotencyKey: `notify:booking-approved:${appointment.id}`,
|
||||
});
|
||||
// Approval is the first point a PENDING (requireApproval) booking becomes
|
||||
@@ -216,7 +217,7 @@ export async function notifyBookingBarberChanged(
|
||||
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}`,
|
||||
deepLinkPath: customerAppointmentLink(appointment),
|
||||
idempotencyKey: `notify:booking-barber-changed:${appointment.id}:${appointment.staffId}`,
|
||||
skipEmail: options.customerSelectedStaff === false,
|
||||
});
|
||||
@@ -232,7 +233,7 @@ export async function notifyBookingCompleted(prisma: PrismaClient, appointment:
|
||||
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}`,
|
||||
deepLinkPath: customerAppointmentLink(appointment),
|
||||
idempotencyKey: `notify:booking-completed:${appointment.id}`,
|
||||
skipEmail: true,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
/**
|
||||
@@ -22,6 +22,42 @@ function hashToken(rawToken: string): string {
|
||||
return createHash("sha256").update(rawToken).digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* A token anyone holding AUTH_SECRET can recompute from the appointment id
|
||||
* alone — exists because the booking-time token above is random and only
|
||||
* its hash is stored, so no later email (reminder, reschedule, cancel...)
|
||||
* has any way to include a working link for a guest. Without this, every
|
||||
* link in a guest's follow-up emails dead-ends at a sign-in page they have
|
||||
* no account for. Accepted by validateGuestAccessToken alongside the stored
|
||||
* one (never instead of it), still subject to the same row's expiry and
|
||||
* revokedAt, so revoking a guest appointment's access kills this link too.
|
||||
*/
|
||||
const DERIVATION_CONTEXT = "nextchair-guest-access-v1";
|
||||
|
||||
export function deriveGuestAccessToken(appointmentId: string): string {
|
||||
const secret = process.env.AUTH_SECRET;
|
||||
if (!secret) throw new Error("AUTH_SECRET must be set to derive guest appointment links.");
|
||||
// Purpose-specific key, same reasoning as src/lib/booking-draft/sign.ts.
|
||||
const key = createHmac("sha256", secret).update(DERIVATION_CONTEXT).digest();
|
||||
return createHmac("sha256", key).update(appointmentId).digest("base64url");
|
||||
}
|
||||
|
||||
/**
|
||||
* The customer-facing link to an appointment, for any notification. A
|
||||
* registered customer's access is session-based, so it's the bare path; a
|
||||
* guest (no linked User) gets a derived token appended. If the secret is
|
||||
* missing, degrades to the bare path rather than failing the notification.
|
||||
*/
|
||||
export function customerAppointmentLink(appointment: { id: string; customer: { userId: string | null } }): string {
|
||||
const path = `/appointments/${appointment.id}`;
|
||||
if (appointment.customer.userId) return path;
|
||||
try {
|
||||
return `${path}?token=${deriveGuestAccessToken(appointment.id)}`;
|
||||
} catch {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Issues this appointment's guest access token, generating one if none
|
||||
* exists or overwriting the previous hash if one does — deliberately a
|
||||
@@ -51,6 +87,15 @@ export async function issueGuestAccessToken(
|
||||
return rawToken;
|
||||
}
|
||||
|
||||
function matchesDerivedToken(appointmentId: string, providedHash: Buffer): boolean {
|
||||
try {
|
||||
const derivedHash = Buffer.from(hashToken(deriveGuestAccessToken(appointmentId)), "hex");
|
||||
return providedHash.length === derivedHash.length && timingSafeEqual(providedHash, derivedHash);
|
||||
} catch {
|
||||
return false; // no AUTH_SECRET — only the stored token can match
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True only if `rawToken` is the CURRENT token for exactly this
|
||||
* appointment, not expired, and not revoked. Compares hashes with
|
||||
@@ -70,7 +115,8 @@ export async function validateGuestAccessToken(
|
||||
|
||||
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;
|
||||
const matchesStored = provided.length === stored.length && timingSafeEqual(provided, stored);
|
||||
if (!matchesStored && !matchesDerivedToken(params.appointmentId, provided)) return false;
|
||||
|
||||
// Best-effort — a stale lastUsedAt from a lost race isn't a security concern.
|
||||
await prisma.guestAppointmentAccessToken
|
||||
|
||||
@@ -2,6 +2,7 @@ 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";
|
||||
import { customerAppointmentLink } from "./guest-access.service";
|
||||
|
||||
/**
|
||||
* Reminder timing — hardcoded, not yet a BookingRule column: there's no
|
||||
@@ -11,16 +12,18 @@ import type { Appointment } from "./booking.service";
|
||||
* would be two new BookingRule fields feeding the existing Booking Rules
|
||||
* editor — not built this phase.
|
||||
*
|
||||
* Customer and staff reminders deliberately have separate offset lists,
|
||||
* not one shared one — a 2-hour-before reminder on top of the 24-hour one
|
||||
* was more noise than value for a customer, but a barber still benefits
|
||||
* from both (a same-day heads-up on top of the previous day's one).
|
||||
* Customer and staff reminders are deliberately different:
|
||||
* - A customer gets one reminder, 24 hours ahead. If the booking was made
|
||||
* too late for that (a same-day or next-morning booking, common for a
|
||||
* barbershop), they get a 2-hour reminder instead — never both, and
|
||||
* never nothing — since a booking made with under 24 hours' notice is
|
||||
* exactly the kind most likely to be forgotten.
|
||||
* - A barber gets one, 2 hours ahead — a same-day heads-up. A day-ahead
|
||||
* one just duplicates what's already in their calendar.
|
||||
*/
|
||||
const CUSTOMER_REMINDER_HOURS_BEFORE = [24] as const;
|
||||
const STAFF_REMINDER_HOURS_BEFORE = [24, 2] as const;
|
||||
const ALL_REMINDER_HOURS_BEFORE = Array.from(
|
||||
new Set<number>([...CUSTOMER_REMINDER_HOURS_BEFORE, ...STAFF_REMINDER_HOURS_BEFORE]),
|
||||
).sort((a, b) => b - a);
|
||||
const CUSTOMER_REMINDER_HOURS_BEFORE = 24;
|
||||
const CUSTOMER_FALLBACK_REMINDER_HOURS_BEFORE = 2;
|
||||
const STAFF_REMINDER_HOURS_BEFORE = 2;
|
||||
|
||||
function formatDateLabel(date: Date, timezone: string): string {
|
||||
return date.toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short", timeZone: timezone });
|
||||
@@ -57,13 +60,12 @@ export async function cancelScheduledReminders(prisma: PrismaClient, appointment
|
||||
|
||||
/**
|
||||
* 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
|
||||
* appointment — see the timing rules above. A reminder time that's already
|
||||
* passed is never scheduled retroactively. 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.
|
||||
* 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;
|
||||
@@ -75,37 +77,40 @@ export async function scheduleAppointmentReminders(prisma: PrismaClient, appoint
|
||||
const now = new Date();
|
||||
const versionSuffix = appointment.startAt.getTime();
|
||||
|
||||
for (const hoursBefore of ALL_REMINDER_HOURS_BEFORE) {
|
||||
const scheduledFor = new Date(appointment.startAt.getTime() - hoursBefore * 60 * 60 * 1000);
|
||||
if (scheduledFor <= now) continue;
|
||||
const timeBefore = (hours: number) => new Date(appointment.startAt.getTime() - hours * 60 * 60 * 1000);
|
||||
const customerHoursBefore =
|
||||
timeBefore(CUSTOMER_REMINDER_HOURS_BEFORE) > now
|
||||
? CUSTOMER_REMINDER_HOURS_BEFORE
|
||||
: timeBefore(CUSTOMER_FALLBACK_REMINDER_HOURS_BEFORE) > now
|
||||
? CUSTOMER_FALLBACK_REMINDER_HOURS_BEFORE
|
||||
: null;
|
||||
|
||||
if ((CUSTOMER_REMINDER_HOURS_BEFORE as readonly number[]).includes(hoursBefore)) {
|
||||
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,
|
||||
});
|
||||
}
|
||||
if (customerHoursBefore !== null) {
|
||||
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: customerAppointmentLink(appointment),
|
||||
idempotencyKey: `notify:booking-reminder-${customerHoursBefore}h:${appointment.id}:${versionSuffix}`,
|
||||
scheduledFor: timeBefore(customerHoursBefore),
|
||||
});
|
||||
}
|
||||
|
||||
if ((STAFF_REMINDER_HOURS_BEFORE as readonly number[]).includes(hoursBefore)) {
|
||||
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,
|
||||
});
|
||||
}
|
||||
if (timeBefore(STAFF_REMINDER_HOURS_BEFORE) > now) {
|
||||
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-${STAFF_REMINDER_HOURS_BEFORE}h:${appointment.id}:${versionSuffix}`,
|
||||
scheduledFor: timeBefore(STAFF_REMINDER_HOURS_BEFORE),
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user