diff --git a/docs/notifications.md b/docs/notifications.md index f37ed26..4a69da6 100644 --- a/docs/notifications.md +++ b/docs/notifications.md @@ -47,7 +47,7 @@ no-email types" below for a separate, smaller mechanism that trims one channel off a handful of these without making them toggleable). - **Customer-facing**: only `BOOKING_REMINDER` is optional (and only - controls the 24h reminder — see "Reminder timing"). Everything else + controls the customer reminder — see "Reminder timing"). Everything else (confirmations, changes, cancellations, loyalty) always notifies the customer on every resolvable channel — not finding out your appointment was cancelled is a real risk, not just noise. @@ -75,6 +75,7 @@ preference, that stored value always wins, in either direction — this function only governs the "nobody has touched this yet" fallback. Most staff/owner types default to **on**. These default to **off** +(on every channel) instead — a solo owner-barber otherwise gets emailed/pushed/in-app'd for everything the moment they sign in, with no gentle on-ramp: @@ -82,10 +83,16 @@ everything the moment they sign in, with no gentle on-ramp: |---|---| | `STAFF_NEW_APPOINTMENT` | Noisy for a busy barber; `STAFF_APPOINTMENT_CANCELLED` (which stays on) is the one that actually needs acting on. | | `STAFF_APPOINTMENT_RESCHEDULED` | Same reasoning. | -| `STAFF_APPOINTMENT_REMINDER` | A same-day heads-up is nice-to-have, not essential, for the person actually doing the cutting. | | `OWNER_NEW_BOOKING` | Same as STAFF_NEW_APPOINTMENT, from the owner's side. | | `OWNER_NO_SHOW` | The barber who marked it already knows — there's no staff-facing equivalent by design. | +Defaults can also differ **per channel**, not just per type +(`DEFAULT_DISABLED_CHANNELS` in `types.ts`). Currently one: +`STAFF_APPOINTMENT_REMINDER` is **on** for push and in-app but **off** for +email — a barber gets a nudge on their phone two hours out without a second +email for something that's about to happen anyway. Email is opt-in under +Settings → Notifications. + ## Fixed no-email types A few types are `EMAIL` never, always, and unconditionally — not a @@ -144,17 +151,41 @@ all. ## Reminder timing `src/services/scheduling/reminder-scheduling.ts` schedules reminders at -fixed hours-before-appointment offsets, independently for the customer -and the assigned staff member: +fixed offsets before the appointment, independently for the customer and +the assigned staff member: -- **Customer** (`BOOKING_REMINDER`): 24 hours before only. -- **Staff** (`STAFF_APPOINTMENT_REMINDER`): 24 hours and 2 hours before - (each its own send) — but see the defaults table above, this type - defaults to off, so nothing actually sends unless the staff member - opts in. +- **Customer** (`BOOKING_REMINDER`): one reminder, **24 hours** before. If + the booking was made too late for that (a same-day or next-morning + booking), they get one **2 hours** before instead — never both, and never + nothing, unless the appointment is under 2 hours away. +- **Staff** (`STAFF_APPOINTMENT_REMINDER`): one reminder, **2 hours** + before. No 24-hour one. Push and in-app on by default, email opt-in — + see "Defaults" above. +- **Owner**: no reminders. -An offset that's already in the past by the time the appointment is -booked/approved is silently skipped, never scheduled retroactively. +A reminder time that's already in the past when the booking is +made/approved is never scheduled retroactively. + +**In-app reminders are held back until they're due.** Every scheduled +notification's in-app copy is created `SCHEDULED`, not `SENT`, so it stays +out of the notification bell and unread count until the background +processor releases it at the reminder time (`releaseDueInAppReminders` in +`reminder-worker.ts`). Cancelling or rescheduling cancels it along with the +email/push copies, so a stale reminder never appears. + +**Stale-send guard.** Before sending (or releasing) a reminder, the worker +checks the appointment is still `CONFIRMED` and hasn't started; if not, the +delivery is marked `CANCELLED` (not `FAILED`, so it doesn't count toward the +owner's delivery-failure alert). This is a backstop for the processor being +down long enough that a "24 hours before" reminder would otherwise arrive +after the appointment. + +**Guest links.** A guest has no account, so every customer-facing link in +their emails carries an access token (`customerAppointmentLink` in +`guest-access.service.ts`). The booking-time token is random and only its +hash is stored, so later emails use a token *derived* from the appointment +id and `AUTH_SECRET` instead; `validateGuestAccessToken` accepts either, and +revoking a guest appointment's access kills both. --- @@ -169,10 +200,10 @@ booked/approved is silently skipped, never scheduled retroactively. | `BOOKING_CANCELLED` | The appointment is cancelled, by anyone | "Booking cancelled" (carries a "This was a late cancellation." note when applicable — not a separate type) | Always | | `BOOKING_BARBER_CHANGED` | An owner reassigns the appointment to a different staff member | "Your barber has changed" | Only if the customer originally picked a specific staff member — see "Fixed no-email types" | | `BOOKING_COMPLETED` | The appointment is marked completed | "Thanks for visiting" | Never | -| `BOOKING_REMINDER` *(optional)* | 24 hours before a confirmed appointment | "Upcoming appointment reminder" | Default on | +| `BOOKING_REMINDER` *(optional)* | 24 hours before a confirmed appointment (2 hours before if booked with less than 24 hours' notice) | "Upcoming appointment reminder" | Default on | | `LOYALTY_STAMP_ADDED` | A loyalty stamp is added to their card | "You've earned a loyalty stamp" | Never | | `LOYALTY_REWARD_EARNED` | Their stamp count reaches the reward threshold | "You've earned a reward" | Always | -| `LOYALTY_REWARD_EXPIRING` | A daily sweep finds their reward is nearing its expiry date (fires once per calendar day it's still found, not once total) | "Your reward is expiring soon" | Always | +| `LOYALTY_REWARD_EXPIRING` | A reward's expiry is within 7 days — sent once when it enters that window, and once more when it's down to its last day (two at most per reward) | "Your reward is expiring soon" | Always | | `LOYALTY_REWARD_REDEEMED` | Their reward is applied to an appointment | "Reward applied" | Never | ## Staff-facing @@ -182,7 +213,7 @@ booked/approved is silently skipped, never scheduled retroactively. | `STAFF_NEW_APPOINTMENT` *(optional)* | A new appointment is booked with them | "New appointment" | Off | | `STAFF_APPOINTMENT_RESCHEDULED` *(optional)* | One of their appointments is rescheduled | "Appointment rescheduled" | Off | | `STAFF_APPOINTMENT_CANCELLED` *(optional)* | One of their appointments is cancelled | "Appointment cancelled" | On | -| `STAFF_APPOINTMENT_REMINDER` *(optional)* | 24 hours and 2 hours before their appointment | "Upcoming appointment reminder" | Off | +| `STAFF_APPOINTMENT_REMINDER` *(optional)* | 2 hours before their appointment | "Upcoming appointment reminder" | In-app and push on, email off | | `STAFF_SCHEDULE_CHANGED` *(optional)* | An **owner** edits their working hours/breaks/time-off on their behalf — never fires from the self-service editors, since notifying someone about their own edit would just be noise | "Your schedule was updated" | On | | `STAFF_INVITATION_RECEIVED` *(optional)* | They're added as a new team member | "You've been invited to join `{business}`" | On | | `STAFF_APPOINTMENT_CHECKED_IN` | Defined in the schema, has a recipient assigned — **but nothing in the codebase currently triggers it.** Preference-controlled for forward-compatibility, but has no toggle in the UI since there's nothing to preview. Left dormant on purpose | — | — | @@ -254,3 +285,7 @@ Not queued, not preference-controlled, no in-app record. (shared by customer/staff/owner), wired up per audience in `src/app/(customer)/settings/notifications/page.tsx` and `src/app/(staff)/staff/settings/notifications/page.tsx`. +- **Guest-to-account merge** (what happens to a guest's history when they + later sign up): `src/services/customer/guest-merge.service.ts`, called from + `authorizeCredentials` (OTP sign-in), `src/auth.ts`'s Google `signIn` + event, and `createCustomerByStaff`. diff --git a/src/app/(customer)/settings/notifications/page.tsx b/src/app/(customer)/settings/notifications/page.tsx index 5f04cfb..1d75ef4 100644 --- a/src/app/(customer)/settings/notifications/page.tsx +++ b/src/app/(customer)/settings/notifications/page.tsx @@ -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), })), ); diff --git a/src/app/(staff)/staff/settings/notifications/page.tsx b/src/app/(staff)/staff/settings/notifications/page.tsx index deb38a6..daf617f 100644 --- a/src/app/(staff)/staff/settings/notifications/page.tsx +++ b/src/app/(staff)/staff/settings/notifications/page.tsx @@ -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), })), ); diff --git a/src/auth.ts b/src/auth.ts index dbdc24f..ebf0201 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -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) { diff --git a/src/lib/auth/__tests__/sign-in-guest-merge.integration.test.ts b/src/lib/auth/__tests__/sign-in-guest-merge.integration.test.ts new file mode 100644 index 0000000..be57f3b --- /dev/null +++ b/src/lib/auth/__tests__/sign-in-guest-merge.integration.test.ts @@ -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(); + }); +}); diff --git a/src/lib/auth/authorize-credentials.ts b/src/lib/auth/authorize-credentials.ts index e268bb7..2e9bf34 100644 --- a/src/lib/auth/authorize-credentials.ts +++ b/src/lib/auth/authorize-credentials.ts @@ -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 }; } diff --git a/src/services/customer/__tests__/guest-merge.service.integration.test.ts b/src/services/customer/__tests__/guest-merge.service.integration.test.ts new file mode 100644 index 0000000..d4d8f26 --- /dev/null +++ b/src/services/customer/__tests__/guest-merge.service.integration.test.ts @@ -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(); + }); + }); +}); diff --git a/src/services/customer/customer.service.ts b/src/services/customer/customer.service.ts index ee8cdcd..e3dd4f5 100644 --- a/src/services/customer/customer.service.ts +++ b/src/services/customer/customer.service.ts @@ -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 }; } /** diff --git a/src/services/customer/guest-merge.service.ts b/src/services/customer/guest-merge.service.ts new file mode 100644 index 0000000..97a21b6 --- /dev/null +++ b/src/services/customer/guest-merge.service.ts @@ -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 { + 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 { + try { + await claimGuestProfilesForUser(prisma, userId); + } catch (error) { + console.error(`[GuestMerge] could not merge guest profiles for user ${userId}`, error); + } +} diff --git a/src/services/loyalty/__tests__/loyalty-owner.integration.test.ts b/src/services/loyalty/__tests__/loyalty-owner.integration.test.ts index 5ff10d3..4b2fe7c 100644 --- a/src/services/loyalty/__tests__/loyalty-owner.integration.test.ts +++ b/src/services/loyalty/__tests__/loyalty-owner.integration.test.ts @@ -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`, + ]); + }); }); }); diff --git a/src/services/loyalty/loyalty-notifications.ts b/src/services/loyalty/loyalty-notifications.ts index 797f06e..27380bf 100644 --- a/src/services/loyalty/loyalty-notifications.ts +++ b/src/services/loyalty/loyalty-notifications.ts @@ -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 { 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}`, }); } diff --git a/src/services/loyalty/loyalty.service.ts b/src/services/loyalty/loyalty.service.ts index 903d076..f37d062 100644 --- a/src/services/loyalty/loyalty.service.ts +++ b/src/services/loyalty/loyalty.service.ts @@ -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 { 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", }); } } diff --git a/src/services/notifications/__tests__/notification.service.integration.test.ts b/src/services/notifications/__tests__/notification.service.integration.test.ts index 3bbab00..0492847 100644 --- a/src/services/notifications/__tests__/notification.service.integration.test.ts +++ b/src/services/notifications/__tests__/notification.service.integration.test.ts @@ -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 = [ diff --git a/src/services/notifications/__tests__/reminder-worker.integration.test.ts b/src/services/notifications/__tests__/reminder-worker.integration.test.ts index 77a4a4b..e2a99b5 100644 --- a/src/services/notifications/__tests__/reminder-worker.integration.test.ts +++ b/src/services/notifications/__tests__/reminder-worker.integration.test.ts @@ -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); + }); + }); }); diff --git a/src/services/notifications/__tests__/types.test.ts b/src/services/notifications/__tests__/types.test.ts index 7c19709..6cc7fae 100644 --- a/src/services/notifications/__tests__/types.test.ts +++ b/src/services/notifications/__tests__/types.test.ts @@ -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); + } + }); +}); diff --git a/src/services/notifications/notification.service.ts b/src/services/notifications/notification.service.ts index 44b2d06..e652239 100644 --- a/src/services/notifications/notification.service.ts +++ b/src/services/notifications/notification.service.ts @@ -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 { 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 { 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 { 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; diff --git a/src/services/notifications/reminder-worker.ts b/src/services/notifications/reminder-worker.ts index 6b000ca..52fa4a4 100644 --- a/src/services/notifications/reminder-worker.ts +++ b/src/services/notifications/reminder-worker.ts @@ -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> { + 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(); + 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 } }, diff --git a/src/services/notifications/types.ts b/src/services/notifications/types.ts index 392817e..11f876c 100644 --- a/src/services/notifications/types.ts +++ b/src/services/notifications/types.ts @@ -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.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>> = { + [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); } diff --git a/src/services/scheduling/__tests__/booking-notifications.integration.test.ts b/src/services/scheduling/__tests__/booking-notifications.integration.test.ts index 0462020..4099e16 100644 --- a/src/services/scheduling/__tests__/booking-notifications.integration.test.ts +++ b/src/services/scheduling/__tests__/booking-notifications.integration.test.ts @@ -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(), ); diff --git a/src/services/scheduling/__tests__/guest-access.integration.test.ts b/src/services/scheduling/__tests__/guest-access.integration.test.ts new file mode 100644 index 0000000..867f23e --- /dev/null +++ b/src/services/scheduling/__tests__/guest-access.integration.test.ts @@ -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}`); + }); +}); diff --git a/src/services/scheduling/__tests__/reminder-scheduling.integration.test.ts b/src/services/scheduling/__tests__/reminder-scheduling.integration.test.ts index e6a7db4..6be5c90 100644 --- a/src/services/scheduling/__tests__/reminder-scheduling.integration.test.ts +++ b/src/services/scheduling/__tests__/reminder-scheduling.integration.test.ts @@ -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); }); }); diff --git a/src/services/scheduling/booking-notifications.ts b/src/services/scheduling/booking-notifications.ts index 7b15e7d..be1f503 100644 --- a/src/services/scheduling/booking-notifications.ts +++ b/src/services/scheduling/booking-notifications.ts @@ -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, }); diff --git a/src/services/scheduling/guest-access.service.ts b/src/services/scheduling/guest-access.service.ts index ecb6f85..72fbab2 100644 --- a/src/services/scheduling/guest-access.service.ts +++ b/src/services/scheduling/guest-access.service.ts @@ -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 diff --git a/src/services/scheduling/reminder-scheduling.ts b/src/services/scheduling/reminder-scheduling.ts index f34ee97..e929fd4 100644 --- a/src/services/scheduling/reminder-scheduling.ts +++ b/src/services/scheduling/reminder-scheduling.ts @@ -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([...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 { 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), + }); } } diff --git a/vitest.integration.config.mts b/vitest.integration.config.mts index 6c11ec0..8475ad0 100644 --- a/vitest.integration.config.mts +++ b/vitest.integration.config.mts @@ -5,6 +5,18 @@ export default defineConfig({ test: { environment: "node", globals: false, + // Loads .env before any test file's imports run. Several modules under + // test (e.g. @/lib/auth/permissions -> @/lib/db/prisma) build the + // database connection at import time, so a test that imports one of + // them before fixtures.ts (which also loads dotenv) used to die with + // "Database connection is not configured" — a pure import-order + // accident, not a real failure. + setupFiles: ["dotenv/config"], + // E2E_TEST_MODE switches off the OTP rate limits (see otp.service.ts) + // for Playwright. Integration tests assert those limits exist, so pin it + // off here even if the surrounding environment/.env has it on — dotenv + // never overrides a variable that's already set. + env: { E2E_TEST_MODE: "false" }, include: ["**/*.integration.test.ts"], // "**/node_modules/**" (not "node_modules/**") — see vitest.config.mts's // own comment for why this matters once a nested node_modules