Files
NextChair/docs/notifications.md
T
Stefan Lazic 4573587b78
CI / ci (push) Canceled after 0s
Email Notifictions
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.
2026-09-12 18:27:11 +01:00

15 KiB

Notifications reference

A living reference for every notification NextChair sends — what it is, who gets it, on which channels, and whether the recipient can turn it off. Unlike the other files in docs/ (dated phase summaries), this one is meant to be kept current as notifications change; if you add, remove, or retarget one, update this file in the same change.

Two separate mechanisms

Most of what's on this page goes through the queued notification system: an event happens, a Notification + one NotificationDelivery row per applicable channel is created immediately, but the actual EMAIL/PUSH send only happens when the background processor runs (see src/services/notifications/reminder-worker.ts, triggered by POST /api/cron/process-notifications — see the root README's "Notification delivery" section for how that's scheduled on a real deployment). IN_APP deliveries have no send step; they're just a database row the notification bell/centre reads directly.

One-time codes (below, in their own section) are different — they're sent synchronously, the moment they're requested, with no queue at all.

Either way, nothing actually reaches an inbox unless that business has EMAIL_PROVIDER=brevo configured (Manager's Settings → Email, or a business's own .env) — with the default EMAIL_PROVIDER=console, every send just logs to the container's console instead.

Channels

  • EMAIL — sent only if the recipient has a resolvable email address (a registered user's User.email, or a guest customer's CustomerProfile.email).
  • PUSH — sent only if the recipient has at least one active (not revoked) browser push subscription.
  • IN_APP — only meaningful for a recipient with a real account (recipientUserId set) — a guest customer never gets one, since they have nothing to sign into.

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 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 (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. (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).

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 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 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

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 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

One-time codes (not part of the system above)

Sent immediately and synchronously — see src/lib/auth/otp.service.ts. Not queued, not preference-controlled, no in-app record.

Purpose Sent to When
login The email being signed in with Every sign-in attempt
register The email registering Customer self-registration
staff-invite The invited email Accepting a staff invitation (/invite/accept)
install The administrator's email The install wizard's administrator-verification step
email-change The new email address A signed-in user changing their account email

Platform-level (Manager, not tied to any business)

  • Settings → Email → Send Test Email — a manual, one-off admin action in the Manager app, unrelated to any business's own notification queue. See manager/src/lib/test-email.ts.

Where things live, if you're changing any of this

  • 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, 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, src/services/staff/staff-invitation.service.ts, and reminder-worker.ts (for the self-monitoring alert).
  • Channel resolution / preference enforcement: src/services/notifications/notification.service.ts's resolveChannels/isChannelEnabled.
  • The actual send: src/services/notifications/providers/ (Brevo/ console email, web-push/console push) plus reminder-worker.ts, which processes the queue.
  • Settings UI: src/features/staff/components/notification-preferences-form.tsx (shared by customer/staff/owner), wired up per audience in src/app/(customer)/settings/notifications/page.tsx and src/app/(staff)/staff/settings/notifications/page.tsx.