# 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 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 | 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 (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`'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`. - **Guest-to-account merge** (what happens to a guest's history when they later sign up): `src/services/customer/guest-merge.service.ts`, called from `authorizeCredentials` (OTP sign-in), `src/auth.ts`'s Google `signIn` event, and `createCustomerByStaff`.