Skip to main content

Notification Preferences + Crons — 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: Extend per-clinic notification preferences with three new toggles, add missed-appointment and feedback/review cron jobs, gate WhatsApp toasts behind a module check, and surface notification settings in the superadmin clinic detail page. Architecture: New JSONB fields are additive (no migration). Two new scheduled files (missed-appointments.ts, feedback-email.ts) are registered in scheduled.ts and run at the existing 09:00 UTC cron. feedbackEmailSentAt is added to appointments via ensureAppointmentsSchema. The UI gains a useWhatsApp() hook from a new context, and the superadmin clinic detail page gains a “Notifications” tab backed by the existing PUT clinic endpoint. Tech Stack: Hono, Drizzle ORM, Neon, React, TanStack Query, shadcn/ui

File Map

FileChange
server/src/schema/clinics.tsExtend notificationPreferences JSONB type
server/src/lib/schema-ensure.tsAdd feedbackEmailSentAt DDL to ensureAppointmentsSchema + missed enum value
server/src/scheduled/missed-appointments.tsNew — missed-appointment cron
server/src/scheduled/feedback-email.tsNew — feedback/review email cron
server/src/lib/email.tsAdd sendMissedAppointmentEmail + sendFeedbackReviewEmail
server/src/scheduled.tsRegister two new handlers + guard reminder cron on appointmentRemindersEnabled
ui/src/lib/serverComm.tsExtend NotificationPreferences + Clinic.notificationPreferences interfaces
ui/src/contexts/WhatsAppContext.tsxNewWhatsAppProvider + useWhatsApp()
ui/src/App.tsxWrap app with WhatsAppProvider
ui/src/components/appointments/AppointmentDetailPage.tsxGate WhatsApp fetch/display behind useWhatsApp().enabled
ui/src/components/settings/NotificationSettings.tsxExtend to 5 toggles + Google Review URL input
ui/src/lib/queryKeys.tsAdd qk.clinic.notificationPrefs()
ui/src/components/superadmin/ClinicNotificationsTab.tsxNew — read/write notification prefs in superadmin
ui/src/components/superadmin/ClinicDetailsPage.tsxAdd “Notifications” tab + import

Task 1: Extend TypeScript types for notificationPreferences

Files:
  • Modify: server/src/schema/clinics.ts:82-85
  • Modify: ui/src/lib/serverComm.ts:104-107 (Clinic interface) and 2252-2255 (NotificationPreferences interface)
  • Step 1: Update the Drizzle schema type in server/src/schema/clinics.ts
Replace the existing notificationPreferences JSONB type annotation (lines 82–85):
  notificationPreferences: jsonb('notification_preferences').$type<{
    stockAlertEmailEnabled?: boolean;
    eodEmailEnabled?: boolean;
    appointmentRemindersEnabled?: boolean;
    missedAppointmentsEnabled?: boolean;
    feedbackEmailEnabled?: boolean;
    googleReviewUrl?: string;
  }>(),
  • Step 2: Update the NotificationPreferences interface in ui/src/lib/serverComm.ts
Replace the NotificationPreferences interface (around line 2252):
export interface NotificationPreferences {
  stockAlertEmailEnabled?: boolean;
  eodEmailEnabled?: boolean;
  appointmentRemindersEnabled?: boolean;
  missedAppointmentsEnabled?: boolean;
  feedbackEmailEnabled?: boolean;
  googleReviewUrl?: string;
}
  • Step 3: Update the inline type on the Clinic interface in ui/src/lib/serverComm.ts
Replace the notificationPreferences property (around line 104–107) in the Clinic interface:
  notificationPreferences?: {
    stockAlertEmailEnabled?: boolean;
    eodEmailEnabled?: boolean;
    appointmentRemindersEnabled?: boolean;
    missedAppointmentsEnabled?: boolean;
    feedbackEmailEnabled?: boolean;
    googleReviewUrl?: string;
  } | null;
  • Step 4: Add qk.clinic.notificationPrefs() to the query key factory
In ui/src/lib/queryKeys.ts, add to the clinic section:
  clinic: {
    settings: () => ['clinic', clinicScope(), 'settings'] as const,
    stats: () => ['clinic', clinicScope(), 'stats'] as const,
    notificationPrefs: () => ['clinic', clinicScope(), 'notification-prefs'] as const,
  },
  • Step 5: TypeScript compile check
cd /Users/ssh/Documents/Beta-App/odontoX/server && npx tsc --noEmit 2>&1 | head -30
cd /Users/ssh/Documents/Beta-App/odontoX/ui && npx tsc --noEmit 2>&1 | head -30
Expected: no new type errors.
  • Step 6: Commit
git add server/src/schema/clinics.ts ui/src/lib/serverComm.ts ui/src/lib/queryKeys.ts
git commit -m "feat(notifications): extend notificationPreferences type with 3 new fields + googleReviewUrl"

Task 2: Add feedbackEmailSentAt to appointments schema + missed status

Files:
  • Modify: server/src/lib/schema-ensure.ts
  • Modify: server/src/schema/appointments.ts (add missed to enum for Drizzle type inference)
The appointment status enum appointment_status does not include 'missed'. We need to add it to both the Postgres enum (via ensureAppointmentsSchema) and the Drizzle schema. feedbackEmailSentAt is a new timestamp column for deduplication.
  • Step 1: Add missed to the Drizzle appointmentStatusEnum in server/src/schema/appointments.ts
export const appointmentStatusEnum = pgEnum('appointment_status',
  ['requested', 'scheduled', 'confirmed', 'in_progress', 'completed', 'cancelled', 'no_show', 'missed']);
  • Step 2: Add DDL statements to ensureAppointmentsSchema in server/src/lib/schema-ensure.ts
Inside ensureAppointmentsSchema, before the line appointmentsEnsured = true;, add:
    await db.execute(sql`
      ALTER TYPE app.appointment_status ADD VALUE IF NOT EXISTS 'missed';
    `);
    await db.execute(sql`
      ALTER TABLE app.appointments ADD COLUMN IF NOT EXISTS feedback_email_sent_at timestamp;
    `);
Note: ALTER TYPE ... ADD VALUE IF NOT EXISTS is idempotent in Postgres 9.1+. The IF NOT EXISTS clause prevents errors on re-runs.
  • Step 3: Verify TypeScript compiles
cd /Users/ssh/Documents/Beta-App/odontoX/server && npx tsc --noEmit 2>&1 | head -20
Expected: no new errors.
  • Step 4: Commit
git add server/src/schema/appointments.ts server/src/lib/schema-ensure.ts
git commit -m "feat(notifications): add missed status + feedbackEmailSentAt column to appointments schema-ensure"

Task 3: Add email functions — sendMissedAppointmentEmail and sendFeedbackReviewEmail

Files:
  • Modify: server/src/lib/email.ts (append two new exported functions at end of file)
These follow the same pattern as sendAppointmentReminderEmail: options interface extending BaseEmailOptions, call getClinicBranding, build HTML via render(), send via sendEmailViaZepto.
  • Step 1: Append sendMissedAppointmentEmail to server/src/lib/email.ts
interface SendMissedAppointmentEmailOptions extends BaseEmailOptions {
  email: string;
  patientName: string;
  clinicName: string;
  clinicId: string;
  appointmentDate: string;
  rebookUrl: string;
}

export async function sendMissedAppointmentEmail(options: SendMissedAppointmentEmailOptions): Promise<void> {
  const { email, patientName, clinicName, clinicId, appointmentDate, rebookUrl } = options;
  const branding = await getClinicBranding(clinicId, clinicName);

  const subject = `We missed you — ${clinicName}`;

  const html = `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto;padding:24px;color:#1a1a1a">
  <h2 style="color:#1a1a1a">We missed you, ${patientName}</h2>
  <p>We noticed you weren't able to make your appointment on <strong>${appointmentDate}</strong> at <strong>${clinicName}</strong>.</p>
  <p>No worries — life happens! We'd love to see you soon. Book a new appointment at a time that works for you.</p>
  <p style="margin:32px 0">
    <a href="${rebookUrl}" style="background:#2563eb;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;font-weight:600">Book a New Appointment</a>
  </p>
  <p style="color:#6b7280;font-size:13px">If you have any questions, reply to this email or contact us directly.</p>
  <hr style="border:none;border-top:1px solid #e5e7eb;margin:24px 0">
  <p style="color:#9ca3af;font-size:12px">${branding.name}${branding.address ? ' · ' + branding.address : ''}${branding.phone ? ' · ' + branding.phone : ''}</p>
</body>
</html>`;

  await sendEmailViaZepto(
    [{ email, name: patientName }],
    subject,
    html,
    {
      customApiKey: options.customZeptoApiKey,
      customFromEmail: options.customFromEmail,
      fromName: branding.name,
    }
  );
}
  • Step 2: Append sendFeedbackReviewEmail to server/src/lib/email.ts
interface SendFeedbackReviewEmailOptions extends BaseEmailOptions {
  email: string;
  patientName: string;
  clinicName: string;
  clinicId: string;
  appointmentDate: string;
  googleReviewUrl: string;
}

export async function sendFeedbackReviewEmail(options: SendFeedbackReviewEmailOptions): Promise<void> {
  const { email, patientName, clinicName, clinicId, appointmentDate, googleReviewUrl } = options;
  const branding = await getClinicBranding(clinicId, clinicName);

  const subject = `How was your visit at ${clinicName}?`;

  const html = `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto;padding:24px;color:#1a1a1a">
  <h2 style="color:#1a1a1a">Thank you for visiting us, ${patientName}!</h2>
  <p>We hope your visit on <strong>${appointmentDate}</strong> at <strong>${clinicName}</strong> went well.</p>
  <p>Your feedback helps us improve and helps other patients find us. If you have a moment, we'd really appreciate a quick review.</p>
  <p style="margin:32px 0">
    <a href="${googleReviewUrl}" style="background:#16a34a;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;font-weight:600">Leave a Review on Google</a>
  </p>
  <p style="color:#6b7280;font-size:13px">It only takes 30 seconds and means a lot to our team. Thank you!</p>
  <hr style="border:none;border-top:1px solid #e5e7eb;margin:24px 0">
  <p style="color:#9ca3af;font-size:12px">${branding.name}${branding.address ? ' · ' + branding.address : ''}${branding.phone ? ' · ' + branding.phone : ''}</p>
</body>
</html>`;

  await sendEmailViaZepto(
    [{ email, name: patientName }],
    subject,
    html,
    {
      customApiKey: options.customZeptoApiKey,
      customFromEmail: options.customFromEmail,
      fromName: branding.name,
    }
  );
}
  • Step 3: TypeScript compile check
cd /Users/ssh/Documents/Beta-App/odontoX/server && npx tsc --noEmit 2>&1 | head -20
Expected: no errors.
  • Step 4: Commit
git add server/src/lib/email.ts
git commit -m "feat(notifications): add sendMissedAppointmentEmail and sendFeedbackReviewEmail"

Task 4: New cron — missed-appointments.ts

Files:
  • Create: server/src/scheduled/missed-appointments.ts
Logic: Runs at 09:00 UTC. Finds appointments where status = 'confirmed' OR 'scheduled' and appointmentDate = yesterday PKT. For each, checks notificationPreferences.missedAppointmentsEnabled === true. Sends email to patient (if email exists), sends WhatsApp if module is ON. Marks status 'missed' after notifying. Uses getTxDb for the status update. The existing appointment-reminders.ts has an inline WhatsApp-only missed logic that fired 1 hour after the slot — this new cron is a different, more complete flow (email + WA + status update, run daily). Both can coexist: the existing inline logic sends WA within 1.5 hours post-slot; this new one sends a full day-after follow-up only when missedAppointmentsEnabled is on.
  • Step 1: Create server/src/scheduled/missed-appointments.ts
import { getReadDb, getTxDb } from '../lib/db';
import { appointments, patients, clinics } from '../schema';
import { eq, and, or, inArray } from 'drizzle-orm';
import { decryptPatientPHI } from '../lib/encryption';
import { isWhatsAppConfiguredForClinic, sendMissedAppointment } from '../lib/whatsapp';
import { sendMissedAppointmentEmail } from '../lib/email';
import { formatDatePKT } from '../lib/pkt';

const CLINIC_TIMEZONE = 'Asia/Karachi';

function yesterdayPKT(): string {
  const d = new Date(Date.now() - 24 * 60 * 60 * 1000);
  return formatDatePKT(d);
}

function formatPKTDate(d: Date): string {
  return d.toLocaleDateString('en-US', { timeZone: CLINIC_TIMEZONE, weekday: 'long', month: 'long', day: 'numeric' });
}

function formatPKTTime(d: Date): string {
  return d.toLocaleTimeString('en-US', { timeZone: CLINIC_TIMEZONE, hour: 'numeric', minute: '2-digit', hour12: true });
}

export async function handleMissedAppointments(env: any, ctx: ExecutionContext): Promise<void> {
  console.log('[MissedAppts] Starting missed appointments job:', new Date().toISOString());

  const yesterday = yesterdayPKT();
  console.log(`[MissedAppts] Checking PKT date: ${yesterday}`);

  try {
    const readDb = getReadDb();

    const rows = await readDb.select({
      appointment: appointments,
      patient: patients,
      clinic: clinics,
    })
      .from(appointments)
      .innerJoin(patients, eq(appointments.patientId, patients.id))
      .innerJoin(clinics, eq(appointments.clinicId, clinics.id))
      .where(and(
        eq(appointments.appointmentDate, yesterday),
        or(
          eq(appointments.status, 'confirmed'),
          eq(appointments.status, 'scheduled'),
        ),
      ));

    if (rows.length === 0) {
      console.log('[MissedAppts] No missed appointments found.');
      return;
    }

    console.log(`[MissedAppts] Found ${rows.length} potentially missed appointments.`);

    const { db: txDb, end } = getTxDb();
    ctx.waitUntil(end());

    const appUrl = env.APP_URL || 'https://go.odontox.io';
    let processed = 0;

    for (const { appointment, patient, clinic } of rows) {
      if (clinic.notificationPreferences?.missedAppointmentsEnabled !== true) continue;

      const decPat = decryptPatientPHI(patient as Record<string, any>) as typeof patient & { whatsappPhone?: string | null };
      const appDateTime = new Date(`${appointment.appointmentDate}T${appointment.appointmentTime}Z`);
      const apptDateStr = formatPKTDate(appDateTime);
      const apptTimeStr = formatPKTTime(appDateTime);
      const patientName = `${decPat.firstName} ${decPat.lastName}`;
      const rebookUrl = `${appUrl}/portal`;

      if (decPat.email) {
        try {
          await sendMissedAppointmentEmail({
            email: decPat.email,
            patientName,
            clinicName: clinic.name,
            clinicId: clinic.id,
            appointmentDate: `${apptDateStr} at ${apptTimeStr}`,
            rebookUrl,
            customZeptoApiKey: env.ZEPTO_API_KEY,
            customFromEmail: env.ZEPTO_FROM_EMAIL,
          });
        } catch (err) {
          console.error(`[MissedAppts] Email failed for patient ${patient.id}:`, err);
        }
      }

      const waPhone = decPat.whatsappPhone || null;
      if (waPhone && await isWhatsAppConfiguredForClinic(clinic.id)) {
        try {
          await sendMissedAppointment({
            patientPhone: waPhone,
            patientName,
            clinicName: clinic.name,
            date: apptDateStr,
            time: apptTimeStr,
            clinicId: clinic.id,
            patientId: patient.id,
          });
        } catch (err) {
          console.error(`[MissedAppts] WhatsApp failed for patient ${patient.id}:`, err);
        }
      }

      try {
        await txDb.update(appointments)
          .set({ status: 'missed', updatedAt: new Date() })
          .where(eq(appointments.id, appointment.id));
        processed++;
      } catch (err) {
        console.error(`[MissedAppts] Status update failed for appointment ${appointment.id}:`, err);
      }
    }

    console.log(`[MissedAppts] Processed ${processed} missed appointments.`);
  } catch (err) {
    console.error('[MissedAppts] Fatal error:', err);
  }
}
  • Step 2: TypeScript compile check
cd /Users/ssh/Documents/Beta-App/odontoX/server && npx tsc --noEmit 2>&1 | head -20
Expected: no errors.
  • Step 3: Commit
git add server/src/scheduled/missed-appointments.ts
git commit -m "feat(notifications): add missed-appointments cron handler"

Task 5: New cron — feedback-email.ts

Files:
  • Create: server/src/scheduled/feedback-email.ts
Logic: Runs at 09:00 UTC. Finds appointments where status = 'completed', appointmentDate = 2 days ago PKT, and feedbackEmailSentAt IS NULL. For each clinic, checks feedbackEmailEnabled === true AND googleReviewUrl is set. Sends feedback email. Marks feedbackEmailSentAt to prevent duplicates. Uses getTxDb for the mark-sent update.
  • Step 1: Create server/src/scheduled/feedback-email.ts
import { getReadDb, getTxDb } from '../lib/db';
import { appointments, patients, clinics } from '../schema';
import { eq, and, isNull, sql } from 'drizzle-orm';
import { decryptPatientPHI } from '../lib/encryption';
import { sendFeedbackReviewEmail } from '../lib/email';
import { ensureAppointmentsSchema } from '../lib/schema-ensure';
import { formatDatePKT } from '../lib/pkt';

const CLINIC_TIMEZONE = 'Asia/Karachi';

function twoDaysAgoPKT(): string {
  const d = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
  return formatDatePKT(d);
}

function formatPKTDate(d: Date): string {
  return d.toLocaleDateString('en-US', { timeZone: CLINIC_TIMEZONE, weekday: 'long', month: 'long', day: 'numeric' });
}

function formatPKTTime(d: Date): string {
  return d.toLocaleTimeString('en-US', { timeZone: CLINIC_TIMEZONE, hour: 'numeric', minute: '2-digit', hour12: true });
}

export async function handleFeedbackEmails(env: any, ctx: ExecutionContext): Promise<void> {
  console.log('[FeedbackEmail] Starting feedback email job:', new Date().toISOString());

  const targetDate = twoDaysAgoPKT();
  console.log(`[FeedbackEmail] Checking PKT date: ${targetDate}`);

  try {
    const readDb = getReadDb();

    await ensureAppointmentsSchema(readDb);

    const rows = await readDb.select({
      appointment: appointments,
      patient: patients,
      clinic: clinics,
    })
      .from(appointments)
      .innerJoin(patients, eq(appointments.patientId, patients.id))
      .innerJoin(clinics, eq(appointments.clinicId, clinics.id))
      .where(and(
        eq(appointments.appointmentDate, targetDate),
        eq(appointments.status, 'completed'),
        isNull(sql`${appointments}.feedback_email_sent_at`),
      ));

    if (rows.length === 0) {
      console.log('[FeedbackEmail] No eligible appointments for feedback emails.');
      return;
    }

    console.log(`[FeedbackEmail] Found ${rows.length} appointments to process.`);

    const { db: txDb, end } = getTxDb();
    ctx.waitUntil(end());

    let sent = 0;

    for (const { appointment, patient, clinic } of rows) {
      const prefs = clinic.notificationPreferences;
      if (prefs?.feedbackEmailEnabled !== true || !prefs.googleReviewUrl) continue;

      const decPat = decryptPatientPHI(patient as Record<string, any>) as typeof patient;
      if (!decPat.email) continue;

      const appDateTime = new Date(`${appointment.appointmentDate}T${appointment.appointmentTime}Z`);
      const apptDateStr = formatPKTDate(appDateTime);
      const apptTimeStr = formatPKTTime(appDateTime);
      const patientName = `${decPat.firstName} ${decPat.lastName}`;

      try {
        await sendFeedbackReviewEmail({
          email: decPat.email,
          patientName,
          clinicName: clinic.name,
          clinicId: clinic.id,
          appointmentDate: `${apptDateStr} at ${apptTimeStr}`,
          googleReviewUrl: prefs.googleReviewUrl,
          customZeptoApiKey: env.ZEPTO_API_KEY,
          customFromEmail: env.ZEPTO_FROM_EMAIL,
        });

        await txDb.execute(sql`
          UPDATE app.appointments
          SET feedback_email_sent_at = NOW()
          WHERE id = ${appointment.id}
        `);

        sent++;
      } catch (err) {
        console.error(`[FeedbackEmail] Failed for appointment ${appointment.id}:`, err);
      }
    }

    console.log(`[FeedbackEmail] Sent ${sent} feedback emails.`);
  } catch (err) {
    console.error('[FeedbackEmail] Fatal error:', err);
  }
}
  • Step 2: TypeScript compile check
cd /Users/ssh/Documents/Beta-App/odontoX/server && npx tsc --noEmit 2>&1 | head -20
Expected: no errors.
  • Step 3: Commit
git add server/src/scheduled/feedback-email.ts
git commit -m "feat(notifications): add feedback-email cron handler"

Task 6: Register new crons in scheduled.ts + guard existing reminder cron

Files:
  • Modify: server/src/scheduled.ts
Two changes:
  1. Import and call both new handlers at the 0 9 * * * cron.
  2. Guard handleAppointmentReminders call with appointmentRemindersEnabled !== false — the absence of the key means true (existing behaviour preserved for all current clinics). This guard lives inside handleAppointmentReminders itself is complex; instead we pass the preference check to the handler or do it at the call site in scheduled.ts. Since handleAppointmentReminders currently processes all clinics, the cleanest approach is to add the guard inside appointment-reminders.ts per-appointment (already iterates per clinic).
For the scheduled.ts registration, just add the two new calls alongside the existing 0 9 * * *-compatible block.
  • Step 1: Add imports and register both new handlers in server/src/scheduled.ts
Add to the imports at the top of scheduled.ts:
import { handleMissedAppointments } from './scheduled/missed-appointments';
import { handleFeedbackEmails } from './scheduled/feedback-email';
Add both calls in the scheduled handler body, after handleAppointmentInvoiceGeneration:
            // 8. MISSED APPOINTMENTS + FEEDBACK EMAILS (run on 09:00 UTC cron only)
            if (controller.cron === '0 9 * * *') {
                await handleMissedAppointments(env, ctx);
                await handleFeedbackEmails(env, ctx);
            }
  • Step 2: Guard appointmentRemindersEnabled in appointment-reminders.ts
In server/src/scheduled/appointment-reminders.ts, inside the processWindow loop, after fetching upcomingAppointments, add a per-clinic guard. The query already joins clinics. Add the check inside the for (const item of upcomingAppointments) loop, right after destructuring:
            for (const item of upcomingAppointments) {
                const { appointment, patient, doctor, clinic } = item;
                // appointmentRemindersEnabled defaults to true when absent
                if (clinic.notificationPreferences?.appointmentRemindersEnabled === false) continue;
                // ... rest of existing loop
  • Step 3: TypeScript compile check
cd /Users/ssh/Documents/Beta-App/odontoX/server && npx tsc --noEmit 2>&1 | head -20
Expected: no errors.
  • Step 4: Commit
git add server/src/scheduled.ts server/src/scheduled/appointment-reminders.ts
git commit -m "feat(notifications): register missed-appointments and feedback-email crons; guard reminder cron on appointmentRemindersEnabled"

Task 7: useWhatsApp() hook via context

Files:
  • Create: ui/src/contexts/WhatsAppContext.tsx
  • Modify: ui/src/App.tsx
The WhatsApp config endpoint (/api/v1/protected/whatsapp/config) is behind requireModule('whatsapp_api') — it returns a 403 when the module is off. The provider handles this gracefully: a 403/non-OK response means enabled = false.
  • Step 1: Create ui/src/contexts/WhatsAppContext.tsx
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { fetchWithAuth } from '@/lib/serverComm';

interface WhatsAppContextValue {
  enabled: boolean;
  loading: boolean;
}

const WhatsAppContext = createContext<WhatsAppContextValue>({ enabled: false, loading: true });

export function WhatsAppProvider({ children, clinicId }: { children: ReactNode; clinicId?: string }) {
  const [enabled, setEnabled] = useState(false);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    if (!clinicId) {
      setEnabled(false);
      setLoading(false);
      return;
    }

    fetchWithAuth('/api/v1/protected/whatsapp/config')
      .then(async (res) => {
        if (!res.ok) {
          setEnabled(false);
          return;
        }
        const data = await res.json();
        setEnabled(!!(data.isConfigured ?? data.configured) && !!(data.enabled));
      })
      .catch(() => setEnabled(false))
      .finally(() => setLoading(false));
  }, [clinicId]);

  return (
    <WhatsAppContext.Provider value={{ enabled, loading }}>
      {children}
    </WhatsAppContext.Provider>
  );
}

export function useWhatsApp(): WhatsAppContextValue {
  return useContext(WhatsAppContext);
}
  • Step 2: Wrap the app with WhatsAppProvider in ui/src/App.tsx
In App.tsx, import WhatsAppProvider:
import { WhatsAppProvider } from '@/contexts/WhatsAppContext';
Find where ClinicEventsProvider or ModuleProvider wraps the authenticated app and add WhatsAppProvider inside it, passing the current clinicId. Look for the existing pattern:
<ClinicEventsProvider>
  {/* ... existing providers ... */}
</ClinicEventsProvider>
Wrap authenticated routes with WhatsAppProvider:
<WhatsAppProvider clinicId={user?.clinicId}>
  {/* ... existing authenticated content ... */}
</WhatsAppProvider>
Place it at the same level as ModuleProvider so both are available to child components.
  • Step 3: Gate WhatsApp display in AppointmentDetailPage.tsx
In ui/src/components/appointments/AppointmentDetailPage.tsx, import useWhatsApp:
import { useWhatsApp } from '@/contexts/WhatsAppContext';
Inside the component, add:
const { enabled: whatsappEnabled } = useWhatsApp();
Find any place where WhatsApp messages are fetched or WhatsApp-specific UI is shown (e.g., getMessages({ patientId, type: 'whatsapp' })) and wrap with the guard:
if (whatsappEnabled) {
  getMessages({ patientId, type: 'whatsapp' }).then(msgs => setWaMessages(msgs)).catch(() => {});
}
For any rendered WhatsApp sections, wrap with {whatsappEnabled && (<.../>)}.
  • Step 4: TypeScript compile check
cd /Users/ssh/Documents/Beta-App/odontoX/ui && npx tsc --noEmit 2>&1 | head -20
Expected: no errors.
  • Step 5: Commit
git add ui/src/contexts/WhatsAppContext.tsx ui/src/App.tsx ui/src/components/appointments/AppointmentDetailPage.tsx
git commit -m "feat(notifications): add WhatsAppProvider + useWhatsApp hook; gate WA toasts behind module check"

Task 8: Extend NotificationSettings.tsx to 5 toggles + Google Review URL

Files:
  • Modify: ui/src/components/settings/NotificationSettings.tsx
The current component has 2 toggles (stock alerts, EOD). We extend it with:
  • Section header “Patient Emails” (new section) with 3 new toggles
  • Rename existing section to “Staff Emails”
  • When feedbackEmailEnabled is ON, show a text input for googleReviewUrl
  • Save googleReviewUrl along with the toggle (call updateClinicNotificationPrefs with full prefs including the URL)
The toggle function handles booleans; we need a separate saveUrl function for the text field.
  • Step 1: Rewrite ui/src/components/settings/NotificationSettings.tsx
import { useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { toast } from 'sonner';
import { Bell, Package, FileText, Calendar, UserX, Star } from 'lucide-react';
import { qk } from '@/lib/queryKeys';
import {
  getCurrentUser,
  getClinicNotificationPrefs,
  updateClinicNotificationPrefs,
  type NotificationPreferences,
} from '@/lib/serverComm';

export default function NotificationSettings() {
  const [prefs, setPrefs] = useState<NotificationPreferences>({});
  const [saving, setSaving] = useState<string | null>(null);
  const [urlDraft, setUrlDraft] = useState('');
  const [savingUrl, setSavingUrl] = useState(false);

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

  useEffect(() => {
    if (dataQuery.data?.prefs) {
      setPrefs(dataQuery.data.prefs);
      setUrlDraft(dataQuery.data.prefs.googleReviewUrl ?? '');
    }
  }, [dataQuery.data]);

  const toggle = async (key: keyof NotificationPreferences) => {
    if (!clinicId || typeof prefs[key] === 'string') return;
    const updated = { ...prefs, [key]: !prefs[key] };
    setPrefs(updated);
    setSaving(key);
    try {
      await updateClinicNotificationPrefs(clinicId, updated);
      toast.success('Preference saved');
    } catch {
      setPrefs(prefs);
      toast.error('Failed to save preference');
    } finally {
      setSaving(null);
    }
  };

  const saveGoogleReviewUrl = async () => {
    if (!clinicId) return;
    setSavingUrl(true);
    try {
      const updated = { ...prefs, googleReviewUrl: urlDraft.trim() || undefined };
      await updateClinicNotificationPrefs(clinicId, updated);
      setPrefs(updated);
      toast.success('Google Review link saved');
    } catch {
      toast.error('Failed to save link');
    } finally {
      setSavingUrl(false);
    }
  };

  return (
    <div className="space-y-6">
      <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">
          Opt in or out of automated emails sent on behalf of your clinic.
        </p>
      </div>

      {/* ── Patient Emails ─────────────────────────────────────── */}
      <div>
        <h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">Patient Emails</h3>
        <div className="space-y-3">
          <Card>
            <CardHeader className="pb-3">
              <CardTitle className="text-sm flex items-center gap-2">
                <Calendar className="h-4 w-4" /> Appointment reminders
              </CardTitle>
              <CardDescription className="text-xs">
                We'll remind patients by email 24 hours before their visit. Turn this off only if your clinic handles all reminders manually.
              </CardDescription>
            </CardHeader>
            <CardContent>
              <div className="flex items-center gap-3">
                <Switch
                  id="appointment-reminders"
                  checked={prefs.appointmentRemindersEnabled !== false}
                  disabled={loading || saving === 'appointmentRemindersEnabled'}
                  onCheckedChange={() => toggle('appointmentRemindersEnabled')}
                />
                <Label htmlFor="appointment-reminders" className="text-sm cursor-pointer">
                  {prefs.appointmentRemindersEnabled !== false ? 'Enabled' : 'Disabled'}
                </Label>
              </div>
            </CardContent>
          </Card>

          <Card>
            <CardHeader className="pb-3">
              <CardTitle className="text-sm flex items-center gap-2">
                <UserX className="h-4 w-4" /> Missed visit follow-ups
              </CardTitle>
              <CardDescription className="text-xs">
                If a patient doesn't show up for a confirmed appointment, we'll send them a friendly email the next morning with a link to rebook.
              </CardDescription>
            </CardHeader>
            <CardContent>
              <div className="flex items-center gap-3">
                <Switch
                  id="missed-appointments"
                  checked={!!prefs.missedAppointmentsEnabled}
                  disabled={loading || saving === 'missedAppointmentsEnabled'}
                  onCheckedChange={() => toggle('missedAppointmentsEnabled')}
                />
                <Label htmlFor="missed-appointments" className="text-sm cursor-pointer">
                  {prefs.missedAppointmentsEnabled ? 'Enabled' : 'Disabled'}
                </Label>
              </div>
            </CardContent>
          </Card>

          <Card>
            <CardHeader className="pb-3">
              <CardTitle className="text-sm flex items-center gap-2">
                <Star className="h-4 w-4" /> Post-visit feedback & review request
              </CardTitle>
              <CardDescription className="text-xs">
                Two days after a completed appointment, we'll email the patient a thank-you message and a link to leave a Google review. Requires a Google Review link below.
              </CardDescription>
            </CardHeader>
            <CardContent className="space-y-4">
              <div className="flex items-center gap-3">
                <Switch
                  id="feedback-email"
                  checked={!!prefs.feedbackEmailEnabled}
                  disabled={loading || saving === 'feedbackEmailEnabled'}
                  onCheckedChange={() => toggle('feedbackEmailEnabled')}
                />
                <Label htmlFor="feedback-email" className="text-sm cursor-pointer">
                  {prefs.feedbackEmailEnabled ? 'Enabled' : 'Disabled'}
                </Label>
              </div>
              {prefs.feedbackEmailEnabled && (
                <div className="space-y-2">
                  <Label htmlFor="google-review-url" className="text-xs text-muted-foreground">
                    Google Review linkpaste your clinic's Google review URL here so we can include it in feedback emails
                  </Label>
                  <div className="flex gap-2">
                    <Input
                      id="google-review-url"
                      placeholder="https://g.page/r/..."
                      value={urlDraft}
                      onChange={(e) => setUrlDraft(e.target.value)}
                      className="text-sm"
                    />
                    <Button
                      size="sm"
                      onClick={saveGoogleReviewUrl}
                      disabled={savingUrl || urlDraft.trim() === (prefs.googleReviewUrl ?? '')}
                    >
                      {savingUrl ? 'Saving…' : 'Save'}
                    </Button>
                  </div>
                </div>
              )}
            </CardContent>
          </Card>
        </div>
      </div>

      {/* ── Staff Emails ───────────────────────────────────────── */}
      <div>
        <h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">Staff Emails</h3>
        <div className="space-y-3">
          <Card>
            <CardHeader className="pb-3">
              <CardTitle className="text-sm flex items-center gap-2">
                <Package className="h-4 w-4" /> Low stock alerts
              </CardTitle>
              <CardDescription className="text-xs">
                Email all admins when an item's quantity drops to or below its reorder point. Max one email per item per 24 hours.
              </CardDescription>
            </CardHeader>
            <CardContent>
              <div className="flex items-center gap-3">
                <Switch
                  id="stock-alert"
                  checked={!!prefs.stockAlertEmailEnabled}
                  disabled={loading || saving === 'stockAlertEmailEnabled'}
                  onCheckedChange={() => toggle('stockAlertEmailEnabled')}
                />
                <Label htmlFor="stock-alert" className="text-sm cursor-pointer">
                  {prefs.stockAlertEmailEnabled ? 'Enabled' : 'Disabled'}
                </Label>
              </div>
            </CardContent>
          </Card>

          <Card>
            <CardHeader className="pb-3">
              <CardTitle className="text-sm flex items-center gap-2">
                <FileText className="h-4 w-4" /> Daily close report
              </CardTitle>
              <CardDescription className="text-xs">
                Email all admins a daily end-of-day financial summary with AI analysis and PDF attachment at 9 PM PKT.
              </CardDescription>
            </CardHeader>
            <CardContent>
              <div className="flex items-center gap-3">
                <Switch
                  id="eod-email"
                  checked={!!prefs.eodEmailEnabled}
                  disabled={loading || saving === 'eodEmailEnabled'}
                  onCheckedChange={() => toggle('eodEmailEnabled')}
                />
                <Label htmlFor="eod-email" className="text-sm cursor-pointer">
                  {prefs.eodEmailEnabled ? 'Enabled' : 'Disabled'}
                </Label>
              </div>
            </CardContent>
          </Card>
        </div>
      </div>
    </div>
  );
}
  • Step 2: TypeScript compile check
cd /Users/ssh/Documents/Beta-App/odontoX/ui && npx tsc --noEmit 2>&1 | head -20
Expected: no errors.
  • Step 3: Commit
git add ui/src/components/settings/NotificationSettings.tsx
git commit -m "feat(notifications): extend NotificationSettings to 5 toggles + Google Review URL input"

Task 9: Superadmin clinic notifications tab

Files:
  • Create: ui/src/components/superadmin/ClinicNotificationsTab.tsx
  • Modify: ui/src/components/superadmin/ClinicDetailsPage.tsx
The superadmin tab shows the clinic’s current notification prefs and allows toggling them using the existing PUT clinic endpoint. It uses getClinicDetails + updateClinicNotificationPrefs from serverComm.ts. No new API needed. Uses TanStack Query with qk.superadmin.clinics.detail(clinicId) as the query key (invalidate on save).
  • Step 1: Create ui/src/components/superadmin/ClinicNotificationsTab.tsx
import { useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { toast } from '@/lib/toast';
import { Bell, Calendar, UserX, Star, Package, FileText, MessageSquare } from 'lucide-react';
import {
  getClinicDetails,
  updateClinicNotificationPrefs,
  type NotificationPreferences,
} from '@/lib/serverComm';
import { qk } from '@/lib/queryKeys';

interface ClinicNotificationsTabProps {
  clinicId: string;
}

interface NotifRow {
  key: keyof NotificationPreferences;
  label: string;
  description: string;
  icon: React.ReactNode;
  defaultValue: boolean;
}

const NOTIF_ROWS: NotifRow[] = [
  {
    key: 'appointmentRemindersEnabled',
    label: 'Appointment reminders',
    description: 'Reminder emails 24 h before each appointment. Defaults to ON — present in all existing clinics.',
    icon: <Calendar className="h-4 w-4" />,
    defaultValue: true,
  },
  {
    key: 'missedAppointmentsEnabled',
    label: 'Missed visit follow-ups',
    description: 'Email + WhatsApp the morning after a no-show. Opt-in.',
    icon: <UserX className="h-4 w-4" />,
    defaultValue: false,
  },
  {
    key: 'feedbackEmailEnabled',
    label: 'Feedback & review request',
    description: 'Email 2 days after a completed visit. Requires googleReviewUrl.',
    icon: <Star className="h-4 w-4" />,
    defaultValue: false,
  },
  {
    key: 'stockAlertEmailEnabled',
    label: 'Low stock alerts',
    description: 'Email admins when inventory drops to reorder threshold.',
    icon: <Package className="h-4 w-4" />,
    defaultValue: false,
  },
  {
    key: 'eodEmailEnabled',
    label: 'Daily close report',
    description: 'EOD financial report with AI summary at 9 PM PKT.',
    icon: <FileText className="h-4 w-4" />,
    defaultValue: false,
  },
];

export default function ClinicNotificationsTab({ clinicId }: ClinicNotificationsTabProps) {
  const queryClient = useQueryClient();
  const [saving, setSaving] = useState<string | null>(null);

  const clinicQuery = useQuery({
    queryKey: qk.superadmin.clinics.detail(clinicId),
    queryFn: () => getClinicDetails(clinicId),
    enabled: !!clinicId,
  });

  const prefs: NotificationPreferences = clinicQuery.data?.notificationPreferences ?? {};
  const googleReviewUrl = prefs.googleReviewUrl;

  const toggle = async (row: NotifRow) => {
    if (!clinicId) return;
    const currentValue = prefs[row.key] !== undefined ? !!prefs[row.key] : row.defaultValue;
    const updated: NotificationPreferences = { ...prefs, [row.key]: !currentValue };
    setSaving(row.key);
    try {
      await updateClinicNotificationPrefs(clinicId, updated);
      await queryClient.invalidateQueries({ queryKey: qk.superadmin.clinics.detail(clinicId) });
      toast.success('Saved');
    } catch {
      toast.error('Failed to save');
    } finally {
      setSaving(null);
    }
  };

  if (clinicQuery.isLoading) {
    return <div className="animate-pulse h-40 rounded-lg bg-muted" />;
  }

  return (
    <div className="space-y-6">
      <div>
        <h3 className="text-base font-semibold flex items-center gap-2">
          <Bell className="h-4 w-4" /> Notification Preferences
        </h3>
        <p className="text-sm text-muted-foreground mt-1">
          Superadmin viewchanges take effect on the next cron run.
        </p>
      </div>

      <div className="space-y-3">
        {NOTIF_ROWS.map((row) => {
          const value = prefs[row.key] !== undefined ? !!prefs[row.key] : row.defaultValue;
          return (
            <Card key={row.key}>
              <CardHeader className="pb-2">
                <CardTitle className="text-sm flex items-center gap-2">
                  {row.icon} {row.label}
                </CardTitle>
                <CardDescription className="text-xs">{row.description}</CardDescription>
              </CardHeader>
              <CardContent>
                <div className="flex items-center gap-3">
                  <Switch
                    checked={value}
                    disabled={clinicQuery.isLoading || saving === row.key}
                    onCheckedChange={() => toggle(row)}
                  />
                  <Label className="text-sm">{value ? 'On' : 'Off'}</Label>
                </div>
              </CardContent>
            </Card>
          );
        })}
      </div>

      {/* Google Review URL — read-only summary */}
      <Card>
        <CardHeader className="pb-2">
          <CardTitle className="text-sm flex items-center gap-2">
            <Star className="h-4 w-4" /> Google Review URL
          </CardTitle>
          <CardDescription className="text-xs">
            Set by clinic admin in SettingsNotifications. Required for feedback emails.
          </CardDescription>
        </CardHeader>
        <CardContent>
          {googleReviewUrl ? (
            <a
              href={googleReviewUrl}
              target="_blank"
              rel="noopener noreferrer"
              className="text-sm text-blue-600 underline break-all"
            >
              {googleReviewUrl.length > 60 ? googleReviewUrl.slice(0, 60) + '…' : googleReviewUrl}
            </a>
          ) : (
            <Badge variant="outline" className="text-xs text-muted-foreground">Not set</Badge>
          )}
        </CardContent>
      </Card>

      {/* WhatsApp module status — informational */}
      <Card>
        <CardHeader className="pb-2">
          <CardTitle className="text-sm flex items-center gap-2">
            <MessageSquare className="h-4 w-4" /> WhatsApp module
          </CardTitle>
          <CardDescription className="text-xs">
            Controlled in the Modules tab. Missed-visit WhatsApp messages fire when both module is active and missed-visit follow-ups are on.
          </CardDescription>
        </CardHeader>
        <CardContent>
          <p className="text-xs text-muted-foreground">
            See the Modules tab to enable or disable WhatsApp for this clinic.
          </p>
        </CardContent>
      </Card>
    </div>
  );
}
  • Step 2: Add “Notifications” tab to ClinicDetailsPage.tsx
In ui/src/components/superadmin/ClinicDetailsPage.tsx: Add import at top of file:
import ClinicNotificationsTab from './ClinicNotificationsTab';
Add Bell to the existing lucide-react import line (it may already be present; add if missing):
import { ..., Bell } from 'lucide-react';
Add the TabsTrigger inside <TabsList> after the “settings” trigger (around line 1135):
            <TabsTrigger value="notifications" className="h-8 px-2.5 text-xs sm:h-9 sm:px-3 sm:text-sm flex items-center gap-1.5">
              <Bell className="h-3.5 w-3.5" />
              <span>Notifications</span>
            </TabsTrigger>
Add the TabsContent after the “activity” tab content (at the very end of the <Tabs> block, before </Tabs>):
          <TabsContent value="notifications" className="space-y-6">
            <ClinicNotificationsTab clinicId={clinicId} />
          </TabsContent>
  • Step 3: TypeScript compile check
cd /Users/ssh/Documents/Beta-App/odontoX/ui && npx tsc --noEmit 2>&1 | head -20
Expected: no errors.
  • Step 4: Commit
git add ui/src/components/superadmin/ClinicNotificationsTab.tsx ui/src/components/superadmin/ClinicDetailsPage.tsx
git commit -m "feat(superadmin): add Notifications tab to clinic detail page with per-clinic preference toggles"

Task 10: Build, deploy, smoke-test

Files: None new.
  • Step 1: Full UI build
cd /Users/ssh/Documents/Beta-App/odontoX/ui && npm run build 2>&1 | tail -20
Expected: build succeeds, no TypeScript errors.
  • Step 2: Full server build
cd /Users/ssh/Documents/Beta-App/odontoX/server && npx tsc --noEmit 2>&1 | head -30
Expected: no errors.
  • Step 3: Deploy using odontox-commit-deploy skill
Use superpowers:odontox-commit-deploy to deploy to staging.
  • Step 4: Smoke-test — new cron logic (manual trigger via superadmin CronHelper)
  1. In superadmin → CronHelper, manually trigger the 0 9 * * * cron.
  2. Verify logs show [MissedAppts] and [FeedbackEmail] job lines without fatal errors.
  • Step 5: Smoke-test — NotificationSettings UI
  1. Log into a test clinic admin account.
  2. Go to Settings → Notifications.
  3. Verify 5 toggles appear in two sections.
  4. Toggle “Post-visit feedback & review request” ON — verify Google Review URL input appears.
  5. Enter a URL, click Save — verify toast “Google Review link saved”.
  6. Reload page — verify all values persist.
  • Step 6: Smoke-test — superadmin notifications tab
  1. Go to Superadmin → Clinics → any clinic → Notifications tab.
  2. Verify all 5 toggles are visible with correct current state.
  3. Toggle one preference — verify it saves and reloads correctly.
  • Step 7: Smoke-test — WhatsApp gating
  1. Log into a clinic that does NOT have the WhatsApp module enabled.
  2. Go to Appointments → any appointment.
  3. Verify no WhatsApp-specific fetch calls appear in the Network tab.

Self-Review Checklist

Spec coverage

Spec sectionTask
Extend notificationPreferences JSONB — 3 new fields + googleReviewUrlTask 1
Update TS types in serverComm.ts and clinics.tsTask 1
New cron: Missed Appointments — status=‘missed’, email + optional WATask 4
ensureAppointmentsSchemafeedbackEmailSentAt columnTask 2
New cron: Feedback & Review Email — 2 days post-completionTask 5
Email templates for missed + feedbackTask 3
Admin Notifications UI — 5 toggles + Google Review URL inputTask 8
WhatsApp toast gating — useWhatsApp() hook + gate in AppointmentDetailPageTask 7
Superadmin per-clinic notifications viewTask 9
Register both crons in scheduled.tsTask 6
Guard appointment reminders on appointmentRemindersEnabledTask 6

Key constraints verified

  • feedbackEmailSentAt deduplication: isNull(sql\$.feedback_email_sent_at`)in query +UPDATE SET feedback_email_sent_at = NOW()` after send — no duplicate sends.
  • missedAppointmentsEnabled default is false (opt-in): guard is !== true — absent key skips the clinic. ✓
  • appointmentRemindersEnabled default is true (opt-out): guard is === false — absent key does NOT skip. ✓
  • getTxDb in scheduled tasks: uses ctx.waitUntil(end()) pattern (matching appointment-invoices.ts), not try/finally (which is the HTTP route pattern). ✓
  • getReadDb() for read queries in all cron files. ✓
  • New email functions use sendEmailViaZepto directly with inline HTML — no React Email renderer needed for these simple templates, keeping the bundle lean. ✓
  • WhatsApp /config endpoint is behind requireModule('whatsapp_api') — the WhatsAppProvider handles 403 as enabled = false. ✓
  • Superadmin tab uses qk.superadmin.clinics.detail(clinicId) — no clinicScope() (cross-clinic key). ✓