Skip to main content

Spec: Inventory UX Polish, Smart Alerts, SWR Cache, Permission Fix

Date: 2026-04-29
Scope: Four independent features shipped as one branch.

1. SWR Cache — Skeleton under 2 seconds

Problem

Every module navigation triggers fresh API fetches because React unmounts components and discards state. The existing serverComm.ts cache deduplicates in-flight requests but does not persist data between navigations.

Solution

Extend the existing in-memory cache map with TTL + stale-while-revalidate (SWR): Cache entry shape:
{ data: unknown; fetchedAt: number; inFlight: boolean }
TTL: 90 seconds for list/summary endpoints. Configurable per-key constant at top of file. SWR behaviour:
  • On fetchWithAuth hit with a warm cache entry → return cached data immediately, then fire a background refresh (no loading state shown).
  • Background refresh: on success, update cache silently. On 401/compromised-session, dispatch auth:force-logout as today — auth errors always propagate. On any other error (network, 5xx), keep stale data and swallow — no crash.
  • On cache miss (first visit or TTL expired and no stale entry) → fetch normally, skeleton shows once.
  • inFlight flag prevents duplicate concurrent fetches for the same key while stale data is being served.
Cache invalidation: Any mutation (POST/PUT/DELETE) to a resource clears all cache entries whose key prefix matches the resource path (e.g. writing to /inventory/items/... clears /inventory/items and /inventory/summary). Never cached: 401, 403, non-2xx responses (already excluded). Endpoints covered by SWR TTL:
  • /inventory/items, /inventory/summary, /inventory/alerts, /inventory/suppliers
  • /patients
  • /appointments
  • /lab-cases, /laboratories, /lab-services
  • /services
  • /expenses list
Auth safety: The background refresh path goes through the same fetchWithAuth → token refresh → 401 propagation chain. No new error surfaces are introduced.

2. Clinical Notes 403 Fix — Reception Role

Problem

AppointmentDetailPage.tsx:98 and PatientDetails.tsx:~170 both call getClinicalNotesByPatient() unconditionally regardless of role. The render is gated correctly (!isReceptionist && has('clinical.notes.view')) but the fetch fires anyway, generating 403 worker logs and unnecessary error noise. The .catch(() => {}) already prevents crashes/logouts but the call should never happen.

Solution

Wrap both fetch calls with a guard before firing:
  • AppointmentDetailPage.tsx: add if (has('clinical.notes.view')) before the getClinicalNotesByPatient(...) call. The has() hook is already imported.
  • PatientDetails.tsx: add if (!isReceptionist) (already in scope at that line) before the dynamic-import chain that calls getClinicalNotesByPatient.
No schema changes. No new components.

3. Inventory: New Item Full Page + Lab Preset

Problem

“New item” opens a compact modal (InventoryItemDialog). Other modules (patients, lab cases, appointments) use full-page forms. The supplier field is free text — clinics already maintain a laboratory catalog in Settings that should be reused.

Solution

Full-page new/edit: Extend the existing ?item=<uuid> URL-param pattern. When the param is new, render a new InventoryItemFormPage component instead of InventoryItemPage. InventoryPage changes:
  • “New item” button → setSearchParams({ item: 'new' }) instead of opening dialog.
  • “Edit item” in row dropdown → setSearchParams({ item: id, edit: '1' }) (full page, same as create).
  • Both render InventoryItemFormPage when itemId === 'new' or edit === '1'.
InventoryItemFormPage:
  • Extracted form fields from InventoryItemDialog (same fields, same save logic, same validation).
  • Full-page layout: back arrow + page title, two-column grid on desktop, single column on mobile.
  • On save success → setSearchParams({}) (back to list) + toast.
  • On cancel → setSearchParams({}).
Lab/Supplier field: Replace free-text supplier Input with a Select that calls getLaboratories() (already in serverComm) on mount.
  • Shows lab name + (optional) phone.
  • Falls back gracefully: if fetch fails or returns empty, shows a + Add lab in Settings helper link and allows free-text entry as a fallback.
  • The existing InventoryItemDialog modal is removed entirely. Both create and edit use the same full-page form.
InventoryItemDialog — can be deleted once full-page edit is wired up. No other consumers.

4. Smart Alerts: Stock Alert Email + EOD Email

4a. Opt-in Settings Schema

Add a new jsonb column to clinics table:
notificationPreferences: jsonb('notification_preferences').$type<{
  stockAlertEmailEnabled: boolean;    // default false
  eodEmailEnabled: boolean;           // default false
}>()
Drizzle migration required. Default null (treated as all-disabled). Settings UI — two opt-in toggles:
  • Inventory section in Settings → “Email me when stock drops below reorder point” toggle.
  • EOD / Daily Close section in Settings → “Email daily EOD report at 9 PM” toggle.
Both toggles persist via PATCH /clinics/:id (existing endpoint) writing to notificationPreferences.

4b. Stock Alert Email

Trigger: On any stock-write path in inventory.ts (receive, consume, adjust) — after the DB write succeeds, check if item.quantity ≤ item.reorderPoint AND clinic.notificationPreferences.stockAlertEmailEnabled === true. Dedup: Add emailSentAt timestamp column to inventory_alerts table (migration). Only send if no alert email was sent for this item in the last 24 hours (emailSentAt IS NULL OR emailSentAt < NOW() - INTERVAL '24 hours'). Flow:
  1. Stock write completes.
  2. Fetch updated item qty.
  3. If qty ≤ reorderPoint: upsert inventory_alerts row (type: reorder) and check dedup.
  4. If dedup passes: fetch clinic admin email, render StockAlertEmail template, send via ZeptoMail, update emailSentAt.
  5. Fetch all admin users of the clinic (not just primary email), send to each. All in same request, fire-and-forget pattern (don’t await email send, use .catch(console.error) so stock write never fails due to email error).
New email template: server/src/emails/StockAlertEmail.tsx
  • Item name, current qty, reorder point, suggested reorder quantity, clinic branding.
  • CTA: “View Inventory” deep link.

4c. EOD Email with AI Summary + PDF

Cron trigger: Add 0 16 * * * to wrangler.toml (16:00 UTC = 21:00 PKT). This slot is not in the existing set (0 4, 0 9, 0 14, 0 19). New scheduled handler: server/src/scheduled/eod-email-report.ts
handleEODEmailReport(env):
  1. Fetch all clinics where notificationPreferences.eodEmailEnabled = true
  2. For each clinic (parallel):
     a. Get today's date in PKT (YYYY-MM-DD)
     b. Re-use existing EOD report query logic (extract from expenses.ts into a shared helper)
     c. Call DeepSeek AI summary (same prompt as existing eod-report/ai-summary endpoint)
     d. Render EOD PDF server-side using @react-pdf/renderer (existing EODExpenseReportPdf template)
     e. Send EODReportEmail to **all admin users** of the clinic via ZeptoMail with PDF as base64 attachment
     f. Log success/failure per clinic — never throw (one clinic failing must not block others)
New email template: server/src/emails/EODReportEmail.tsx
  • Clinic name, date, AI summary (markdown rendered as plain text sections).
  • Key figures: total revenue, total expenses, net, appointments seen.
  • “View full report in OdontoX” CTA.
  • PDF attached as EOD-Report-YYYY-MM-DD.pdf.
Shared EOD helper: Extract EOD DB query from expenses.ts into server/src/lib/eod-report.ts so both the HTTP route and the scheduled job use identical data-fetching logic. scheduled.ts wiring: Add a new branch for 16:00 cron trigger that calls handleEODEmailReport.

Data migrations required

TableChange
clinicsAdd notification_preferences jsonb column
inventory_alertsAdd email_sent_at timestamp column
Both are additive, nullable — no backfill needed, no downtime risk.

Files touched (summary)

FileChange
ui/src/lib/serverComm.tsSWR cache upgrade
ui/src/components/appointments/AppointmentDetailPage.tsxPermission guard on clinical notes fetch
ui/src/components/patients/PatientDetails.tsxPermission guard on clinical notes fetch
ui/src/components/inventory/InventoryPage.tsxNew item → full-page nav, remove dialog wiring
ui/src/components/inventory/InventoryItemFormPage.tsxNew: full-page create/edit form with lab select
ui/src/components/inventory/InventoryItemDialog.tsxDelete (replaced by full-page form)
ui/src/components/settings/InventorySettings.tsxNew: opt-in toggle for stock alerts
ui/src/components/settings/SettingsModule.tsxRegister InventorySettings section
server/src/schema/clinics.tsAdd notificationPreferences jsonb
server/src/schema/inventory_alerts.tsAdd emailSentAt timestamp
server/src/routes/inventory.tsPost-write stock alert trigger
server/src/routes/expenses.tsExtract EOD query to shared helper
server/src/lib/eod-report.tsNew: shared EOD query helper
server/src/emails/StockAlertEmail.tsxNew: email template
server/src/emails/EODReportEmail.tsxNew: email template
server/src/scheduled/eod-email-report.tsNew: scheduled handler
server/src/scheduled.tsWire 16:00 cron to EOD email handler
server/wrangler.tomlAdd 0 16 * * * cron trigger
server/src/lib/email.tsAdd sendStockAlertEmail, sendEODReportEmail functions

Out of scope

  • Push/in-app notifications for stock alerts (future).
  • Per-staff email routing (currently always goes to clinic admin).
  • EOD email for non-admin roles.
  • React Query migration (deferred).