Skip to main content

Notification Event Matrix — Design

Date: 2026-05-21 Status: Approved Author: ssh + Claude

Problem

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’s notificationPreferences 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).
ColumnMeaning
patientThe patient on the appointment / invoice / treatment plan
doctorThe specific assigned doctor on the appointment
adminsClinic leadership: roles owner + admin + manager
staffEveryone else on the team: receptionist + doctor (when not the assigned doctor on the event)
The role → audience mapping lives in 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

EventPatientDoctorAdminsStaff
appointment.requested
appointment.scheduled
appointment.confirmed
appointment.completed
appointment.cancelled
appointment.rescheduled
appointment.noshow

Appointment crons (folded in from old flat toggles)

EventPatientDoctorAdminsStaff
appointment.reminder (24/8/4h)
appointment.missed_followup
appointment.feedback (2-day post-visit)

Billing / documents

EventPatientDoctorAdminsStaff
invoice.issued
quotation.sent

Treatment plans

EventPatientDoctorAdminsStaff
treatment_plan.created
treatment_plan.accepted

Inventory / operations

EventPatientDoctorAdminsStaff
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

Extend clinics.notificationPreferences JSONB. No schema change.
type NotificationPreferences = {
  // New: the matrix
  emailMatrix?: {
    [eventKey: string]: {
      patient?: boolean;
      doctor?: boolean;
      admins?: boolean;
      staff?: boolean;
    };
  };
  // Preserved
  googleReviewUrl?: string;
  // Deprecated (still read for one release for safety, removed in follow-up):
  // appointmentRemindersEnabled, missedAppointmentsEnabled, feedbackEmailEnabled,
  // stockAlertEmailEnabled, eodEmailEnabled
};
The event keys are the dotted strings listed above. Missing keys fall back to the hardcoded defaults table (single source of truth in server/src/lib/notification-defaults.ts).

Enforcement helper

Single chokepoint in server/src/lib/email.ts (or sibling file):
async function shouldSendEmail(
  clinicId: string,
  event: NotificationEventKey,
  audience: 'patient' | 'doctor' | 'admins' | 'staff'
): Promise<boolean>;
Behavior:
  1. Load clinics.notificationPreferences.emailMatrix[event][audience] if set.
  2. Otherwise fall back to the default in notification-defaults.ts.
  3. Return the boolean.
Every clinic-event email send site is rewritten to call this helper before calling sendEmailViaZepto. The audited send sites (file:line refs from the codebase audit) are:
  • server/src/lib/email.tssendAppointmentRequestedEmail (2042), sendAppointmentScheduledEmail (2115), sendAppointmentConfirmedEmail (2188), sendAppointmentCompletedEmail (2254), sendAppointmentCancellationEmail (1488), sendAppointmentRescheduledEmail (1553), sendAppointmentNoShowEmail (2318)
  • server/src/lib/email.tssendInvoiceEmail (2625), sendQuotationEmail (2524)
  • server/src/lib/email.tssendTreatmentPlanAcceptedPatient (2863), sendTreatmentPlanAcceptedAdmin (2901), sendNewTreatmentPlanEmail
  • server/src/scheduled/appointment-reminders.ts (159) — currently checks flat flag, switch to matrix
  • server/src/scheduled/missed-appointments.ts (63) — currently checks flat flag, switch to matrix
  • server/src/scheduled/feedback-email.ts (63) — currently checks flat flag, switch to matrix
  • server/src/scheduled/eod-email-report.ts (200) — currently checks flat flag, switch to matrix
  • server/src/scheduled/inventory-expiry-alerts.ts — newly gated
  • server/src/routes/inventory.ts (103) — currently checks flat flag, switch to matrix
For multi-audience sends, the caller resolves each recipient’s audience and short-circuits the send if 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):
  1. For every clinic, read the existing flat flags from notificationPreferences.
  2. Build a default emailMatrix (the hardcoded defaults table).
  3. Where a flat flag exists, override the corresponding patient cell:
    • appointmentRemindersEnabled = falseemailMatrix["appointment.reminder"].patient = false
    • missedAppointmentsEnabled = falseemailMatrix["appointment.missed_followup"].patient = false
    • feedbackEmailEnabled = falseemailMatrix["appointment.feedback"].patient = false
    • stockAlertEmailEnabled = falseemailMatrix["inventory.lowstock"].admins = false
    • eodEmailEnabled = falseemailMatrix["eod.report"].admins = false
  4. Write the merged matrix back.
  5. Leave flat flags in place for one release (read-fallback safety net), schedule a follow-up to drop them.
Existing clinics will see reduced spam immediately (the new “OFF for staff everywhere” defaults take effect). A one-time superadmin-sent email + an in-app banner on Settings → Notifications announces: “We tightened notification defaults to reduce spam. If you want the old behaviour, adjust here.”

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 as notificationPreferences.superadminKillSwitch: { appointmentEmails?: true }. The shouldSendEmail helper 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 type clinic.notification_preferences.updated. Diff payload includes the changed cells. Same for the superadmin kill switch.

Testing

  • Unit: shouldSendEmail returns 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

  1. 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).
  2. Ship the UI in the same release.
  3. Send superadmin announcement email after deploy verifies green.
  4. 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 type
  • server/src/migrations/0034_notification_matrix.sql (or numbered next available)
Modified:
  • server/src/lib/email.ts — add shouldSendEmail, gate all clinic-event sends
  • server/src/schema/clinics.ts — extend NotificationPreferences type
  • server/src/routes/clinics.ts — accept matrix in PATCH body, audit log
  • server/src/scheduled/{appointment-reminders,missed-appointments,feedback-email,eod-email-report,inventory-expiry-alerts}.ts — read matrix instead of flat flags
  • server/src/routes/inventory.ts — read matrix instead of flat flag
  • ui/src/components/settings/NotificationSettings.tsx — replace with matrix UI
  • ui/src/components/superadmin/ClinicNotificationsTab.tsx — replace with matrix UI + kill switch
  • docs/api-reference.md — update notification prefs payload shape