Core logic: types.ts (defaults), notification.service.ts (skipEmail + dedup), booking-notifications.ts, loyalty-notifications.ts, reminder-scheduling.ts, booking.service.ts + scheduling.service.ts (staff-selection capture), features/owner/actions/appointments.ts. Schema: prisma/schema.prisma + new migration 20260912165903_add_staff_selected_by_customer. UI: both notification-preferences pages (customer, staff) now use the same default logic as the actual send decision. Tests: notification.service.integration.test.ts, booking-notifications.integration.test.ts, reminder-scheduling.integration.test.ts, loyalty-notifications.integration.test.ts, plus a new createTestAppointment fixture. Docs: docs/notifications.md rewritten to match. Default changes Exactly as specified — full table is in docs/notifications.md. In short: STAFF_NEW_APPOINTMENT, STAFF_APPOINTMENT_RESCHEDULED, STAFF_APPOINTMENT_REMINDER, OWNER_NEW_BOOKING, OWNER_NO_SHOW now default off; everything else that was already toggleable is untouched. No longer email (fixed, not a toggle) BOOKING_COMPLETED, LOYALTY_STAMP_ADDED, LOYALTY_REWARD_REDEEMED — always. BOOKING_BARBER_CHANGED — only when the customer originally booked "any available barber." The customer's 2-hour reminder is removed entirely (24h stays); staff keeps both, just off by default. All via a new skipEmail flag on notify(), checked in one place — no new preference rows for these. Deduplication One new EQUIVALENCE_GROUPS map in notification.service.ts (STAFF_NEW_APPOINTMENT>OWNER_NEW_BOOKING, STAFF_APPOINTMENT_CANCELLED>OWNER_BOOKING_CANCELLED). Inside notify(), before creating deliveries, it checks for an already-created equivalent notification for the same (businessId, appointmentId, recipientUserId); if found and at-least-as-relevant, the new one gets zero deliveries on every channel (row still created for audit trail). Only listed types participate, so OWNER_NO_SHOW and any future actionable owner type are never touched.
This commit is contained in:
18 files changed
+999
-172
No files matched your search
+145
-43
@@ -37,62 +37,155 @@ every send just logs to the container's console instead.
|
||||
(`recipientUserId` set) — a guest customer never gets one, since they
|
||||
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`
|
||||
— this is the single source of truth; everything not listed there is
|
||||
**required** (always sent on every channel it has a valid target for, no
|
||||
toggle anywhere). Currently:
|
||||
— this is the single source of truth for which types have a toggle at
|
||||
all; everything not listed there is **required** (always sent on every
|
||||
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
|
||||
else (confirmations, changes, cancellations, loyalty) always notifies
|
||||
the customer — not finding out your appointment was cancelled is a
|
||||
real risk, not just noise.
|
||||
- **Customer-facing**: only `BOOKING_REMINDER` is optional (and only
|
||||
controls the 24h reminder — see "Reminder timing"). Everything else
|
||||
(confirmations, changes, cancellations, loyalty) always notifies the
|
||||
customer on every resolvable channel — not finding out your
|
||||
appointment was cancelled is a real risk, not just noise.
|
||||
- **Staff- and owner-facing**: every type is optional. The owner of a
|
||||
business is commonly also its only barber — one `StaffProfile`, one
|
||||
account — so without this, a single new booking would unconditionally
|
||||
notify that one person twice (`STAFF_NEW_APPOINTMENT` +
|
||||
`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
|
||||
"Appointment reminders" group; staff see their own six groups, plus five
|
||||
more owner-only groups if they hold `VIEW_REPORTING` (i.e. are actually
|
||||
an owner — see `src/services/notifications/recipients.ts`'s
|
||||
`getOwnerUserIds`, which checks the same permission). A preference with
|
||||
no saved row yet defaults to **on** — turning something off is always an
|
||||
explicit, deliberate action.
|
||||
`getOwnerUserIds`, which checks the same permission).
|
||||
|
||||
**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
|
||||
|
||||
| Type | Fires when | Default subject |
|
||||
|---|---|---|
|
||||
| `BOOKING_CONFIRMED` | A new booking is created and doesn't need approval | "Booking confirmed" |
|
||||
| `BOOKING_PENDING_APPROVAL` | A new booking is created but the business requires approval first | "Booking request received" |
|
||||
| `BOOKING_APPROVED` | An owner approves a pending booking | "Booking approved" |
|
||||
| `BOOKING_RESCHEDULED` | The appointment's time changes (including an owner reassigning staff, which also triggers a reschedule) | "Booking rescheduled" |
|
||||
| `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_BARBER_CHANGED` | An owner reassigns the appointment to a different staff member | "Your barber has changed" |
|
||||
| `BOOKING_COMPLETED` | The appointment is marked completed | "Thanks for visiting" |
|
||||
| `BOOKING_REMINDER` *(optional)* | 24 hours and 2 hours before a confirmed appointment (each is its own send) | "Upcoming appointment reminder" |
|
||||
| `LOYALTY_STAMP_ADDED` | A loyalty stamp is added to their card | "You've earned a loyalty stamp" |
|
||||
| `LOYALTY_REWARD_EARNED` | Their stamp count reaches the reward threshold | "You've earned a reward" |
|
||||
| `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_REDEEMED` | Their reward is applied to an appointment | "Reward applied" |
|
||||
| Type | Fires when | Default subject | Email |
|
||||
|---|---|---|---|
|
||||
| `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" | Always |
|
||||
| `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" | Always |
|
||||
| `BOOKING_CANCELLED` | The appointment is cancelled, by anyone | "Booking cancelled" (carries a "This was a late cancellation." note when applicable — not a separate type) | Always |
|
||||
| `BOOKING_BARBER_CHANGED` | An owner reassigns the appointment to a different staff member | "Your barber has changed" | Only if the customer originally picked a specific staff member — see "Fixed no-email types" |
|
||||
| `BOOKING_COMPLETED` | The appointment is marked completed | "Thanks for visiting" | Never |
|
||||
| `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" | Never |
|
||||
| `LOYALTY_REWARD_EARNED` | Their stamp count reaches the reward threshold | "You've earned a reward" | Always |
|
||||
| `LOYALTY_REWARD_EXPIRING` | A daily sweep finds their reward is nearing its expiry date (fires once per calendar day it's still found, not once total) | "Your reward is expiring soon" | Always |
|
||||
| `LOYALTY_REWARD_REDEEMED` | Their reward is applied to an appointment | "Reward applied" | Never |
|
||||
|
||||
## Staff-facing
|
||||
|
||||
| Type | Fires when | Default subject |
|
||||
|---|---|---|
|
||||
| `STAFF_NEW_APPOINTMENT` *(optional)* | A new appointment is booked with them | "New appointment" |
|
||||
| `STAFF_APPOINTMENT_RESCHEDULED` *(optional)* | One of their appointments is rescheduled | "Appointment rescheduled" |
|
||||
| `STAFF_APPOINTMENT_CANCELLED` *(optional)* | One of their appointments is cancelled | "Appointment cancelled" |
|
||||
| `STAFF_APPOINTMENT_REMINDER` *(optional)* | 24 hours and 2 hours before their appointment | "Upcoming appointment reminder" |
|
||||
| `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_INVITATION_RECEIVED` *(optional)* | They're added as a new team member | "You've been invited to join `{business}`" |
|
||||
| `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 | — |
|
||||
| Type | Fires when | Default subject | Default |
|
||||
|---|---|---|---|
|
||||
| `STAFF_NEW_APPOINTMENT` *(optional)* | A new appointment is booked with them | "New appointment" | Off |
|
||||
| `STAFF_APPOINTMENT_RESCHEDULED` *(optional)* | One of their appointments is rescheduled | "Appointment rescheduled" | Off |
|
||||
| `STAFF_APPOINTMENT_CANCELLED` *(optional)* | One of their appointments is cancelled | "Appointment cancelled" | On |
|
||||
| `STAFF_APPOINTMENT_REMINDER` *(optional)* | 24 hours and 2 hours before their appointment | "Upcoming appointment reminder" | Off |
|
||||
| `STAFF_SCHEDULE_CHANGED` *(optional)* | An **owner** edits their working hours/breaks/time-off on their behalf — never fires from the self-service editors, since notifying someone about their own edit would just be noise | "Your schedule was updated" | On |
|
||||
| `STAFF_INVITATION_RECEIVED` *(optional)* | They're added as a new team member | "You've been invited to join `{business}`" | On |
|
||||
| `STAFF_APPOINTMENT_CHECKED_IN` | Defined in the schema, has a recipient assigned — **but nothing in the codebase currently triggers it.** Preference-controlled for forward-compatibility, but has no toggle in the UI since there's nothing to preview. Left dormant on purpose | — | — |
|
||||
|
||||
## 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
|
||||
anywhere in the schema.
|
||||
|
||||
| Type | Fires when | Default subject |
|
||||
|---|---|---|
|
||||
| `OWNER_NEW_BOOKING` *(optional)* | A new booking is made anywhere in the business | "New booking" |
|
||||
| `OWNER_BOOKING_CANCELLED` *(optional)* | A booking is cancelled anywhere in the business | "Booking cancelled" |
|
||||
| `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_STAFF_INVITATION_ACCEPTED` *(optional)* | An invited staff member verifies their email and joins | "Invitation accepted" |
|
||||
| `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" |
|
||||
| Type | Fires when | Default subject | Default |
|
||||
|---|---|---|---|
|
||||
| `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" | 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" | Off |
|
||||
| `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" | On |
|
||||
|
||||
---
|
||||
|
||||
@@ -136,7 +229,16 @@ Not queued, not preference-controlled, no in-app record.
|
||||
- **What exists / who it's for**: `prisma/schema.prisma`'s
|
||||
`NotificationType` enum, and `RECIPIENT_KIND` in
|
||||
`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`,
|
||||
`reminder-scheduling.ts`, `src/services/loyalty/loyalty-notifications.ts`,
|
||||
`src/services/staff/staff-notifications.ts`,
|
||||
|
||||
Reference in new issue
Block a user