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
+972 -145

No files matched your search

+145 -43
View File
@@ -37,62 +37,155 @@ every send just logs to the container's console instead.
(`recipientUserId` set) — a guest customer never gets one, since they (`recipientUserId` set) — a guest customer never gets one, since they
have nothing to sign into. have nothing to sign into.
## Preferences — who can turn what off ## Preferences — who can turn what off, and what's on by default
See `src/services/notifications/types.ts`'s `PREFERENCE_CONTROLLED_TYPES` See `src/services/notifications/types.ts`'s `PREFERENCE_CONTROLLED_TYPES`
— this is the single source of truth; everything not listed there is — this is the single source of truth for which types have a toggle at
**required** (always sent on every channel it has a valid target for, no all; everything not listed there is **required** (always sent on every
toggle anywhere). Currently: channel it has a valid target for, no toggle anywhere — see "Fixed
no-email types" below for a separate, smaller mechanism that trims one
channel off a handful of these without making them toggleable).
- **Customer-facing**: only `BOOKING_REMINDER` is optional. Everything - **Customer-facing**: only `BOOKING_REMINDER` is optional (and only
else (confirmations, changes, cancellations, loyalty) always notifies controls the 24h reminder — see "Reminder timing"). Everything else
the customer — not finding out your appointment was cancelled is a (confirmations, changes, cancellations, loyalty) always notifies the
real risk, not just noise. customer on every resolvable channel — not finding out your
appointment was cancelled is a real risk, not just noise.
- **Staff- and owner-facing**: every type is optional. The owner of a - **Staff- and owner-facing**: every type is optional. The owner of a
business is commonly also its only barber — one `StaffProfile`, one business is commonly also its only barber — one `StaffProfile`, one
account — so without this, a single new booking would unconditionally account — so without this, a single new booking would unconditionally
notify that one person twice (`STAFF_NEW_APPOINTMENT` + notify that one person twice (`STAFF_NEW_APPOINTMENT` +
`OWNER_NEW_BOOKING`) on every channel, including in-app, with no way to `OWNER_NEW_BOOKING`) on every channel, including in-app, with no way to
quiet either copy. quiet either copy. (Cross-role dedup, below, now also prevents the
literal duplicate even when both happen to be enabled.)
Toggles live at **Settings → Notifications** — customers see one Toggles live at **Settings → Notifications** — customers see one
"Appointment reminders" group; staff see their own six groups, plus five "Appointment reminders" group; staff see their own six groups, plus five
more owner-only groups if they hold `VIEW_REPORTING` (i.e. are actually more owner-only groups if they hold `VIEW_REPORTING` (i.e. are actually
an owner — see `src/services/notifications/recipients.ts`'s an owner — see `src/services/notifications/recipients.ts`'s
`getOwnerUserIds`, which checks the same permission). A preference with `getOwnerUserIds`, which checks the same permission).
no saved row yet defaults to **on** — turning something off is always an
explicit, deliberate action. **Defaults**: `types.ts`'s `defaultPreferenceIsEnabled(type)` is the
single source of truth for what a toggle shows/does before anyone has
ever saved a preference row for it — used both by the actual send
decision (`notification.service.ts`'s `isChannelEnabled`) and by the
Settings pages themselves, so a toggle's initial on/off state always
matches what will actually happen. Once a user explicitly saves a
preference, that stored value always wins, in either direction — this
function only governs the "nobody has touched this yet" fallback.
Most staff/owner types default to **on**. These default to **off**
instead — a solo owner-barber otherwise gets emailed/pushed/in-app'd for
everything the moment they sign in, with no gentle on-ramp:
| Type | Why |
|---|---|
| `STAFF_NEW_APPOINTMENT` | Noisy for a busy barber; `STAFF_APPOINTMENT_CANCELLED` (which stays on) is the one that actually needs acting on. |
| `STAFF_APPOINTMENT_RESCHEDULED` | Same reasoning. |
| `STAFF_APPOINTMENT_REMINDER` | A same-day heads-up is nice-to-have, not essential, for the person actually doing the cutting. |
| `OWNER_NEW_BOOKING` | Same as STAFF_NEW_APPOINTMENT, from the owner's side. |
| `OWNER_NO_SHOW` | The barber who marked it already knows — there's no staff-facing equivalent by design. |
## Fixed no-email types
A few types are `EMAIL` never, always, and unconditionally — not a
default that can be toggled back on, because the type either has no
preference toggle at all or (for `BOOKING_BARBER_CHANGED`) the decision
is per-booking context a stored preference can't express. This is
`NotifyParams.skipEmail` in `notification.service.ts` — a single boolean
the caller passes, checked in `resolveChannels()`'s EMAIL branch
independently of the preference system. PUSH/IN_APP are unaffected in
every case below.
| Type | Condition |
|---|---|
| `BOOKING_COMPLETED` | Always — a "thanks for visiting" email after every single cut was more noise than value. |
| `LOYALTY_STAMP_ADDED` | Always — a stamp is added on nearly every visit. |
| `LOYALTY_REWARD_REDEEMED` | Always — the customer is present when a reward is redeemed; an email receipt afterward added little. |
| `BOOKING_BARBER_CHANGED` | Only when the customer originally booked without picking a specific staff member ("any available barber" — see `Appointment.staffSelectedByCustomer`) and hasn't been reassigned before. Someone who booked "any available" is less likely to care who they end up with; someone who picked this barber specifically still gets emailed. |
## Cross-role deduplication
A person who holds two roles for the same booking (most commonly a solo
owner-barber: one `User`, one `StaffProfile`, and a role with
`VIEW_REPORTING`) can be the target of two *equivalent* notifications for
one underlying event — e.g. a new booking fires both
`STAFF_NEW_APPOINTMENT` (they're the assigned staff) and
`OWNER_NEW_BOOKING` (they're also an owner). Since both resolve to the
identical `recipientUserId`, `notification.service.ts`'s `notify()`
detects this and suppresses the less-specific one — on every channel,
not just email — rather than relying on the person to manually mute one
side.
`EQUIVALENCE_GROUPS` in that file lists which types are considered the
same underlying event, and their relative priority:
| Group | Types (higher priority first) |
|---|---|
| `new_appointment` | `STAFF_NEW_APPOINTMENT` > `OWNER_NEW_BOOKING` |
| `appointment_cancelled` | `STAFF_APPOINTMENT_CANCELLED` > `OWNER_BOOKING_CANCELLED` |
When an equivalent, higher-or-equal-priority notification already exists
for the same `(businessId, appointmentId, recipientUserId)`, the new one
is still created as a `Notification` row (audit trail, keeps
`idempotencyKey` semantics intact) but with **zero** deliveries — nothing
sends on any channel. This relies on call order: every real call site
(`booking-notifications.ts`) already notifies the STAFF_* type before
looping the OWNER_* type for the same event, so the more specific one is
always created first. Only types explicitly listed in
`EQUIVALENCE_GROUPS` ever participate — e.g. `OWNER_NO_SHOW` has no staff
equivalent and is never touched by this, and any future owner-only
*actionable* type (e.g. "approve this booking") would likewise stay
untouched simply by not being added to the map. Unrelated events for the
same appointment (a confirmation, then later a cancellation) are never
conflated — they're different types in different groups, or no group at
all.
## Reminder timing
`src/services/scheduling/reminder-scheduling.ts` schedules reminders at
fixed hours-before-appointment offsets, independently for the customer
and the assigned staff member:
- **Customer** (`BOOKING_REMINDER`): 24 hours before only.
- **Staff** (`STAFF_APPOINTMENT_REMINDER`): 24 hours and 2 hours before
(each its own send) — but see the defaults table above, this type
defaults to off, so nothing actually sends unless the staff member
opts in.
An offset that's already in the past by the time the appointment is
booked/approved is silently skipped, never scheduled retroactively.
--- ---
## Customer-facing ## Customer-facing
| Type | Fires when | Default subject | | Type | Fires when | Default subject | Email |
|---|---|---| |---|---|---|---|
| `BOOKING_CONFIRMED` | A new booking is created and doesn't need approval | "Booking confirmed" | | `BOOKING_CONFIRMED` | A new booking is created and doesn't need approval | "Booking confirmed" | Always |
| `BOOKING_PENDING_APPROVAL` | A new booking is created but the business requires approval first | "Booking request received" | | `BOOKING_PENDING_APPROVAL` | A new booking is created but the business requires approval first | "Booking request received" | Always |
| `BOOKING_APPROVED` | An owner approves a pending booking | "Booking approved" | | `BOOKING_APPROVED` | An owner approves a pending booking | "Booking approved" | Always |
| `BOOKING_RESCHEDULED` | The appointment's time changes (including an owner reassigning staff, which also triggers a reschedule) | "Booking rescheduled" | | `BOOKING_RESCHEDULED` | The appointment's time changes (including an owner reassigning staff, which also triggers a reschedule) | "Booking rescheduled" | 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) | | `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" | | `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" | | `BOOKING_COMPLETED` | The appointment is marked completed | "Thanks for visiting" | Never |
| `BOOKING_REMINDER` *(optional)* | 24 hours and 2 hours before a confirmed appointment (each is its own send) | "Upcoming appointment reminder" | | `BOOKING_REMINDER` *(optional)* | 24 hours before a confirmed appointment | "Upcoming appointment reminder" | Default on |
| `LOYALTY_STAMP_ADDED` | A loyalty stamp is added to their card | "You've earned a loyalty stamp" | | `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" | | `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" | | `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_REDEEMED` | Their reward is applied to an appointment | "Reward applied" | | `LOYALTY_REWARD_REDEEMED` | Their reward is applied to an appointment | "Reward applied" | Never |
## Staff-facing ## Staff-facing
| Type | Fires when | Default subject | | Type | Fires when | Default subject | Default |
|---|---|---| |---|---|---|---|
| `STAFF_NEW_APPOINTMENT` *(optional)* | A new appointment is booked with them | "New appointment" | | `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" | | `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" | | `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" | | `STAFF_APPOINTMENT_REMINDER` *(optional)* | 24 hours and 2 hours before their appointment | "Upcoming appointment reminder" | 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" | | `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}`" | | `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 | — | | `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 | — | — |
## Owner-facing ## Owner-facing
@@ -100,13 +193,13 @@ Sent to *every* active staff member whose role holds `VIEW_REPORTING` —
that can be more than one person; there's no single "the owner" column that can be more than one person; there's no single "the owner" column
anywhere in the schema. anywhere in the schema.
| Type | Fires when | Default subject | | Type | Fires when | Default subject | Default |
|---|---|---| |---|---|---|---|
| `OWNER_NEW_BOOKING` *(optional)* | A new booking is made anywhere in the business | "New booking" | | `OWNER_NEW_BOOKING` *(optional)* | A new booking is made anywhere in the business | "New booking" | Off |
| `OWNER_BOOKING_CANCELLED` *(optional)* | A booking is cancelled anywhere in the business | "Booking cancelled" | | `OWNER_BOOKING_CANCELLED` *(optional)* | A booking is cancelled anywhere in the business | "Booking cancelled" | On |
| `OWNER_NO_SHOW` *(optional)* | A customer is marked as a no-show (staff-facing equivalent doesn't exist — the barber who marked it already knows) | "No-show recorded" | | `OWNER_NO_SHOW` *(optional)* | A customer is marked as a no-show (staff-facing equivalent doesn't exist — the barber who marked it already knows) | "No-show recorded" | Off |
| `OWNER_STAFF_INVITATION_ACCEPTED` *(optional)* | An invited staff member verifies their email and joins | "Invitation accepted" | | `OWNER_STAFF_INVITATION_ACCEPTED` *(optional)* | An invited staff member verifies their email and joins | "Invitation accepted" | On |
| `OWNER_REPEATED_DELIVERY_FAILURE` *(optional)* | The background processor itself detects repeated delivery failures that day — a self-monitoring alert, not triggered by a domain event. At most once per business per day | "Some notifications failed to send" | | `OWNER_REPEATED_DELIVERY_FAILURE` *(optional)* | The background processor itself detects repeated delivery failures that day — a self-monitoring alert, not triggered by a domain event. At most once per business per day | "Some notifications failed to send" | On |
--- ---
@@ -136,7 +229,16 @@ Not queued, not preference-controlled, no in-app record.
- **What exists / who it's for**: `prisma/schema.prisma`'s - **What exists / who it's for**: `prisma/schema.prisma`'s
`NotificationType` enum, and `RECIPIENT_KIND` in `NotificationType` enum, and `RECIPIENT_KIND` in
`src/services/notifications/types.ts`. `src/services/notifications/types.ts`.
- **Whether it's optional**: `PREFERENCE_CONTROLLED_TYPES`, same file. - **Whether it's optional, and its default**: `PREFERENCE_CONTROLLED_TYPES`
and `defaultPreferenceIsEnabled()`, same file.
- **Fixed no-email types**: `NotifyParams.skipEmail` in
`notification.service.ts`, set at the relevant call site in
`booking-notifications.ts`/`loyalty-notifications.ts`.
- **Cross-role dedup**: `EQUIVALENCE_GROUPS` in `notification.service.ts`.
- **Whether the customer picked a specific barber originally**:
`Appointment.staffSelectedByCustomer`, set once at booking creation in
`booking.service.ts`'s `createBooking()`, read at reassignment time in
`src/features/owner/actions/appointments.ts`.
- **When it actually fires**: `src/services/scheduling/booking-notifications.ts`, - **When it actually fires**: `src/services/scheduling/booking-notifications.ts`,
`reminder-scheduling.ts`, `src/services/loyalty/loyalty-notifications.ts`, `reminder-scheduling.ts`, `src/services/loyalty/loyalty-notifications.ts`,
`src/services/staff/staff-notifications.ts`, `src/services/staff/staff-notifications.ts`,
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE `Appointment` ADD COLUMN `staffSelectedByCustomer` BOOLEAN NOT NULL DEFAULT true;
+12
View File
@@ -1124,6 +1124,18 @@ model Appointment {
staffId String staffId String
staff StaffProfile @relation(fields: [staffId], references: [id], onDelete: Restrict) staff StaffProfile @relation(fields: [staffId], references: [id], onDelete: Restrict)
/// Whether the customer picked THIS specific staff member when they
/// originally booked, as opposed to "any available barber" (the system
/// then auto-assigns one — see BookingService.createBooking). Set once
/// at creation and never rewritten by a later reassignment — it
/// describes the ORIGINAL booking, used to decide whether a later
/// staff reassignment is worth emailing the customer about (see
/// notifyBookingBarberChanged): someone who never expressed a
/// preference is less likely to care who they end up with. Defaults to
/// true so every appointment created before this field existed keeps
/// notifying on reassignment exactly as it did before.
staffSelectedByCustomer Boolean @default(true)
status AppointmentStatus @default(CONFIRMED) status AppointmentStatus @default(CONFIRMED)
/// Stored as UTC instants; the business's `timezone` (see Business model) /// Stored as UTC instants; the business's `timezone` (see Business model)
@@ -5,6 +5,7 @@ import { TopNav } from "@/components/navigation/top-nav";
import { NotificationChannel, NotificationType } from "@/generated/prisma/enums"; import { NotificationChannel, NotificationType } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db/prisma"; import { prisma } from "@/lib/db/prisma";
import { getNotificationPreferences } from "@/services/notifications/notification.service"; import { getNotificationPreferences } from "@/services/notifications/notification.service";
import { defaultPreferenceIsEnabled } from "@/services/notifications/types";
import { NotificationPreferencesForm } from "@/features/staff/components/notification-preferences-form"; import { NotificationPreferencesForm } from "@/features/staff/components/notification-preferences-form";
import type { NotificationPreferenceGroup, PreferenceCellState } from "@/features/staff/components/notification-preferences-form"; import type { NotificationPreferenceGroup, PreferenceCellState } from "@/features/staff/components/notification-preferences-form";
import { PushSubscribeToggle } from "@/features/notifications/components/push-subscribe-toggle"; import { PushSubscribeToggle } from "@/features/notifications/components/push-subscribe-toggle";
@@ -25,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 ?? true, isEnabled: rows.find((r) => r.type === group.type && r.channel === channel)?.isEnabled ?? defaultPreferenceIsEnabled(group.type),
})), })),
); );
@@ -6,6 +6,7 @@ import { NotificationChannel, NotificationType, Permission } from "@/generated/p
import { prisma } from "@/lib/db/prisma"; import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions"; import { hasPermission } from "@/lib/auth/permissions";
import { getNotificationPreferences } from "@/services/notifications/notification.service"; import { getNotificationPreferences } from "@/services/notifications/notification.service";
import { defaultPreferenceIsEnabled } from "@/services/notifications/types";
import { NotificationPreferencesForm } from "@/features/staff/components/notification-preferences-form"; import { NotificationPreferencesForm } from "@/features/staff/components/notification-preferences-form";
import type { NotificationPreferenceGroup, PreferenceCellState } from "@/features/staff/components/notification-preferences-form"; import type { NotificationPreferenceGroup, PreferenceCellState } from "@/features/staff/components/notification-preferences-form";
import { PushSubscribeToggle } from "@/features/notifications/components/push-subscribe-toggle"; import { PushSubscribeToggle } from "@/features/notifications/components/push-subscribe-toggle";
@@ -52,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 ?? true, isEnabled: rows.find((r) => r.type === group.type && r.channel === channel)?.isEnabled ?? defaultPreferenceIsEnabled(group.type),
})), })),
); );
+3 -1
View File
@@ -183,7 +183,9 @@ export async function ownerRescheduleAppointmentAction(params: {
entityId: params.appointmentId, entityId: params.appointmentId,
metadata: { fromStaffId: previousStaffId, toStaffId: params.staffId }, metadata: { fromStaffId: previousStaffId, toStaffId: params.staffId },
}); });
await notifyBookingBarberChanged(prisma, result.appointment); await notifyBookingBarberChanged(prisma, result.appointment, {
customerSelectedStaff: appointment.staffSelectedByCustomer,
});
} }
await syncAppointmentSafely(prisma, params.appointmentId); await syncAppointmentSafely(prisma, params.appointmentId);
@@ -141,8 +141,31 @@ describe("loyalty notifications (integration)", () => {
const redeemedNotifications = await prisma.notification.findMany({ const redeemedNotifications = await prisma.notification.findMany({
where: { recipientUserId: userId, type: "LOYALTY_REWARD_REDEEMED" }, where: { recipientUserId: userId, type: "LOYALTY_REWARD_REDEEMED" },
include: { deliveries: true },
}); });
expect(redeemedNotifications).toHaveLength(1); expect(redeemedNotifications).toHaveLength(1);
expect(redeemedNotifications[0]!.loyaltyRewardId).toBe(reward.id); expect(redeemedNotifications[0]!.loyaltyRewardId).toBe(reward.id);
// No longer emailed by default — the customer is present when a reward
// is redeemed, so an email receipt afterward was more noise than value.
const channels = redeemedNotifications[0]!.deliveries.map((d) => d.channel);
expect(channels).not.toContain("EMAIL");
expect(channels).toContain("IN_APP");
});
it("a stamp-added notification no longer emails by default (still in-app)", async () => {
await enableLoyalty(5);
const { staffId } = await createTestStaff(business);
const { customerId, userId } = await createTestCustomer(business);
const appointment = await completedAppointment(staffId, customerId);
await awardStampForCompletedAppointment(prisma, { appointmentId: appointment.id, staffId });
const notification = await prisma.notification.findFirstOrThrow({
where: { recipientUserId: userId, type: "LOYALTY_STAMP_ADDED" },
include: { deliveries: true },
});
const channels = notification.deliveries.map((d) => d.channel);
expect(channels).not.toContain("EMAIL");
expect(channels).toContain("IN_APP");
}); });
}); });
@@ -17,6 +17,7 @@ async function resolveCustomerRecipient(prisma: PrismaClient, customerId: string
}); });
} }
/** No longer emailed by default (still push/in-app) — a stamp is added on nearly every visit, so an email each time was more noise than value. */
export async function notifyLoyaltyStampAdded( export async function notifyLoyaltyStampAdded(
prisma: PrismaClient, prisma: PrismaClient,
params: { businessId: string; customerId: string; transactionId: string; appointmentId?: string }, params: { businessId: string; customerId: string; transactionId: string; appointmentId?: string },
@@ -32,6 +33,7 @@ export async function notifyLoyaltyStampAdded(
bodyText: "You've earned a loyalty stamp. Keep it up!", bodyText: "You've earned a loyalty stamp. Keep it up!",
deepLinkPath: "/loyalty", deepLinkPath: "/loyalty",
idempotencyKey: `notify:loyalty-stamp-added:${params.transactionId}`, idempotencyKey: `notify:loyalty-stamp-added:${params.transactionId}`,
skipEmail: true,
}); });
} }
@@ -74,6 +76,7 @@ export async function notifyLoyaltyRewardExpiring(
}); });
} }
/** No longer emailed by default (still push/in-app) — the customer is present when a reward is redeemed (it's applied to their current visit), so an email receipt afterward was more noise than value. */
export async function notifyLoyaltyRewardRedeemed( export async function notifyLoyaltyRewardRedeemed(
prisma: PrismaClient, prisma: PrismaClient,
params: { businessId: string; customerId: string; rewardId: string; rewardLabel: string; appointmentId: string }, params: { businessId: string; customerId: string; rewardId: string; rewardLabel: string; appointmentId: string },
@@ -90,5 +93,6 @@ export async function notifyLoyaltyRewardRedeemed(
bodyText: `Your reward has been applied: ${params.rewardLabel}.`, bodyText: `Your reward has been applied: ${params.rewardLabel}.`,
deepLinkPath: "/loyalty", deepLinkPath: "/loyalty",
idempotencyKey: `notify:loyalty-reward-redeemed:${params.rewardId}`, idempotencyKey: `notify:loyalty-reward-redeemed:${params.rewardId}`,
skipEmail: true,
}); });
} }
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { NotificationChannel, NotificationType } from "@/generated/prisma/enums"; import { NotificationChannel, NotificationType } from "@/generated/prisma/enums";
import { import {
cleanupTestBusiness, cleanupTestBusiness,
createTestAppointment,
createTestBusiness, createTestBusiness,
createTestCustomer, createTestCustomer,
createTestGuestCustomer, createTestGuestCustomer,
@@ -197,6 +198,15 @@ describe("NotificationService (integration)", () => {
channel: NotificationChannel.IN_APP, channel: NotificationChannel.IN_APP,
isEnabled: false, isEnabled: false,
}); });
// STAFF_NEW_APPOINTMENT now defaults to off (see types.ts) — explicitly
// enable it here so this test isolates "one type's preference doesn't
// leak into another's", independent of what either defaults to.
await setNotificationPreference(prisma, {
userId,
type: NotificationType.STAFF_NEW_APPOINTMENT,
channel: NotificationChannel.IN_APP,
isEnabled: true,
});
const { notification: ownerNotification } = await notify(prisma, { const { notification: ownerNotification } = await notify(prisma, {
businessId: business.businessId, businessId: business.businessId,
@@ -217,6 +227,101 @@ 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 () => {
const { userId } = await createTestCustomer(business);
const disabledByDefault = [
NotificationType.STAFF_NEW_APPOINTMENT,
NotificationType.STAFF_APPOINTMENT_RESCHEDULED,
NotificationType.STAFF_APPOINTMENT_REMINDER,
NotificationType.OWNER_NEW_BOOKING,
NotificationType.OWNER_NO_SHOW,
];
for (const type of disabledByDefault) {
const { notification } = await notify(prisma, {
businessId: business.businessId,
type,
recipientUserId: userId,
bodyText: "x",
idempotencyKey: `test:${userId}:default-off:${type}`,
});
expect(notification.deliveries).toHaveLength(0);
}
});
it("STAFF_APPOINTMENT_CANCELLED, STAFF_SCHEDULE_CHANGED, STAFF_INVITATION_RECEIVED, OWNER_BOOKING_CANCELLED, OWNER_STAFF_INVITATION_ACCEPTED, and OWNER_REPEATED_DELIVERY_FAILURE still default to on with no preference row", async () => {
const { userId } = await createTestCustomer(business);
const stillEnabledByDefault = [
NotificationType.STAFF_APPOINTMENT_CANCELLED,
NotificationType.STAFF_SCHEDULE_CHANGED,
NotificationType.STAFF_INVITATION_RECEIVED,
NotificationType.OWNER_BOOKING_CANCELLED,
NotificationType.OWNER_STAFF_INVITATION_ACCEPTED,
NotificationType.OWNER_REPEATED_DELIVERY_FAILURE,
];
for (const type of stillEnabledByDefault) {
const { notification } = await notify(prisma, {
businessId: business.businessId,
type,
recipientUserId: userId,
bodyText: "x",
idempotencyKey: `test:${userId}:default-on:${type}`,
});
expect(notification.deliveries.map((d) => d.channel)).toContain(NotificationChannel.IN_APP);
}
});
it("an explicit preference always wins over the new default, in both directions", async () => {
const { userId } = await createTestCustomer(business);
// A type that now defaults OFF, explicitly turned ON.
await setNotificationPreference(prisma, {
userId,
type: NotificationType.STAFF_NEW_APPOINTMENT,
channel: NotificationChannel.EMAIL,
isEnabled: true,
});
// A type that still defaults ON, explicitly turned OFF.
await setNotificationPreference(prisma, {
userId,
type: NotificationType.STAFF_APPOINTMENT_CANCELLED,
channel: NotificationChannel.EMAIL,
isEnabled: false,
});
const { notification: onOverride } = await notify(prisma, {
businessId: business.businessId,
type: NotificationType.STAFF_NEW_APPOINTMENT,
recipientUserId: userId,
bodyText: "x",
idempotencyKey: `test:${userId}:on-override`,
});
expect(onOverride.deliveries.map((d) => d.channel)).toContain(NotificationChannel.EMAIL);
const { notification: offOverride } = await notify(prisma, {
businessId: business.businessId,
type: NotificationType.STAFF_APPOINTMENT_CANCELLED,
recipientUserId: userId,
bodyText: "x",
idempotencyKey: `test:${userId}:off-override`,
});
expect(offOverride.deliveries.map((d) => d.channel)).not.toContain(NotificationChannel.EMAIL);
});
it("skipEmail suppresses only EMAIL, never PUSH or IN_APP", async () => {
const { userId } = await createTestCustomer(business);
const { notification } = await notify(prisma, {
businessId: business.businessId,
type: NotificationType.BOOKING_COMPLETED,
recipientUserId: userId,
bodyText: "Thanks for visiting!",
idempotencyKey: `test:${userId}:booking-completed-skip-email`,
skipEmail: true,
});
const channels = notification.deliveries.map((d) => d.channel);
expect(channels).not.toContain(NotificationChannel.EMAIL);
expect(channels).toContain(NotificationChannel.IN_APP);
});
it("setNotificationPreference now accepts every STAFF/OWNER-facing type (previously refused everything but the two reminders)", async () => { it("setNotificationPreference now accepts every STAFF/OWNER-facing type (previously refused everything but the two reminders)", async () => {
const { userId } = await createTestCustomer(business); const { userId } = await createTestCustomer(business);
await expect( await expect(
@@ -245,14 +350,14 @@ describe("NotificationService (integration)", () => {
const { notification: n1 } = await notify(prisma, { const { notification: n1 } = await notify(prisma, {
businessId: business.businessId, businessId: business.businessId,
type: NotificationType.STAFF_NEW_APPOINTMENT, type: NotificationType.STAFF_APPOINTMENT_CANCELLED,
recipientUserId: staffUserId, recipientUserId: staffUserId,
bodyText: "New appointment booked.", bodyText: "New appointment booked.",
idempotencyKey: `test:${staffId}:appt-1`, idempotencyKey: `test:${staffId}:appt-1`,
}); });
await notify(prisma, { await notify(prisma, {
businessId: business.businessId, businessId: business.businessId,
type: NotificationType.STAFF_NEW_APPOINTMENT, type: NotificationType.STAFF_APPOINTMENT_CANCELLED,
recipientUserId: staffUserId, recipientUserId: staffUserId,
bodyText: "Another appointment booked.", bodyText: "Another appointment booked.",
idempotencyKey: `test:${staffId}:appt-2`, idempotencyKey: `test:${staffId}:appt-2`,
@@ -281,4 +386,187 @@ describe("NotificationService (integration)", () => {
expect(unreadOnly).toHaveLength(0); expect(unreadOnly).toHaveLength(0);
}); });
}); });
/**
* Cross-role deduplication: a solo owner-barber (one recipientUserId
* holding both roles) should get only one equivalent notification per
* booking event — see notification.service.ts's EQUIVALENCE_GROUPS.
* Call order matters (staff before owner, matching every real call site
* in booking-notifications.ts) — see that map's own doc comment.
*/
describe("notify — cross-role deduplication", () => {
// STAFF_NEW_APPOINTMENT and OWNER_NEW_BOOKING both default to off (see
// types.ts) — every test below explicitly enables both so it's
// actually exercising dedup, not just the unrelated default-value
// change (which is covered separately above).
async function enableNewBookingTypes(...userIds: string[]) {
for (const userId of userIds) {
await setNotificationPreference(prisma, { userId, type: NotificationType.STAFF_NEW_APPOINTMENT, channel: NotificationChannel.IN_APP, isEnabled: true });
await setNotificationPreference(prisma, { userId, type: NotificationType.OWNER_NEW_BOOKING, channel: NotificationChannel.IN_APP, isEnabled: true });
}
}
it("a lower-priority OWNER_NEW_BOOKING called after an equivalent STAFF_NEW_APPOINTMENT for the same recipient+appointment is suppressed (zero deliveries, row still created)", async () => {
const staff = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const { appointmentId } = await createTestAppointment(business, { customerId, staffId: staff.staffId });
await enableNewBookingTypes(staff.userId);
const { notification: staffNotification } = await notify(prisma, {
businessId: business.businessId,
type: NotificationType.STAFF_NEW_APPOINTMENT,
recipientUserId: staff.userId,
appointmentId,
bodyText: "New appointment.",
idempotencyKey: `test:dedup:staff-new-appointment:${appointmentId}`,
});
expect(staffNotification.deliveries.length).toBeGreaterThan(0);
// Same person (staff.userId) is also "the owner" here — this is the
// solo owner-barber case the dedup exists for.
const { notification: ownerNotification } = await notify(prisma, {
businessId: business.businessId,
type: NotificationType.OWNER_NEW_BOOKING,
recipientUserId: staff.userId,
appointmentId,
bodyText: "New booking.",
idempotencyKey: `test:dedup:owner-new-booking:${appointmentId}`,
});
expect(ownerNotification.deliveries).toHaveLength(0);
// The row itself still exists (audit trail) — just with no deliveries.
const stillExists = await prisma.notification.findUnique({ where: { id: ownerNotification.id } });
expect(stillExists).not.toBeNull();
});
it("cancellation dedup: STAFF_APPOINTMENT_CANCELLED then OWNER_BOOKING_CANCELLED for the same recipient+appointment — only the staff copy has deliveries", async () => {
const staff = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const { appointmentId } = await createTestAppointment(business, { customerId, staffId: staff.staffId });
await notify(prisma, {
businessId: business.businessId,
type: NotificationType.STAFF_APPOINTMENT_CANCELLED,
recipientUserId: staff.userId,
appointmentId,
bodyText: "Cancelled.",
idempotencyKey: `test:dedup:staff-cancelled:${appointmentId}`,
});
const { notification: ownerNotification } = await notify(prisma, {
businessId: business.businessId,
type: NotificationType.OWNER_BOOKING_CANCELLED,
recipientUserId: staff.userId,
appointmentId,
bodyText: "Cancelled.",
idempotencyKey: `test:dedup:owner-cancelled:${appointmentId}`,
});
expect(ownerNotification.deliveries).toHaveLength(0);
});
it("a booking confirmation and a later cancellation for the same appointment are NOT deduped against each other — different events, not equivalent", async () => {
const staff = await createTestStaff(business);
const { customerId, userId } = await createTestCustomer(business);
const { appointmentId } = await createTestAppointment(business, { customerId, staffId: staff.staffId });
await notify(prisma, {
businessId: business.businessId,
type: NotificationType.BOOKING_CONFIRMED,
recipientUserId: userId,
recipientCustomerId: customerId,
appointmentId,
bodyText: "Confirmed.",
idempotencyKey: `test:dedup:booking-confirmed:${appointmentId}`,
});
const { notification: cancelled } = await notify(prisma, {
businessId: business.businessId,
type: NotificationType.BOOKING_CANCELLED,
recipientUserId: userId,
recipientCustomerId: customerId,
appointmentId,
bodyText: "Cancelled.",
idempotencyKey: `test:dedup:booking-cancelled:${appointmentId}`,
});
// BOOKING_CONFIRMED/BOOKING_CANCELLED aren't in any equivalence group
// at all, so this is unaffected regardless of order.
expect(cancelled.deliveries.length).toBeGreaterThan(0);
});
it("does not dedup across different appointments, even for the same recipient and equivalent types", async () => {
const staff = await createTestStaff(business);
const { customerId: customerA } = await createTestCustomer(business);
const { customerId: customerB } = await createTestCustomer(business);
const { appointmentId: appointmentA } = await createTestAppointment(business, { customerId: customerA, staffId: staff.staffId });
const { appointmentId: appointmentB } = await createTestAppointment(business, { customerId: customerB, staffId: staff.staffId });
await enableNewBookingTypes(staff.userId);
await notify(prisma, {
businessId: business.businessId,
type: NotificationType.STAFF_NEW_APPOINTMENT,
recipientUserId: staff.userId,
appointmentId: appointmentA,
bodyText: "New appointment.",
idempotencyKey: `test:dedup:staff-new-appointment:${appointmentA}`,
});
const { notification: ownerNotification } = await notify(prisma, {
businessId: business.businessId,
type: NotificationType.OWNER_NEW_BOOKING,
recipientUserId: staff.userId,
appointmentId: appointmentB,
bodyText: "New booking.",
idempotencyKey: `test:dedup:owner-new-booking:${appointmentB}`,
});
expect(ownerNotification.deliveries.length).toBeGreaterThan(0);
});
it("does not dedup across different recipients, even for the same appointment and equivalent types", async () => {
const staffPerson = await createTestStaff(business);
const ownerPerson = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const { appointmentId } = await createTestAppointment(business, { customerId, staffId: staffPerson.staffId });
await enableNewBookingTypes(staffPerson.userId, ownerPerson.userId);
await notify(prisma, {
businessId: business.businessId,
type: NotificationType.STAFF_NEW_APPOINTMENT,
recipientUserId: staffPerson.userId,
appointmentId,
bodyText: "New appointment.",
idempotencyKey: `test:dedup:staff-new-appointment:${appointmentId}:${staffPerson.userId}`,
});
const { notification: ownerNotification } = await notify(prisma, {
businessId: business.businessId,
type: NotificationType.OWNER_NEW_BOOKING,
recipientUserId: ownerPerson.userId,
appointmentId,
bodyText: "New booking.",
idempotencyKey: `test:dedup:owner-new-booking:${appointmentId}:${ownerPerson.userId}`,
});
expect(ownerNotification.deliveries.length).toBeGreaterThan(0);
});
it("if the lower-priority OWNER_* is somehow created first, a later higher-priority STAFF_* still sends in full (order-inverted case)", async () => {
const staff = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const { appointmentId } = await createTestAppointment(business, { customerId, staffId: staff.staffId });
await enableNewBookingTypes(staff.userId);
await notify(prisma, {
businessId: business.businessId,
type: NotificationType.OWNER_NEW_BOOKING,
recipientUserId: staff.userId,
appointmentId,
bodyText: "New booking.",
idempotencyKey: `test:dedup:owner-new-booking:${appointmentId}`,
});
const { notification: staffNotification } = await notify(prisma, {
businessId: business.businessId,
type: NotificationType.STAFF_NEW_APPOINTMENT,
recipientUserId: staff.userId,
appointmentId,
bodyText: "New appointment.",
idempotencyKey: `test:dedup:staff-new-appointment:${appointmentId}`,
});
expect(staffNotification.deliveries.length).toBeGreaterThan(0);
});
});
}); });
@@ -1,7 +1,7 @@
import { Prisma } from "@/generated/prisma/client"; import { Prisma } from "@/generated/prisma/client";
import type { PrismaClient } from "@/generated/prisma/client"; import type { PrismaClient } from "@/generated/prisma/client";
import { NotificationChannel, NotificationDeliveryStatus, NotificationType } from "@/generated/prisma/enums"; import { NotificationChannel, NotificationDeliveryStatus, NotificationType } from "@/generated/prisma/enums";
import { isPreferenceControlled } from "./types"; import { defaultPreferenceIsEnabled, isPreferenceControlled } from "./types";
/** /**
* The notification domain layer: creates Notification + NotificationDelivery * The notification domain layer: creates Notification + NotificationDelivery
@@ -32,6 +32,17 @@ export interface NotifyParams {
idempotencyKey: string; idempotencyKey: string;
/** Omit for "send as soon as the worker picks it up" (every non-reminder type). When set, EMAIL/PUSH deliveries are created directly as SCHEDULED for this time rather than PENDING — see reminder-scheduling.ts, which is the only caller that sets this. */ /** Omit for "send as soon as the worker picks it up" (every non-reminder type). When set, EMAIL/PUSH deliveries are created directly as SCHEDULED for this time rather than PENDING — see reminder-scheduling.ts, which is the only caller that sets this. */
scheduledFor?: Date; scheduledFor?: Date;
/**
* When true, EMAIL is never resolved for this call, regardless of any
* stored preference — for a fixed "this no longer emails" policy
* (e.g. BOOKING_COMPLETED, LOYALTY_REWARD_REDEEMED) rather than a
* user-toggleable default. PUSH/IN_APP are unaffected. Deliberately
* separate from the preference system: these types either aren't
* preference-controlled at all, or (BOOKING_BARBER_CHANGED) the
* decision depends on per-booking context a stored preference can't
* express.
*/
skipEmail?: boolean;
} }
type NotificationWithDeliveries = Prisma.NotificationGetPayload<{ include: { deliveries: true } }>; type NotificationWithDeliveries = Prisma.NotificationGetPayload<{ include: { deliveries: true } }>;
@@ -113,7 +124,10 @@ async function isChannelEnabled(
const preference = await prisma.notificationPreference.findUnique({ const preference = await prisma.notificationPreference.findUnique({
where: { userId_type_channel: { userId: params.userId, type: params.type, channel: params.channel } }, where: { userId_type_channel: { userId: params.userId, type: params.type, channel: params.channel } },
}); });
return preference?.isEnabled ?? true; // sensible default: on unless the user explicitly turned it off // A stored row always wins, whichever way it's set — only the fallback
// for "nobody has ever touched this" varies by type (see types.ts's
// defaultPreferenceIsEnabled).
return preference?.isEnabled ?? defaultPreferenceIsEnabled(params.type);
} }
async function resolveChannels( async function resolveChannels(
@@ -137,7 +151,11 @@ async function resolveChannels(
} }
const emailAddress = await resolveEmailAddress(prisma, params); const emailAddress = await resolveEmailAddress(prisma, params);
if (emailAddress && (await isChannelEnabled(prisma, { userId: params.recipientUserId, type: params.type, channel: NotificationChannel.EMAIL }))) { if (
emailAddress &&
!params.skipEmail &&
(await isChannelEnabled(prisma, { userId: params.recipientUserId, type: params.type, channel: NotificationChannel.EMAIL }))
) {
channels.push(NotificationChannel.EMAIL); channels.push(NotificationChannel.EMAIL);
} }
@@ -151,6 +169,81 @@ async function resolveChannels(
return channels; return channels;
} }
/**
* Notification types that are two views of the SAME underlying booking
* event, from two different roles — a "new appointment" booked for a
* staff member is the exact same event as "a new booking" for the
* owner. When one person holds both roles for the same appointment
* (a solo owner-barber), they resolve to the identical recipientUserId
* (see recipients.ts's getOwnerUserIds vs appointment.staff.userId), and
* should only ever get ONE of the two — the one more directly tied to
* their actual involvement (higher `priority`), per the product rule
* "deduplicate equivalent information, don't suppress a genuinely
* different actionable purpose". Only types listed here ever participate
* in dedup — e.g. OWNER_NO_SHOW has no staff equivalent by design (the
* barber who marked it already knows) and is deliberately absent, as
* would any future owner-only actionable type (e.g. "approve this
* booking") — nothing about those is touched by this map.
*/
const EQUIVALENCE_GROUPS: Partial<Record<NotificationType, { group: string; priority: number }>> = {
[NotificationType.STAFF_NEW_APPOINTMENT]: { group: "new_appointment", priority: 2 },
[NotificationType.OWNER_NEW_BOOKING]: { group: "new_appointment", priority: 1 },
[NotificationType.STAFF_APPOINTMENT_CANCELLED]: { group: "appointment_cancelled", priority: 2 },
[NotificationType.OWNER_BOOKING_CANCELLED]: { group: "appointment_cancelled", priority: 1 },
};
type EquivalenceOutcome =
| { action: "suppress"; existingType: NotificationType }
| { action: "order-inverted"; existingType: NotificationType };
/**
* Checks for an already-created, equivalent (same group) notification for
* this exact recipient+appointment. Relies on call order: every current
* call site (booking-notifications.ts) already notifies STAFF_* before
* looping OWNER_* for the same event, so by the time an OWNER_* call
* runs, an equivalent STAFF_* row (if any) already exists in the
* database.
*
* - The existing row's priority >= this call's → "suppress": the caller
* should create zero deliveries (still create the Notification row
* itself — keeps idempotencyKey semantics intact and leaves an audit
* trail of "this would have fired").
* - The existing row's priority < this call's → "order-inverted": this
* shouldn't happen given today's call order; proceed normally, but
* this is surfaced so a future call-order regression is at least
* observable rather than silently wrong. Retroactively un-sending an
* already-SENT IN_APP row for a case that can't currently occur would
* be disproportionate — see this file's own design notes.
*/
async function checkEquivalenceGroup(
prisma: PrismaClient,
params: Pick<NotifyParams, "businessId" | "type" | "appointmentId" | "recipientUserId">,
): Promise<EquivalenceOutcome | null> {
const info = EQUIVALENCE_GROUPS[params.type];
if (!info || !params.recipientUserId || !params.appointmentId) return null;
const otherTypesInGroup = (Object.keys(EQUIVALENCE_GROUPS) as NotificationType[]).filter(
(t) => t !== params.type && EQUIVALENCE_GROUPS[t]!.group === info.group,
);
if (otherTypesInGroup.length === 0) return null;
const existing = await prisma.notification.findFirst({
where: {
businessId: params.businessId,
appointmentId: params.appointmentId,
recipientUserId: params.recipientUserId,
type: { in: otherTypesInGroup },
},
select: { type: true },
});
if (!existing) return null;
const existingPriority = EQUIVALENCE_GROUPS[existing.type]!.priority;
return existingPriority >= info.priority
? { action: "suppress", existingType: existing.type }
: { action: "order-inverted", existingType: existing.type };
}
/** /**
* Creates a Notification and one NotificationDelivery per applicable * Creates a Notification and one NotificationDelivery per applicable
* channel — idempotent on `idempotencyKey`: a second call with the same * channel — idempotent on `idempotencyKey`: a second call with the same
@@ -170,7 +263,21 @@ export async function notify(prisma: PrismaClient, params: NotifyParams): Promis
}); });
if (existing) return { created: false, notification: existing }; if (existing) return { created: false, notification: existing };
const channels = await resolveChannels(prisma, params); let channels = await resolveChannels(prisma, params);
const equivalence = await checkEquivalenceGroup(prisma, params);
if (equivalence?.action === "suppress") {
// Someone already has an equivalent, at-least-as-relevant notification
// for this exact booking (e.g. they're both the owner and the
// assigned staff) — don't duplicate it on any channel. The
// Notification row below is still created (audit trail, consistent
// idempotencyKey behaviour), just with zero deliveries.
channels = [];
} else if (equivalence?.action === "order-inverted") {
console.warn(
`[NotificationService] ${params.type} has higher priority than an existing equivalent ${equivalence.existingType} for the same recipient/appointment, but the earlier notification can't be retroactively suppressed — check call order for this event.`,
);
}
try { try {
const notification = await prisma.notification.create({ const notification = await prisma.notification.create({
+34
View File
@@ -83,3 +83,37 @@ const PREFERENCE_CONTROLLED_TYPES = new Set<NotificationType>([
export function isPreferenceControlled(type: NotificationType): boolean { export function isPreferenceControlled(type: NotificationType): boolean {
return PREFERENCE_CONTROLLED_TYPES.has(type); return PREFERENCE_CONTROLLED_TYPES.has(type);
} }
/**
* Which preference-controlled types default to OFF when no
* NotificationPreference row exists yet — every other preference-
* controlled type keeps defaulting to on (today's original blanket
* behaviour). A stored row always wins regardless of what's here (see
* NotificationService.isChannelEnabled's `preference?.isEnabled ?? ...`),
* so changing this set never touches anyone's explicit choice — only
* what a brand-new user/business sees before they've ever visited
* Settings. The owner of a business is commonly also its only barber, so
* without softer defaults here a solo owner's very first booking would
* arrive as two emails (STAFF_NEW_APPOINTMENT + OWNER_NEW_BOOKING) they
* never asked for — see also the dedup logic in notification.service.ts,
* which independently prevents a *duplicate* even when both default on.
*/
const DEFAULT_DISABLED_TYPES = new Set<NotificationType>([
NotificationType.STAFF_NEW_APPOINTMENT,
NotificationType.STAFF_APPOINTMENT_RESCHEDULED,
NotificationType.STAFF_APPOINTMENT_REMINDER,
NotificationType.OWNER_NEW_BOOKING,
NotificationType.OWNER_NO_SHOW,
]);
/**
* 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
* actual send decision (isChannelEnabled) and by the Settings pages that
* render each toggle's initial on/off state, so the UI can never show a
* toggle as on for something that actually defaults to off (or vice
* versa). Meaningless for a required (non-preference-controlled) type.
*/
export function defaultPreferenceIsEnabled(type: NotificationType): boolean {
return !DEFAULT_DISABLED_TYPES.has(type);
}
@@ -1,9 +1,12 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest"; 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 { 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 { getAppointmentById } from "../appointment.service";
import { notifyBookingBarberChanged, notifyBookingCompleted } from "../booking-notifications";
import { cancelBooking, createBooking, rescheduleBooking } from "../booking.service"; 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"; import type { TestBusiness } from "./fixtures";
function nextWeekday(timezone: string, target: Weekday, minDaysAhead = 7): CalendarDate { function nextWeekday(timezone: string, target: Weekday, minDaysAhead = 7): CalendarDate {
@@ -66,15 +69,19 @@ describe("booking notifications (integration)", () => {
expect(result.success).toBe(true); expect(result.success).toBe(true);
if (!result.success) return; 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(); const types = notifications.map((n) => n.type).sort();
// A booking far enough out (monday is >= 7 days ahead — see nextWeekday) also schedules both // A booking far enough out (monday is >= 7 days ahead — see
// 24h/2h reminders for the customer and the staff member — see reminder-scheduling.ts. // 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( expect(types).toEqual(
[ [
"BOOKING_CONFIRMED", "BOOKING_CONFIRMED",
"BOOKING_REMINDER", "BOOKING_REMINDER",
"BOOKING_REMINDER",
"OWNER_NEW_BOOKING", "OWNER_NEW_BOOKING",
"STAFF_APPOINTMENT_REMINDER", "STAFF_APPOINTMENT_REMINDER",
"STAFF_APPOINTMENT_REMINDER", "STAFF_APPOINTMENT_REMINDER",
@@ -89,9 +96,49 @@ describe("booking notifications (integration)", () => {
const staffNotification = notifications.find((n) => n.type === "STAFF_NEW_APPOINTMENT"); const staffNotification = notifications.find((n) => n.type === "STAFF_NEW_APPOINTMENT");
expect(staffNotification?.recipientUserId).toBe(staffUserId); 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"); const ownerNotification = notifications.find((n) => n.type === "OWNER_NEW_BOOKING");
expect(ownerNotification?.recipientUserId).toBe(staffUserId); 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 () => { 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({ const cancelNotifications = await prisma.notification.findMany({
where: { appointmentId: created.appointment.id, type: { in: ["BOOKING_CANCELLED", "STAFF_APPOINTMENT_CANCELLED", "OWNER_BOOKING_CANCELLED"] } }, where: { appointmentId: created.appointment.id, type: { in: ["BOOKING_CANCELLED", "STAFF_APPOINTMENT_CANCELLED", "OWNER_BOOKING_CANCELLED"] } },
include: { deliveries: true },
}); });
expect(cancelNotifications).toHaveLength(3); 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. // Cancelling again is rejected by cancelBooking itself (already CANCELLED) — never reaches notify() a second time.
const secondAttempt = await cancelBooking(prisma, { appointmentId: created.appointment.id }); const secondAttempt = await cancelBooking(prisma, { appointmentId: created.appointment.id });
expect(secondAttempt.success).toBe(false); expect(secondAttempt.success).toBe(false);
@@ -205,4 +259,56 @@ describe("booking notifications (integration)", () => {
}); });
expect(stillOnlyOnce).toBe(1); 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");
});
});
}); });
@@ -170,6 +170,31 @@ export async function createTestGuestCustomer(
return { customerId: created.id, email, phone }; return { customerId: created.id, email, phone };
} }
/**
* A minimal, directly-inserted Appointment row — for tests that only need
* a real appointmentId to satisfy the Notification.appointmentId foreign
* key (e.g. notification-service dedup tests), not the full
* booking.service.ts availability/validation workflow. Deliberately
* skips AppointmentService line items — nothing here reads them.
*/
export async function createTestAppointment(
business: TestBusiness,
params: { customerId: string; staffId: string; startAt?: Date; staffSelectedByCustomer?: boolean },
): Promise<{ appointmentId: string }> {
const startAt = params.startAt ?? new Date(Date.now() + 24 * 60 * 60_000);
const appointment = await prisma.appointment.create({
data: {
businessId: business.businessId,
customerId: params.customerId,
staffId: params.staffId,
startAt,
endAt: new Date(startAt.getTime() + 30 * 60_000),
...(params.staffSelectedByCustomer !== undefined ? { staffSelectedByCustomer: params.staffSelectedByCustomer } : {}),
},
});
return { appointmentId: appointment.id };
}
export async function cleanupTestBusiness(business: TestBusiness): Promise<void> { export async function cleanupTestBusiness(business: TestBusiness): Promise<void> {
// Appointment.staff is onDelete: Restrict (see prisma/schema.prisma) — a // Appointment.staff is onDelete: Restrict (see prisma/schema.prisma) — a
// deliberate production safeguard against deleting a staff member with // deliberate production safeguard against deleting a staff member with
@@ -1,7 +1,8 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest"; 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 { 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 { cancelBooking, createBooking, rescheduleBooking } from "../booking.service"; import { cancelBooking, createBooking, rescheduleBooking } from "../booking.service";
import { cleanupTestBusiness, createTestBusiness, createTestCustomer, createTestStaff, prisma } from "./fixtures"; import { cleanupTestBusiness, createTestBusiness, createTestCustomer, createTestStaff, prisma } from "./fixtures";
import type { TestBusiness } from "./fixtures"; import type { TestBusiness } from "./fixtures";
@@ -25,6 +26,17 @@ async function reminderDeliveries(appointmentId: string) {
}); });
} }
/**
* Customer and staff reminders now have independently-controllable
* defaults (see reminder-scheduling.ts's split CUSTOMER_/STAFF_
* REMINDER_HOURS_BEFORE, and types.ts's DEFAULT_DISABLED_TYPES): a
* customer's reminder is 24h-before only and defaults on; a staff
* member's is still 24h+2h-before but defaults OFF (an explicit
* NotificationPreference row is needed to see it). Most tests below
* enable the staff preference up front specifically to exercise that
* still-working mechanism end to end — see "with the staff reminder
* preference enabled" for why.
*/
describe("reminder scheduling (integration)", () => { describe("reminder scheduling (integration)", () => {
let business: TestBusiness; let business: TestBusiness;
let monday: CalendarDate; let monday: CalendarDate;
@@ -38,7 +50,7 @@ describe("reminder scheduling (integration)", () => {
await cleanupTestBusiness(business); await cleanupTestBusiness(business);
}); });
it("schedules 24h and 2h EMAIL reminders for both customer and staff, as SCHEDULED (not PENDING)", async () => { it("by default, only schedules the customer's 24h EMAIL reminder — the staff member gets none (disabled by default)", async () => {
const { staffId } = await createTestStaff(business); const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business); const { customerId } = await createTestCustomer(business);
@@ -52,12 +64,155 @@ 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(4); // 2 offsets × (customer + staff) expect(deliveries).toHaveLength(1);
expect(deliveries[0]!.notification.type).toBe("BOOKING_REMINDER");
expect(deliveries[0]!.status).toBe("SCHEDULED");
expect(deliveries[0]!.scheduledFor!.getTime()).toBe(
result.appointment.startAt.getTime() - 24 * 60 * 60 * 1000,
);
});
it("never schedules a 2-hour reminder for the customer, even with the staff preference enabled", async () => {
const { staffId, userId: staffUserId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
await setNotificationPreference(prisma, {
userId: staffUserId,
type: NotificationType.STAFF_APPOINTMENT_REMINDER,
channel: NotificationChannel.EMAIL,
isEnabled: true,
});
const result = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
if (!result.success) throw new Error("setup failed");
const deliveries = await reminderDeliveries(result.appointment.id);
const 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) {
await setNotificationPreference(prisma, {
userId: staffUserId,
type: NotificationType.STAFF_APPOINTMENT_REMINDER,
channel: NotificationChannel.EMAIL,
isEnabled: true,
});
}
it("schedules 24h and 2h EMAIL reminders for the staff member, alongside the customer's 24h one, as SCHEDULED (not PENDING)", async () => {
const { staffId, userId: staffUserId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
await enableStaffReminderEmail(staffUserId);
const result = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
if (!result.success) throw new Error("setup failed");
const deliveries = await reminderDeliveries(result.appointment.id);
expect(deliveries).toHaveLength(3); // customer 24h + staff 24h + staff 2h
for (const d of deliveries) { for (const d of deliveries) {
expect(d.status).toBe("SCHEDULED"); expect(d.status).toBe("SCHEDULED");
expect(d.scheduledFor).not.toBeNull(); 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");
expect(staffReminders).toHaveLength(2);
});
it("does not schedule a reminder offset that would already be in the past", async () => {
const { staffId, userId: staffUserId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
await enableStaffReminderEmail(staffUserId);
// Book for 3 hours from now — the customer's only offset (24h) and
// the staff's 24h offset are both already past; the staff's 2h
// offset is still in the future.
const result = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: new Date(Date.now() + 3 * 60 * 60_000),
}).catch(() => null);
// This may fail validation depending on min-notice/working-hours fixtures; only assert the
// reminder-skipping behaviour when the booking itself actually succeeded.
if (!result || !result.success) return;
const deliveries = await reminderDeliveries(result.appointment.id);
expect(deliveries).toHaveLength(1);
expect(deliveries[0]!.notification.type).toBe("STAFF_APPOINTMENT_REMINDER");
expect(deliveries[0]!.notification.idempotencyKey).toContain("reminder-2h");
});
it("rescheduling cancels the old reminders and schedules fresh ones for the new time", async () => {
const { staffId, userId: staffUserId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
await enableStaffReminderEmail(staffUserId);
const created = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
if (!created.success) throw new Error("setup failed");
const originalDeliveries = await reminderDeliveries(created.appointment.id);
expect(originalDeliveries).toHaveLength(3);
const rescheduled = await rescheduleBooking(prisma, {
appointmentId: created.appointment.id,
startAt: wallClockToInstant(monday, 14 * 60, business.timezone),
});
expect(rescheduled.success).toBe(true);
if (!rescheduled.success) return;
const allDeliveries = await reminderDeliveries(created.appointment.id);
const cancelledCount = allDeliveries.filter((d) => d.status === "CANCELLED").length;
const scheduledCount = allDeliveries.filter((d) => d.status === "SCHEDULED").length;
expect(cancelledCount).toBe(3); // the original 3, now superseded
expect(scheduledCount).toBe(3); // 3 fresh ones for the new time
const freshOnes = allDeliveries.filter((d) => d.status === "SCHEDULED");
for (const d of freshOnes) {
expect(d.scheduledFor!.getTime()).toBeLessThan(rescheduled.appointment.startAt.getTime());
}
});
it("cancelling the appointment cancels its scheduled reminders", async () => {
const { staffId, userId: staffUserId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
await enableStaffReminderEmail(staffUserId);
const created = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
if (!created.success) throw new Error("setup failed");
const cancelled = await cancelBooking(prisma, { appointmentId: created.appointment.id });
expect(cancelled.success).toBe(true);
const deliveries = await reminderDeliveries(created.appointment.id);
expect(deliveries.every((d) => d.status === "CANCELLED")).toBe(true);
expect(deliveries).toHaveLength(3);
});
}); });
it("does not schedule reminders for a PENDING (requireApproval) booking", async () => { it("does not schedule reminders for a PENDING (requireApproval) booking", async () => {
@@ -77,82 +232,4 @@ describe("reminder scheduling (integration)", () => {
const deliveries = await reminderDeliveries(result.appointment.id); const deliveries = await reminderDeliveries(result.appointment.id);
expect(deliveries).toHaveLength(0); expect(deliveries).toHaveLength(0);
}); });
it("does not schedule a reminder offset that would already be in the past", async () => {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
// Book for 1 hour from now — the 24h and 2h-before offsets are both already past.
const result = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: new Date(Date.now() + 3 * 60 * 60_000), // 3h out, staff working hours permitting — see note below
}).catch(() => null);
// This may fail validation depending on min-notice/working-hours fixtures; only assert the
// reminder-skipping behaviour when the booking itself actually succeeded.
if (!result || !result.success) return;
const deliveries = await reminderDeliveries(result.appointment.id);
// 3h out: the 2h-before offset is still in the future, the 24h-before offset is in the past.
expect(deliveries.length).toBeLessThanOrEqual(2);
expect(deliveries.every((d) => d.notification.idempotencyKey.includes("reminder-2h"))).toBe(true);
});
it("rescheduling cancels the old reminders and schedules fresh ones for the new time", async () => {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
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(4);
const rescheduled = await rescheduleBooking(prisma, {
appointmentId: created.appointment.id,
startAt: wallClockToInstant(monday, 14 * 60, business.timezone),
});
expect(rescheduled.success).toBe(true);
if (!rescheduled.success) return;
const allDeliveries = await reminderDeliveries(created.appointment.id);
const cancelledCount = allDeliveries.filter((d) => d.status === "CANCELLED").length;
const scheduledCount = allDeliveries.filter((d) => d.status === "SCHEDULED").length;
expect(cancelledCount).toBe(4); // the original 4, now superseded
expect(scheduledCount).toBe(4); // 4 fresh ones for the new time
const freshOnes = allDeliveries.filter((d) => d.status === "SCHEDULED");
for (const d of freshOnes) {
expect(d.scheduledFor!.getTime()).toBeLessThan(rescheduled.appointment.startAt.getTime());
}
});
it("cancelling the appointment cancels its scheduled reminders", async () => {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
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(4);
});
}); });
@@ -186,8 +186,28 @@ export async function notifyBookingApproved(prisma: PrismaClient, appointment: A
await scheduleAppointmentReminders(prisma, appointment); await scheduleAppointmentReminders(prisma, appointment);
} }
/** The owner reassigns an appointment to a different staff member — distinct from a plain time reschedule (see notifyBookingRescheduled, which still also fires for a reassignment since the underlying rescheduleBooking() call can't distinguish "why", see ownerRescheduleAppointmentAction). This tells the customer specifically who they're now booked with. */ /**
export async function notifyBookingBarberChanged(prisma: PrismaClient, appointment: Appointment): Promise<void> { * The owner reassigns an appointment to a different staff member —
* distinct from a plain time reschedule (see notifyBookingRescheduled,
* which still also fires for a reassignment since the underlying
* rescheduleBooking() call can't distinguish "why", see
* ownerRescheduleAppointmentAction). This tells the customer specifically
* who they're now booked with.
*
* `customerSelectedStaff` should reflect the ORIGINAL booking (see
* Appointment.staffSelectedByCustomer), not the current staffId — a
* customer who booked "any available barber" and never expressed a
* preference doesn't need an email every time the business reassigns
* who's cutting their hair, though push/in-app still fire either way
* (the deep link is still useful, this is specifically about inbox
* noise). Defaults to true (email) so every existing call site keeps
* behaving exactly as before unless it explicitly opts out.
*/
export async function notifyBookingBarberChanged(
prisma: PrismaClient,
appointment: Appointment,
options: { customerSelectedStaff?: boolean } = {},
): Promise<void> {
await notifySafely(prisma, { await notifySafely(prisma, {
businessId: appointment.businessId, businessId: appointment.businessId,
type: NotificationType.BOOKING_BARBER_CHANGED, type: NotificationType.BOOKING_BARBER_CHANGED,
@@ -198,10 +218,11 @@ export async function notifyBookingBarberChanged(prisma: PrismaClient, appointme
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: `/appointments/${appointment.id}`,
idempotencyKey: `notify:booking-barber-changed:${appointment.id}:${appointment.staffId}`, idempotencyKey: `notify:booking-barber-changed:${appointment.id}:${appointment.staffId}`,
skipEmail: options.customerSelectedStaff === false,
}); });
} }
/** The appointment reaches COMPLETED — a distinct, non-loyalty "thanks for visiting" notice (see LoyaltyService for the separate stamp/reward notifications, which fire independently). */ /** The appointment reaches COMPLETED — a distinct, non-loyalty "thanks for visiting" notice (see LoyaltyService for the separate stamp/reward notifications, which fire independently). No longer emailed by default (still push/in-app) — a receipt-style email after every single visit was more noise than value. */
export async function notifyBookingCompleted(prisma: PrismaClient, appointment: Appointment): Promise<void> { export async function notifyBookingCompleted(prisma: PrismaClient, appointment: Appointment): Promise<void> {
await notifySafely(prisma, { await notifySafely(prisma, {
businessId: appointment.businessId, businessId: appointment.businessId,
@@ -213,6 +234,7 @@ export async function notifyBookingCompleted(prisma: PrismaClient, appointment:
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: `/appointments/${appointment.id}`,
idempotencyKey: `notify:booking-completed:${appointment.id}`, idempotencyKey: `notify:booking-completed:${appointment.id}`,
skipEmail: true,
}); });
} }
@@ -143,6 +143,11 @@ export async function createBooking(
status, status,
notes: input.notes, notes: input.notes,
createdByUserId: input.createdByUserId, createdByUserId: input.createdByUserId,
// input.staffId (not the resolved local `staffId` above, which is
// always concrete by this point) is undefined exactly when the
// customer asked for "any available barber" — see
// Appointment.staffSelectedByCustomer in schema.prisma.
staffSelectedByCustomer: !!input.staffId,
effectiveBuffer: context.effectiveBuffer, effectiveBuffer: context.effectiveBuffer,
services: context.services.map((s) => ({ services: context.services.map((s) => ({
serviceId: s.serviceId, serviceId: s.serviceId,
+15 -2
View File
@@ -10,8 +10,17 @@ import type { Appointment } from "./booking.service";
* docs/phase-7-summary.md, "Known limitations"). A natural home for this * docs/phase-7-summary.md, "Known limitations"). A natural home for this
* 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,
* not one shared one — a 2-hour-before reminder on top of the 24-hour one
* was more noise than value for a customer, but a barber still benefits
* from both (a same-day heads-up on top of the previous day's one).
*/ */
const REMINDER_HOURS_BEFORE = [24, 2] as const; const CUSTOMER_REMINDER_HOURS_BEFORE = [24] as const;
const STAFF_REMINDER_HOURS_BEFORE = [24, 2] as const;
const ALL_REMINDER_HOURS_BEFORE = Array.from(
new Set<number>([...CUSTOMER_REMINDER_HOURS_BEFORE, ...STAFF_REMINDER_HOURS_BEFORE]),
).sort((a, b) => b - a);
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 });
@@ -66,10 +75,11 @@ 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 REMINDER_HOURS_BEFORE) { for (const hoursBefore of ALL_REMINDER_HOURS_BEFORE) {
const scheduledFor = new Date(appointment.startAt.getTime() - hoursBefore * 60 * 60 * 1000); const scheduledFor = new Date(appointment.startAt.getTime() - hoursBefore * 60 * 60 * 1000);
if (scheduledFor <= now) continue; if (scheduledFor <= now) continue;
if ((CUSTOMER_REMINDER_HOURS_BEFORE as readonly number[]).includes(hoursBefore)) {
await notifySafely(prisma, { await notifySafely(prisma, {
businessId: appointment.businessId, businessId: appointment.businessId,
type: NotificationType.BOOKING_REMINDER, type: NotificationType.BOOKING_REMINDER,
@@ -82,7 +92,9 @@ export async function scheduleAppointmentReminders(prisma: PrismaClient, appoint
idempotencyKey: `notify:booking-reminder-${hoursBefore}h:${appointment.id}:${versionSuffix}`, idempotencyKey: `notify:booking-reminder-${hoursBefore}h:${appointment.id}:${versionSuffix}`,
scheduledFor, scheduledFor,
}); });
}
if ((STAFF_REMINDER_HOURS_BEFORE as readonly number[]).includes(hoursBefore)) {
await notifySafely(prisma, { await notifySafely(prisma, {
businessId: appointment.businessId, businessId: appointment.businessId,
type: NotificationType.STAFF_APPOINTMENT_REMINDER, type: NotificationType.STAFF_APPOINTMENT_REMINDER,
@@ -96,3 +108,4 @@ export async function scheduleAppointmentReminders(prisma: PrismaClient, appoint
}); });
} }
} }
}
@@ -65,6 +65,8 @@ export interface CreateAppointmentParams {
createdByUserId?: string; createdByUserId?: string;
effectiveBuffer: { beforeMinutes: number; afterMinutes: number }; effectiveBuffer: { beforeMinutes: number; afterMinutes: number };
services: AppointmentServiceLine[]; services: AppointmentServiceLine[];
/** See Appointment.staffSelectedByCustomer in schema.prisma. */
staffSelectedByCustomer: boolean;
} }
export interface RescheduleAppointmentParams { export interface RescheduleAppointmentParams {
@@ -188,6 +190,7 @@ export async function createAppointment(prisma: PrismaClient, params: CreateAppo
status: params.status, status: params.status,
notes: params.notes, notes: params.notes,
createdByUserId: params.createdByUserId, createdByUserId: params.createdByUserId,
staffSelectedByCustomer: params.staffSelectedByCustomer,
services: { services: {
create: params.services.map((s) => ({ create: params.services.map((s) => ({
serviceId: s.serviceId, serviceId: s.serviceId,