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
| File | Change |
|---|---|
server/src/schema/clinics.ts | Extend notificationPreferences JSONB type |
server/src/lib/schema-ensure.ts | Add feedbackEmailSentAt DDL to ensureAppointmentsSchema + missed enum value |
server/src/scheduled/missed-appointments.ts | New — missed-appointment cron |
server/src/scheduled/feedback-email.ts | New — feedback/review email cron |
server/src/lib/email.ts | Add sendMissedAppointmentEmail + sendFeedbackReviewEmail |
server/src/scheduled.ts | Register two new handlers + guard reminder cron on appointmentRemindersEnabled |
ui/src/lib/serverComm.ts | Extend NotificationPreferences + Clinic.notificationPreferences interfaces |
ui/src/contexts/WhatsAppContext.tsx | New — WhatsAppProvider + useWhatsApp() |
ui/src/App.tsx | Wrap app with WhatsAppProvider |
ui/src/components/appointments/AppointmentDetailPage.tsx | Gate WhatsApp fetch/display behind useWhatsApp().enabled |
ui/src/components/settings/NotificationSettings.tsx | Extend to 5 toggles + Google Review URL input |
ui/src/lib/queryKeys.ts | Add qk.clinic.notificationPrefs() |
ui/src/components/superadmin/ClinicNotificationsTab.tsx | New — read/write notification prefs in superadmin |
ui/src/components/superadmin/ClinicDetailsPage.tsx | Add “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) and2252-2255(NotificationPreferences interface) -
Step 1: Update the Drizzle schema type in
server/src/schema/clinics.ts
notificationPreferences JSONB type annotation (lines 82–85):
- Step 2: Update the
NotificationPreferencesinterface inui/src/lib/serverComm.ts
NotificationPreferences interface (around line 2252):
- Step 3: Update the inline type on the
Clinicinterface inui/src/lib/serverComm.ts
notificationPreferences property (around line 104–107) in the Clinic interface:
- Step 4: Add
qk.clinic.notificationPrefs()to the query key factory
ui/src/lib/queryKeys.ts, add to the clinic section:
- Step 5: TypeScript compile check
- Step 6: Commit
Task 2: Add feedbackEmailSentAt to appointments schema + missed status
Files:
- Modify:
server/src/lib/schema-ensure.ts - Modify:
server/src/schema/appointments.ts(addmissedto enum for Drizzle type inference)
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
missedto the DrizzleappointmentStatusEnuminserver/src/schema/appointments.ts
- Step 2: Add DDL statements to
ensureAppointmentsSchemainserver/src/lib/schema-ensure.ts
ensureAppointmentsSchema, before the line appointmentsEnsured = true;, add:
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
- Step 4: Commit
Task 3: Add email functions — sendMissedAppointmentEmail and sendFeedbackReviewEmail
Files:
- Modify:
server/src/lib/email.ts(append two new exported functions at end of file)
sendAppointmentReminderEmail: options interface extending BaseEmailOptions, call getClinicBranding, build HTML via render(), send via sendEmailViaZepto.
- Step 1: Append
sendMissedAppointmentEmailtoserver/src/lib/email.ts
- Step 2: Append
sendFeedbackReviewEmailtoserver/src/lib/email.ts
- Step 3: TypeScript compile check
- Step 4: Commit
Task 4: New cron — missed-appointments.ts
Files:
- Create:
server/src/scheduled/missed-appointments.ts
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
- Step 2: TypeScript compile check
- Step 3: Commit
Task 5: New cron — feedback-email.ts
Files:
- Create:
server/src/scheduled/feedback-email.ts
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
- Step 2: TypeScript compile check
- Step 3: Commit
Task 6: Register new crons in scheduled.ts + guard existing reminder cron
Files:
- Modify:
server/src/scheduled.ts
- Import and call both new handlers at the
0 9 * * *cron. - Guard
handleAppointmentReminderscall withappointmentRemindersEnabled !== false— the absence of the key meanstrue(existing behaviour preserved for all current clinics). This guard lives insidehandleAppointmentRemindersitself is complex; instead we pass the preference check to the handler or do it at the call site inscheduled.ts. SincehandleAppointmentReminderscurrently processes all clinics, the cleanest approach is to add the guard insideappointment-reminders.tsper-appointment (already iterates per clinic).
0 9 * * *-compatible block.
- Step 1: Add imports and register both new handlers in
server/src/scheduled.ts
scheduled.ts:
handleAppointmentInvoiceGeneration:
- Step 2: Guard
appointmentRemindersEnabledinappointment-reminders.ts
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:
- Step 3: TypeScript compile check
- Step 4: Commit
Task 7: useWhatsApp() hook via context
Files:
- Create:
ui/src/contexts/WhatsAppContext.tsx - Modify:
ui/src/App.tsx
/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
- Step 2: Wrap the app with
WhatsAppProviderinui/src/App.tsx
App.tsx, import WhatsAppProvider:
ClinicEventsProvider or ModuleProvider wraps the authenticated app and add WhatsAppProvider inside it, passing the current clinicId. Look for the existing pattern:
WhatsAppProvider:
ModuleProvider so both are available to child components.
- Step 3: Gate WhatsApp display in
AppointmentDetailPage.tsx
ui/src/components/appointments/AppointmentDetailPage.tsx, import useWhatsApp:
getMessages({ patientId, type: 'whatsapp' })) and wrap with the guard:
{whatsappEnabled && (<.../>)}.
- Step 4: TypeScript compile check
- Step 5: Commit
Task 8: Extend NotificationSettings.tsx to 5 toggles + Google Review URL
Files:
- Modify:
ui/src/components/settings/NotificationSettings.tsx
- Section header “Patient Emails” (new section) with 3 new toggles
- Rename existing section to “Staff Emails”
- When
feedbackEmailEnabledis ON, show a text input forgoogleReviewUrl - Save
googleReviewUrlalong with the toggle (callupdateClinicNotificationPrefswith full prefs including the URL)
toggle function handles booleans; we need a separate saveUrl function for the text field.
- Step 1: Rewrite
ui/src/components/settings/NotificationSettings.tsx
- Step 2: TypeScript compile check
- Step 3: Commit
Task 9: Superadmin clinic notifications tab
Files:- Create:
ui/src/components/superadmin/ClinicNotificationsTab.tsx - Modify:
ui/src/components/superadmin/ClinicDetailsPage.tsx
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
- Step 2: Add “Notifications” tab to
ClinicDetailsPage.tsx
ui/src/components/superadmin/ClinicDetailsPage.tsx:
Add import at top of file:
Bell to the existing lucide-react import line (it may already be present; add if missing):
TabsTrigger inside <TabsList> after the “settings” trigger (around line 1135):
TabsContent after the “activity” tab content (at the very end of the <Tabs> block, before </Tabs>):
- Step 3: TypeScript compile check
- Step 4: Commit
Task 10: Build, deploy, smoke-test
Files: None new.- Step 1: Full UI build
- Step 2: Full server build
- Step 3: Deploy using odontox-commit-deploy skill
superpowers:odontox-commit-deploy to deploy to staging.
- Step 4: Smoke-test — new cron logic (manual trigger via superadmin CronHelper)
- In superadmin → CronHelper, manually trigger the
0 9 * * *cron. - Verify logs show
[MissedAppts]and[FeedbackEmail]job lines without fatal errors.
- Step 5: Smoke-test — NotificationSettings UI
- Log into a test clinic admin account.
- Go to Settings → Notifications.
- Verify 5 toggles appear in two sections.
- Toggle “Post-visit feedback & review request” ON — verify Google Review URL input appears.
- Enter a URL, click Save — verify toast “Google Review link saved”.
- Reload page — verify all values persist.
- Step 6: Smoke-test — superadmin notifications tab
- Go to Superadmin → Clinics → any clinic → Notifications tab.
- Verify all 5 toggles are visible with correct current state.
- Toggle one preference — verify it saves and reloads correctly.
- Step 7: Smoke-test — WhatsApp gating
- Log into a clinic that does NOT have the WhatsApp module enabled.
- Go to Appointments → any appointment.
- Verify no WhatsApp-specific fetch calls appear in the Network tab.
Self-Review Checklist
Spec coverage
| Spec section | Task |
|---|---|
Extend notificationPreferences JSONB — 3 new fields + googleReviewUrl | Task 1 |
Update TS types in serverComm.ts and clinics.ts | Task 1 |
| New cron: Missed Appointments — status=‘missed’, email + optional WA | Task 4 |
ensureAppointmentsSchema — feedbackEmailSentAt column | Task 2 |
| New cron: Feedback & Review Email — 2 days post-completion | Task 5 |
| Email templates for missed + feedback | Task 3 |
| Admin Notifications UI — 5 toggles + Google Review URL input | Task 8 |
WhatsApp toast gating — useWhatsApp() hook + gate in AppointmentDetailPage | Task 7 |
| Superadmin per-clinic notifications view | Task 9 |
| Register both crons in scheduled.ts | Task 6 |
Guard appointment reminders on appointmentRemindersEnabled | Task 6 |
Key constraints verified
feedbackEmailSentAtdeduplication:isNull(sql\$.feedback_email_sent_at`)in query +UPDATE SET feedback_email_sent_at = NOW()` after send — no duplicate sends.missedAppointmentsEnableddefault isfalse(opt-in): guard is!== true— absent key skips the clinic. ✓appointmentRemindersEnableddefault istrue(opt-out): guard is=== false— absent key does NOT skip. ✓getTxDbin scheduled tasks: usesctx.waitUntil(end())pattern (matchingappointment-invoices.ts), not try/finally (which is the HTTP route pattern). ✓getReadDb()for read queries in all cron files. ✓- New email functions use
sendEmailViaZeptodirectly with inline HTML — no React Email renderer needed for these simple templates, keeping the bundle lean. ✓ - WhatsApp
/configendpoint is behindrequireModule('whatsapp_api')— theWhatsAppProviderhandles 403 asenabled = false. ✓ - Superadmin tab uses
qk.superadmin.clinics.detail(clinicId)— noclinicScope()(cross-clinic key). ✓

