Core logic: types.ts (defaults), notification.service.ts (skipEmail + dedup), booking-notifications.ts, loyalty-notifications.ts, reminder-scheduling.ts, booking.service.ts + scheduling.service.ts (staff-selection capture), features/owner/actions/appointments.ts. Schema: prisma/schema.prisma + new migration 20260912165903_add_staff_selected_by_customer. UI: both notification-preferences pages (customer, staff) now use the same default logic as the actual send decision. Tests: notification.service.integration.test.ts, booking-notifications.integration.test.ts, reminder-scheduling.integration.test.ts, loyalty-notifications.integration.test.ts, plus a new createTestAppointment fixture. Docs: docs/notifications.md rewritten to match. Default changes Exactly as specified — full table is in docs/notifications.md. In short: STAFF_NEW_APPOINTMENT, STAFF_APPOINTMENT_RESCHEDULED, STAFF_APPOINTMENT_REMINDER, OWNER_NEW_BOOKING, OWNER_NO_SHOW now default off; everything else that was already toggleable is untouched. No longer email (fixed, not a toggle) BOOKING_COMPLETED, LOYALTY_STAMP_ADDED, LOYALTY_REWARD_REDEEMED — always. BOOKING_BARBER_CHANGED — only when the customer originally booked "any available barber." The customer's 2-hour reminder is removed entirely (24h stays); staff keeps both, just off by default. All via a new skipEmail flag on notify(), checked in one place — no new preference rows for these. Deduplication One new EQUIVALENCE_GROUPS map in notification.service.ts (STAFF_NEW_APPOINTMENT>OWNER_NEW_BOOKING, STAFF_APPOINTMENT_CANCELLED>OWNER_BOOKING_CANCELLED). Inside notify(), before creating deliveries, it checks for an already-created equivalent notification for the same (businessId, appointmentId, recipientUserId); if found and at-least-as-relevant, the new one gets zero deliveries on every channel (row still created for audit trail). Only listed types participate, so OWNER_NO_SHOW and any future actionable owner type are never touched.
This commit is contained in:
18 files changed
+999
-172
No files matched your search
@@ -1,9 +1,12 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { Weekday } from "@/generated/prisma/enums";
|
||||
import { NotificationChannel, NotificationType, Weekday } from "@/generated/prisma/enums";
|
||||
import { addDays, todayIn, wallClockToInstant, weekdayOf } from "@/lib/time/business-time";
|
||||
import type { CalendarDate } from "@/lib/time/business-time";
|
||||
import { setNotificationPreference } from "@/services/notifications/notification.service";
|
||||
import { getAppointmentById } from "../appointment.service";
|
||||
import { notifyBookingBarberChanged, notifyBookingCompleted } from "../booking-notifications";
|
||||
import { cancelBooking, createBooking, rescheduleBooking } from "../booking.service";
|
||||
import { cleanupTestBusiness, createTestBusiness, createTestCustomer, createTestGuestCustomer, createTestStaff, prisma } from "./fixtures";
|
||||
import { cleanupTestBusiness, createTestAppointment, createTestBusiness, createTestCustomer, createTestGuestCustomer, createTestStaff, prisma } from "./fixtures";
|
||||
import type { TestBusiness } from "./fixtures";
|
||||
|
||||
function nextWeekday(timezone: string, target: Weekday, minDaysAhead = 7): CalendarDate {
|
||||
@@ -66,15 +69,19 @@ describe("booking notifications (integration)", () => {
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) return;
|
||||
|
||||
const notifications = await prisma.notification.findMany({ where: { appointmentId: result.appointment.id } });
|
||||
const notifications = await prisma.notification.findMany({
|
||||
where: { appointmentId: result.appointment.id },
|
||||
include: { deliveries: true },
|
||||
});
|
||||
const types = notifications.map((n) => n.type).sort();
|
||||
// A booking far enough out (monday is >= 7 days ahead — see nextWeekday) also schedules both
|
||||
// 24h/2h reminders for the customer and the staff member — see reminder-scheduling.ts.
|
||||
// A booking far enough out (monday is >= 7 days ahead — see
|
||||
// nextWeekday) also schedules the customer's 24h reminder (only one —
|
||||
// the 2h leg was removed, see reminder-scheduling.ts) and the staff
|
||||
// member's 24h+2h reminders.
|
||||
expect(types).toEqual(
|
||||
[
|
||||
"BOOKING_CONFIRMED",
|
||||
"BOOKING_REMINDER",
|
||||
"BOOKING_REMINDER",
|
||||
"OWNER_NEW_BOOKING",
|
||||
"STAFF_APPOINTMENT_REMINDER",
|
||||
"STAFF_APPOINTMENT_REMINDER",
|
||||
@@ -89,9 +96,49 @@ describe("booking notifications (integration)", () => {
|
||||
const staffNotification = notifications.find((n) => n.type === "STAFF_NEW_APPOINTMENT");
|
||||
expect(staffNotification?.recipientUserId).toBe(staffUserId);
|
||||
|
||||
// Same staff member holds both roles in this test, so OWNER_NEW_BOOKING also targets them.
|
||||
// Same staff member holds both roles in this test, so OWNER_NEW_BOOKING
|
||||
// also targets them — but the row has zero deliveries: dedup suppresses
|
||||
// it in favour of the STAFF_NEW_APPOINTMENT they already got (see
|
||||
// notification.service.ts's EQUIVALENCE_GROUPS).
|
||||
const ownerNotification = notifications.find((n) => n.type === "OWNER_NEW_BOOKING");
|
||||
expect(ownerNotification?.recipientUserId).toBe(staffUserId);
|
||||
expect(ownerNotification?.deliveries).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("an owner who is a DIFFERENT person from the assigned staff gets their own full, undeduped OWNER_NEW_BOOKING", async () => {
|
||||
const { staffId, userId: staffUserId } = await createTestStaff(business);
|
||||
const otherOwner = await createTestStaff(business);
|
||||
await grantOwnerPermission(otherOwner.userId);
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
// STAFF_NEW_APPOINTMENT/OWNER_NEW_BOOKING both default to off (see
|
||||
// types.ts) — enable both explicitly so this test isolates "different
|
||||
// people don't dedup against each other" from the unrelated default.
|
||||
await setNotificationPreference(prisma, { userId: staffUserId, type: NotificationType.STAFF_NEW_APPOINTMENT, channel: NotificationChannel.IN_APP, isEnabled: true });
|
||||
await setNotificationPreference(prisma, { userId: otherOwner.userId, type: NotificationType.OWNER_NEW_BOOKING, channel: NotificationChannel.IN_APP, isEnabled: true });
|
||||
|
||||
const result = await createBooking(prisma, {
|
||||
businessId: business.businessId,
|
||||
customerId,
|
||||
staffId,
|
||||
serviceIds: [business.haircutServiceId],
|
||||
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) return;
|
||||
|
||||
const staffNotification = await prisma.notification.findFirst({
|
||||
where: { appointmentId: result.appointment.id, type: "STAFF_NEW_APPOINTMENT" },
|
||||
include: { deliveries: true },
|
||||
});
|
||||
expect(staffNotification?.recipientUserId).toBe(staffUserId);
|
||||
expect(staffNotification?.deliveries.length).toBeGreaterThan(0);
|
||||
|
||||
const ownerNotification = await prisma.notification.findFirst({
|
||||
where: { appointmentId: result.appointment.id, type: "OWNER_NEW_BOOKING" },
|
||||
include: { deliveries: true },
|
||||
});
|
||||
expect(ownerNotification?.recipientUserId).toBe(otherOwner.userId);
|
||||
expect(ownerNotification?.deliveries.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("a guest booking's customer notification has no recipientUserId but does have a deep link with the guest token", async () => {
|
||||
@@ -194,9 +241,16 @@ describe("booking notifications (integration)", () => {
|
||||
|
||||
const cancelNotifications = await prisma.notification.findMany({
|
||||
where: { appointmentId: created.appointment.id, type: { in: ["BOOKING_CANCELLED", "STAFF_APPOINTMENT_CANCELLED", "OWNER_BOOKING_CANCELLED"] } },
|
||||
include: { deliveries: true },
|
||||
});
|
||||
expect(cancelNotifications).toHaveLength(3);
|
||||
|
||||
// Same staff member holds both roles — OWNER_BOOKING_CANCELLED is
|
||||
// deduped against STAFF_APPOINTMENT_CANCELLED (zero deliveries), same
|
||||
// as the new-booking case above.
|
||||
const ownerCancelNotification = cancelNotifications.find((n) => n.type === "OWNER_BOOKING_CANCELLED");
|
||||
expect(ownerCancelNotification?.deliveries).toHaveLength(0);
|
||||
|
||||
// Cancelling again is rejected by cancelBooking itself (already CANCELLED) — never reaches notify() a second time.
|
||||
const secondAttempt = await cancelBooking(prisma, { appointmentId: created.appointment.id });
|
||||
expect(secondAttempt.success).toBe(false);
|
||||
@@ -205,4 +259,56 @@ describe("booking notifications (integration)", () => {
|
||||
});
|
||||
expect(stillOnlyOnce).toBe(1);
|
||||
});
|
||||
|
||||
it("notifyBookingCompleted no longer emails by default (still in-app)", async () => {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
const { appointmentId } = await createTestAppointment(business, { customerId, staffId });
|
||||
const appointment = await getAppointmentById(prisma, appointmentId);
|
||||
|
||||
await notifyBookingCompleted(prisma, appointment!);
|
||||
|
||||
const notification = await prisma.notification.findFirst({
|
||||
where: { appointmentId, type: "BOOKING_COMPLETED" },
|
||||
include: { deliveries: true },
|
||||
});
|
||||
const channels = notification?.deliveries.map((d) => d.channel) ?? [];
|
||||
expect(channels).not.toContain("EMAIL");
|
||||
expect(channels).toContain("IN_APP");
|
||||
});
|
||||
|
||||
describe("notifyBookingBarberChanged — email depends on the ORIGINAL booking's staff selection", () => {
|
||||
it("emails the customer when they originally picked this staff member specifically", async () => {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
const { appointmentId } = await createTestAppointment(business, { customerId, staffId, staffSelectedByCustomer: true });
|
||||
const appointment = await getAppointmentById(prisma, appointmentId);
|
||||
|
||||
await notifyBookingBarberChanged(prisma, appointment!, { customerSelectedStaff: true });
|
||||
|
||||
const notification = await prisma.notification.findFirst({
|
||||
where: { appointmentId, type: "BOOKING_BARBER_CHANGED" },
|
||||
include: { deliveries: true },
|
||||
});
|
||||
expect(notification?.deliveries.map((d) => d.channel)).toContain("EMAIL");
|
||||
});
|
||||
|
||||
it("does not email the customer when they originally booked 'any available barber'", async () => {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
const { appointmentId } = await createTestAppointment(business, { customerId, staffId, staffSelectedByCustomer: false });
|
||||
const appointment = await getAppointmentById(prisma, appointmentId);
|
||||
|
||||
await notifyBookingBarberChanged(prisma, appointment!, { customerSelectedStaff: false });
|
||||
|
||||
const notification = await prisma.notification.findFirst({
|
||||
where: { appointmentId, type: "BOOKING_BARBER_CHANGED" },
|
||||
include: { deliveries: true },
|
||||
});
|
||||
const channels = notification?.deliveries.map((d) => d.channel) ?? [];
|
||||
expect(channels).not.toContain("EMAIL");
|
||||
// Still notified on other channels — this is "don't email", not "don't notify".
|
||||
expect(channels).toContain("IN_APP");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user