Notification Event Matrix — Design
Date: 2026-05-21 Status: Approved Author: ssh + ClaudeProblem
Clinic-operational emails (appointment created, rescheduled, invoice issued, etc.) fan out unconditionally to patient + assigned doctor + every clinic staff member. Receptionists and non-assigned doctors get CC’d on every action across the clinic, producing inbox spam. Today’snotificationPreferences JSONB on clinics only gates 5 cron-driven events (reminders, missed-followup, feedback, stock alerts, EOD report). All synchronous/operational sends are ungated.
The fix: introduce a per-event × per-audience matrix of email opt-ins, surfaced in admin Settings → Notifications and mirrored in superadmin’s per-clinic override view.
Goals
- Per-clinic control over which audience receives which event by email.
- Reduce default spam: existing clinics get the new defaults applied (no grandfathering).
- Single source of truth for both the admin UI and the superadmin override UI.
- One enforcement chokepoint (a helper) so we don’t repeat gate logic at 15+ call sites.
- Audit log of toggle changes.
Non-goals
- WhatsApp triggers (out of scope; still gated by the WhatsApp module toggle only).
- Per-user opt-outs (matrix is clinic-wide; per-user prefs like unread-message digest stay where they are).
- Auth/system emails (OTP, password reset, trial/billing, onboarding, marketplace, referrals — these are OdontoX system events and must always send).
Audiences (matrix columns)
Four columns per event. Some events have N/A columns (e.g. no “assigned doctor” for an invoice).| Column | Meaning |
|---|---|
patient | The patient on the appointment / invoice / treatment plan |
doctor | The specific assigned doctor on the appointment |
admins | Clinic leadership: roles owner + admin + manager |
staff | Everyone else on the team: receptionist + doctor (when not the assigned doctor on the event) |
server/src/lib/notification-defaults.ts next to the defaults table, so it’s a single source of truth. If a user holds multiple roles (rare but possible), the most-privileged audience wins (admins beats staff) and shouldSendEmail is called once per resolved audience to avoid duplicate sends.
Events (matrix rows)
Legend: ✅ default ON, ❌ default OFF, — N/A.Appointment lifecycle
| Event | Patient | Doctor | Admins | Staff |
|---|---|---|---|---|
appointment.requested | ✅ | — | ❌ | ❌ |
appointment.scheduled | ✅ | ✅ | ❌ | ❌ |
appointment.confirmed | ✅ | ✅ | ❌ | ❌ |
appointment.completed | ❌ | ❌ | ❌ | ❌ |
appointment.cancelled | ✅ | ✅ | ❌ | ❌ |
appointment.rescheduled | ✅ | ✅ | ❌ | ❌ |
appointment.noshow | ✅ | ❌ | ❌ | ❌ |
Appointment crons (folded in from old flat toggles)
| Event | Patient | Doctor | Admins | Staff |
|---|---|---|---|---|
appointment.reminder (24/8/4h) | ✅ | ❌ | ❌ | ❌ |
appointment.missed_followup | ✅ | ❌ | ❌ | ❌ |
appointment.feedback (2-day post-visit) | ✅ | — | — | — |
Billing / documents
| Event | Patient | Doctor | Admins | Staff |
|---|---|---|---|---|
invoice.issued | ✅ | — | ✅ | ❌ |
quotation.sent | ✅ | — | ❌ | ❌ |
Treatment plans
| Event | Patient | Doctor | Admins | Staff |
|---|---|---|---|---|
treatment_plan.created | ✅ | — | ❌ | ❌ |
treatment_plan.accepted | ✅ | ❌ | ✅ | ❌ |
Inventory / operations
| Event | Patient | Doctor | Admins | Staff |
|---|---|---|---|---|
inventory.lowstock | — | — | ✅ | ❌ |
inventory.expiry | — | — | ✅ | ❌ |
eod.report (9 PM PKT) | — | — | ✅ | ❌ |
Out of matrix (intentional)
messages.unread_digest— per-user pref, not clinic-wide.contact_form.submission— submitter + system event, not clinic-operational.- All auth / billing / trial / onboarding / marketplace / referral emails — OdontoX system, never gated.
Storage
Extendclinics.notificationPreferences JSONB. No schema change.
server/src/lib/notification-defaults.ts).
Enforcement helper
Single chokepoint inserver/src/lib/email.ts (or sibling file):
- Load
clinics.notificationPreferences.emailMatrix[event][audience]if set. - Otherwise fall back to the default in
notification-defaults.ts. - Return the boolean.
sendEmailViaZepto. The audited send sites (file:line refs from the codebase audit) are:
server/src/lib/email.ts—sendAppointmentRequestedEmail(2042),sendAppointmentScheduledEmail(2115),sendAppointmentConfirmedEmail(2188),sendAppointmentCompletedEmail(2254),sendAppointmentCancellationEmail(1488),sendAppointmentRescheduledEmail(1553),sendAppointmentNoShowEmail(2318)server/src/lib/email.ts—sendInvoiceEmail(2625),sendQuotationEmail(2524)server/src/lib/email.ts—sendTreatmentPlanAcceptedPatient(2863),sendTreatmentPlanAcceptedAdmin(2901),sendNewTreatmentPlanEmailserver/src/scheduled/appointment-reminders.ts(159) — currently checks flat flag, switch to matrixserver/src/scheduled/missed-appointments.ts(63) — currently checks flat flag, switch to matrixserver/src/scheduled/feedback-email.ts(63) — currently checks flat flag, switch to matrixserver/src/scheduled/eod-email-report.ts(200) — currently checks flat flag, switch to matrixserver/src/scheduled/inventory-expiry-alerts.ts— newly gatedserver/src/routes/inventory.ts(103) — currently checks flat flag, switch to matrix
shouldSendEmail returns false for that audience. Per-recipient filtering, not all-or-nothing per event.
Migration
One-shot DB migration (no destructive change; pure JSONB merge):- For every clinic, read the existing flat flags from
notificationPreferences. - Build a default
emailMatrix(the hardcoded defaults table). - Where a flat flag exists, override the corresponding patient cell:
appointmentRemindersEnabled = false→emailMatrix["appointment.reminder"].patient = falsemissedAppointmentsEnabled = false→emailMatrix["appointment.missed_followup"].patient = falsefeedbackEmailEnabled = false→emailMatrix["appointment.feedback"].patient = falsestockAlertEmailEnabled = false→emailMatrix["inventory.lowstock"].admins = falseeodEmailEnabled = false→emailMatrix["eod.report"].admins = false
- Write the merged matrix back.
- Leave flat flags in place for one release (read-fallback safety net), schedule a follow-up to drop them.
UI — Admin
Location:ui/src/components/settings/NotificationSettings.tsx (existing file, replace contents).
Layout: grouped tables, one section per category (Appointments / Crons / Billing / Treatment plans / Inventory & ops). Each table has event rows × 4 audience checkbox columns. N/A cells render as a dash.
Affordances:
- Per-row bulk action: “Turn off all” / “Reset to default” on each row.
- Per-column bulk action: “Silence clinic staff everywhere” / “Restore admins” at the top of each column.
- Reset all to defaults button at top of page.
- Mobile: tables collapse to per-event accordion (event name → 4 toggles inside).
googleReviewUrl field stays where it is (rendered next to appointment.feedback row).
UI — Superadmin
Location:ui/src/components/superadmin/ClinicNotificationsTab.tsx (existing file, replace contents).
Same matrix UI as admin, scoped to the selected clinic. Plus:
- Kill switch: a superadmin-only “Force OFF all appointment emails for this clinic” toggle that hard-disables
appointment.*regardless of clinic settings. Stored asnotificationPreferences.superadminKillSwitch: { appointmentEmails?: true }. TheshouldSendEmailhelper checks this first. - All edits logged via existing impersonation audit trail.
Audit log
Existing audit infrastructure (used for plan changes and impersonation) gets a new event typeclinic.notification_preferences.updated. Diff payload includes the changed cells. Same for the superadmin kill switch.
Testing
- Unit:
shouldSendEmailreturns expected booleans across (a) clinic with no matrix, (b) clinic with partial overrides, (c) clinic with superadmin kill switch. - Unit: migration logic correctly maps old flat flags to matrix cells.
- Integration: hitting the appointment-create endpoint with the matrix off for the staff audience confirms no email is sent to staff but patient + doctor still receive.
- Integration: superadmin override save round-trips correctly and appears on next page load.
- Manual: verify ssh & Associates test tenant gets new defaults applied by migration without losing its current explicit toggles (
appointmentRemindersEnabled=true, etc.).
Rollout
- Ship migration + helper + send-site refactors behind no flag — they’re backward compatible (default matrix = current behaviour for the 5 already-gated events, NEW gates for everything else).
- Ship the UI in the same release.
- Send superadmin announcement email after deploy verifies green.
- One release later: remove the deprecated flat flags from the schema and read-fallback path.
Files changed (preview)
New:server/src/lib/notification-defaults.ts— default matrix + event key typeserver/src/migrations/0034_notification_matrix.sql(or numbered next available)
server/src/lib/email.ts— addshouldSendEmail, gate all clinic-event sendsserver/src/schema/clinics.ts— extendNotificationPreferencestypeserver/src/routes/clinics.ts— accept matrix in PATCH body, audit logserver/src/scheduled/{appointment-reminders,missed-appointments,feedback-email,eod-email-report,inventory-expiry-alerts}.ts— read matrix instead of flat flagsserver/src/routes/inventory.ts— read matrix instead of flat flagui/src/components/settings/NotificationSettings.tsx— replace with matrix UIui/src/components/superadmin/ClinicNotificationsTab.tsx— replace with matrix UI + kill switchdocs/api-reference.md— update notification prefs payload shape

