Skip to main content

Notification Event Matrix Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
Goal: Replace OdontoX’s 5 flat per-clinic email toggles with a per-event × per-audience email opt-in matrix, gating ~15 unconditional send sites behind a single shouldSendEmail() helper, with admin + superadmin UIs, an audit-logged write path, and a superadmin kill switch. Architecture: A defaults table + role→audience mapping in server/src/lib/notification-defaults.ts. A single chokepoint shouldSendEmail(clinicId, event, audience) in server/src/lib/email-gate.ts that reads clinics.notificationPreferences.emailMatrix, falls back to defaults, and obeys a superadmin kill switch. Every clinic-operational email send site checks the gate per recipient. The UI is a grouped checkbox table (event rows × patient | doctor | admins | staff columns) in both admin Settings and superadmin per-clinic override view. Tech Stack: TypeScript, Hono + Drizzle on Cloudflare Workers (server), React + TanStack Query + Tailwind + shadcn/ui (UI), Vitest tests, PostgreSQL JSONB storage, Drizzle migrations in server/drizzle/. Spec: docs/superpowers/specs/2026-05-21-notification-event-matrix-design.md

Phase 1 — Foundation (no behavior change yet)

Task 1: Defaults + role-mapping module

Files:
  • Create: server/src/lib/notification-defaults.ts
  • Test: server/src/lib/notification-defaults.test.ts
  • Step 1: Write the failing test
// server/src/lib/notification-defaults.test.ts
import { describe, it, expect } from 'vitest';
import {
  NOTIFICATION_EVENTS,
  DEFAULT_EMAIL_MATRIX,
  resolveAudienceForRole,
  type NotificationEventKey,
  type EmailAudience,
} from './notification-defaults';

describe('notification-defaults', () => {
  it('exposes every event from the spec', () => {
    const keys: NotificationEventKey[] = [
      'appointment.requested', 'appointment.scheduled', 'appointment.confirmed',
      'appointment.completed', 'appointment.cancelled', 'appointment.rescheduled',
      'appointment.noshow', 'appointment.reminder', 'appointment.missed_followup',
      'appointment.feedback', 'invoice.issued', 'quotation.sent',
      'treatment_plan.created', 'treatment_plan.accepted',
      'inventory.lowstock', 'inventory.expiry', 'eod.report',
    ];
    for (const k of keys) expect(NOTIFICATION_EVENTS).toContain(k);
  });

  it('defaults: appointment.scheduled is ON for patient + doctor only', () => {
    expect(DEFAULT_EMAIL_MATRIX['appointment.scheduled']).toEqual({
      patient: true, doctor: true, admins: false, staff: false,
    });
  });

  it('defaults: appointment.completed is OFF for everyone (pure noise)', () => {
    expect(DEFAULT_EMAIL_MATRIX['appointment.completed']).toEqual({
      patient: false, doctor: false, admins: false, staff: false,
    });
  });

  it('defaults: invoice.issued is ON for patient + admins, OFF for staff', () => {
    expect(DEFAULT_EMAIL_MATRIX['invoice.issued']).toEqual({
      patient: true, doctor: false, admins: true, staff: false,
    });
  });

  it('role mapping: owner/admin/manager → admins audience', () => {
    expect(resolveAudienceForRole('owner', { isAssignedDoctor: false })).toBe('admins');
    expect(resolveAudienceForRole('admin', { isAssignedDoctor: false })).toBe('admins');
    expect(resolveAudienceForRole('manager', { isAssignedDoctor: false })).toBe('admins');
  });

  it('role mapping: receptionist → staff', () => {
    expect(resolveAudienceForRole('receptionist', { isAssignedDoctor: false })).toBe('staff');
  });

  it('role mapping: doctor → doctor when assigned, staff otherwise', () => {
    expect(resolveAudienceForRole('doctor', { isAssignedDoctor: true })).toBe('doctor');
    expect(resolveAudienceForRole('doctor', { isAssignedDoctor: false })).toBe('staff');
  });
});
  • Step 2: Run test to verify it fails
Run: cd server && pnpm vitest run src/lib/notification-defaults.test.ts Expected: FAIL — module does not exist.
  • Step 3: Write minimal implementation
// server/src/lib/notification-defaults.ts

export const NOTIFICATION_EVENTS = [
  'appointment.requested',
  'appointment.scheduled',
  'appointment.confirmed',
  'appointment.completed',
  'appointment.cancelled',
  'appointment.rescheduled',
  'appointment.noshow',
  'appointment.reminder',
  'appointment.missed_followup',
  'appointment.feedback',
  'invoice.issued',
  'quotation.sent',
  'treatment_plan.created',
  'treatment_plan.accepted',
  'inventory.lowstock',
  'inventory.expiry',
  'eod.report',
] as const;

export type NotificationEventKey = (typeof NOTIFICATION_EVENTS)[number];

export type EmailAudience = 'patient' | 'doctor' | 'admins' | 'staff';

export type EmailMatrix = {
  [K in NotificationEventKey]?: Partial<Record<EmailAudience, boolean>>;
};

export const DEFAULT_EMAIL_MATRIX: Required<{ [K in NotificationEventKey]: Record<EmailAudience, boolean> }> = {
  // Appointment lifecycle
  'appointment.requested':       { patient: true,  doctor: false, admins: false, staff: false },
  'appointment.scheduled':       { patient: true,  doctor: true,  admins: false, staff: false },
  'appointment.confirmed':       { patient: true,  doctor: true,  admins: false, staff: false },
  'appointment.completed':       { patient: false, doctor: false, admins: false, staff: false },
  'appointment.cancelled':       { patient: true,  doctor: true,  admins: false, staff: false },
  'appointment.rescheduled':     { patient: true,  doctor: true,  admins: false, staff: false },
  'appointment.noshow':          { patient: true,  doctor: false, admins: false, staff: false },
  // Crons
  'appointment.reminder':        { patient: true,  doctor: false, admins: false, staff: false },
  'appointment.missed_followup': { patient: true,  doctor: false, admins: false, staff: false },
  'appointment.feedback':        { patient: true,  doctor: false, admins: false, staff: false },
  // Billing / docs
  'invoice.issued':              { patient: true,  doctor: false, admins: true,  staff: false },
  'quotation.sent':              { patient: true,  doctor: false, admins: false, staff: false },
  // Treatment plans
  'treatment_plan.created':      { patient: true,  doctor: false, admins: false, staff: false },
  'treatment_plan.accepted':     { patient: true,  doctor: false, admins: true,  staff: false },
  // Inventory / ops
  'inventory.lowstock':          { patient: false, doctor: false, admins: true,  staff: false },
  'inventory.expiry':            { patient: false, doctor: false, admins: true,  staff: false },
  'eod.report':                  { patient: false, doctor: false, admins: true,  staff: false },
};

/**
 * OdontoX role → matrix audience. Most-privileged wins when a user holds
 * multiple effective roles. `doctor` resolves to the `doctor` column only
 * when they are the assigned doctor on the event; otherwise they fall into
 * the `staff` column.
 */
export function resolveAudienceForRole(
  role: string,
  ctx: { isAssignedDoctor: boolean }
): EmailAudience {
  if (role === 'owner' || role === 'admin' || role === 'manager') return 'admins';
  if (role === 'doctor') return ctx.isAssignedDoctor ? 'doctor' : 'staff';
  if (role === 'receptionist') return 'staff';
  // Unknown roles fall through to staff (most restrictive default OFF).
  return 'staff';
}
  • Step 4: Run test to verify it passes
Run: cd server && pnpm vitest run src/lib/notification-defaults.test.ts Expected: PASS (8 tests).
  • Step 5: Commit
git add server/src/lib/notification-defaults.ts server/src/lib/notification-defaults.test.ts
git commit -m "feat(notif): defaults + role→audience mapping for email matrix"

Task 2: shouldSendEmail gate helper

Files:
  • Create: server/src/lib/email-gate.ts
  • Test: server/src/lib/email-gate.test.ts
  • Step 1: Write the failing test
// server/src/lib/email-gate.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { shouldSendEmail, __setClinicPrefsLoaderForTest } from './email-gate';
import type { EmailMatrix } from './notification-defaults';

type Stub = { emailMatrix?: EmailMatrix; superadminKillSwitch?: { appointmentEmails?: boolean } };

function stubClinic(prefs: Stub) {
  __setClinicPrefsLoaderForTest(async () => prefs);
}

describe('shouldSendEmail', () => {
  beforeEach(() => __setClinicPrefsLoaderForTest(null));

  it('returns default when matrix is empty', async () => {
    stubClinic({});
    // appointment.scheduled defaults: patient=true, staff=false
    expect(await shouldSendEmail('c1', 'appointment.scheduled', 'patient')).toBe(true);
    expect(await shouldSendEmail('c1', 'appointment.scheduled', 'staff')).toBe(false);
  });

  it('uses clinic override when set', async () => {
    stubClinic({ emailMatrix: { 'appointment.scheduled': { patient: false } } });
    expect(await shouldSendEmail('c1', 'appointment.scheduled', 'patient')).toBe(false);
    // Audience not overridden → falls back to default
    expect(await shouldSendEmail('c1', 'appointment.scheduled', 'doctor')).toBe(true);
  });

  it('superadmin kill switch overrides every appointment.* event to false', async () => {
    stubClinic({
      emailMatrix: { 'appointment.scheduled': { patient: true, doctor: true } },
      superadminKillSwitch: { appointmentEmails: true },
    });
    expect(await shouldSendEmail('c1', 'appointment.scheduled', 'patient')).toBe(false);
    expect(await shouldSendEmail('c1', 'appointment.scheduled', 'doctor')).toBe(false);
    // Non-appointment events unaffected
    expect(await shouldSendEmail('c1', 'invoice.issued', 'patient')).toBe(true);
  });

  it('fails closed on missing clinicId', async () => {
    expect(await shouldSendEmail(null as any, 'appointment.scheduled', 'patient')).toBe(false);
  });
});
  • Step 2: Run test to verify it fails
Run: cd server && pnpm vitest run src/lib/email-gate.test.ts Expected: FAIL — module not found.
  • Step 3: Write minimal implementation
// server/src/lib/email-gate.ts
import { getReadDb } from './db';
import { clinics } from '../schema';
import { eq } from 'drizzle-orm';
import {
  DEFAULT_EMAIL_MATRIX,
  type EmailAudience,
  type EmailMatrix,
  type NotificationEventKey,
} from './notification-defaults';

type ClinicPrefs = {
  emailMatrix?: EmailMatrix;
  superadminKillSwitch?: { appointmentEmails?: boolean };
};

type Loader = ((clinicId: string) => Promise<ClinicPrefs>) | null;
let testLoader: Loader = null;

export function __setClinicPrefsLoaderForTest(loader: Loader) {
  testLoader = loader;
}

async function loadPrefs(clinicId: string): Promise<ClinicPrefs> {
  if (testLoader) return testLoader(clinicId);
  const db = getReadDb();
  const [row] = await db
    .select({ notificationPreferences: clinics.notificationPreferences })
    .from(clinics)
    .where(eq(clinics.id, clinicId))
    .limit(1);
  return (row?.notificationPreferences as ClinicPrefs) ?? {};
}

/**
 * The single chokepoint for deciding whether a clinic-operational email
 * should be sent to a given audience. Returns false on any failure
 * (fail-closed). Callers must check this per recipient.
 */
export async function shouldSendEmail(
  clinicId: string | null | undefined,
  event: NotificationEventKey,
  audience: EmailAudience
): Promise<boolean> {
  if (!clinicId) return false;
  try {
    const prefs = await loadPrefs(clinicId);

    // Superadmin kill switch trumps everything for appointment events.
    if (event.startsWith('appointment.') && prefs.superadminKillSwitch?.appointmentEmails) {
      return false;
    }

    const override = prefs.emailMatrix?.[event]?.[audience];
    if (typeof override === 'boolean') return override;

    return DEFAULT_EMAIL_MATRIX[event][audience];
  } catch (err) {
    console.error('[email-gate] failed to load prefs:', err);
    return false;
  }
}
  • Step 4: Run test to verify it passes
Run: cd server && pnpm vitest run src/lib/email-gate.test.ts Expected: PASS (4 tests).
  • Step 5: Commit
git add server/src/lib/email-gate.ts server/src/lib/email-gate.test.ts
git commit -m "feat(notif): shouldSendEmail gate helper with kill switch"

Task 3: Extend NotificationPreferences schema type

Files:
  • Modify: server/src/schema/clinics.ts:94-101
  • Step 1: Edit the type
Replace:
  notificationPreferences: jsonb('notification_preferences').$type<{
    stockAlertEmailEnabled?: boolean;
    eodEmailEnabled?: boolean;
    appointmentRemindersEnabled?: boolean;
    missedAppointmentsEnabled?: boolean;
    feedbackEmailEnabled?: boolean;
    googleReviewUrl?: string;
  }>(),
With:
  notificationPreferences: jsonb('notification_preferences').$type<{
    // New matrix shape — preferred
    emailMatrix?: import('../lib/notification-defaults').EmailMatrix;
    superadminKillSwitch?: { appointmentEmails?: boolean };
    googleReviewUrl?: string;
    // Deprecated flat flags — read-only fallback, dropped one release later
    stockAlertEmailEnabled?: boolean;
    eodEmailEnabled?: boolean;
    appointmentRemindersEnabled?: boolean;
    missedAppointmentsEnabled?: boolean;
    feedbackEmailEnabled?: boolean;
  }>(),
  • Step 2: Verify typecheck
Run: cd server && pnpm typecheck Expected: no new errors. (If import('...').EmailMatrix causes circular import issues, switch to a direct import type { EmailMatrix } from '../lib/notification-defaults' at the top of clinics.ts.)
  • Step 3: Commit
git add server/src/schema/clinics.ts
git commit -m "feat(notif): extend NotificationPreferences with emailMatrix + kill switch"

Task 4: SQL migration — backfill matrix from flat flags

Files:
  • Create: server/drizzle/0051_notification_event_matrix.sql
  • Step 1: Write the migration
-- 0051_notification_event_matrix.sql
-- Backfill clinics.notification_preferences.emailMatrix from existing flat
-- flags. Existing flat flags remain in place for one release as a read
-- fallback; the next migration drops them.

BEGIN;

UPDATE app.clinics
SET notification_preferences = COALESCE(notification_preferences, '{}'::jsonb)
  || jsonb_build_object(
    'emailMatrix', jsonb_build_object(
      'appointment.requested',       jsonb_build_object('patient', true,  'doctor', false, 'admins', false, 'staff', false),
      'appointment.scheduled',       jsonb_build_object('patient', true,  'doctor', true,  'admins', false, 'staff', false),
      'appointment.confirmed',       jsonb_build_object('patient', true,  'doctor', true,  'admins', false, 'staff', false),
      'appointment.completed',       jsonb_build_object('patient', false, 'doctor', false, 'admins', false, 'staff', false),
      'appointment.cancelled',       jsonb_build_object('patient', true,  'doctor', true,  'admins', false, 'staff', false),
      'appointment.rescheduled',     jsonb_build_object('patient', true,  'doctor', true,  'admins', false, 'staff', false),
      'appointment.noshow',          jsonb_build_object('patient', true,  'doctor', false, 'admins', false, 'staff', false),
      'appointment.reminder',        jsonb_build_object(
        'patient', COALESCE((notification_preferences->>'appointmentRemindersEnabled')::boolean, true),
        'doctor', false, 'admins', false, 'staff', false
      ),
      'appointment.missed_followup', jsonb_build_object(
        'patient', COALESCE((notification_preferences->>'missedAppointmentsEnabled')::boolean, true),
        'doctor', false, 'admins', false, 'staff', false
      ),
      'appointment.feedback',        jsonb_build_object(
        'patient', COALESCE((notification_preferences->>'feedbackEmailEnabled')::boolean, true),
        'doctor', false, 'admins', false, 'staff', false
      ),
      'invoice.issued',              jsonb_build_object('patient', true,  'doctor', false, 'admins', true,  'staff', false),
      'quotation.sent',              jsonb_build_object('patient', true,  'doctor', false, 'admins', false, 'staff', false),
      'treatment_plan.created',      jsonb_build_object('patient', true,  'doctor', false, 'admins', false, 'staff', false),
      'treatment_plan.accepted',     jsonb_build_object('patient', true,  'doctor', false, 'admins', true,  'staff', false),
      'inventory.lowstock',          jsonb_build_object(
        'patient', false, 'doctor', false,
        'admins', COALESCE((notification_preferences->>'stockAlertEmailEnabled')::boolean, true),
        'staff', false
      ),
      'inventory.expiry',            jsonb_build_object('patient', false, 'doctor', false, 'admins', true,  'staff', false),
      'eod.report',                  jsonb_build_object(
        'patient', false, 'doctor', false,
        'admins', COALESCE((notification_preferences->>'eodEmailEnabled')::boolean, true),
        'staff', false
      )
    )
  )
WHERE notification_preferences IS NULL
   OR NOT (notification_preferences ? 'emailMatrix');

COMMIT;
  • Step 2: Update drizzle journal
Append a new entry to server/drizzle/meta/_journal.json for this migration. Inspect the existing pattern in that file (it tracks idx, version, when, tag, breakpoints) and follow it. The migration tag must match the filename without the .sql extension: 0051_notification_event_matrix.
  • Step 3: Validate the SQL locally against an empty DB
Run a Postgres syntax check (do NOT run against production):
cd server && pnpm drizzle-kit check:pg
Expected: no errors. SKIP execution against any live tenant. Migration runs in CI/CD per the normal deploy flow.
  • Step 4: Commit
git add server/drizzle/0051_notification_event_matrix.sql server/drizzle/meta/_journal.json
git commit -m "feat(notif): migration 0051 backfill email matrix from flat flags"

Task 5: serverComm type updates

Files:
  • Modify: ui/src/lib/serverComm.ts:106 (the NotificationPreferences interface and the notificationPreferences? shape)
  • Step 1: Read current shape
Run: grep -n "NotificationPreferences\|notificationPreferences" ui/src/lib/serverComm.ts | head -20
  • Step 2: Edit the interface
Locate the existing NotificationPreferences interface (around line 106) and replace it with:
export type EmailAudience = 'patient' | 'doctor' | 'admins' | 'staff';

export type NotificationEventKey =
  | 'appointment.requested' | 'appointment.scheduled' | 'appointment.confirmed'
  | 'appointment.completed' | 'appointment.cancelled' | 'appointment.rescheduled'
  | 'appointment.noshow' | 'appointment.reminder' | 'appointment.missed_followup'
  | 'appointment.feedback' | 'invoice.issued' | 'quotation.sent'
  | 'treatment_plan.created' | 'treatment_plan.accepted'
  | 'inventory.lowstock' | 'inventory.expiry' | 'eod.report';

export type EmailMatrix = {
  [K in NotificationEventKey]?: Partial<Record<EmailAudience, boolean>>;
};

export interface NotificationPreferences {
  emailMatrix?: EmailMatrix;
  superadminKillSwitch?: { appointmentEmails?: boolean };
  googleReviewUrl?: string;
  // Deprecated flat flags retained until backend cleanup release
  stockAlertEmailEnabled?: boolean;
  eodEmailEnabled?: boolean;
  appointmentRemindersEnabled?: boolean;
  missedAppointmentsEnabled?: boolean;
  feedbackEmailEnabled?: boolean;
}
  • Step 3: Verify typecheck
Run: cd ui && pnpm typecheck Expected: any old call sites that reference removed flat flag types still compile because the deprecated fields are preserved as optional. If any compile error appears, address it in the call site (likely NotificationSettings.tsx, which is being rewritten in Task 16 — defer the fix to that task).
  • Step 4: Commit
git add ui/src/lib/serverComm.ts
git commit -m "feat(notif): client types for email matrix"

Phase 2 — Backend enforcement

Task 6: Gate appointment lifecycle dispatcher

Files:
  • Modify: server/src/lib/email.ts:2376-2498 (sendAppointmentStatusEmails)
  • Test: server/src/lib/email-appointment-gate.test.ts (new)
The dispatcher already centralizes per-status routing. We add shouldSendEmail checks per recipient before each send*Email call.
  • Step 1: Write the failing test
// server/src/lib/email-appointment-gate.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { __setClinicPrefsLoaderForTest } from './email-gate';

// Mock the individual send functions so we can assert which ones fired.
vi.mock('./email', async (orig) => {
  const real = await orig<typeof import('./email')>();
  return {
    ...real,
    sendAppointmentScheduledEmail: vi.fn(),
    sendAppointmentRequestedEmail: vi.fn(),
    sendAppointmentConfirmedEmail: vi.fn(),
    sendAppointmentCancellationEmail: vi.fn(),
    sendAppointmentNoShowEmail: vi.fn(),
  };
});

import * as email from './email';

describe('sendAppointmentStatusEmails gating', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    __setClinicPrefsLoaderForTest(async () => ({}));
  });

  it('with kill switch ON: sends no emails for scheduled', async () => {
    __setClinicPrefsLoaderForTest(async () => ({
      superadminKillSwitch: { appointmentEmails: true },
    }));
    await email.sendAppointmentStatusEmails({
      patientEmail: '[email protected]',
      patientName: 'P',
      doctorEmail: '[email protected]',
      doctorName: 'D',
      appointmentDate: '2026-06-01',
      appointmentTime: '10:00:00',
      appointmentType: 'consult',
      status: 'scheduled',
      clinicId: 'c1',
      clinicName: 'Test',
    });
    expect(email.sendAppointmentScheduledEmail).not.toHaveBeenCalled();
  });

  it('with patient=false override: skips patient email, still sends doctor', async () => {
    __setClinicPrefsLoaderForTest(async () => ({
      emailMatrix: { 'appointment.scheduled': { patient: false } },
    }));
    await email.sendAppointmentStatusEmails({
      patientEmail: '[email protected]', patientName: 'P',
      doctorEmail: '[email protected]', doctorName: 'D',
      appointmentDate: '2026-06-01', appointmentTime: '10:00:00',
      appointmentType: 'consult', status: 'scheduled',
      clinicId: 'c1', clinicName: 'Test',
    });
    const calls = (email.sendAppointmentScheduledEmail as any).mock.calls;
    expect(calls.length).toBe(1);
    expect(calls[0][0].email).toBe('[email protected]');
  });
});
  • Step 2: Run the test (expected fail)
Run: cd server && pnpm vitest run src/lib/email-appointment-gate.test.ts Expected: FAIL — dispatcher does not yet gate.
  • Step 3: Add gating to the dispatcher
In server/src/lib/email.ts, at the top of the file near other imports, add:
import { shouldSendEmail } from './email-gate';
Then modify each branch of the switch (status) block in sendAppointmentStatusEmails (starting at line 2399) to gate per recipient. Pattern for each case:
case 'scheduled':
  if (await shouldSendEmail(clinicId, 'appointment.scheduled', 'patient')) {
    await sendAppointmentScheduledEmail({
      email: patientEmail, patientName, doctorName,
      appointmentDate, appointmentTime, appointmentType,
      clinicName, clinicId, location,
      isForDoctor: false,
      confirmUrl: context.confirmUrl, cancelUrl: context.cancelUrl, viewUrl,
    });
  }
  if (doctorEmail && await shouldSendEmail(clinicId, 'appointment.scheduled', 'doctor')) {
    await sendAppointmentScheduledEmail({
      email: doctorEmail, patientName, doctorName,
      appointmentDate, appointmentTime, appointmentType,
      clinicName, clinicId, location,
      isForDoctor: true,
    });
  }
  break;
Apply the same wrap pattern to all other cases: requested'appointment.requested', confirmed'appointment.confirmed', cancelled'appointment.cancelled', no_show'appointment.noshow'. For completed keep the existing no-email behavior. Search for any other sendAppointment*Email direct calls outside this dispatcher — there is at least one in sendAppointmentRescheduledEmail. Find with:
grep -n "sendAppointmentRescheduledEmail(" server/src/routes/ server/src/lib/ 2>/dev/null
For each direct caller (not via the dispatcher), wrap the call in a shouldSendEmail(clinicId, 'appointment.rescheduled', audience) check. Audience = 'patient' if the recipient is the patient, 'doctor' if it’s the doctor.
  • Step 4: Run tests, expect pass
Run: cd server && pnpm vitest run src/lib/email-appointment-gate.test.ts Expected: PASS (2 tests).
  • Step 5: Commit
git add server/src/lib/email.ts server/src/lib/email-appointment-gate.test.ts
git commit -m "feat(notif): gate appointment lifecycle emails through matrix"

Task 7: Gate appointment.reminder cron

Files:
  • Modify: server/src/scheduled/appointment-reminders.ts:159
  • Step 1: Add the matrix check
Open the file and locate the existing flat-flag check at line 159:
if (clinic.notificationPreferences?.appointmentRemindersEnabled === false) continue;
Replace with a matrix-aware check. Import the helper at the top:
import { shouldSendEmail } from '../lib/email-gate';
Replace the check with:
if (!(await shouldSendEmail(clinic.id, 'appointment.reminder', 'patient'))) continue;
  • Step 2: Verify typecheck + existing tests
Run: cd server && pnpm typecheck && pnpm vitest run src/scheduled/ Expected: typecheck passes. If src/scheduled/ has no tests, just check the typecheck.
  • Step 3: Commit
git add server/src/scheduled/appointment-reminders.ts
git commit -m "feat(notif): gate appointment reminder cron through matrix"

Task 8: Gate missed-appointments cron

Files:
  • Modify: server/src/scheduled/missed-appointments.ts:63
  • Step 1: Edit the check
Add import:
import { shouldSendEmail } from '../lib/email-gate';
Replace the existing line:
if (clinic.notificationPreferences?.missedAppointmentsEnabled !== true) continue;
With:
if (!(await shouldSendEmail(clinic.id, 'appointment.missed_followup', 'patient'))) continue;
  • Step 2: Verify typecheck
Run: cd server && pnpm typecheck
  • Step 3: Commit
git add server/src/scheduled/missed-appointments.ts
git commit -m "feat(notif): gate missed-appointment cron through matrix"

Task 9: Gate feedback-email cron

Files:
  • Modify: server/src/scheduled/feedback-email.ts:62
  • Step 1: Edit the check
Add import at top:
import { shouldSendEmail } from '../lib/email-gate';
Locate the existing check in the per-row loop (around line 62):
const prefs = clinic.notificationPreferences;
if (prefs?.feedbackEmailEnabled !== true || !prefs.googleReviewUrl) continue;
Replace with:
const prefs = clinic.notificationPreferences;
if (!prefs?.googleReviewUrl) continue; // Google Review URL is still a hard requirement
if (!(await shouldSendEmail(clinic.id, 'appointment.feedback', 'patient'))) continue;
  • Step 2: Verify typecheck
Run: cd server && pnpm typecheck
  • Step 3: Commit
git add server/src/scheduled/feedback-email.ts
git commit -m "feat(notif): gate feedback cron through matrix"

Task 10: Gate eod-email-report cron

Files:
  • Modify: server/src/scheduled/eod-email-report.ts:200
The existing implementation pre-filters clinics with a SQL WHERE notification_preferences->>'eodEmailEnabled' = 'true'. We replace that with a matrix-aware SQL filter (cheaper than loading every clinic).
  • Step 1: Replace the SQL filter
Locate the query around line 200:
const eligibleClinics = await db.select()
    .from(clinics)
    .where(and(
        sql`${clinics.notificationPreferences}->>'eodEmailEnabled' = 'true'`,
        eq(clinics.isActive, true),
        ne(clinics.subscriptionStatus, 'suspended'),
    ));
Replace the predicate with one that reads the matrix path (and falls back to default = true when not set):
const eligibleClinics = await db.select()
    .from(clinics)
    .where(and(
        // emailMatrix.eod.report.admins, defaulting to true when the path is missing
        sql`COALESCE(
          (${clinics.notificationPreferences}->'emailMatrix'->'eod.report'->>'admins')::boolean,
          true
        ) = true`,
        eq(clinics.isActive, true),
        ne(clinics.subscriptionStatus, 'suspended'),
    ));
The migration ensures every clinic has the matrix populated, so the COALESCE(..., true) is purely defensive.
  • Step 2: Verify typecheck
Run: cd server && pnpm typecheck
  • Step 3: Commit
git add server/src/scheduled/eod-email-report.ts
git commit -m "feat(notif): gate EOD report cron through matrix SQL filter"

Task 11: Gate inventory low-stock route

Files:
  • Modify: server/src/routes/inventory.ts:60
  • Step 1: Edit
Add import at top:
import { shouldSendEmail } from '../lib/email-gate';
Locate the existing check (around line 60):
if (!clinic?.notificationPreferences?.stockAlertEmailEnabled) return;
Replace with:
if (!(await shouldSendEmail(clinic!.id, 'inventory.lowstock', 'admins'))) return;
(Use non-null assertion only if the surrounding code already verified clinic; if not, keep the optional chain and pass clinic?.id — the helper returns false on falsy clinicId.)
  • Step 2: Verify typecheck
Run: cd server && pnpm typecheck
  • Step 3: Commit
git add server/src/routes/inventory.ts
git commit -m "feat(notif): gate low-stock alert through matrix"

Task 12: Gate inventory-expiry-alerts cron (newly gated)

Files:
  • Modify: server/src/scheduled/inventory-expiry-alerts.ts
  • Step 1: Inspect the existing code
Run: grep -n "sendEmailViaZepto\|sendInventoryAlert\|sendExpiry" server/src/scheduled/inventory-expiry-alerts.ts | head -10 Identify the per-clinic loop and the email send call site.
  • Step 2: Add gate before each email send
At the top of the file, add:
import { shouldSendEmail } from '../lib/email-gate';
Wrap each per-clinic send call with:
if (!(await shouldSendEmail(clinic.id, 'inventory.expiry', 'admins'))) continue;
placed immediately before the existing send call inside the loop.
  • Step 3: Verify typecheck
Run: cd server && pnpm typecheck
  • Step 4: Commit
git add server/src/scheduled/inventory-expiry-alerts.ts
git commit -m "feat(notif): gate inventory-expiry alerts through matrix"

Task 13: Gate billing emails (invoice, quotation, installments, receipts)

Files (all sites except Stripe subscription webhook):
  • Modify: server/src/routes/invoices.tsx:646
  • Modify: server/src/routes/receipts.tsx:448
  • Modify: server/src/routes/quotations.tsx:1113
  • Modify: server/src/routes/installments.ts:486 and :625
Do NOT modify server/src/routes/payment.ts:241,443 — those are Stripe subscription webhooks, which are OdontoX system events and intentionally always send.
  • Step 1: Pattern — invoice.issued to patient
At the top of each file, add:
import { shouldSendEmail } from '../lib/email-gate';
For each await sendInvoiceEmail({ ... }) call to the patient (the call sites in invoices.tsx, receipts.tsx, installments.ts), wrap with:
if (await shouldSendEmail(clinicId, 'invoice.issued', 'patient')) {
  await sendInvoiceEmail({ ... });
}
clinicId should already be in scope at each site — verify with a quick read of each. If a site doesn’t have clinicId in scope, lift it from the request context (req.user.clinicId or c.get('clinicId') — match the surrounding pattern). If any site additionally fans the invoice email out to all clinic admins (none in the clinic-operational paths today, but if added in future), gate each admin recipient with audience = 'admins'.
  • Step 2: Pattern — quotation.sent to patient
For the quotations.tsx:1113 call:
if (await shouldSendEmail(clinicId, 'quotation.sent', 'patient')) {
  await sendQuotationEmail({ ... });
}
  • Step 3: Typecheck
Run: cd server && pnpm typecheck Expected: clean.
  • Step 4: Commit
git add server/src/routes/invoices.tsx server/src/routes/receipts.tsx \
        server/src/routes/quotations.tsx server/src/routes/installments.ts
git commit -m "feat(notif): gate billing emails (invoice, quotation, installments) through matrix"

Task 14: Gate treatment-plan emails

Files:
  • Modify: server/src/routes/treatment-plans.ts (around line 843 — patient accepted email, plus the admin notification path)
  • Step 1: Locate all send sites
Run: grep -n "sendTreatmentPlanAcceptedPatient\|sendTreatmentPlanAcceptedAdmin\|sendNewTreatmentPlanEmail" server/src/routes/treatment-plans.ts
  • Step 2: Add gating
At the top of treatment-plans.ts:
import { shouldSendEmail } from '../lib/email-gate';
Wrap the patient call:
if (await shouldSendEmail(clinicId, 'treatment_plan.accepted', 'patient')) {
  await sendTreatmentPlanAcceptedPatient({ ... });
}
Wrap the admin call:
if (await shouldSendEmail(clinicId, 'treatment_plan.accepted', 'admins')) {
  await sendTreatmentPlanAcceptedAdmin({ ... });
}
If sendNewTreatmentPlanEmail is present (the “plan created” notification), wrap it with 'treatment_plan.created' + 'patient'.
  • Step 3: Typecheck
Run: cd server && pnpm typecheck
  • Step 4: Commit
git add server/src/routes/treatment-plans.ts
git commit -m "feat(notif): gate treatment-plan emails through matrix"

Phase 3 — API + audit

Task 15: Update PATCH /clinics/:id to accept matrix + write audit log

Files:
  • Modify: server/src/routes/clinics.ts:825-826
  • Step 1: Find the current handler
Run: sed -n '740,840p' server/src/routes/clinics.ts Identify the request body validation/typing block and the update step. The current PATCH already accepts notificationPreferences as a JSON blob (line 825-826).
  • Step 2: Add validation + audit log
In the PATCH handler, after if (body.notificationPreferences !== undefined) { updateData.notificationPreferences = body.notificationPreferences; }, add (a) validation that the matrix only contains known keys and (b) an audit log entry.
import { recordAuditLog } from '../lib/audit-helper';
import { NOTIFICATION_EVENTS } from '../lib/notification-defaults';

// ... inside the handler, after the existing assignment ...

if (body.notificationPreferences !== undefined) {
  // Lightweight validation: drop unknown event keys defensively
  const incoming = body.notificationPreferences as Record<string, any>;
  if (incoming?.emailMatrix && typeof incoming.emailMatrix === 'object') {
    for (const key of Object.keys(incoming.emailMatrix)) {
      if (!NOTIFICATION_EVENTS.includes(key as any)) {
        delete incoming.emailMatrix[key];
      }
    }
  }
  updateData.notificationPreferences = incoming;

  // Capture the previous matrix for the audit diff. The diff lives inside the
  // existing PATCH transaction context; if the route doesn't have one yet,
  // fetch `before` once at the top of the handler when notificationPreferences
  // is present in the body, and reuse it here.
  const beforeMatrix = beforeRow?.notificationPreferences?.emailMatrix ?? null;
  const afterMatrix = incoming?.emailMatrix ?? null;

  // Fire-and-forget audit log; helper swallows its own errors
  void recordAuditLog({
    clinicId: clinicIdParam,
    actorUserId: req.user.id,
    onBehalfOfUserId: req.user.id,
    impersonation: !!req.impersonation,
    action: 'clinic.notification_preferences.updated',
    entityType: 'clinic',
    entityId: clinicIdParam,
    changes: { before: beforeMatrix, after: afterMatrix },
  });
}
The beforeRow reference assumes the PATCH handler already loads the clinic before applying the update; if not, add a db.select({...}).from(clinics).where(eq(clinics.id, clinicIdParam)).limit(1) immediately before the update. For impersonation context, match the existing pattern in the codebase — the audit helper already accepts actorUserId, onBehalfOfUserId, impersonation separately. If the request comes through the superadmin impersonation middleware, req.user is the impersonated clinic user and the actor is somewhere on c.get('superadmin') or similar — copy whatever pattern the existing audit-logging call sites in clinics.ts use.
  • Step 3: Typecheck
Run: cd server && pnpm typecheck
  • Step 4: Commit
git add server/src/routes/clinics.ts
git commit -m "feat(notif): validate matrix on PATCH + audit-log changes"

Phase 4 — Frontend

Task 16: Admin matrix UI (replace NotificationSettings.tsx)

Files:
  • Replace: ui/src/components/settings/NotificationSettings.tsx (entire file)
  • Step 1: Write the new component
Replace the entire file with a grouped-table layout. Keep the existing TanStack Query loader pattern.
// ui/src/components/settings/NotificationSettings.tsx
import { useEffect, useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { toast } from 'sonner';
import { Bell, Banknote, Calendar, ClipboardList, Package } from 'lucide-react';
import { qk } from '@/lib/queryKeys';
import {
  getCurrentUser,
  getClinicNotificationPrefs,
  updateClinicNotificationPrefs,
  type NotificationPreferences,
  type NotificationEventKey,
  type EmailAudience,
  type EmailMatrix,
} from '@/lib/serverComm';

type EventRow = {
  key: NotificationEventKey;
  label: string;
  audiences: Partial<Record<EmailAudience, 'on' | 'na'>>; // 'na' = greyed-out cell
};

type Group = { title: string; icon: React.ComponentType<{ className?: string }>; rows: EventRow[] };

const GROUPS: Group[] = [
  {
    title: 'Appointment lifecycle',
    icon: Calendar,
    rows: [
      { key: 'appointment.requested',   label: 'Appointment requested',   audiences: { patient: 'on', doctor: 'na', admins: 'on', staff: 'on' } },
      { key: 'appointment.scheduled',   label: 'Appointment scheduled',   audiences: { patient: 'on', doctor: 'on', admins: 'on', staff: 'on' } },
      { key: 'appointment.confirmed',   label: 'Appointment confirmed',   audiences: { patient: 'on', doctor: 'on', admins: 'on', staff: 'on' } },
      { key: 'appointment.completed',   label: 'Appointment completed',   audiences: { patient: 'on', doctor: 'on', admins: 'on', staff: 'on' } },
      { key: 'appointment.cancelled',   label: 'Appointment cancelled',   audiences: { patient: 'on', doctor: 'on', admins: 'on', staff: 'on' } },
      { key: 'appointment.rescheduled', label: 'Appointment rescheduled', audiences: { patient: 'on', doctor: 'on', admins: 'on', staff: 'on' } },
      { key: 'appointment.noshow',      label: 'Patient no-show',         audiences: { patient: 'on', doctor: 'on', admins: 'on', staff: 'on' } },
    ],
  },
  {
    title: 'Reminders & follow-ups (scheduled)',
    icon: Bell,
    rows: [
      { key: 'appointment.reminder',         label: 'Appointment reminder (24/8/4h)', audiences: { patient: 'on', doctor: 'on', admins: 'on', staff: 'on' } },
      { key: 'appointment.missed_followup',  label: 'Missed appointment follow-up',   audiences: { patient: 'on', doctor: 'on', admins: 'on', staff: 'on' } },
      { key: 'appointment.feedback',         label: 'Post-visit feedback request',    audiences: { patient: 'on', doctor: 'na', admins: 'na', staff: 'na' } },
    ],
  },
  {
    title: 'Billing & documents',
    icon: Banknote,
    rows: [
      { key: 'invoice.issued',  label: 'Invoice issued',  audiences: { patient: 'on', doctor: 'na', admins: 'on', staff: 'on' } },
      { key: 'quotation.sent',  label: 'Quotation sent',  audiences: { patient: 'on', doctor: 'na', admins: 'on', staff: 'on' } },
    ],
  },
  {
    title: 'Treatment plans',
    icon: ClipboardList,
    rows: [
      { key: 'treatment_plan.created',  label: 'Treatment plan created',  audiences: { patient: 'on', doctor: 'na', admins: 'on', staff: 'on' } },
      { key: 'treatment_plan.accepted', label: 'Treatment plan accepted', audiences: { patient: 'on', doctor: 'on', admins: 'on', staff: 'on' } },
    ],
  },
  {
    title: 'Inventory & operations',
    icon: Package,
    rows: [
      { key: 'inventory.lowstock', label: 'Low-stock alert',   audiences: { patient: 'na', doctor: 'na', admins: 'on', staff: 'on' } },
      { key: 'inventory.expiry',   label: 'Expiring inventory', audiences: { patient: 'na', doctor: 'na', admins: 'on', staff: 'on' } },
      { key: 'eod.report',         label: 'End-of-day report',  audiences: { patient: 'na', doctor: 'na', admins: 'on', staff: 'on' } },
    ],
  },
];

const AUDIENCE_COLUMNS: { key: EmailAudience; label: string }[] = [
  { key: 'patient', label: 'Patient' },
  { key: 'doctor',  label: 'Assigned doctor' },
  { key: 'admins',  label: 'Clinic admins' },
  { key: 'staff',   label: 'Clinic staff' },
];

const DEFAULTS_FOR_RESET: EmailMatrix = {
  // Same defaults as the server. Keep in sync with server/src/lib/notification-defaults.ts.
  'appointment.requested':       { patient: true,  doctor: false, admins: false, staff: false },
  'appointment.scheduled':       { patient: true,  doctor: true,  admins: false, staff: false },
  'appointment.confirmed':       { patient: true,  doctor: true,  admins: false, staff: false },
  'appointment.completed':       { patient: false, doctor: false, admins: false, staff: false },
  'appointment.cancelled':       { patient: true,  doctor: true,  admins: false, staff: false },
  'appointment.rescheduled':     { patient: true,  doctor: true,  admins: false, staff: false },
  'appointment.noshow':          { patient: true,  doctor: false, admins: false, staff: false },
  'appointment.reminder':        { patient: true,  doctor: false, admins: false, staff: false },
  'appointment.missed_followup': { patient: true,  doctor: false, admins: false, staff: false },
  'appointment.feedback':        { patient: true,  doctor: false, admins: false, staff: false },
  'invoice.issued':              { patient: true,  doctor: false, admins: true,  staff: false },
  'quotation.sent':              { patient: true,  doctor: false, admins: false, staff: false },
  'treatment_plan.created':      { patient: true,  doctor: false, admins: false, staff: false },
  'treatment_plan.accepted':     { patient: true,  doctor: false, admins: true,  staff: false },
  'inventory.lowstock':          { patient: false, doctor: false, admins: true,  staff: false },
  'inventory.expiry':            { patient: false, doctor: false, admins: true,  staff: false },
  'eod.report':                  { patient: false, doctor: false, admins: true,  staff: false },
};

function effectiveCell(matrix: EmailMatrix | undefined, key: NotificationEventKey, aud: EmailAudience): boolean {
  const override = matrix?.[key]?.[aud];
  if (typeof override === 'boolean') return override;
  return DEFAULTS_FOR_RESET[key][aud];
}

export default function NotificationSettings() {
  const [matrix, setMatrix] = useState<EmailMatrix>({});
  const [googleUrl, setGoogleUrl] = useState('');
  const [savingUrl, setSavingUrl] = useState(false);

  const dataQuery = useQuery({
    queryKey: qk.clinic.notificationPrefs(),
    queryFn: async () => {
      const userRes = await getCurrentUser();
      const id = (userRes.user as any).clinicId || (userRes.user as any).primaryClinicId;
      if (!id) return { clinicId: null as string | null, prefs: {} as NotificationPreferences };
      const prefs = await getClinicNotificationPrefs(id);
      return { clinicId: id as string, prefs };
    },
  });
  const clinicId = dataQuery.data?.clinicId ?? null;
  const loading = dataQuery.isLoading;

  useEffect(() => {
    if (dataQuery.data?.prefs) {
      setMatrix(dataQuery.data.prefs.emailMatrix ?? {});
      setGoogleUrl(dataQuery.data.prefs.googleReviewUrl ?? '');
    }
  }, [dataQuery.data]);

  const persist = async (next: EmailMatrix) => {
    if (!clinicId) return;
    setMatrix(next);
    try {
      const prefs: NotificationPreferences = {
        ...(dataQuery.data?.prefs ?? {}),
        emailMatrix: next,
        googleReviewUrl: googleUrl || undefined,
      };
      await updateClinicNotificationPrefs(clinicId, prefs);
    } catch {
      toast.error('Failed to save');
      // Revert on failure
      setMatrix(matrix);
    }
  };

  const toggleCell = (key: NotificationEventKey, aud: EmailAudience) => {
    const current = effectiveCell(matrix, key, aud);
    const next: EmailMatrix = {
      ...matrix,
      [key]: { ...(matrix[key] ?? {}), [aud]: !current },
    };
    void persist(next);
  };

  const silenceColumnAcross = (aud: EmailAudience) => {
    const next: EmailMatrix = { ...matrix };
    for (const group of GROUPS) {
      for (const row of group.rows) {
        if (row.audiences[aud] === 'on') {
          next[row.key] = { ...(next[row.key] ?? {}), [aud]: false };
        }
      }
    }
    void persist(next);
  };

  const resetAll = () => void persist({});

  const saveGoogleUrl = async () => {
    if (!clinicId) return;
    setSavingUrl(true);
    try {
      await updateClinicNotificationPrefs(clinicId, {
        ...(dataQuery.data?.prefs ?? {}),
        emailMatrix: matrix,
        googleReviewUrl: googleUrl.trim() || undefined,
      });
      toast.success('Google Review link saved');
    } catch {
      toast.error('Failed to save link');
    } finally {
      setSavingUrl(false);
    }
  };

  if (loading) return <div className="text-sm text-muted-foreground">Loading…</div>;

  return (
    <div className="space-y-6">
      <div className="flex items-center justify-between">
        <div>
          <h2 className="text-lg font-semibold flex items-center gap-2">
            <Bell className="h-5 w-5" /> Email Notifications
          </h2>
          <p className="text-sm text-muted-foreground mt-1">
            Choose who receives each email by audience. Defaults are tuned to reduce inbox noise.
          </p>
        </div>
        <Button variant="outline" size="sm" onClick={resetAll}>Reset to defaults</Button>
      </div>

      {/* Column-bulk action row */}
      <div className="flex flex-wrap gap-2">
        {AUDIENCE_COLUMNS.map(c => (
          <Button key={c.key} variant="ghost" size="sm" onClick={() => silenceColumnAcross(c.key)}>
            Silence “{c.label}” everywhere
          </Button>
        ))}
      </div>

      {GROUPS.map(group => (
        <Card key={group.title}>
          <CardHeader>
            <CardTitle className="flex items-center gap-2 text-base">
              <group.icon className="h-4 w-4" /> {group.title}
            </CardTitle>
          </CardHeader>
          <CardContent>
            <div className="hidden md:block overflow-x-auto">
              <table className="w-full text-sm">
                <thead className="text-muted-foreground">
                  <tr>
                    <th className="text-left font-normal py-2">Event</th>
                    {AUDIENCE_COLUMNS.map(c => (
                      <th key={c.key} className="text-center font-normal py-2 px-3">{c.label}</th>
                    ))}
                  </tr>
                </thead>
                <tbody>
                  {group.rows.map(row => (
                    <tr key={row.key} className="border-t">
                      <td className="py-3 pr-4">{row.label}</td>
                      {AUDIENCE_COLUMNS.map(c => (
                        <td key={c.key} className="text-center py-3 px-3">
                          {row.audiences[c.key] === 'on' ? (
                            <Checkbox
                              checked={effectiveCell(matrix, row.key, c.key)}
                              onCheckedChange={() => toggleCell(row.key, c.key)}
                              aria-label={`${row.label} to ${c.label}`}
                            />
                          ) : (
                            <span className="text-muted-foreground"></span>
                          )}
                        </td>
                      ))}
                    </tr>
                  ))}
                </tbody>
              </table>
            </div>

            {/* Mobile: per-event accordion */}
            <div className="md:hidden space-y-3">
              {group.rows.map(row => (
                <div key={row.key} className="border rounded-md p-3">
                  <div className="font-medium text-sm mb-2">{row.label}</div>
                  <div className="grid grid-cols-2 gap-2">
                    {AUDIENCE_COLUMNS.map(c => (
                      row.audiences[c.key] === 'on' ? (
                        <label key={c.key} className="flex items-center gap-2 text-sm">
                          <Checkbox
                            checked={effectiveCell(matrix, row.key, c.key)}
                            onCheckedChange={() => toggleCell(row.key, c.key)}
                          />
                          {c.label}
                        </label>
                      ) : null
                    ))}
                  </div>
                </div>
              ))}
            </div>
          </CardContent>
        </Card>
      ))}

      {/* Google Review URL field (related to the feedback row above) */}
      <Card>
        <CardHeader>
          <CardTitle className="text-base">Google Review link</CardTitle>
          <CardDescription>Required for the post-visit feedback email.</CardDescription>
        </CardHeader>
        <CardContent className="flex gap-2">
          <Input value={googleUrl} onChange={e => setGoogleUrl(e.target.value)} placeholder="https://g.page/r/..." />
          <Button onClick={saveGoogleUrl} disabled={savingUrl}>Save</Button>
        </CardContent>
      </Card>
    </div>
  );
}
  • Step 2: Run typecheck + lint
Run: cd ui && pnpm typecheck && pnpm lint Expected: clean (fix any minor Banknote import path or shadcn Checkbox path mismatches — these are codebase-specific).
  • Step 3: Local smoke
Run: cd ui && pnpm dev and verify in the browser:
  • Page loads under Settings → Notifications.
  • Toggling a cell saves (toast / no error).
  • Hard refresh: state persists.
  • “Silence Clinic staff everywhere” sets the staff column off for all editable rows.
  • “Reset to defaults” clears overrides back to baseline.
  • Step 4: Commit
git add ui/src/components/settings/NotificationSettings.tsx
git commit -m "feat(notif): admin notification matrix UI"

Task 17: Superadmin matrix UI + kill switch

Files:
  • Replace: ui/src/components/superadmin/ClinicNotificationsTab.tsx
The superadmin component takes a clinicId prop (verify against the existing component’s signature). Reuse the same matrix UX as Task 16, plus a kill-switch toggle at the top.
  • Step 1: Inspect current component
Run: head -50 ui/src/components/superadmin/ClinicNotificationsTab.tsx Note the prop signature and any superadmin-specific API helper used to load/save prefs (likely getClinicNotificationPrefs(clinicId) / updateClinicNotificationPrefs(clinicId, prefs) — same helpers).
  • Step 2: Write the new component
Replace the file with a component that:
  1. Accepts { clinicId: string } as props.
  2. Loads prefs via getClinicNotificationPrefs(clinicId).
  3. Renders the same grouped tables from Task 16 (factor the table into a shared sub-component if you want — but inline duplication is acceptable for a single follow-on consumer).
  4. Adds a red-bordered “Superadmin kill switch” card at the top:
<Card className="border-red-500/40">
  <CardHeader>
    <CardTitle className="text-base text-red-600">Kill switch (superadmin only)</CardTitle>
    <CardDescription>
      Hard-disables every appointment email regardless of the clinic’s settings.
      Use only when a clinic is generating complaints.
    </CardDescription>
  </CardHeader>
  <CardContent>
    <label className="flex items-center gap-2 text-sm">
      <Switch
        checked={!!prefs.superadminKillSwitch?.appointmentEmails}
        onCheckedChange={(v) =>
          persistKillSwitch({ appointmentEmails: v })
        }
      />
      Force OFF all appointment.* emails
    </label>
  </CardContent>
</Card>
persistKillSwitch reuses updateClinicNotificationPrefs(clinicId, { ...prefs, superadminKillSwitch: next }).
  • Step 3: Typecheck + smoke
Run: cd ui && pnpm typecheck Local smoke: open the superadmin tenant view → Notifications tab, verify matrix loads, toggle kill switch, hard refresh, confirm persists.
  • Step 4: Commit
git add ui/src/components/superadmin/ClinicNotificationsTab.tsx
git commit -m "feat(notif): superadmin matrix + appointment kill switch"

Phase 5 — Wrap-up

Task 18: API docs + smoke + deploy

Files:
  • Modify: docs/api-reference.md
  • Step 1: Update API reference
Add to the existing PATCH /clinics/:id section a notificationPreferences payload example:
{
  "notificationPreferences": {
    "emailMatrix": {
      "appointment.scheduled": { "patient": true, "doctor": true, "admins": false, "staff": false },
      "invoice.issued": { "patient": true, "admins": true }
    },
    "superadminKillSwitch": { "appointmentEmails": false },
    "googleReviewUrl": "https://g.page/r/..."
  }
}
Plus a note: “All flat *Enabled flags are deprecated; new integrations should write the matrix only.”
  • Step 2: Manual end-to-end smoke against ssh & Associates test tenant (dev env)
Following the memory pinned for the test tenant: verify on the dev/staging deployment (NOT production) that:
  1. Notification settings page loads with defaults populated by migration.
  2. Turning off appointment.scheduled → patient blocks the patient email when a new appointment is created via the dev API.
  3. The doctor still receives an email if their cell is ON.
  4. Superadmin kill switch turns OFF all appointment emails regardless of cell state.
  5. Audit log entry appears for the toggle save.
Document any deviations and fix before deploy.
  • Step 3: Use odontox-commit-deploy skill
Per the pinned commit/deploy memory, invoke the odontox-commit-deploy skill to:
  • Run pre-deploy safety checks (typecheck, lint, build).
  • Draft release notes (DO NOT mention superadmin in public release notes — internal only — per pinned memory).
  • Stage to dev, then promote canonical via wrangler pages deployment.
  • Force-promote Cloudflare canonical after the Pages deploy completes.
  • Step 4: Send announcement email
After deploy lands cleanly, send the superadmin announcement email to clinic owners explaining the new defaults and pointing to Settings → Notifications. Voice: first-person plural, no repeated wordmark in body per pinned memory. Subject draft:
“We tightened email notification defaults to cut inbox noise”
Body draft (engineer to refine):
Hi, We have updated email notification defaults across OdontoX to cut down on inbox noise — clinic staff no longer receive automatic emails for every appointment created, rescheduled, or cancelled. Patients and the assigned doctor still receive the emails they need. If you want any of the old behaviour back, you can adjust the matrix yourself under Settings → Notifications. — The OdontoX team
Send via the existing transactional send infrastructure to clinic owners only. Do not send to [email protected] per pinned memory; default test/sandbox target stays [email protected].
  • Step 5: Final commit if any cleanup needed
git add docs/api-reference.md
git commit -m "docs(api): notificationPreferences matrix shape"

Follow-up (not in this plan)

One release later, after the matrix has been live and observed:
  • Drop the deprecated flat flags (stockAlertEmailEnabled, eodEmailEnabled, appointmentRemindersEnabled, missedAppointmentsEnabled, feedbackEmailEnabled) from the TS type and add a migration that removes them from the JSONB column.
  • Consider extending the matrix to gate WhatsApp triggers as a separate whatsappMatrix (currently out of scope).

Self-review notes

Spec coverage: every spec section maps to a task — defaults (Task 1), helper (Task 2), schema extension (Task 3), migration (Task 4), enforcement at every send site listed in the spec (Tasks 6–14), PATCH + audit (Task 15), admin UI (Task 16), superadmin UI + kill switch (Task 17), API docs + rollout + announcement (Task 18). WhatsApp non-goal is explicitly preserved. Type consistency: EmailAudience, NotificationEventKey, EmailMatrix are defined once in notification-defaults.ts and re-exported on the client in serverComm.ts. The PATCH validation in Task 15 uses the same NOTIFICATION_EVENTS constant from Task 1. The defaults table appears twice (Task 1 server, Task 16 client) — intentional duplication called out in the client file with a “keep in sync” comment. Placeholders: none. Every code-changing step has the actual code or the exact command to inspect existing code before writing the change. The deploy step delegates to the odontox-commit-deploy skill per project memory.