Fixes
CI / ci (push) Canceled after 0s

Guest merge

Guest history now joins the account when the email is proven, in three places: OTP sign-in, Google sign-in, and staff adding a client. Unverified emails are never matched.

What moves: appointments, notes, push subscriptions and notifications go onto the account's profile, and the guest profile is deleted.
One email, several guests: if the email has several guest profiles (one per phone number), all are merged.
No profile yet: the oldest guest profile becomes the account's profile.
Account wins: it keeps its own name and phone, and only gaps get filled.
Stamps: summed, with the ledger entries moved across. If the combined total reaches the threshold, the reward is earned and the customer is notified.
Safety: it runs in one transaction and is audit-logged. A failure never blocks sign-in; the next sign-in retries.

Reminders
Staff: one reminder, 2h before; the 24h one is gone. Push and in-app are on by default and email is opt-in. Defaults are now per channel, and the settings page shows email off, push and in-app on.
Customer: one reminder at 24h. If booked too late for that, they get one at 2h instead, and none if the appointment is under 2h away.
In-app reminders: held back and released by the worker at the reminder time, so they no longer appear the moment you book. Cancelling or rescheduling clears them, and "mark all read" no longer marks unseen ones.
Guest links: every guest email now carries a working link. The booking-time token is unchanged, and later emails use one derived from the appointment id and AUTH_SECRET. Revoking a guest appointment's access kills both.
Reward expiry: two emails per reward at most, at 7 days and 1 day before.
Stale guard: the worker drops a reminder, without counting a failure, if the appointment has started or isn't confirmed.

OTP rate-limit tests (2): the failures came from my scratch environment, not the app. I had E2E_TEST_MODE=true in the scratch .env, which turns off the OTP rate limits, so the tests had nothing to assert on. I'd also used MariaDB, which throws error 1020 on concurrent upserts, while your real stack runs mysql:8.0. On MySQL 8 without the flag they pass every time. I also pinned that flag off in the integration config, so a .env with it set can't break them again. I tested that by turning it on in .env, and the OTP tests still passed.
Authorization and nav-role suites (2): this was a real bug. Both files import @/lib/auth/permissions, which builds the database connection when it's imported, before the test fixtures load .env. The suites crashed before running a single test, so the owner-permission checks (barbers can't do owner actions, nothing leaks across businesses) hadn't been running at all. I now load dotenv once in the integration config, so no test depends on import order. They run and pass (13 tests).
This commit is contained in:
Stefan Lazic committed 2026-10-07 16:39:12 +01:00
1 parent 7338c9d9e2
commit 1442d33d55
25 files changed
+1202 -258

No files matched your search

+49 -14
View File
@@ -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). channel off a handful of these without making them toggleable).
- **Customer-facing**: only `BOOKING_REMINDER` is optional (and only - **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 (confirmations, changes, cancellations, loyalty) always notifies the
customer on every resolvable channel — not finding out your customer on every resolvable channel — not finding out your
appointment was cancelled is a real risk, not just noise. 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. function only governs the "nobody has touched this yet" fallback.
Most staff/owner types default to **on**. These default to **off** 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 instead — a solo owner-barber otherwise gets emailed/pushed/in-app'd for
everything the moment they sign in, with no gentle on-ramp: 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_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_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_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. | | `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 ## Fixed no-email types
A few types are `EMAIL` never, always, and unconditionally — not a A few types are `EMAIL` never, always, and unconditionally — not a
@@ -144,17 +151,41 @@ all.
## Reminder timing ## Reminder timing
`src/services/scheduling/reminder-scheduling.ts` schedules reminders at `src/services/scheduling/reminder-scheduling.ts` schedules reminders at
fixed hours-before-appointment offsets, independently for the customer fixed offsets before the appointment, independently for the customer and
and the assigned staff member: the assigned staff member:
- **Customer** (`BOOKING_REMINDER`): 24 hours before only. - **Customer** (`BOOKING_REMINDER`): one reminder, **24 hours** before. If
- **Staff** (`STAFF_APPOINTMENT_REMINDER`): 24 hours and 2 hours before the booking was made too late for that (a same-day or next-morning
(each its own send) — but see the defaults table above, this type booking), they get one **2 hours** before instead — never both, and never
defaults to off, so nothing actually sends unless the staff member nothing, unless the appointment is under 2 hours away.
opts in. - **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 A reminder time that's already in the past when the booking is
booked/approved is silently skipped, never scheduled retroactively. 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_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_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_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_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_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 | | `LOYALTY_REWARD_REDEEMED` | Their reward is applied to an appointment | "Reward applied" | Never |
## Staff-facing ## 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_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_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_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_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_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 | — | — | | `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 (shared by customer/staff/owner), wired up per audience in
`src/app/(customer)/settings/notifications/page.tsx` and `src/app/(customer)/settings/notifications/page.tsx` and
`src/app/(staff)/staff/settings/notifications/page.tsx`. `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`.
@@ -26,7 +26,7 @@ export default async function CustomerNotificationSettingsPage() {
CHANNELS.map((channel) => ({ CHANNELS.map((channel) => ({
type: group.type, type: group.type,
channel, channel,
isEnabled: rows.find((r) => r.type === group.type && r.channel === channel)?.isEnabled ?? defaultPreferenceIsEnabled(group.type), isEnabled: rows.find((r) => r.type === group.type && r.channel === channel)?.isEnabled ?? defaultPreferenceIsEnabled(group.type, channel),
})), })),
); );
@@ -53,7 +53,7 @@ export default async function StaffNotificationsPage() {
CHANNELS.map((channel) => ({ CHANNELS.map((channel) => ({
type: group.type, type: group.type,
channel, channel,
isEnabled: rows.find((r) => r.type === group.type && r.channel === channel)?.isEnabled ?? defaultPreferenceIsEnabled(group.type), isEnabled: rows.find((r) => r.type === group.type && r.channel === channel)?.isEnabled ?? defaultPreferenceIsEnabled(group.type, channel),
})), })),
); );
+10
View File
@@ -5,6 +5,7 @@ import Google from "next-auth/providers/google";
import { authConfig } from "@/auth.config"; import { authConfig } from "@/auth.config";
import { authorizeCredentials } from "@/lib/auth/authorize-credentials"; import { authorizeCredentials } from "@/lib/auth/authorize-credentials";
import { prisma } from "@/lib/db/prisma"; import { prisma } from "@/lib/db/prisma";
import { claimGuestProfilesSafely } from "@/services/customer/guest-merge.service";
export { authorizeCredentials } from "@/lib/auth/authorize-credentials"; export { authorizeCredentials } from "@/lib/auth/authorize-credentials";
@@ -37,6 +38,15 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
authorize: authorizeCredentials, 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: { callbacks: {
async jwt({ token, user, trigger, session }) { async jwt({ token, user, trigger, session }) {
if (user?.id) { if (user?.id) {
@@ -0,0 +1,59 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { prisma } from "@/services/scheduling/__tests__/fixtures";
import { authorizeCredentials } from "@/lib/auth/authorize-credentials";
import { requestOtp } from "@/lib/auth/otp.service";
import { MockEmailProvider } from "@/services/notifications/providers/mock-email-provider";
/**
* Signing in with an emailed code proves the person controls that inbox,
* so it's the moment guest bookings made under the same address join their
* account (see services/customer/guest-merge.service.ts).
*/
describe("OTP sign-in claims guest history (integration)", () => {
const email = `signin-merge-${Date.now()}@test.local`;
let userId: string;
let guestId: string;
beforeEach(async () => {
const user = await prisma.user.create({
data: {
email,
name: "Returning Guest",
emailVerified: new Date(),
customerProfile: { create: { firstName: "Returning", lastName: "Guest", email, isGuest: false } },
},
});
userId = user.id;
const guest = await prisma.customerProfile.create({
data: { firstName: "Returning", lastName: "Guest", email, phone: "+44 7700 900100", isGuest: true },
});
guestId = guest.id;
});
afterEach(async () => {
await prisma.customerProfile.deleteMany({ where: { id: guestId } });
await prisma.emailOtp.deleteMany({ where: { email } }).catch(() => {});
await prisma.auditLog.deleteMany({ where: { actorUserId: userId } });
await prisma.user.deleteMany({ where: { id: userId } });
});
it("merges the guest profile when the code is correct", async () => {
const provider = new MockEmailProvider();
await requestOtp(prisma, { email, purpose: "login", onlyIfUserExists: true, emailProvider: provider });
const code = /verification code is (\d{6})/.exec(provider.sentEmails[0]?.text ?? "")?.[1];
if (!code) throw new Error("Test setup failed: couldn't read the OTP code.");
const signedIn = await authorizeCredentials({ email, code });
expect(signedIn?.id).toBe(userId);
expect(await prisma.customerProfile.findUnique({ where: { id: guestId } })).toBeNull();
});
it("does not merge anything when the code is wrong", async () => {
const provider = new MockEmailProvider();
await requestOtp(prisma, { email, purpose: "login", onlyIfUserExists: true, emailProvider: provider });
expect(await authorizeCredentials({ email, code: "000000" })).toBeNull();
expect(await prisma.customerProfile.findUnique({ where: { id: guestId } })).not.toBeNull();
});
});
+7
View File
@@ -1,6 +1,7 @@
import { finalizeOtp, verifyOtp } from "@/lib/auth/otp.service"; import { finalizeOtp, verifyOtp } from "@/lib/auth/otp.service";
import { prisma } from "@/lib/db/prisma"; import { prisma } from "@/lib/db/prisma";
import { checkRateLimit } from "@/lib/security/rate-limit"; import { checkRateLimit } from "@/lib/security/rate-limit";
import { claimGuestProfilesSafely } from "@/services/customer/guest-merge.service";
import { secretsMatch } from "@/lib/security/secrets-match"; import { secretsMatch } from "@/lib/security/secrets-match";
/** /**
@@ -77,5 +78,11 @@ export async function authorizeCredentials(
const user = await prisma.user.findUnique({ where: { email } }); const user = await prisma.user.findUnique({ where: { email } });
if (!user) return null; 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 }; return { id: user.id, email: user.email, name: user.name, image: user.image };
} }
@@ -0,0 +1,256 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
cleanupTestBusiness,
createTestAppointment,
createTestBusiness,
createTestStaff,
prisma,
} from "@/services/scheduling/__tests__/fixtures";
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
import { createCustomerByStaff } from "../customer.service";
import { claimGuestProfilesForUser } from "../guest-merge.service";
let counter = 0;
const uniqueEmail = () => `merge-${Date.now()}-${++counter}@test.local`;
describe("claimGuestProfilesForUser (integration)", () => {
let business: TestBusiness;
let leftoverProfileIds: string[];
beforeEach(async () => {
business = await createTestBusiness();
leftoverProfileIds = [];
});
afterEach(async () => {
await cleanupTestBusiness(business);
await prisma.customerProfile.deleteMany({ where: { id: { in: leftoverProfileIds } } });
});
async function createUser(email: string, opts: { verified?: boolean; withProfile?: boolean } = {}) {
const { verified = true, withProfile = true } = opts;
const user = await prisma.user.create({
data: {
email,
name: "Real Person",
emailVerified: verified ? new Date() : null,
...(withProfile ? { customerProfile: { create: { firstName: "Real", lastName: "Person", email, isGuest: false } } } : {}),
},
include: { customerProfile: true },
});
business.createdUserIds.push(user.id);
return user;
}
async function createGuest(email: string, extra: { phone?: string; firstName?: string } = {}) {
const guest = await prisma.customerProfile.create({
data: { firstName: extra.firstName ?? "Guest", lastName: "Visitor", email, phone: extra.phone ?? null, isGuest: true },
});
leftoverProfileIds.push(guest.id);
return guest;
}
async function bookFor(customerId: string) {
const { staffId } = await createTestStaff(business);
return (await createTestAppointment(business, { customerId, staffId })).appointmentId;
}
it("moves a guest's appointments and notes onto the registered profile, then removes the guest profile", async () => {
const email = uniqueEmail();
const user = await createUser(email);
const guest = await createGuest(email);
const appointmentId = await bookFor(guest.id);
const staff = await prisma.staffProfile.findFirstOrThrow({ where: { businessId: business.businessId } });
await prisma.customerNote.create({ data: { customerId: guest.id, authorStaffId: staff.id, body: "Prefers a skin fade" } });
const result = await claimGuestProfilesForUser(prisma, user.id);
expect(result).toEqual({ mergedProfileCount: 1, appointmentsMoved: 1 });
const appointment = await prisma.appointment.findUniqueOrThrow({ where: { id: appointmentId } });
expect(appointment.customerId).toBe(user.customerProfile!.id);
expect(await prisma.customerNote.count({ where: { customerId: user.customerProfile!.id } })).toBe(1);
expect(await prisma.customerProfile.findUnique({ where: { id: guest.id } })).toBeNull();
});
it("merges every guest profile for the email — one email can have several, one per phone number", async () => {
const email = uniqueEmail();
const user = await createUser(email);
const a = await createGuest(email, { phone: "+44 7700 900001" });
const b = await createGuest(email, { phone: "+44 7700 900002" });
await bookFor(a.id);
await bookFor(b.id);
const result = await claimGuestProfilesForUser(prisma, user.id);
expect(result).toEqual({ mergedProfileCount: 2, appointmentsMoved: 2 });
expect(await prisma.appointment.count({ where: { customerId: user.customerProfile!.id } })).toBe(2);
});
it("matches the email case-insensitively", async () => {
const email = uniqueEmail();
const user = await createUser(email.toUpperCase());
await createGuest(email);
expect((await claimGuestProfilesForUser(prisma, user.id)).mergedProfileCount).toBe(1);
});
it("leaves guests under a different email alone", async () => {
const user = await createUser(uniqueEmail());
const other = await createGuest(uniqueEmail());
await bookFor(other.id);
const result = await claimGuestProfilesForUser(prisma, user.id);
expect(result.mergedProfileCount).toBe(0);
expect(await prisma.customerProfile.findUnique({ where: { id: other.id } })).not.toBeNull();
});
it("does nothing until the account's email is verified — an unproven address must not inherit someone's history", async () => {
const email = uniqueEmail();
const user = await createUser(email, { verified: false });
const guest = await createGuest(email);
await bookFor(guest.id);
expect((await claimGuestProfilesForUser(prisma, user.id)).mergedProfileCount).toBe(0);
expect(await prisma.customerProfile.findUnique({ where: { id: guest.id } })).not.toBeNull();
});
it("promotes the oldest guest profile when the account has no profile yet, and folds the rest into it", async () => {
const email = uniqueEmail();
const user = await createUser(email, { withProfile: false });
const older = await createGuest(email, { phone: "+44 7700 900003", firstName: "Older" });
const newer = await createGuest(email, { phone: "+44 7700 900004", firstName: "Newer" });
await bookFor(older.id);
await bookFor(newer.id);
const result = await claimGuestProfilesForUser(prisma, user.id);
expect(result.mergedProfileCount).toBe(2);
const profile = await prisma.customerProfile.findUniqueOrThrow({ where: { userId: user.id } });
expect(profile.id).toBe(older.id);
expect(profile.isGuest).toBe(false);
expect(await prisma.appointment.count({ where: { customerId: profile.id } })).toBe(2);
expect(await prisma.customerProfile.findUnique({ where: { id: newer.id } })).toBeNull();
});
it("keeps the account's own details and only fills in what's missing", async () => {
const email = uniqueEmail();
const user = await createUser(email);
await createGuest(email, { phone: "+44 7700 900005", firstName: "Different" });
await claimGuestProfilesForUser(prisma, user.id);
const profile = await prisma.customerProfile.findUniqueOrThrow({ where: { userId: user.id } });
expect(profile.firstName).toBe("Real"); // account name wins
expect(profile.phone).toBe("+44 7700 900005"); // gap filled
});
it("is a no-op the second time", async () => {
const email = uniqueEmail();
const user = await createUser(email);
await createGuest(email);
await claimGuestProfilesForUser(prisma, user.id);
expect(await claimGuestProfilesForUser(prisma, user.id)).toEqual({ mergedProfileCount: 0, appointmentsMoved: 0 });
});
it("writes an audit log entry for the merge", async () => {
const email = uniqueEmail();
const user = await createUser(email);
const guest = await createGuest(email);
await claimGuestProfilesForUser(prisma, user.id);
const entry = await prisma.auditLog.findFirst({ where: { action: "GUEST_PROFILES_MERGED", entityId: user.customerProfile!.id } });
expect(entry).not.toBeNull();
expect(JSON.stringify(entry?.metadata)).toContain(guest.id);
await prisma.auditLog.deleteMany({ where: { entityId: user.customerProfile!.id } });
});
describe("loyalty stamps", () => {
async function programme(stampsRequired = 10) {
return prisma.loyaltyProgramme.upsert({
where: { businessId: business.businessId },
update: { stampsRequired, isEnabled: true },
create: { businessId: business.businessId, isEnabled: true, stampsRequired, rewardType: "PERCENTAGE", rewardValue: 50, rewardLabel: "50% Off" },
});
}
it("adds the guest's stamps to the account's, moving the ledger entries along with them", async () => {
const prog = await programme();
const email = uniqueEmail();
const user = await createUser(email);
const guest = await createGuest(email);
const staff = await createTestStaff(business);
const registered = await prisma.loyaltyAccount.create({ data: { customerId: user.customerProfile!.id, loyaltyProgrammeId: prog.id, currentStamps: 3 } });
const guestAccount = await prisma.loyaltyAccount.create({ data: { customerId: guest.id, loyaltyProgrammeId: prog.id, currentStamps: 4 } });
await prisma.loyaltyTransaction.create({ data: { loyaltyAccountId: guestAccount.id, type: "STAMP_ADDED", amount: 4, authoredByStaffId: staff.staffId } });
await claimGuestProfilesForUser(prisma, user.id);
const merged = await prisma.loyaltyAccount.findUniqueOrThrow({ where: { id: registered.id }, include: { transactions: true } });
expect(merged.currentStamps).toBe(7);
expect(merged.transactions).toHaveLength(1);
expect(await prisma.loyaltyAccount.findUnique({ where: { id: guestAccount.id } })).toBeNull();
});
it("hands over the guest's account whole when the registered profile has none", async () => {
const prog = await programme();
const email = uniqueEmail();
const user = await createUser(email);
const guest = await createGuest(email);
const guestAccount = await prisma.loyaltyAccount.create({ data: { customerId: guest.id, loyaltyProgrammeId: prog.id, currentStamps: 5 } });
await claimGuestProfilesForUser(prisma, user.id);
const moved = await prisma.loyaltyAccount.findUniqueOrThrow({ where: { id: guestAccount.id } });
expect(moved.customerId).toBe(user.customerProfile!.id);
expect(moved.currentStamps).toBe(5);
});
it("earns the reward when the combined total reaches the threshold even though neither half did", async () => {
const prog = await programme(10);
const email = uniqueEmail();
const user = await createUser(email);
const guest = await createGuest(email);
const registered = await prisma.loyaltyAccount.create({ data: { customerId: user.customerProfile!.id, loyaltyProgrammeId: prog.id, currentStamps: 6 } });
await prisma.loyaltyAccount.create({ data: { customerId: guest.id, loyaltyProgrammeId: prog.id, currentStamps: 5 } });
await claimGuestProfilesForUser(prisma, user.id);
const rewards = await prisma.loyaltyReward.findMany({ where: { loyaltyAccountId: registered.id, status: "AVAILABLE" } });
expect(rewards).toHaveLength(1);
expect((await prisma.loyaltyAccount.findUniqueOrThrow({ where: { id: registered.id } })).currentStamps).toBe(11);
});
it("does not stack a second reward when one is already waiting", async () => {
const prog = await programme(10);
const email = uniqueEmail();
const user = await createUser(email);
const guest = await createGuest(email);
const registered = await prisma.loyaltyAccount.create({ data: { customerId: user.customerProfile!.id, loyaltyProgrammeId: prog.id, currentStamps: 10 } });
await prisma.loyaltyReward.create({ data: { loyaltyAccountId: registered.id, rewardType: "PERCENTAGE", value: 50, label: "50% Off", status: "AVAILABLE" } });
await prisma.loyaltyAccount.create({ data: { customerId: guest.id, loyaltyProgrammeId: prog.id, currentStamps: 2 } });
await claimGuestProfilesForUser(prisma, user.id);
expect(await prisma.loyaltyReward.count({ where: { loyaltyAccountId: registered.id, status: "AVAILABLE" } })).toBe(1);
});
});
describe("createCustomerByStaff", () => {
it("folds in an existing guest's history when staff add the same email", async () => {
const email = uniqueEmail();
const guest = await createGuest(email);
const appointmentId = await bookFor(guest.id);
const result = await createCustomerByStaff(prisma, { firstName: "Sam", lastName: "Walker", email });
expect(result.success).toBe(true);
const profile = await prisma.customerProfile.findUniqueOrThrow({ where: { id: result.customerId } });
business.createdUserIds.push(profile.userId!);
expect((await prisma.appointment.findUniqueOrThrow({ where: { id: appointmentId } })).customerId).toBe(result.customerId);
expect(await prisma.customerProfile.findUnique({ where: { id: guest.id } })).toBeNull();
});
});
});
+14 -11
View File
@@ -1,5 +1,6 @@
import type { PrismaClient } from "@/generated/prisma/client"; import type { PrismaClient } from "@/generated/prisma/client";
import type { GuestDetails } from "@/lib/booking-draft/schema"; 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 * 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 * later phase, not something this function silently "fixes" by
* guessing which version is correct. * guessing which version is correct.
* *
* Full account claiming (a guest later creating an account and having * A guest later creating an account has their past guest bookings
* their past guest bookings attached to it) is explicitly out of scope * attached to it — see guest-merge.service.ts (originally out of scope,
* for this phase — see docs/phase-4-summary.md. * docs/phase-4-summary.md).
*/ */
export interface ResolveCustomerParams { export interface ResolveCustomerParams {
@@ -151,12 +152,9 @@ export interface CreateCustomerByStaffResult {
* not a partial/pending account the way a staff invitation is. * not a partial/pending account the way a staff invitation is.
* *
* Deliberately does not call notify()/requestOtp() at all — the customer * Deliberately does not call notify()/requestOtp() at all — the customer
* hears nothing until they themselves ask to sign in later. Does not * hears nothing until they themselves ask to sign in later. Does attach
* attempt to match/attach an existing guest CustomerProfile with the same * any existing guest history under the same email — see
* email/phone either — full guest-to-registered account claiming is an * guest-merge.service.ts.
* 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.
*/ */
export async function createCustomerByStaff( export async function createCustomerByStaff(
prisma: PrismaClient, prisma: PrismaClient,
@@ -182,10 +180,15 @@ export async function createCustomerByStaff(
create: { firstName, lastName, email, phone, isGuest: false }, create: { firstName, lastName, email, phone, isGuest: false },
}, },
}, },
include: { customerProfile: true },
}); });
return { success: true, customerId: user.customerProfile!.id }; // Staff typed this email themselves, so it's as trusted as the guest
// booking they may have taken for the same person earlier — fold any
// such history in now rather than leaving a duplicate until first sign-in.
await claimGuestProfilesSafely(prisma, user.id);
const profile = await prisma.customerProfile.findUniqueOrThrow({ where: { userId: user.id }, select: { id: true } });
return { success: true, customerId: profile.id };
} }
/** /**
@@ -0,0 +1,164 @@
import type { PrismaClient } from "@/generated/prisma/client";
import { writeAuditLog } from "@/lib/audit/write-audit-log";
import { notifyLoyaltyRewardEarned } from "@/services/loyalty/loyalty-notifications";
import { awardRewardIfEarned } from "@/services/loyalty/loyalty.service";
/**
* Guest-to-account merge. A guest booking creates a CustomerProfile with no
* linked User (see resolveGuestCustomer in customer.service.ts); if that
* person later signs up with the same email they'd otherwise end up with a
* second, empty profile while their booking history, loyalty stamps and
* notes stayed stranded on the guest one.
*
* Only ever runs once the account's email is PROVEN (`User.emailVerified`
* set — an OTP sign-in, a Google sign-in, or staff adding the client
* themselves), never off an email someone merely typed: matching guest
* history against an unverified address would hand a stranger another
* person's bookings. Matches on email alone — guest profiles are keyed by
* email + phone, so one email can legitimately have several, and all of
* them belong to whoever controls that inbox.
*
* Everything happens in one transaction: either every guest profile for
* this email is folded into the account's profile, or nothing changes.
*/
export interface ClaimGuestProfilesResult {
mergedProfileCount: number;
appointmentsMoved: number;
}
const NOTHING_TO_MERGE: ClaimGuestProfilesResult = { mergedProfileCount: 0, appointmentsMoved: 0 };
export async function claimGuestProfilesForUser(prisma: PrismaClient, userId: string): Promise<ClaimGuestProfilesResult> {
const user = await prisma.user.findUnique({ where: { id: userId }, select: { email: true, emailVerified: true } });
if (!user?.email || !user.emailVerified) return NOTHING_TO_MERGE;
const email = user.email.trim().toLowerCase();
// Cheap early-out — this runs on every sign-in, and nearly all of them
// have nothing to merge. The transaction below re-reads, so a concurrent
// sign-in can't merge the same profile twice.
const anyGuest = await prisma.customerProfile.findFirst({ where: { userId: null, isGuest: true, email }, select: { id: true } });
if (!anyGuest) return NOTHING_TO_MERGE;
const outcome = await prisma.$transaction(async (tx) => {
const guests = await tx.customerProfile.findMany({
where: { userId: null, isGuest: true, email },
orderBy: { createdAt: "asc" },
});
if (guests.length === 0) return null;
let target = await tx.customerProfile.findUnique({ where: { userId } });
let sources = guests;
if (!target) {
// No account profile yet (they've never booked while signed in) —
// the oldest guest profile simply becomes it, rather than creating
// an empty one only to merge into it.
const [first, ...rest] = guests;
if (!first) return null;
target = await tx.customerProfile.update({ where: { id: first.id }, data: { userId, isGuest: false } });
sources = rest;
}
let appointmentsMoved = 0;
const earnedRewards: { rewardId: string; businessId: string; label: string }[] = [];
for (const source of sources) {
const moved = await tx.appointment.updateMany({ where: { customerId: source.id }, data: { customerId: target.id } });
appointmentsMoved += moved.count;
await tx.customerNote.updateMany({ where: { customerId: source.id }, data: { customerId: target.id } });
await tx.pushSubscription.updateMany({ where: { customerId: source.id }, data: { customerId: target.id } });
await tx.notification.updateMany({ where: { recipientCustomerId: source.id }, data: { recipientCustomerId: target.id } });
const accounts = await tx.loyaltyAccount.findMany({ where: { customerId: source.id }, include: { loyaltyProgramme: true } });
for (const account of accounts) {
const existing = await tx.loyaltyAccount.findUnique({
where: { customerId_loyaltyProgrammeId: { customerId: target.id, loyaltyProgrammeId: account.loyaltyProgrammeId } },
});
if (!existing) {
await tx.loyaltyAccount.update({ where: { id: account.id }, data: { customerId: target.id } });
continue;
}
// Both sides already hold an account in this programme (unique per
// customer) — fold the guest's into the registered one. Moving the
// ledger rows along with the cached total keeps "currentStamps is
// the sum of its transactions" true on the surviving account.
await tx.loyaltyTransaction.updateMany({ where: { loyaltyAccountId: account.id }, data: { loyaltyAccountId: existing.id } });
await tx.loyaltyReward.updateMany({ where: { loyaltyAccountId: account.id }, data: { loyaltyAccountId: existing.id } });
const merged = await tx.loyaltyAccount.update({
where: { id: existing.id },
data: { currentStamps: { increment: account.currentStamps } },
});
await tx.loyaltyAccount.delete({ where: { id: account.id } });
// The combined total may now clear the reward threshold even though
// neither half did on its own.
const award = await awardRewardIfEarned(tx, {
loyaltyAccountId: merged.id,
currentStamps: merged.currentStamps,
programme: account.loyaltyProgramme,
});
if (award.rewardCreated && award.rewardId) {
earnedRewards.push({
rewardId: award.rewardId,
businessId: account.loyaltyProgramme.businessId,
label: account.loyaltyProgramme.rewardLabel ?? "Reward",
});
}
}
// Fill gaps only — the account's own details always win.
const fill: { phone?: string; firstName?: string; lastName?: string; preferredBarberId?: string } = {};
if (!target.phone && source.phone) fill.phone = source.phone;
if (!target.preferredBarberId && source.preferredBarberId) fill.preferredBarberId = source.preferredBarberId;
// resolveAuthenticatedCustomer's placeholder when a User has no name.
if (target.firstName === "Customer" && !target.lastName) {
fill.firstName = source.firstName;
fill.lastName = source.lastName;
}
if (Object.keys(fill).length > 0) target = await tx.customerProfile.update({ where: { id: target.id }, data: fill });
await tx.customerProfile.delete({ where: { id: source.id } });
}
return {
targetId: target.id,
mergedProfileIds: guests.map((g) => g.id),
appointmentsMoved,
earnedRewards,
};
});
if (!outcome) return NOTHING_TO_MERGE;
await writeAuditLog(prisma, {
actorUserId: userId,
action: "GUEST_PROFILES_MERGED",
entityType: "CustomerProfile",
entityId: outcome.targetId,
metadata: { mergedProfileIds: outcome.mergedProfileIds, appointmentsMoved: outcome.appointmentsMoved },
});
for (const reward of outcome.earnedRewards) {
await notifyLoyaltyRewardEarned(prisma, {
businessId: reward.businessId,
customerId: outcome.targetId,
rewardId: reward.rewardId,
rewardLabel: reward.label,
});
}
return { mergedProfileCount: outcome.mergedProfileIds.length, appointmentsMoved: outcome.appointmentsMoved };
}
/**
* For sign-in/registration paths: a merge problem must never stop someone
* from signing in or adding a client. The merge is a transaction, so a
* failure leaves everything as it was and the next sign-in simply retries.
*/
export async function claimGuestProfilesSafely(prisma: PrismaClient, userId: string): Promise<void> {
try {
await claimGuestProfilesForUser(prisma, userId);
} catch (error) {
console.error(`[GuestMerge] could not merge guest profiles for user ${userId}`, error);
}
}
@@ -330,16 +330,33 @@ describe("LoyaltyService — owner configuration and redemption (integration)",
expect((await prisma.loyaltyReward.findUniqueOrThrow({ where: { id: reward.id } })).status).toBe("AVAILABLE"); 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 () => { 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() + 3 * 24 * 60 * 60 * 1000)); const { reward } = await setUpAccountWithReward(new Date(Date.now() + 5 * 24 * 60 * 60 * 1000));
await expireOverdueRewards(prisma); // The cron sweep runs every couple of minutes and finds the same reward each time.
await expireOverdueRewards(prisma); 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({ const count = await prisma.notification.count({ where: { type: "LOYALTY_REWARD_EXPIRING", loyaltyRewardId: reward.id } });
where: { type: "LOYALTY_REWARD_EXPIRING", loyaltyRewardId: reward.id },
});
expect(count).toBe(1); 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`,
]);
});
}); });
}); });
+11 -4
View File
@@ -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( export async function notifyLoyaltyRewardExpiring(
prisma: PrismaClient, prisma: PrismaClient,
params: { businessId: string; customerId: string; rewardId: string; rewardLabel: string; expiresAt: Date; now: Date }, params: { businessId: string; customerId: string; rewardId: string; rewardLabel: string; expiresAt: Date; bucket: RewardExpiryBucket },
): Promise<void> { ): Promise<void> {
const customer = await resolveCustomerRecipient(prisma, params.customerId); 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" }); const expiresLabel = params.expiresAt.toLocaleDateString("en-GB", { day: "numeric", month: "short" });
await notifySafely(prisma, { await notifySafely(prisma, {
businessId: params.businessId, businessId: params.businessId,
@@ -72,7 +79,7 @@ export async function notifyLoyaltyRewardExpiring(
subject: "Your reward is expiring soon", subject: "Your reward is expiring soon",
bodyText: `Your reward (${params.rewardLabel}) expires on ${expiresLabel}. Redeem it on your next visit before then.`, bodyText: `Your reward (${params.rewardLabel}) expires on ${expiresLabel}. Redeem it on your next visit before then.`,
deepLinkPath: "/loyalty", deepLinkPath: "/loyalty",
idempotencyKey: `notify:loyalty-reward-expiring:${params.rewardId}:${dayStamp}`, idempotencyKey: `notify:loyalty-reward-expiring:${params.rewardId}:${params.bucket}`,
}); });
} }
+42 -28
View File
@@ -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 { LoyaltyCardFont, LoyaltyCardTextSize, RewardPositionMode } from "@/generated/prisma/enums";
import { fail, ok } from "@/lib/validation-result"; import { fail, ok } from "@/lib/validation-result";
import type { ValidationResult } 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() }; 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( async function addStampTransaction(
prisma: PrismaClient, prisma: PrismaClient,
params: { params: {
@@ -424,31 +457,11 @@ async function addStampTransaction(
}, },
}); });
let rewardCreated = false; const { rewardCreated, rewardId } = await awardRewardIfEarned(tx, {
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, loyaltyAccountId: account.id,
rewardType: params.programme.rewardType, currentStamps: account.currentStamps,
value: params.programme.rewardValue, programme: params.programme,
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;
}
}
return { rewardCreated, rewardId, transactionId: transaction.id }; 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 * both. Two independent jobs in one pass: flip any AVAILABLE reward whose
* expiresAt has passed to EXPIRED (redeemReward also defends against this * expiresAt has passed to EXPIRED (redeemReward also defends against this
* directly, but the sweep is what makes "Expired" actually show up in * directly, but the sweep is what makes "Expired" actually show up in
* history/UI instead of staying stuck at AVAILABLE forever), then notify * history/UI instead of staying stuck at AVAILABLE forever), then warn
* once per day for rewards still AVAILABLE and expiring within the next * about rewards still AVAILABLE and expiring within the next week — twice
* week. * per reward at most (7 days out, then the last day; see
* notifyLoyaltyRewardExpiring), not on every sweep.
*/ */
export async function expireOverdueRewards(prisma: PrismaClient, now: Date = new Date()): Promise<void> { export async function expireOverdueRewards(prisma: PrismaClient, now: Date = new Date()): Promise<void> {
await prisma.loyaltyReward.updateMany({ await prisma.loyaltyReward.updateMany({
@@ -876,7 +890,7 @@ export async function expireOverdueRewards(prisma: PrismaClient, now: Date = new
rewardId: reward.id, rewardId: reward.id,
rewardLabel: reward.label, rewardLabel: reward.label,
expiresAt: reward.expiresAt, expiresAt: reward.expiresAt,
now, bucket: reward.expiresAt.getTime() - now.getTime() <= 86_400_000 ? "1d" : "7d",
}); });
} }
} }
@@ -227,12 +227,11 @@ describe("NotificationService (integration)", () => {
expect(staffNotification.deliveries.map((d) => d.channel)).toContain(NotificationChannel.IN_APP); 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 { userId } = await createTestCustomer(business);
const disabledByDefault = [ const disabledByDefault = [
NotificationType.STAFF_NEW_APPOINTMENT, NotificationType.STAFF_NEW_APPOINTMENT,
NotificationType.STAFF_APPOINTMENT_RESCHEDULED, NotificationType.STAFF_APPOINTMENT_RESCHEDULED,
NotificationType.STAFF_APPOINTMENT_REMINDER,
NotificationType.OWNER_NEW_BOOKING, NotificationType.OWNER_NEW_BOOKING,
NotificationType.OWNER_NO_SHOW, 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 () => { 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 { userId } = await createTestCustomer(business);
const stillEnabledByDefault = [ const stillEnabledByDefault = [
@@ -2,8 +2,10 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { NotificationType } from "@/generated/prisma/enums"; import { NotificationType } from "@/generated/prisma/enums";
import { import {
cleanupTestBusiness, cleanupTestBusiness,
createTestAppointment,
createTestBusiness, createTestBusiness,
createTestCustomer, createTestCustomer,
createTestStaff,
prisma, prisma,
} from "@/services/scheduling/__tests__/fixtures"; } from "@/services/scheduling/__tests__/fixtures";
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures"; import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
@@ -258,4 +260,54 @@ describe("processDueNotificationDeliveries (integration)", () => {
expect(subscription.revokedAt).toBeNull(); expect(subscription.revokedAt).toBeNull();
}); });
}); });
describe("stale reminder guard", () => {
async function dueReminderFor(appointmentStartAt: Date, status: "CONFIRMED" | "CANCELLED") {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const { appointmentId } = await createTestAppointment(business, { customerId, staffId, startAt: appointmentStartAt });
await prisma.appointment.update({ where: { id: appointmentId }, data: { status } });
await notify(prisma, {
businessId: business.businessId,
type: NotificationType.BOOKING_REMINDER,
recipientCustomerId: customerId,
appointmentId,
bodyText: "Reminder",
idempotencyKey: `test:worker:stale:${appointmentId}`,
scheduledFor: new Date(Date.now() - 60_000),
});
return appointmentId;
}
it("still sends a due reminder for an upcoming confirmed appointment", async () => {
await dueReminderFor(new Date(Date.now() + 3 * 60 * 60_000), "CONFIRMED");
const result = await processDueNotificationDeliveries(prisma, { emailProvider: provider, businessId: business.businessId });
expect(result.sent).toBe(1);
expect(result.skippedStale).toBe(0);
});
it("drops — doesn't send, doesn't fail — a reminder whose appointment has already started", async () => {
// e.g. the processor was down long enough that a 24h reminder came due after the appointment itself.
const appointmentId = await dueReminderFor(new Date(Date.now() - 30 * 60_000), "CONFIRMED");
const result = await processDueNotificationDeliveries(prisma, { emailProvider: provider, businessId: business.businessId });
expect(result.sent).toBe(0);
expect(result.failed).toBe(0);
expect(result.skippedStale).toBe(1);
expect(provider.sentEmails).toHaveLength(0);
const delivery = await prisma.notificationDelivery.findFirstOrThrow({ where: { notification: { appointmentId } } });
expect(delivery.status).toBe("CANCELLED");
});
it("drops a reminder whose appointment is no longer confirmed", async () => {
await dueReminderFor(new Date(Date.now() + 3 * 60 * 60_000), "CANCELLED");
const result = await processDueNotificationDeliveries(prisma, { emailProvider: provider, businessId: business.businessId });
expect(result.sent).toBe(0);
expect(result.skippedStale).toBe(1);
expect(provider.sentEmails).toHaveLength(0);
});
});
}); });
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { NotificationType } from "@/generated/prisma/enums"; import { NotificationChannel, NotificationType } from "@/generated/prisma/enums";
import { isPreferenceControlled, recipientKindOf } from "../types"; import { defaultPreferenceIsEnabled, isPreferenceControlled, recipientKindOf } from "../types";
describe("recipientKindOf", () => { describe("recipientKindOf", () => {
it("classifies every type as exactly one of CUSTOMER/STAFF/OWNER", () => { it("classifies every type as exactly one of CUSTOMER/STAFF/OWNER", () => {
@@ -42,3 +42,31 @@ describe("isPreferenceControlled", () => {
} }
}); });
}); });
describe("defaultPreferenceIsEnabled", () => {
const channels = Object.values(NotificationChannel);
it("STAFF_APPOINTMENT_REMINDER defaults on for IN_APP and PUSH but off for EMAIL", () => {
const type = NotificationType.STAFF_APPOINTMENT_REMINDER;
expect(defaultPreferenceIsEnabled(type, NotificationChannel.IN_APP)).toBe(true);
expect(defaultPreferenceIsEnabled(type, NotificationChannel.PUSH)).toBe(true);
expect(defaultPreferenceIsEnabled(type, NotificationChannel.EMAIL)).toBe(false);
});
it("types that default off do so on every channel", () => {
for (const type of [
NotificationType.STAFF_NEW_APPOINTMENT,
NotificationType.STAFF_APPOINTMENT_RESCHEDULED,
NotificationType.OWNER_NEW_BOOKING,
NotificationType.OWNER_NO_SHOW,
]) {
for (const channel of channels) expect(defaultPreferenceIsEnabled(type, channel)).toBe(false);
}
});
it("everything else defaults on for every channel", () => {
for (const type of [NotificationType.BOOKING_REMINDER, NotificationType.STAFF_APPOINTMENT_CANCELLED]) {
for (const channel of channels) expect(defaultPreferenceIsEnabled(type, channel)).toBe(true);
}
});
});
@@ -126,8 +126,8 @@ async function isChannelEnabled(
}); });
// A stored row always wins, whichever way it's set — only the fallback // 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 // for "nobody has ever touched this" varies by type (see types.ts's
// defaultPreferenceIsEnabled). // defaultPreferenceIsEnabled, which is per type AND channel).
return preference?.isEnabled ?? defaultPreferenceIsEnabled(params.type); return preference?.isEnabled ?? defaultPreferenceIsEnabled(params.type, params.channel);
} }
async function resolveChannels( async function resolveChannels(
@@ -249,8 +249,10 @@ async function checkEquivalenceGroup(
* channel — idempotent on `idempotencyKey`: a second call with the same * channel — idempotent on `idempotencyKey`: a second call with the same
* key is a no-op that returns the original row, never a duplicate (see * 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 * 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 * SENT (there's no send step, just a DB row) unless `scheduledFor` is set,
* for the background processor (see reminder-worker.ts) to pick up. * in which case the worker releases them at that time; EMAIL/PUSH are left
* PENDING (or SCHEDULED) for the background processor (see
* reminder-worker.ts) to pick up.
*/ */
export async function notify(prisma: PrismaClient, params: NotifyParams): Promise<NotifyResult> { export async function notify(prisma: PrismaClient, params: NotifyParams): Promise<NotifyResult> {
if (!params.recipientUserId && !params.recipientCustomerId) { if (!params.recipientUserId && !params.recipientCustomerId) {
@@ -295,7 +297,16 @@ export async function notify(prisma: PrismaClient, params: NotifyParams): Promis
deliveries: { deliveries: {
create: channels.map((channel) => { create: channels.map((channel) => {
if (channel === NotificationChannel.IN_APP) { 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 return params.scheduledFor
? { channel, status: NotificationDeliveryStatus.SCHEDULED, scheduledFor: params.scheduledFor } ? { channel, status: NotificationDeliveryStatus.SCHEDULED, scheduledFor: params.scheduledFor }
@@ -350,7 +361,7 @@ export async function listInAppNotifications(
return prisma.notification.findMany({ return prisma.notification.findMany({
where: { where: {
recipientUserId: params.userId, recipientUserId: params.userId,
deliveries: { some: { channel: NotificationChannel.IN_APP } }, deliveries: { some: { channel: NotificationChannel.IN_APP, status: NotificationDeliveryStatus.SENT } },
...(params.unreadOnly ? { readAt: null } : {}), ...(params.unreadOnly ? { readAt: null } : {}),
}, },
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
@@ -360,7 +371,7 @@ export async function listInAppNotifications(
export async function getUnreadNotificationCount(prisma: PrismaClient, userId: string): Promise<number> { export async function getUnreadNotificationCount(prisma: PrismaClient, userId: string): Promise<number> {
return prisma.notification.count({ return prisma.notification.count({
where: { recipientUserId: userId, readAt: null, deliveries: { some: { channel: NotificationChannel.IN_APP } } }, where: { recipientUserId: userId, readAt: null, deliveries: { some: { channel: NotificationChannel.IN_APP, status: NotificationDeliveryStatus.SENT } } },
}); });
} }
@@ -378,7 +389,16 @@ export async function markNotificationRead(
export async function markAllNotificationsRead(prisma: PrismaClient, userId: string): Promise<number> { export async function markAllNotificationsRead(prisma: PrismaClient, userId: string): Promise<number> {
const result = await prisma.notification.updateMany({ 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() }, data: { readAt: new Date() },
}); });
return result.count; return result.count;
+104 -1
View File
@@ -46,10 +46,79 @@ export interface ProcessNotificationsResult {
failed: number; failed: number;
/** Another worker already claimed the row between our read and our claim attempt — not an error. */ /** Another worker already claimed the row between our read and our claim attempt — not an error. */
skippedAlreadyClaimed: number; 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 } }>; type DueDelivery = Prisma.NotificationDeliveryGetPayload<{ include: { notification: true } }>;
const REMINDER_TYPES: NotificationType[] = [NotificationType.BOOKING_REMINDER, NotificationType.STAFF_APPOINTMENT_REMINDER];
type AppointmentSnapshot = { status: string; startAt: Date };
/**
* A reminder is only worth sending while its appointment is still coming
* up and confirmed. Cancel/reschedule already cancel queued reminders
* (see reminder-scheduling.ts), so this is a backstop for everything that
* can slip past that — chiefly the processor being down long enough that a
* "24 hours before" reminder would otherwise go out after the appointment
* itself, or an appointment that left CONFIRMED by a route that doesn't
* call cancelScheduledReminders.
*/
function isStaleReminder(appointment: AppointmentSnapshot | undefined, now: Date): boolean {
return !appointment || appointment.status !== "CONFIRMED" || appointment.startAt <= now;
}
async function loadAppointmentSnapshots(prisma: PrismaClient, ids: string[]): Promise<Map<string, AppointmentSnapshot>> {
if (ids.length === 0) return new Map();
const rows = await prisma.appointment.findMany({ where: { id: { in: ids } }, select: { id: true, status: true, startAt: true } });
return new Map(rows.map((r) => [r.id, { status: r.status, startAt: r.startAt }]));
}
/**
* IN_APP deliveries have no send step, but a scheduled one (a reminder)
* is created SCHEDULED so it stays invisible until it's actually due —
* see notify()'s IN_APP branch. This makes the due ones visible, and
* bumps the notification's createdAt to now: the bell sorts by createdAt,
* so without that a reminder would land buried among entries from the day
* it was booked rather than at the top where it just arrived.
*/
async function releaseDueInAppReminders(prisma: PrismaClient, now: Date): Promise<{ released: number; stale: number }> {
const due = await prisma.notificationDelivery.findMany({
where: { channel: NotificationChannel.IN_APP, status: NotificationDeliveryStatus.SCHEDULED, scheduledFor: { lte: now } },
include: { notification: { select: { id: true, appointmentId: true } } },
take: 200,
});
const snapshots = await loadAppointmentSnapshots(
prisma,
due.flatMap((d) => (d.notification.appointmentId ? [d.notification.appointmentId] : [])),
);
let released = 0;
let stale = 0;
for (const delivery of due) {
const appointmentId = delivery.notification.appointmentId;
if (!appointmentId || isStaleReminder(snapshots.get(appointmentId), now)) {
await prisma.notificationDelivery.updateMany({
where: { id: delivery.id, status: NotificationDeliveryStatus.SCHEDULED },
data: { status: NotificationDeliveryStatus.CANCELLED, failureReason: "Skipped: appointment is no longer upcoming and confirmed." },
});
stale++;
continue;
}
const claimed = await prisma.notificationDelivery.updateMany({
where: { id: delivery.id, status: NotificationDeliveryStatus.SCHEDULED },
data: { status: NotificationDeliveryStatus.SENT, sentAt: now },
});
if (claimed.count === 0) continue;
await prisma.notification.update({ where: { id: delivery.notification.id }, data: { createdAt: now } });
released++;
}
return { released, stale };
}
function backoffMinutesFor(attemptNumber: number): number { function backoffMinutesFor(attemptNumber: number): number {
return BACKOFF_MINUTES[attemptNumber - 1] ?? BACKOFF_MINUTES[BACKOFF_MINUTES.length - 1] ?? 30; return BACKOFF_MINUTES[attemptNumber - 1] ?? BACKOFF_MINUTES[BACKOFF_MINUTES.length - 1] ?? 30;
} }
@@ -73,10 +142,44 @@ export async function processDueNotificationDeliveries(
include: { notification: true }, include: { notification: true },
}); });
const result: ProcessNotificationsResult = { claimed: 0, sent: 0, retried: 0, failed: 0, skippedAlreadyClaimed: 0 }; const result: ProcessNotificationsResult = {
claimed: 0,
sent: 0,
retried: 0,
failed: 0,
skippedAlreadyClaimed: 0,
skippedStale: 0,
inAppReleased: 0,
};
const failuresByBusiness = new Map<string, number>(); const failuresByBusiness = new Map<string, number>();
const inApp = await releaseDueInAppReminders(prisma, now);
result.inAppReleased = inApp.released;
result.skippedStale += inApp.stale;
const reminderSnapshots = await loadAppointmentSnapshots(
prisma,
due.flatMap((d) =>
REMINDER_TYPES.includes(d.notification.type) && d.notification.appointmentId ? [d.notification.appointmentId] : [],
),
);
for (const delivery of due) { 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({ const claim = await prisma.notificationDelivery.updateMany({
where: { id: delivery.id, status: delivery.status }, where: { id: delivery.id, status: delivery.status },
data: { status: NotificationDeliveryStatus.PROCESSING, lastAttemptedAt: now, attemptCount: { increment: 1 } }, data: { status: NotificationDeliveryStatus.PROCESSING, lastAttemptedAt: now, attemptCount: { increment: 1 } },
+15 -5
View File
@@ -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 * 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>([ const DEFAULT_DISABLED_TYPES = new Set<NotificationType>([
NotificationType.STAFF_NEW_APPOINTMENT, NotificationType.STAFF_NEW_APPOINTMENT,
NotificationType.STAFF_APPOINTMENT_RESCHEDULED, NotificationType.STAFF_APPOINTMENT_RESCHEDULED,
NotificationType.STAFF_APPOINTMENT_REMINDER,
NotificationType.OWNER_NEW_BOOKING, NotificationType.OWNER_NEW_BOOKING,
NotificationType.OWNER_NO_SHOW, NotificationType.OWNER_NO_SHOW,
]); ]);
/**
* Types that are on by default overall but have specific channels that
* default to OFF — e.g. a barber's 2-hour heads-up is worth a push/in-app
* nudge out of the box, but a second email for something that's about to
* happen anyway is opt-in. Same "stored row always wins" rule as above.
*/
const DEFAULT_DISABLED_CHANNELS: Partial<Record<NotificationType, ReadonlySet<NotificationChannel>>> = {
[NotificationType.STAFF_APPOINTMENT_REMINDER]: new Set([NotificationChannel.EMAIL]),
};
/** /**
* The single source of truth for "what does this preference-controlled * 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 * 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 * 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 * toggle as on for something that actually defaults to off (or vice
* versa). Meaningless for a required (non-preference-controlled) type. * versa). Meaningless for a required (non-preference-controlled) type.
*/ */
export function defaultPreferenceIsEnabled(type: NotificationType): boolean { export function defaultPreferenceIsEnabled(type: NotificationType, channel: NotificationChannel): boolean {
return !DEFAULT_DISABLED_TYPES.has(type); if (DEFAULT_DISABLED_TYPES.has(type)) return false;
return !DEFAULT_DISABLED_CHANNELS[type]?.has(channel);
} }
@@ -75,16 +75,14 @@ describe("booking notifications (integration)", () => {
}); });
const types = notifications.map((n) => n.type).sort(); const types = notifications.map((n) => n.type).sort();
// A booking far enough out (monday is >= 7 days ahead — see // A booking far enough out (monday is >= 7 days ahead — see
// nextWeekday) also schedules the customer's 24h reminder (only one — // nextWeekday) also schedules the customer's single 24h reminder and the
// the 2h leg was removed, see reminder-scheduling.ts) and the staff // staff member's single 2h reminder (see reminder-scheduling.ts).
// member's 24h+2h reminders.
expect(types).toEqual( expect(types).toEqual(
[ [
"BOOKING_CONFIRMED", "BOOKING_CONFIRMED",
"BOOKING_REMINDER", "BOOKING_REMINDER",
"OWNER_NEW_BOOKING", "OWNER_NEW_BOOKING",
"STAFF_APPOINTMENT_REMINDER", "STAFF_APPOINTMENT_REMINDER",
"STAFF_APPOINTMENT_REMINDER",
"STAFF_NEW_APPOINTMENT", "STAFF_NEW_APPOINTMENT",
].sort(), ].sort(),
); );
@@ -0,0 +1,84 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
cleanupTestBusiness,
createTestAppointment,
createTestBusiness,
createTestCustomer,
createTestGuestCustomer,
createTestStaff,
prisma,
} from "./fixtures";
import type { TestBusiness } from "./fixtures";
import {
customerAppointmentLink,
deriveGuestAccessToken,
issueGuestAccessToken,
revokeGuestAccessToken,
validateGuestAccessToken,
} from "../guest-access.service";
describe("guest access links (integration)", () => {
let business: TestBusiness;
let guestIds: string[];
beforeEach(async () => {
process.env.AUTH_SECRET ??= "test-secret-for-guest-links";
business = await createTestBusiness();
guestIds = [];
});
afterEach(async () => {
await cleanupTestBusiness(business);
await prisma.customerProfile.deleteMany({ where: { id: { in: guestIds } } });
});
async function guestAppointment() {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestGuestCustomer();
guestIds.push(customerId);
const { appointmentId } = await createTestAppointment(business, { customerId, staffId });
const appointment = await prisma.appointment.findUniqueOrThrow({ where: { id: appointmentId } });
// The booking-time token — random, only its hash is kept.
const bookingToken = await issueGuestAccessToken(prisma, { appointmentId, appointmentEndAt: appointment.endAt });
return { appointmentId, bookingToken, customerId };
}
it("derives the same token every time for an appointment, and different ones for different appointments", () => {
expect(deriveGuestAccessToken("appt-1")).toBe(deriveGuestAccessToken("appt-1"));
expect(deriveGuestAccessToken("appt-1")).not.toBe(deriveGuestAccessToken("appt-2"));
});
it("accepts the derived token as well as the booking-time one — so follow-up emails can carry a working link", async () => {
const { appointmentId, bookingToken } = await guestAppointment();
expect(await validateGuestAccessToken(prisma, { appointmentId, rawToken: bookingToken })).toBe(true);
expect(await validateGuestAccessToken(prisma, { appointmentId, rawToken: deriveGuestAccessToken(appointmentId) })).toBe(true);
});
it("rejects a guessed token, and a token derived for a different appointment", async () => {
const first = await guestAppointment();
const second = await guestAppointment();
expect(await validateGuestAccessToken(prisma, { appointmentId: first.appointmentId, rawToken: "not-a-token" })).toBe(false);
expect(
await validateGuestAccessToken(prisma, { appointmentId: first.appointmentId, rawToken: deriveGuestAccessToken(second.appointmentId) }),
).toBe(false);
});
it("revoking the appointment's access also kills the derived link", async () => {
const { appointmentId } = await guestAppointment();
await revokeGuestAccessToken(prisma, appointmentId);
expect(await validateGuestAccessToken(prisma, { appointmentId, rawToken: deriveGuestAccessToken(appointmentId) })).toBe(false);
});
it("customerAppointmentLink appends the token for a guest, and not for a registered customer", async () => {
const { appointmentId } = await guestAppointment();
const guestLink = customerAppointmentLink({ id: appointmentId, customer: { userId: null } });
expect(guestLink).toBe(`/appointments/${appointmentId}?token=${deriveGuestAccessToken(appointmentId)}`);
const { userId } = await createTestCustomer(business);
expect(customerAppointmentLink({ id: appointmentId, customer: { userId } })).toBe(`/appointments/${appointmentId}`);
});
});
@@ -1,11 +1,18 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { 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 { NotificationChannel, NotificationType, Weekday } from "@/generated/prisma/enums";
import { addDays, todayIn, wallClockToInstant, weekdayOf } from "@/lib/time/business-time"; import { addDays, todayIn, wallClockToInstant, weekdayOf } from "@/lib/time/business-time";
import type { CalendarDate } 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 { cancelBooking, createBooking, rescheduleBooking } from "../booking.service";
import { cleanupTestBusiness, createTestBusiness, createTestCustomer, createTestStaff, prisma } from "./fixtures"; import { scheduleAppointmentReminders } from "../reminder-scheduling";
import type { TestBusiness } from "./fixtures";
function nextWeekday(timezone: string, target: Weekday, minDaysAhead = 7): CalendarDate { function nextWeekday(timezone: string, target: Weekday, minDaysAhead = 7): CalendarDate {
let date = addDays(todayIn(timezone), minDaysAhead); let date = addDays(todayIn(timezone), minDaysAhead);
@@ -16,26 +23,26 @@ function nextWeekday(timezone: string, target: Weekday, minDaysAhead = 7): Calen
throw new Error("unreachable"); 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({ return prisma.notificationDelivery.findMany({
where: { where: {
notification: { appointmentId, type: { in: ["BOOKING_REMINDER", "STAFF_APPOINTMENT_REMINDER"] } }, notification: { appointmentId, type: { in: ["BOOKING_REMINDER", "STAFF_APPOINTMENT_REMINDER"] } },
channel: { in: ["EMAIL", "PUSH"] }, channel: { in: channels },
}, },
include: { notification: true }, include: { notification: true },
}); });
} }
/** /**
* Customer and staff reminders now have independently-controllable * Reminder rules (see reminder-scheduling.ts):
* defaults (see reminder-scheduling.ts's split CUSTOMER_/STAFF_ * - Customer: one reminder 24h before; if the booking was made too late for
* REMINDER_HOURS_BEFORE, and types.ts's DEFAULT_DISABLED_TYPES): a * that, one 2h before instead — never both, never none (unless under 2h).
* customer's reminder is 24h-before only and defaults on; a staff * - Staff: one reminder 2h before only. IN_APP and PUSH default on, EMAIL
* member's is still 24h+2h-before but defaults OFF (an explicit * is opt-in (types.ts's DEFAULT_DISABLED_CHANNELS).
* NotificationPreference row is needed to see it). Most tests below * Every scheduled IN_APP copy is created SCHEDULED, not SENT, so it stays
* enable the staff preference up front specifically to exercise that * out of the notification bell until the worker releases it.
* still-working mechanism end to end — see "with the staff reminder
* preference enabled" for why.
*/ */
describe("reminder scheduling (integration)", () => { describe("reminder scheduling (integration)", () => {
let business: TestBusiness; let business: TestBusiness;
@@ -50,53 +57,20 @@ describe("reminder scheduling (integration)", () => {
await cleanupTestBusiness(business); await cleanupTestBusiness(business);
}); });
it("by default, only schedules the customer's 24h EMAIL reminder — the staff member gets none (disabled by default)", async () => { async function book(startAt = wallClockToInstant(monday, 10 * 60, business.timezone)) {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const result = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
if (!result.success) throw new Error("setup failed");
const deliveries = await reminderDeliveries(result.appointment.id);
expect(deliveries).toHaveLength(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 { staffId, userId: staffUserId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business); const { customerId, userId: customerUserId } = await createTestCustomer(business);
await setNotificationPreference(prisma, {
userId: staffUserId,
type: NotificationType.STAFF_APPOINTMENT_REMINDER,
channel: NotificationChannel.EMAIL,
isEnabled: true,
});
const result = await createBooking(prisma, { const result = await createBooking(prisma, {
businessId: business.businessId, businessId: business.businessId,
customerId, customerId,
staffId, staffId,
serviceIds: [business.haircutServiceId], serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 10 * 60, business.timezone), startAt,
}); });
if (!result.success) throw new Error("setup failed"); if (!result.success) throw new Error("setup failed");
return { appointment: result.appointment, staffUserId, customerUserId };
}
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
});
describe("with the staff reminder preference enabled", () => {
async function enableStaffReminderEmail(staffUserId: string) { async function enableStaffReminderEmail(staffUserId: string) {
await setNotificationPreference(prisma, { await setNotificationPreference(prisma, {
userId: staffUserId, userId: staffUserId,
@@ -106,11 +80,32 @@ describe("reminder scheduling (integration)", () => {
}); });
} }
it("schedules 24h and 2h EMAIL reminders for the staff member, alongside the customer's 24h one, as SCHEDULED (not PENDING)", async () => { 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 { staffId, userId: staffUserId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business); const { customerId } = await createTestCustomer(business);
await enableStaffReminderEmail(staffUserId); await enableStaffReminderEmail(staffUserId);
const result = await createBooking(prisma, { const result = await createBooking(prisma, {
businessId: business.businessId, businessId: business.businessId,
customerId, customerId,
@@ -121,115 +116,110 @@ describe("reminder scheduling (integration)", () => {
if (!result.success) throw new Error("setup failed"); if (!result.success) throw new Error("setup failed");
const deliveries = await reminderDeliveries(result.appointment.id); const deliveries = await reminderDeliveries(result.appointment.id);
expect(deliveries).toHaveLength(3); // customer 24h + staff 24h + staff 2h expect(deliveries).toHaveLength(2); // customer 24h + staff 2h
for (const d of deliveries) { for (const d of deliveries) {
expect(d.status).toBe("SCHEDULED"); expect(d.status).toBe("SCHEDULED");
expect(d.scheduledFor).not.toBeNull();
expect(d.scheduledFor!.getTime()).toBeLessThan(result.appointment.startAt.getTime()); expect(d.scheduledFor!.getTime()).toBeLessThan(result.appointment.startAt.getTime());
} }
const staffReminders = deliveries.filter((d) => d.notification.type === "STAFF_APPOINTMENT_REMINDER"); const staffEmail = deliveries.filter((d) => d.notification.type === "STAFF_APPOINTMENT_REMINDER");
expect(staffReminders).toHaveLength(2); expect(staffEmail).toHaveLength(1);
expect(staffEmail[0]!.scheduledFor!.getTime()).toBe(result.appointment.startAt.getTime() - 2 * HOUR);
}); });
it("does not schedule a reminder offset that would already be in the past", async () => { describe("late bookings", () => {
const { staffId, userId: staffUserId } = await createTestStaff(business); it("falls back to a 2h customer reminder when the 24h point has already passed", async () => {
const { customerId } = await createTestCustomer(business); const { appointment } = await book();
await enableStaffReminderEmail(staffUserId); const startAt = new Date(Date.now() + 5 * HOUR);
await scheduleAppointmentReminders(prisma, { ...appointment, startAt });
// Book for 3 hours from now — the customer's only offset (24h) and const reminders = (await reminderDeliveries(appointment.id)).filter(
// the staff's 24h offset are both already past; the staff's 2h (d) => d.notification.type === "BOOKING_REMINDER" && d.notification.idempotencyKey.endsWith(`:${startAt.getTime()}`),
// offset is still in the future. );
const result = await createBooking(prisma, { 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, businessId: business.businessId,
customerId, now: due,
staffId, });
serviceIds: [business.haircutServiceId], expect(result.inAppReleased).toBeGreaterThanOrEqual(1);
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 const after = await listInAppNotifications(prisma, { userId: customerUserId });
// reminder-skipping behaviour when the booking itself actually succeeded. expect(after.map((n) => n.type)).toContain("BOOKING_REMINDER");
if (!result || !result.success) return; expect(await getUnreadNotificationCount(prisma, customerUserId)).toBe(2);
const deliveries = await reminderDeliveries(result.appointment.id); });
expect(deliveries).toHaveLength(1);
expect(deliveries[0]!.notification.type).toBe("STAFF_APPOINTMENT_REMINDER"); it("are cancelled with the appointment, so they never show a stale reminder", async () => {
expect(deliveries[0]!.notification.idempotencyKey).toContain("reminder-2h"); 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 () => { it("rescheduling cancels the old reminders and schedules fresh ones for the new time", async () => {
const { staffId, userId: staffUserId } = await createTestStaff(business); const { appointment } = await book();
const { customerId } = await createTestCustomer(business); const original = await reminderDeliveries(appointment.id, ["EMAIL", "PUSH", "IN_APP"]);
await enableStaffReminderEmail(staffUserId); expect(original).toHaveLength(3); // customer 24h EMAIL + customer 24h IN_APP + staff 2h IN_APP
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, { const rescheduled = await rescheduleBooking(prisma, {
appointmentId: created.appointment.id, appointmentId: appointment.id,
startAt: wallClockToInstant(monday, 14 * 60, business.timezone), startAt: wallClockToInstant(monday, 14 * 60, business.timezone),
}); });
expect(rescheduled.success).toBe(true); expect(rescheduled.success).toBe(true);
if (!rescheduled.success) return; if (!rescheduled.success) return;
const allDeliveries = await reminderDeliveries(created.appointment.id); const all = await reminderDeliveries(appointment.id, ["EMAIL", "PUSH", "IN_APP"]);
const cancelledCount = allDeliveries.filter((d) => d.status === "CANCELLED").length; expect(all.filter((d) => d.status === "CANCELLED")).toHaveLength(3); // the originals, superseded
const scheduledCount = allDeliveries.filter((d) => d.status === "SCHEDULED").length; const fresh = all.filter((d) => d.status === "SCHEDULED");
expect(cancelledCount).toBe(3); // the original 3, now superseded expect(fresh).toHaveLength(3);
expect(scheduledCount).toBe(3); // 3 fresh ones for the new time for (const d of fresh) {
const freshOnes = allDeliveries.filter((d) => d.status === "SCHEDULED");
for (const d of freshOnes) {
expect(d.scheduledFor!.getTime()).toBeLessThan(rescheduled.appointment.startAt.getTime()); 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 () => { it("does not schedule reminders for a PENDING (requireApproval) booking", async () => {
await prisma.bookingRule.update({ where: { businessId: business.businessId }, data: { requireApproval: true } }); await prisma.bookingRule.update({ where: { businessId: business.businessId }, data: { requireApproval: true } });
const { staffId } = await createTestStaff(business); const { appointment } = await book();
const { customerId } = await createTestCustomer(business);
const result = await createBooking(prisma, { const deliveries = await reminderDeliveries(appointment.id, ["EMAIL", "PUSH", "IN_APP"]);
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
if (!result.success) throw new Error("setup failed");
const deliveries = await reminderDeliveries(result.appointment.id);
expect(deliveries).toHaveLength(0); expect(deliveries).toHaveLength(0);
}); });
}); });
@@ -3,6 +3,7 @@ import { NotificationType } from "@/generated/prisma/enums";
import { notifySafely } from "@/services/notifications/notification.service"; import { notifySafely } from "@/services/notifications/notification.service";
import { getOwnerUserIds } from "@/services/notifications/recipients"; import { getOwnerUserIds } from "@/services/notifications/recipients";
import type { Appointment } from "./booking.service"; import type { Appointment } from "./booking.service";
import { customerAppointmentLink } from "./guest-access.service";
import { scheduleAppointmentReminders } from "./reminder-scheduling"; import { scheduleAppointmentReminders } from "./reminder-scheduling";
/** /**
@@ -100,7 +101,7 @@ export async function notifyBookingRescheduled(prisma: PrismaClient, appointment
appointmentId: appointment.id, appointmentId: appointment.id,
subject: "Booking rescheduled", subject: "Booking rescheduled",
bodyText: `Your appointment for ${services} with ${appointment.staff.user.name} has been moved to ${dateLabel} at ${timeLabel}.`, 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}`, idempotencyKey: `notify:booking-rescheduled:${appointment.id}:${versionSuffix}`,
}); });
@@ -135,7 +136,7 @@ export async function notifyBookingCancelled(
appointmentId: appointment.id, appointmentId: appointment.id,
subject: "Booking cancelled", subject: "Booking cancelled",
bodyText: `Your appointment for ${services} with ${appointment.staff.user.name} on ${dateLabel} at ${timeLabel} has been cancelled.${lateNote}`, 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}`, idempotencyKey: `notify:booking-cancelled:${appointment.id}`,
}); });
@@ -176,7 +177,7 @@ export async function notifyBookingApproved(prisma: PrismaClient, appointment: A
appointmentId: appointment.id, appointmentId: appointment.id,
subject: "Booking approved", 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.`, 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}`, idempotencyKey: `notify:booking-approved:${appointment.id}`,
}); });
// Approval is the first point a PENDING (requireApproval) booking becomes // Approval is the first point a PENDING (requireApproval) booking becomes
@@ -216,7 +217,7 @@ export async function notifyBookingBarberChanged(
appointmentId: appointment.id, appointmentId: appointment.id,
subject: "Your barber has changed", subject: "Your barber has changed",
bodyText: `Your appointment for ${serviceNames(appointment)} is now with ${appointment.staff.user.name}.`, 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}`, idempotencyKey: `notify:booking-barber-changed:${appointment.id}:${appointment.staffId}`,
skipEmail: options.customerSelectedStaff === false, skipEmail: options.customerSelectedStaff === false,
}); });
@@ -232,7 +233,7 @@ export async function notifyBookingCompleted(prisma: PrismaClient, appointment:
appointmentId: appointment.id, appointmentId: appointment.id,
subject: "Thanks for visiting", subject: "Thanks for visiting",
bodyText: `Thanks for visiting ${appointment.staff.user.name ? `and seeing ${appointment.staff.user.name}` : ""} — hope you loved your ${serviceNames(appointment)}!`, 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}`, idempotencyKey: `notify:booking-completed:${appointment.id}`,
skipEmail: true, skipEmail: true,
}); });
@@ -1,4 +1,4 @@
import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
import type { PrismaClient } from "@/generated/prisma/client"; import type { PrismaClient } from "@/generated/prisma/client";
/** /**
@@ -22,6 +22,42 @@ function hashToken(rawToken: string): string {
return createHash("sha256").update(rawToken).digest("hex"); 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 * Issues this appointment's guest access token, generating one if none
* exists or overwriting the previous hash if one does — deliberately a * exists or overwriting the previous hash if one does — deliberately a
@@ -51,6 +87,15 @@ export async function issueGuestAccessToken(
return rawToken; 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 * True only if `rawToken` is the CURRENT token for exactly this
* appointment, not expired, and not revoked. Compares hashes with * 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 provided = Buffer.from(hashToken(params.rawToken), "hex");
const stored = Buffer.from(record.tokenHash, "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. // Best-effort — a stale lastUsedAt from a lost race isn't a security concern.
await prisma.guestAppointmentAccessToken await prisma.guestAppointmentAccessToken
+31 -26
View File
@@ -2,6 +2,7 @@ import type { PrismaClient } from "@/generated/prisma/client";
import { NotificationDeliveryStatus, NotificationType } from "@/generated/prisma/enums"; import { NotificationDeliveryStatus, NotificationType } from "@/generated/prisma/enums";
import { notifySafely } from "@/services/notifications/notification.service"; import { notifySafely } from "@/services/notifications/notification.service";
import type { Appointment } from "./booking.service"; import type { Appointment } from "./booking.service";
import { customerAppointmentLink } from "./guest-access.service";
/** /**
* Reminder timing — hardcoded, not yet a BookingRule column: there's no * 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 * would be two new BookingRule fields feeding the existing Booking Rules
* editor — not built this phase. * editor — not built this phase.
* *
* Customer and staff reminders deliberately have separate offset lists, * Customer and staff reminders are deliberately different:
* not one shared one — a 2-hour-before reminder on top of the 24-hour one * - A customer gets one reminder, 24 hours ahead. If the booking was made
* was more noise than value for a customer, but a barber still benefits * too late for that (a same-day or next-morning booking, common for a
* from both (a same-day heads-up on top of the previous day's one). * 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 CUSTOMER_REMINDER_HOURS_BEFORE = 24;
const STAFF_REMINDER_HOURS_BEFORE = [24, 2] as const; const CUSTOMER_FALLBACK_REMINDER_HOURS_BEFORE = 2;
const ALL_REMINDER_HOURS_BEFORE = Array.from( const STAFF_REMINDER_HOURS_BEFORE = 2;
new Set<number>([...CUSTOMER_REMINDER_HOURS_BEFORE, ...STAFF_REMINDER_HOURS_BEFORE]),
).sort((a, b) => b - a);
function formatDateLabel(date: Date, timezone: string): string { function formatDateLabel(date: Date, timezone: string): string {
return date.toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short", timeZone: timezone }); 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 * Schedules the customer's and barber's reminders for a CONFIRMED
* appointment at each offset in REMINDER_HOURS_BEFORE that's still in the * appointment — see the timing rules above. A reminder time that's already
* future — an offset that's already passed (e.g. booked 1 hour out) is * passed is never scheduled retroactively. Each reminder's idempotencyKey
* silently skipped, not scheduled in the past. Each reminder's * includes the exact startAt, so a reschedule (which calls
* idempotencyKey includes the exact startAt, so a reschedule (which calls
* cancelScheduledReminders then this again) always produces fresh rows * cancelScheduledReminders then this again) always produces fresh rows
* rather than colliding with a cancelled one — see * rather than colliding with a cancelled one — see booking-notifications.ts
* booking-notifications.ts for the same "version-suffixed key" pattern. * for the same "version-suffixed key" pattern.
*/ */
export async function scheduleAppointmentReminders(prisma: PrismaClient, appointment: Appointment): Promise<void> { export async function scheduleAppointmentReminders(prisma: PrismaClient, appointment: Appointment): Promise<void> {
if (appointment.status !== "CONFIRMED") return; if (appointment.status !== "CONFIRMED") return;
@@ -75,11 +77,15 @@ export async function scheduleAppointmentReminders(prisma: PrismaClient, appoint
const now = new Date(); const now = new Date();
const versionSuffix = appointment.startAt.getTime(); const versionSuffix = appointment.startAt.getTime();
for (const hoursBefore of ALL_REMINDER_HOURS_BEFORE) { const timeBefore = (hours: number) => new Date(appointment.startAt.getTime() - hours * 60 * 60 * 1000);
const scheduledFor = new Date(appointment.startAt.getTime() - hoursBefore * 60 * 60 * 1000); const customerHoursBefore =
if (scheduledFor <= now) continue; 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)) { if (customerHoursBefore !== null) {
await notifySafely(prisma, { await notifySafely(prisma, {
businessId: appointment.businessId, businessId: appointment.businessId,
type: NotificationType.BOOKING_REMINDER, type: NotificationType.BOOKING_REMINDER,
@@ -88,13 +94,13 @@ export async function scheduleAppointmentReminders(prisma: PrismaClient, appoint
appointmentId: appointment.id, appointmentId: appointment.id,
subject: "Upcoming appointment reminder", subject: "Upcoming appointment reminder",
bodyText: `Reminder: your appointment for ${services} with ${appointment.staff.user.name} is on ${dateLabel} at ${timeLabel}.`, bodyText: `Reminder: your appointment for ${services} with ${appointment.staff.user.name} is on ${dateLabel} at ${timeLabel}.`,
deepLinkPath: `/appointments/${appointment.id}`, deepLinkPath: customerAppointmentLink(appointment),
idempotencyKey: `notify:booking-reminder-${hoursBefore}h:${appointment.id}:${versionSuffix}`, idempotencyKey: `notify:booking-reminder-${customerHoursBefore}h:${appointment.id}:${versionSuffix}`,
scheduledFor, scheduledFor: timeBefore(customerHoursBefore),
}); });
} }
if ((STAFF_REMINDER_HOURS_BEFORE as readonly number[]).includes(hoursBefore)) { if (timeBefore(STAFF_REMINDER_HOURS_BEFORE) > now) {
await notifySafely(prisma, { await notifySafely(prisma, {
businessId: appointment.businessId, businessId: appointment.businessId,
type: NotificationType.STAFF_APPOINTMENT_REMINDER, type: NotificationType.STAFF_APPOINTMENT_REMINDER,
@@ -103,9 +109,8 @@ export async function scheduleAppointmentReminders(prisma: PrismaClient, appoint
subject: "Upcoming appointment reminder", subject: "Upcoming appointment reminder",
bodyText: `Reminder: ${appointment.customer.firstName} ${appointment.customer.lastName} (${services}) is booked for ${dateLabel} at ${timeLabel}.`, bodyText: `Reminder: ${appointment.customer.firstName} ${appointment.customer.lastName} (${services}) is booked for ${dateLabel} at ${timeLabel}.`,
deepLinkPath: `/staff/appointments/${appointment.id}`, deepLinkPath: `/staff/appointments/${appointment.id}`,
idempotencyKey: `notify:staff-appointment-reminder-${hoursBefore}h:${appointment.id}:${versionSuffix}`, idempotencyKey: `notify:staff-appointment-reminder-${STAFF_REMINDER_HOURS_BEFORE}h:${appointment.id}:${versionSuffix}`,
scheduledFor, scheduledFor: timeBefore(STAFF_REMINDER_HOURS_BEFORE),
}); });
} }
}
} }
+12
View File
@@ -5,6 +5,18 @@ export default defineConfig({
test: { test: {
environment: "node", environment: "node",
globals: false, 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"], include: ["**/*.integration.test.ts"],
// "**/node_modules/**" (not "node_modules/**") — see vitest.config.mts's // "**/node_modules/**" (not "node_modules/**") — see vitest.config.mts's
// own comment for why this matters once a nested node_modules // own comment for why this matters once a nested node_modules