Guest merge Guest history now joins the account when the email is proven, in three places: OTP sign-in, Google sign-in, and staff adding a client. Unverified emails are never matched. What moves: appointments, notes, push subscriptions and notifications go onto the account's profile, and the guest profile is deleted. One email, several guests: if the email has several guest profiles (one per phone number), all are merged. No profile yet: the oldest guest profile becomes the account's profile. Account wins: it keeps its own name and phone, and only gaps get filled. Stamps: summed, with the ledger entries moved across. If the combined total reaches the threshold, the reward is earned and the customer is notified. Safety: it runs in one transaction and is audit-logged. A failure never blocks sign-in; the next sign-in retries. Reminders Staff: one reminder, 2h before; the 24h one is gone. Push and in-app are on by default and email is opt-in. Defaults are now per channel, and the settings page shows email off, push and in-app on. Customer: one reminder at 24h. If booked too late for that, they get one at 2h instead, and none if the appointment is under 2h away. In-app reminders: held back and released by the worker at the reminder time, so they no longer appear the moment you book. Cancelling or rescheduling clears them, and "mark all read" no longer marks unseen ones. Guest links: every guest email now carries a working link. The booking-time token is unchanged, and later emails use one derived from the appointment id and AUTH_SECRET. Revoking a guest appointment's access kills both. Reward expiry: two emails per reward at most, at 7 days and 1 day before. Stale guard: the worker drops a reminder, without counting a failure, if the appointment has started or isn't confirmed. OTP rate-limit tests (2): the failures came from my scratch environment, not the app. I had E2E_TEST_MODE=true in the scratch .env, which turns off the OTP rate limits, so the tests had nothing to assert on. I'd also used MariaDB, which throws error 1020 on concurrent upserts, while your real stack runs mysql:8.0. On MySQL 8 without the flag they pass every time. I also pinned that flag off in the integration config, so a .env with it set can't break them again. I tested that by turning it on in .env, and the OTP tests still passed. Authorization and nav-role suites (2): this was a real bug. Both files import @/lib/auth/permissions, which builds the database connection when it's imported, before the test fixtures load .env. The suites crashed before running a single test, so the owner-permission checks (barbers can't do owner actions, nothing leaks across businesses) hadn't been running at all. I now load dotenv once in the integration config, so no test depends on import order. They run and pass (13 tests).
17 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'sCustomerProfile.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
(
recipientUserIdset) — 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_REMINDERis optional (and only controls the customer 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 (on every channel) 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. |
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. |
Defaults can also differ per channel, not just per type
(DEFAULT_DISABLED_CHANNELS in types.ts). Currently one:
STAFF_APPOINTMENT_REMINDER is on for push and in-app but off for
email — a barber gets a nudge on their phone two hours out without a second
email for something that's about to happen anyway. Email is opt-in under
Settings → Notifications.
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 offsets before the appointment, independently for the customer and
the assigned staff member:
- Customer (
BOOKING_REMINDER): one reminder, 24 hours before. If the booking was made too late for that (a same-day or next-morning booking), they get one 2 hours before instead — never both, and never nothing, unless the appointment is under 2 hours away. - Staff (
STAFF_APPOINTMENT_REMINDER): one reminder, 2 hours before. No 24-hour one. Push and in-app on by default, email opt-in — see "Defaults" above. - Owner: no reminders.
A reminder time that's already in the past when the booking is made/approved is never scheduled retroactively.
In-app reminders are held back until they're due. Every scheduled
notification's in-app copy is created SCHEDULED, not SENT, so it stays
out of the notification bell and unread count until the background
processor releases it at the reminder time (releaseDueInAppReminders in
reminder-worker.ts). Cancelling or rescheduling cancels it along with the
email/push copies, so a stale reminder never appears.
Stale-send guard. Before sending (or releasing) a reminder, the worker
checks the appointment is still CONFIRMED and hasn't started; if not, the
delivery is marked CANCELLED (not FAILED, so it doesn't count toward the
owner's delivery-failure alert). This is a backstop for the processor being
down long enough that a "24 hours before" reminder would otherwise arrive
after the appointment.
Guest links. A guest has no account, so every customer-facing link in
their emails carries an access token (customerAppointmentLink in
guest-access.service.ts). The booking-time token is random and only its
hash is stored, so later emails use a token derived from the appointment
id and AUTH_SECRET instead; validateGuestAccessToken accepts either, and
revoking a guest appointment's access kills both.
Customer-facing
| Type | Fires when | Default subject | |
|---|---|---|---|
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 (2 hours before if booked with less than 24 hours' notice) | "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 reward's expiry is within 7 days — sent once when it enters that window, and once more when it's down to its last day (two at most per reward) | "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) |
2 hours before their appointment | "Upcoming appointment reminder" | In-app and push on, email 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'sNotificationTypeenum, andRECIPIENT_KINDinsrc/services/notifications/types.ts. - Whether it's optional, and its default:
PREFERENCE_CONTROLLED_TYPESanddefaultPreferenceIsEnabled(), same file. - Fixed no-email types:
NotifyParams.skipEmailinnotification.service.ts, set at the relevant call site inbooking-notifications.ts/loyalty-notifications.ts. - Cross-role dedup:
EQUIVALENCE_GROUPSinnotification.service.ts. - Whether the customer picked a specific barber originally:
Appointment.staffSelectedByCustomer, set once at booking creation inbooking.service.ts'screateBooking(), read at reassignment time insrc/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, andreminder-worker.ts(for the self-monitoring alert). - Channel resolution / preference enforcement:
src/services/notifications/notification.service.ts'sresolveChannels/isChannelEnabled. - The actual send:
src/services/notifications/providers/(Brevo/ console email, web-push/console push) plusreminder-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 insrc/app/(customer)/settings/notifications/page.tsxandsrc/app/(staff)/staff/settings/notifications/page.tsx. - Guest-to-account merge (what happens to a guest's history when they
later sign up):
src/services/customer/guest-merge.service.ts, called fromauthorizeCredentials(OTP sign-in),src/auth.ts's GooglesignInevent, andcreateCustomerByStaff.