Email Notifictions
CI / ci (push) Canceled after 0s

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:
Stefan Lazic committed 2026-09-12 18:27:11 +01:00
1 parent 7224c87cef
commit 4573587b78
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");
});
});
});