Skip to main content

Inventory UX Polish, Smart Alerts & SWR Cache 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: Fix a broken cache layer causing 4-7s skeletons, guard a 403-spamming clinical notes fetch, convert the inventory new/edit form to a full page with lab presets from settings, and add opt-in stock alert + EOD report emails. Architecture: SWR (stale-while-revalidate) is added to the existing cache.ts module — the cache was broken because shouldCache was called with the HTTP method instead of the endpoint URL; fixing that + adding background-refresh TTLs is the full change. Inventory form becomes a URL-param-routed full page consistent with the existing ?item=<uuid> pattern. Stock alerts fire synchronously (fire-and-forget) on stock writes. EOD emails run on a new 0 16 * * * cron trigger (16:00 UTC = 21:00 PKT). Tech Stack: React + TypeScript (UI), Hono on Cloudflare Workers (server), Drizzle ORM + Neon PostgreSQL, ZeptoMail + React Email, drizzle-kit push:pg for schema changes.

Task 1: Fix SWR cache — skeleton under 2 seconds

Files:
  • Modify: ui/src/lib/cache.ts
  • Modify: ui/src/lib/serverComm.ts
The root bug: serverComm.ts calls shouldCache(method) (e.g. shouldCache('GET')) but shouldCache checks for URL path patterns — so it always returns false and nothing is ever cached. Fix that plus add SWR two-TTL logic and background refresh.
  • Step 1: Replace cache.ts entirely
// ui/src/lib/cache.ts
const FRESH_TTL = 90_000;       // 90 s — return + no bg refresh
const STALE_TTL = 5 * 60_000;  // 5 min — return stale + bg refresh
const BG_REFRESHES = new Set<string>();

interface CacheEntry { data: unknown; timestamp: number }
const cache = new Map<string, CacheEntry>();
const pendingRequests = new Map<string, Promise<unknown>>();

export function generateCacheKey(endpoint: string, params?: Record<string, unknown>): string {
  if (!params) return endpoint;
  const sorted = Object.keys(params).sort().map(k => `${k}=${JSON.stringify(params[k])}`).join('&');
  return `${endpoint}?${sorted}`;
}

const CACHEABLE = [
  '/api/v1/protected/patients',
  '/api/v1/protected/appointments',
  '/api/v1/protected/clinic/stats',
  '/api/v1/protected/staff',
  '/api/v1/protected/inventory',
  '/api/v1/protected/lab-cases',
  '/api/v1/protected/laboratories',
  '/api/v1/protected/lab-services',
  '/api/v1/protected/services',
  '/api/v1/protected/expenses',
  '/api/v1/protected/suppliers',
];

export function shouldCache(endpoint: string): boolean {
  return CACHEABLE.some(p => endpoint.includes(p));
}

export function getCached<T>(key: string): { data: T; stale: boolean } | null {
  const entry = cache.get(key);
  if (!entry) return null;
  const age = Date.now() - entry.timestamp;
  if (age > STALE_TTL) { cache.delete(key); return null; }
  return { data: entry.data as T, stale: age > FRESH_TTL };
}

export function setCached<T>(key: string, data: T): void {
  cache.set(key, { data, timestamp: Date.now() });
}

export function invalidateCache(pattern?: string): void {
  if (!pattern) { cache.clear(); return; }
  const wildcard = pattern.endsWith('*');
  const base = wildcard ? pattern.slice(0, -1) : pattern;
  for (const key of cache.keys()) {
    if (wildcard ? key.startsWith(base) : key === pattern) cache.delete(key);
  }
}

export function clearCache(): void { cache.clear(); }

export function deduplicateRequest<T>(key: string, fn: () => Promise<T>): Promise<T> {
  const pending = pendingRequests.get(key);
  if (pending) return pending as Promise<T>;
  const promise = fn().finally(() => pendingRequests.delete(key));
  pendingRequests.set(key, promise);
  return promise;
}

export function isBgRefreshing(key: string): boolean { return BG_REFRESHES.has(key); }
export function markBgRefresh(key: string): void    { BG_REFRESHES.add(key); }
export function doneBgRefresh(key: string): void    { BG_REFRESHES.delete(key); }
  • Step 2: Fix the shouldCache(method) bug in serverComm.ts and add SWR background refresh
Find this block in fetchWithAuth (around line 800-830):
const method = options.method || 'GET';
// ...
const useCache = shouldCache(method);   // <-- BUG: passes method not endpoint
Change to:
const method = options.method || 'GET';
// ...
const useCache = shouldCache(endpoint); // FIXED: pass endpoint
Then find the cache-hit return block (around line 811-829):
if (shouldUseCache) {
  const cached = getCached<{ status: number; statusText: string; headers: Record<string, string>; body: string }>(cacheKey);
  if (cached) {
    // Don't serve cached 401/403 responses - they might be stale
    if (cached.status === 401 || cached.status === 403) {
      invalidateCache(cacheKey);
    } else {
      // Return cached response
      return new Response(cached.body, {
        status: cached.status,
        statusText: cached.statusText,
        headers: cached.headers,
      });
    }
  }
}
Replace with:
if (shouldUseCache) {
  const cached = getCached<{ status: number; statusText: string; headers: Record<string, string>; body: string }>(cacheKey);
  if (cached) {
    if (cached.data.status === 401 || cached.data.status === 403) {
      invalidateCache(cacheKey);
      // fall through to fresh fetch
    } else {
      // SWR: if stale, kick off background refresh
      if (cached.stale && !isBgRefreshing(cacheKey)) {
        markBgRefresh(cacheKey);
        void (async () => {
          try {
            const freshHeaders = new Headers({ Authorization: token ? `Bearer ${token}` : '' });
            const bgResp = await fetch(fullUrl, { method: 'GET', headers: freshHeaders, credentials: 'include' });
            if (bgResp.ok) {
              const bgHeaders: Record<string, string> = {};
              bgResp.headers.forEach((v, k) => { bgHeaders[k] = v; });
              const bgBody = await bgResp.text();
              setCached(cacheKey, { status: bgResp.status, statusText: bgResp.statusText, headers: bgHeaders, body: bgBody });
            } else if (bgResp.status === 401) {
              invalidateCache(cacheKey); // let next foreground request handle auth
            }
            // other errors: keep stale data
          } catch { /* network error — keep stale */ } finally {
            doneBgRefresh(cacheKey);
          }
        })();
      }
      return new Response(cached.data.body, {
        status: cached.data.status,
        statusText: cached.data.statusText,
        headers: cached.data.headers,
      });
    }
  }
}
Note: the imports at the top of serverComm.ts already import from ./cache — add isBgRefreshing, markBgRefresh, doneBgRefresh to that import.
  • Step 3: Verify TypeScript compiles
cd /Users/ssh/Documents/Beta-App/odontoX/ui && npx tsc --noEmit
Expected: no errors related to cache.ts or serverComm.ts.
  • Step 4: Commit
git add ui/src/lib/cache.ts ui/src/lib/serverComm.ts
git commit -m "perf(ui): fix broken SWR cache — shouldCache(endpoint) not method, add 90s stale-while-revalidate"

Task 2: Fix clinical notes 403 spam in reception role

Files:
  • Modify: ui/src/components/appointments/AppointmentDetailPage.tsx (line ~101)
  • Modify: ui/src/components/patients/PatientDetails.tsx (line ~169)
  • Step 1: Guard the fetch in AppointmentDetailPage.tsx
The has hook is already imported and has('clinical.notes.view') is already used to gate the render. Apply the same guard to the fetch at line ~101. Find:
getClinicalNotesByPatient(data.patientId).then(notes => setClinicalNotes(notes.sort((a, b) => new Date(b.visitDate).getTime() - new Date(a.visitDate).getTime()))).catch(() => {});
Replace with:
if (has('clinical.notes.view')) {
  getClinicalNotesByPatient(data.patientId)
    .then(notes => setClinicalNotes(notes.sort((a, b) => new Date(b.visitDate).getTime() - new Date(a.visitDate).getTime())))
    .catch(() => {});
}
  • Step 2: Guard the fetch in PatientDetails.tsx
isReceptionist is already in scope. Find the block around line ~167-175:
if (activeTab === 'treatments' || activeTab === 'overview') {
  const [notes, plans] = await Promise.all([
    import('@/lib/serverComm').then(m => m.getClinicalNotesByPatient(patient.id)),
    import('@/lib/serverComm').then(m => m.getTreatmentPlans(patient.id))
  ]);
  setClinicalNotes(notes);
  setTreatmentPlans(plans);
}
Replace with:
if (activeTab === 'treatments' || activeTab === 'overview') {
  const [notes, plans] = await Promise.all([
    isReceptionist
      ? Promise.resolve([])
      : import('@/lib/serverComm').then(m => m.getClinicalNotesByPatient(patient.id)),
    import('@/lib/serverComm').then(m => m.getTreatmentPlans(patient.id))
  ]);
  setClinicalNotes(notes);
  setTreatmentPlans(plans);
}
  • Step 3: Verify TypeScript
cd /Users/ssh/Documents/Beta-App/odontoX/ui && npx tsc --noEmit
  • Step 4: Commit
git add ui/src/components/appointments/AppointmentDetailPage.tsx ui/src/components/patients/PatientDetails.tsx
git commit -m "fix(ui): guard clinical notes fetch behind permission — stops 403 spam for reception role"

Task 3: DB schema additions

Files:
  • Modify: server/src/schema/clinics.ts
  • Modify: server/src/schema/inventory_alerts.ts
  • Step 1: Add notificationPreferences to clinics.ts
Open server/src/schema/clinics.ts. After the existing autoShareDocumentsByEmail field, add:
notificationPreferences: jsonb('notification_preferences').$type<{
  stockAlertEmailEnabled?: boolean;
  eodEmailEnabled?: boolean;
}>(),
The jsonb import is already at the top of the file.
  • Step 2: Add emailSentAt to inventory_alerts.ts
Open server/src/schema/inventory_alerts.ts. After createdAt, add:
emailSentAt: timestamp('email_sent_at'),
  • Step 3: Push schema changes to DB
cd /Users/ssh/Documents/Beta-App/odontoX/server && npm run db:push
Expected: Drizzle confirms the two new columns are added. No existing data is affected (both columns are nullable).
  • Step 4: Verify TypeScript
cd /Users/ssh/Documents/Beta-App/odontoX/server && npx tsc --noEmit
  • Step 5: Commit
git add server/src/schema/clinics.ts server/src/schema/inventory_alerts.ts
git commit -m "feat(schema): add notificationPreferences to clinics, emailSentAt to inventory_alerts"

Task 4: Create InventoryItemFormPage — full-page create/edit form

Files:
  • Create: ui/src/components/inventory/InventoryItemFormPage.tsx
This replaces InventoryItemDialog. Accepts itemId (uuid or 'new') and onBack callback. On create, itemId is 'new'. On edit, itemId is the existing item uuid and the component loads the item data on mount.
  • Step 1: Create the file
// ui/src/components/inventory/InventoryItemFormPage.tsx
import { useEffect, useState } from 'react';
import { ChevronLeft } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Skeleton } from '@/components/ui/skeleton';
import { toast } from 'sonner';
import {
  createInventoryItem,
  updateInventoryItem,
  getInventoryItems,
  getLaboratories,
  type InventoryItem,
  type CreateInventoryItemDto,
  type Laboratory,
} from '@/lib/serverComm';
import { numberOrUndefined, numberOrZero, parseNumberInput, type NumberInputValue } from '@/lib/number-input';

interface Props {
  itemId: 'new' | string;
  onBack: () => void;
}

type FormState = {
  name: string;
  category: string;
  sku: string;
  quantity: NumberInputValue;
  unit: string;
  minStock: NumberInputValue;
  maxStock: NumberInputValue;
  reorderPoint: NumberInputValue;
  reorderQuantity: NumberInputValue;
  unitCost: NumberInputValue;
  supplier: string;
  expiryDate: string;
  location: string;
};

const EMPTY: FormState = {
  name: '', category: 'supplies', sku: '', quantity: '', unit: 'piece',
  minStock: '', maxStock: '', reorderPoint: '', reorderQuantity: '',
  unitCost: '', supplier: '', expiryDate: '', location: '',
};

export default function InventoryItemFormPage({ itemId, onBack }: Props) {
  const isNew = itemId === 'new';
  const [form, setForm] = useState<FormState>(EMPTY);
  const [labs, setLabs] = useState<Laboratory[]>([]);
  const [loadingItem, setLoadingItem] = useState(!isNew);
  const [saving, setSaving] = useState(false);

  // Load labs for supplier dropdown
  useEffect(() => {
    getLaboratories().then(setLabs).catch(() => {});
  }, []);

  // Load existing item for edit
  useEffect(() => {
    if (isNew) return;
    setLoadingItem(true);
    getInventoryItems()
      .then(items => {
        const item = items.find((i: InventoryItem) => i.id === itemId);
        if (!item) { toast.error('Item not found'); onBack(); return; }
        setForm({
          name: item.name || '',
          category: item.category || 'supplies',
          sku: item.sku || '',
          quantity: item.quantity ?? '',
          unit: item.unit || 'piece',
          minStock: item.minStock ?? '',
          maxStock: item.maxStock ?? '',
          reorderPoint: (item.reorderPoint ?? '') as NumberInputValue,
          reorderQuantity: (item.reorderQuantity ?? '') as NumberInputValue,
          unitCost: item.unitCost ? Number(item.unitCost) : '',
          supplier: item.supplier || '',
          expiryDate: item.expiryDate || '',
          location: item.location || '',
        });
      })
      .catch(() => { toast.error('Failed to load item'); onBack(); })
      .finally(() => setLoadingItem(false));
  }, [itemId, isNew, onBack]);

  const set = (key: keyof FormState, value: unknown) =>
    setForm(prev => ({ ...prev, [key]: value }));

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!form.name.trim()) { toast.error('Name is required'); return; }
    try {
      setSaving(true);
      const payload: CreateInventoryItemDto = {
        name: form.name.trim(),
        category: form.category || undefined,
        sku: form.sku.trim() || undefined,
        quantity: numberOrZero(form.quantity),
        unit: form.unit || undefined,
        minStock: numberOrUndefined(form.minStock),
        maxStock: numberOrUndefined(form.maxStock),
        reorderPoint: numberOrUndefined(form.reorderPoint),
        reorderQuantity: numberOrUndefined(form.reorderQuantity),
        unitCost: form.unitCost === '' ? undefined : String(numberOrZero(form.unitCost)),
        supplier: form.supplier.trim() || undefined,
        expiryDate: form.expiryDate.trim() || undefined,
        location: form.location.trim() || undefined,
      };
      if (isNew) {
        await createInventoryItem(payload);
        toast.success('Item added');
      } else {
        await updateInventoryItem(itemId, payload);
        toast.success('Item updated');
      }
      onBack();
    } catch (err) {
      toast.error(err instanceof Error ? err.message : 'Failed to save item');
    } finally {
      setSaving(false);
    }
  };

  if (loadingItem) {
    return (
      <div className="space-y-6">
        <Skeleton className="h-8 w-48" />
        <div className="grid grid-cols-2 gap-4">
          {[1,2,3,4,5,6].map(i => <Skeleton key={i} className="h-16 w-full" />)}
        </div>
      </div>
    );
  }

  return (
    <div className="space-y-6 max-w-3xl">
      <div className="flex items-center gap-3">
        <Button variant="ghost" size="sm" onClick={onBack} className="gap-1 -ml-1">
          <ChevronLeft className="h-4 w-4" />
          Back
        </Button>
        <div>
          <h1 className="text-xl font-bold">{isNew ? 'New inventory item' : 'Edit inventory item'}</h1>
          <p className="text-sm text-muted-foreground">
            {isNew ? 'Add a new item to the clinic inventory.' : 'Update details for this item.'}
          </p>
        </div>
      </div>

      <form onSubmit={handleSubmit} className="space-y-6">
        {/* Row 1 */}
        <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
          <div className="space-y-1.5">
            <Label htmlFor="inv-name" className="text-xs">Item name *</Label>
            <Input id="inv-name" value={form.name} onChange={e => set('name', e.target.value)} required placeholder="e.g., Nitrile Gloves (M)" />
          </div>
          <div className="space-y-1.5">
            <Label htmlFor="inv-sku" className="text-xs">SKU</Label>
            <Input id="inv-sku" value={form.sku} onChange={e => set('sku', e.target.value)} placeholder="Auto-generated if empty" />
          </div>
        </div>

        {/* Row 2 */}
        <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
          <div className="space-y-1.5">
            <Label className="text-xs">Category</Label>
            <Select value={form.category} onValueChange={v => set('category', v)}>
              <SelectTrigger><SelectValue /></SelectTrigger>
              <SelectContent>
                <SelectItem value="supplies">Supplies</SelectItem>
                <SelectItem value="medications">Medications</SelectItem>
                <SelectItem value="equipment">Equipment</SelectItem>
                <SelectItem value="materials">Materials</SelectItem>
              </SelectContent>
            </Select>
          </div>
          <div className="space-y-1.5">
            <Label className="text-xs">Supplier / Lab</Label>
            {labs.length > 0 ? (
              <Select value={form.supplier} onValueChange={v => set('supplier', v)}>
                <SelectTrigger><SelectValue placeholder="Select lab or supplier" /></SelectTrigger>
                <SelectContent>
                  {labs.map(lab => (
                    <SelectItem key={lab.id} value={lab.name}>{lab.name}</SelectItem>
                  ))}
                  <SelectItem value="__other__">Other (type below)</SelectItem>
                </SelectContent>
              </Select>
            ) : (
              <Input
                value={form.supplier}
                onChange={e => set('supplier', e.target.value)}
                placeholder="Supplier name — or add labs in Settings"
              />
            )}
            {labs.length > 0 && form.supplier === '__other__' && (
              <Input
                className="mt-1.5"
                value={''}
                onChange={e => set('supplier', e.target.value)}
                placeholder="Enter supplier name"
                autoFocus
              />
            )}
          </div>
        </div>

        {/* Row 3 */}
        <div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
          <div className="space-y-1.5">
            <Label htmlFor="inv-qty" className="text-xs">Opening quantity *</Label>
            <Input id="inv-qty" type="number" min={0} value={form.quantity}
              onChange={e => set('quantity', parseNumberInput(e.target.value))} required />
          </div>
          <div className="space-y-1.5">
            <Label className="text-xs">Unit</Label>
            <Select value={form.unit} onValueChange={v => set('unit', v)}>
              <SelectTrigger><SelectValue /></SelectTrigger>
              <SelectContent>
                <SelectItem value="piece">Piece</SelectItem>
                <SelectItem value="box">Box</SelectItem>
                <SelectItem value="pack">Pack</SelectItem>
                <SelectItem value="bottle">Bottle</SelectItem>
                <SelectItem value="kit">Kit</SelectItem>
                <SelectItem value="roll">Roll</SelectItem>
              </SelectContent>
            </Select>
          </div>
          <div className="space-y-1.5">
            <Label htmlFor="inv-cost" className="text-xs">Unit cost</Label>
            <Input id="inv-cost" type="number" min={0} step="0.01" value={form.unitCost}
              onChange={e => set('unitCost', parseNumberInput(e.target.value))} />
          </div>
        </div>

        {/* Row 4 — stock thresholds */}
        <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
          <div className="space-y-1.5">
            <Label htmlFor="inv-reorder" className="text-xs">Reorder point</Label>
            <Input id="inv-reorder" type="number" min={0} value={form.reorderPoint}
              onChange={e => set('reorderPoint', parseNumberInput(e.target.value))}
              placeholder="Triggers low-stock alert email" />
          </div>
          <div className="space-y-1.5">
            <Label htmlFor="inv-reorderqty" className="text-xs">Reorder quantity</Label>
            <Input id="inv-reorderqty" type="number" min={0} value={form.reorderQuantity}
              onChange={e => set('reorderQuantity', parseNumberInput(e.target.value))}
              placeholder="Suggested order size" />
          </div>
        </div>

        {/* Row 5 */}
        <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
          <div className="space-y-1.5">
            <Label htmlFor="inv-minstock" className="text-xs">Min stock</Label>
            <Input id="inv-minstock" type="number" min={0} value={form.minStock}
              onChange={e => set('minStock', parseNumberInput(e.target.value))} />
          </div>
          <div className="space-y-1.5">
            <Label htmlFor="inv-maxstock" className="text-xs">Max stock</Label>
            <Input id="inv-maxstock" type="number" min={0} value={form.maxStock}
              onChange={e => set('maxStock', parseNumberInput(e.target.value))} />
          </div>
        </div>

        {/* Row 6 */}
        <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
          <div className="space-y-1.5">
            <Label htmlFor="inv-expiry" className="text-xs">Expiry date</Label>
            <Input id="inv-expiry" type="date" value={form.expiryDate}
              onChange={e => set('expiryDate', e.target.value)} />
          </div>
          <div className="space-y-1.5">
            <Label htmlFor="inv-location" className="text-xs">Storage location</Label>
            <Input id="inv-location" value={form.location}
              onChange={e => set('location', e.target.value)}
              placeholder="e.g., Cabinet A, Shelf 2" />
          </div>
        </div>

        <div className="flex items-center gap-3 pt-2">
          <Button type="submit" disabled={saving}>
            {saving ? 'Saving…' : isNew ? 'Add item' : 'Save changes'}
          </Button>
          <Button type="button" variant="outline" onClick={onBack} disabled={saving}>Cancel</Button>
        </div>
      </form>
    </div>
  );
}
  • Step 2: Verify TypeScript
cd /Users/ssh/Documents/Beta-App/odontoX/ui && npx tsc --noEmit
  • Step 3: Commit
git add ui/src/components/inventory/InventoryItemFormPage.tsx
git commit -m "feat(inventory): create InventoryItemFormPage — full-page create/edit with lab preset dropdown"

Task 5: Wire InventoryPage to full-page nav, delete dialog

Files:
  • Modify: ui/src/components/inventory/InventoryPage.tsx
  • Modify: ui/src/components/inventory/InventoryItemPage.tsx
  • Delete: ui/src/components/inventory/InventoryItemDialog.tsx
  • Step 1: Update InventoryPage.tsx
Remove all InventoryItemDialog state and imports. Replace the dialog open/close handlers with URL param navigation. At the top of the file, remove:
import InventoryItemDialog from './InventoryItemDialog';
// remove: editItem, itemDialogOpen state declarations
Add the new form page import:
import InventoryItemFormPage from './InventoryItemFormPage';
Change the “New item” button handler from:
onClick={() => {
  setEditItem(null);
  setItemDialogOpen(true);
}}
to:
onClick={() => setSearchParams({ item: 'new' })}
Change the “Edit item” dropdown menu item handler from:
onClick={() => {
  setEditItem(item);
  setItemDialogOpen(true);
}}
to:
onClick={(e) => { e.stopPropagation(); setSearchParams({ item: item.id, edit: '1' }); }}
The backToList function already exists (setSearchParams({})). Use it as the onBack for the form page. In the item-routing block at the top of the return, extend the existing check:
// BEFORE
if (itemId) {
  return <InventoryItemPage itemId={itemId} onBack={backToList} />;
}

// AFTER — also handle 'new' and edit flag
const editFlag = searchParams.get('edit');
if (itemId === 'new' || (itemId && editFlag === '1')) {
  return <InventoryItemFormPage itemId={itemId} onBack={backToList} />;
}
if (itemId) {
  return <InventoryItemPage itemId={itemId} onBack={backToList} />;
}
Remove the <InventoryItemDialog ...> JSX and all state related to it (editItem, itemDialogOpen) from the component.
  • Step 2: Remove dialog import from InventoryItemPage.tsx
Remove:
import InventoryItemDialog from './InventoryItemDialog';
Replace the “Edit item” button inside InventoryItemPage with a URL-param navigate. Find where InventoryItemDialog is rendered (there’s an Edit button that opens it) and replace:
// find the Edit button state: itemDialogOpen, editingItem
// replace with:
<Button
  variant="outline"
  size="sm"
  onClick={() => setSearchParams({ item: itemId, edit: '1' })}
>
  <Edit2 className="h-4 w-4 mr-1.5" />
  Edit item
</Button>
Remove all InventoryItemDialog state declarations and the <InventoryItemDialog> JSX from this file. Note: InventoryItemPage receives onBack from its parent. It doesn’t control setSearchParams directly — use the useSearchParams hook already imported, or pass a nav callback. Simplest: import useSearchParams at the top (it’s already used in InventoryPage) and call setSearchParams({ item: itemId, edit: '1' }) directly.
  • Step 3: Delete InventoryItemDialog.tsx
rm /Users/ssh/Documents/Beta-App/odontoX/ui/src/components/inventory/InventoryItemDialog.tsx
  • Step 4: Verify TypeScript — no import errors
cd /Users/ssh/Documents/Beta-App/odontoX/ui && npx tsc --noEmit
Expected: clean. If there are remaining InventoryItemDialog import errors, find and remove them.
  • Step 5: Commit
git add -u ui/src/components/inventory/
git commit -m "feat(inventory): new/edit item uses full page, remove InventoryItemDialog modal"

Task 6: Notification settings UI — opt-in toggles

Files:
  • Create: ui/src/components/settings/NotificationSettings.tsx
  • Modify: ui/src/components/settings/SettingsModule.tsx
  • Modify: ui/src/lib/serverComm.ts (add updateClinicNotificationPrefs function)
  • Step 1: Add updateClinicNotificationPrefs to serverComm.ts
Find where updateClinic or similar PATCH clinic functions are defined (search for PATCH.*clinic or updateClinic). Add:
export interface NotificationPreferences {
  stockAlertEmailEnabled?: boolean;
  eodEmailEnabled?: boolean;
}

export async function updateClinicNotificationPrefs(prefs: NotificationPreferences): Promise<void> {
  const response = await fetchWithAuth('/api/v1/protected/clinic', {
    method: 'PATCH',
    body: JSON.stringify({ notificationPreferences: prefs }),
  });
  if (!response.ok) throw new Error('Failed to update notification preferences');
}

export async function getClinicNotificationPrefs(): Promise<NotificationPreferences> {
  const details = await getClinicDetails();
  return (details as any).notificationPreferences ?? {};
}
  • Step 2: Create NotificationSettings.tsx
// ui/src/components/settings/NotificationSettings.tsx
import { useEffect, useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { toast } from 'sonner';
import { Bell, Package, FileText } from 'lucide-react';
import { getClinicNotificationPrefs, updateClinicNotificationPrefs, type NotificationPreferences } from '@/lib/serverComm';

export default function NotificationSettings() {
  const [prefs, setPrefs] = useState<NotificationPreferences>({});
  const [loading, setLoading] = useState(true);
  const [saving, setSaving] = useState<string | null>(null);

  useEffect(() => {
    getClinicNotificationPrefs()
      .then(setPrefs)
      .catch(() => {})
      .finally(() => setLoading(false));
  }, []);

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

  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 to automated email alerts sent to all clinic admins.
        </p>
      </div>

      <Card>
        <CardHeader className="pb-3">
          <CardTitle className="text-sm flex items-center gap-2">
            <Package className="h-4 w-4" /> Inventory — 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 — EOD report email
          </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 every day.
          </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>
  );
}
  • Step 3: Register in SettingsModule.tsx
Open SettingsModule.tsx. Find where settings sections are defined (there’s an array or switch/object mapping section keys to components). Add:
import NotificationSettings from './NotificationSettings';
Add an entry in the settings sections list (same pattern as other entries like LaboratoryCatalog, CompanySetup):
{ key: 'notifications', label: 'Email Notifications', icon: Bell, component: <NotificationSettings /> }
Add the Bell import from lucide-react if not already present.
  • Step 4: Verify TypeScript
cd /Users/ssh/Documents/Beta-App/odontoX/ui && npx tsc --noEmit
  • Step 5: Commit
git add ui/src/components/settings/NotificationSettings.tsx ui/src/components/settings/SettingsModule.tsx ui/src/lib/serverComm.ts
git commit -m "feat(settings): add opt-in notification preferences UI for stock alerts and EOD emails"

Task 7: Expose notificationPreferences on the clinic PATCH endpoint

Files:
  • Modify: server/src/routes/clinic.ts (or wherever the clinic PATCH endpoint is)
  • Step 1: Find the clinic PATCH handler
grep -rn "PATCH\|patch\|updateClinic\|'clinic'" /Users/ssh/Documents/Beta-App/odontoX/server/src/routes/ --include="*.ts" | grep -i "patch\|update" | head -10
  • Step 2: Add notificationPreferences to the accepted PATCH fields
In the clinic update Zod schema (or wherever the body is validated), add:
notificationPreferences: z.object({
  stockAlertEmailEnabled: z.boolean().optional(),
  eodEmailEnabled: z.boolean().optional(),
}).optional(),
In the Drizzle update call, include it:
...(body.notificationPreferences !== undefined && { notificationPreferences: body.notificationPreferences }),
Also expose the field in the GET clinic endpoint so getClinicNotificationPrefs() can read it. Verify that notificationPreferences is included in the SELECT or is returned by the existing clinic GET query. If the query uses .select() without field specification (returns all columns), it will work automatically.
  • Step 3: Verify TypeScript
cd /Users/ssh/Documents/Beta-App/odontoX/server && npx tsc --noEmit
  • Step 4: Commit
git add server/src/routes/
git commit -m "feat(server): expose notificationPreferences in clinic PATCH/GET"

Task 8: Stock alert email template + send function

Files:
  • Create: server/src/emails/StockAlertEmail.tsx
  • Modify: server/src/lib/email.ts
  • Step 1: Create StockAlertEmail.tsx
// server/src/emails/StockAlertEmail.tsx
import * as React from 'react';
import { Section, Text } from '@react-email/components';
import { BaseLayout, ClinicBranding } from './components/BaseLayout';
import { ClinicButton } from './components/ClinicButton';
import * as s from './components/styles';

interface StockAlertEmailProps {
  branding: ClinicBranding;
  itemName: string;
  currentQty: number;
  reorderPoint: number;
  reorderQuantity?: number;
  unit: string;
  appUrl: string;
}

export function StockAlertEmail({
  branding,
  itemName,
  currentQty,
  reorderPoint,
  reorderQuantity,
  unit,
  appUrl,
}: StockAlertEmailProps) {
  return (
    <BaseLayout branding={branding} previewText={`Low stock alert: ${itemName}`}>
      <Section style={s.section}>
        <Text style={s.h2}>Low Stock Alert</Text>
        <Text style={s.body}>
          <strong>{itemName}</strong> has dropped to {currentQty} {unit}
          {reorderPoint !== undefined ? `, which is at or below the reorder point of ${reorderPoint} ${unit}` : ''}.
        </Text>
        {reorderQuantity && (
          <Text style={s.body}>
            Suggested reorder quantity: <strong>{reorderQuantity} {unit}</strong>
          </Text>
        )}
        <Text style={s.bodyMuted}>
          Review your inventory and place a reorder to avoid running out.
        </Text>
        <ClinicButton href={`${appUrl}?view=inventory`} branding={branding}>
          View Inventory
        </ClinicButton>
      </Section>
    </BaseLayout>
  );
}
  • Step 2: Add sendStockAlertEmail to email.ts
At the end of email.ts, add:
export interface SendStockAlertEmailOptions {
  recipients: { email: string; name: string }[];
  branding: ClinicBranding;
  itemName: string;
  currentQty: number;
  reorderPoint: number;
  reorderQuantity?: number;
  unit: string;
  appUrl: string;
  customZeptoApiKey?: string;
  customFromEmail?: string;
}

export async function sendStockAlertEmail(options: SendStockAlertEmailOptions): Promise<void> {
  const { StockAlertEmail } = await import('../emails/StockAlertEmail');
  const html = await render(
    React.createElement(StockAlertEmail, {
      branding: options.branding,
      itemName: options.itemName,
      currentQty: options.currentQty,
      reorderPoint: options.reorderPoint,
      reorderQuantity: options.reorderQuantity,
      unit: options.unit,
      appUrl: options.appUrl,
    })
  );
  await sendEmailViaZepto(
    options.recipients,
    `Low stock: ${options.itemName}`,
    html,
    { customApiKey: options.customZeptoApiKey, customFromEmail: options.customFromEmail }
  );
}
Note: render and React are already imported at the top of email.ts. ClinicBranding is already imported.
  • Step 3: Verify TypeScript
cd /Users/ssh/Documents/Beta-App/odontoX/server && npx tsc --noEmit
  • Step 4: Commit
git add server/src/emails/StockAlertEmail.tsx server/src/lib/email.ts
git commit -m "feat(email): add StockAlertEmail template and sendStockAlertEmail function"

Task 9: Stock alert trigger in inventory route

Files:
  • Modify: server/src/routes/inventory.ts
After every stock-write operation (receive, consume, adjust), check if quantity ≤ reorderPoint and fire an alert email.
  • Step 1: Add a shared maybeFireStockAlert helper at the top of inventory.ts
After the imports, add:
import { sendStockAlertEmail } from '../lib/email';
import { userClinicAssignments, users } from '../schema';
import { ne } from 'drizzle-orm';

async function maybeFireStockAlert(
  db: Awaited<ReturnType<typeof getDatabase>>,
  clinicId: string,
  itemId: string,
  env: any
): Promise<void> {
  try {
    // Fetch updated item
    const [item] = await db.select()
      .from(inventoryItems)
      .where(and(eq(inventoryItems.id, itemId), eq(inventoryItems.clinicId, clinicId)))
      .limit(1);

    if (!item || item.reorderPoint === null || item.reorderPoint === undefined) return;
    if (item.quantity > item.reorderPoint) return;

    // Check clinic opt-in
    const [clinic] = await db.select({ notificationPreferences: clinics.notificationPreferences, name: clinics.name, subdomain: clinics.subdomain })
      .from(clinics)
      .where(eq(clinics.id, clinicId))
      .limit(1);

    if (!clinic?.notificationPreferences?.stockAlertEmailEnabled) return;

    // Dedup: check if alert email sent in last 24h for this item
    const [existingAlert] = await db.select({ emailSentAt: inventoryAlerts.emailSentAt })
      .from(inventoryAlerts)
      .where(and(
        eq(inventoryAlerts.clinicId, clinicId),
        eq(inventoryAlerts.itemId, itemId),
        eq(inventoryAlerts.alertType, 'reorder'),
      ))
      .orderBy(desc(inventoryAlerts.createdAt))
      .limit(1);

    const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
    if (existingAlert?.emailSentAt && new Date(existingAlert.emailSentAt) > twentyFourHoursAgo) return;

    // Upsert alert record
    await db.insert(inventoryAlerts).values({
      clinicId,
      itemId,
      alertType: 'reorder',
      message: `${item.name} quantity (${item.quantity}) is at or below reorder point (${item.reorderPoint})`,
      severity: 'warning',
      emailSentAt: new Date(),
    }).onConflictDoNothing();

    // Fetch all admin users of this clinic
    const adminRows = await db.select({ email: users.email, firstName: users.firstName, lastName: users.lastName })
      .from(userClinicAssignments)
      .innerJoin(users, eq(userClinicAssignments.userId, users.id))
      .where(and(
        eq(userClinicAssignments.clinicId, clinicId),
        eq(userClinicAssignments.role, 'admin'),
        eq(userClinicAssignments.status, 'active'),
      ));

    const recipients = adminRows
      .filter(r => r.email)
      .map(r => ({ email: r.email!, name: `${r.firstName ?? ''} ${r.lastName ?? ''}`.trim() || r.email! }));

    if (recipients.length === 0) return;

    const appUrl = clinic.subdomain
      ? `https://${clinic.subdomain}.odontox.io`
      : 'https://app.odontox.io';

    await sendStockAlertEmail({
      recipients,
      branding: { clinicName: clinic.name ?? 'Your Clinic' },
      itemName: item.name,
      currentQty: item.quantity,
      reorderPoint: item.reorderPoint,
      reorderQuantity: item.reorderQuantity ?? undefined,
      unit: item.unit ?? 'units',
      appUrl,
      customZeptoApiKey: env?.ZEPTO_API_KEY,
      customFromEmail: env?.ZEPTO_FROM_EMAIL,
    });

    console.log(`📦 Stock alert email sent for item: ${item.name} (qty: ${item.quantity})`);
  } catch (err) {
    console.error('Stock alert email failed (non-fatal):', err);
  }
}
Note: clinics and inventoryAlerts are already imported. Add users and userClinicAssignments imports. desc from drizzle-orm is already imported.
  • Step 2: Call maybeFireStockAlert after each stock write
In the receive stock handler, after the db.update completes and before return c.json(...):
void maybeFireStockAlert(db, currentClinicId, itemId, c.env);
Do the same in the consume and adjust handlers. The void ensures the fire-and-forget pattern — the stock write never waits for or fails due to the email. Get c.env — in Hono on CF Workers, the env bindings are on c.env. Verify this works by checking how other routes access env (e.g. const env = c.env as any;).
  • Step 3: Verify TypeScript
cd /Users/ssh/Documents/Beta-App/odontoX/server && npx tsc --noEmit
  • Step 4: Commit
git add server/src/routes/inventory.ts
git commit -m "feat(inventory): fire stock alert email on stock write when qty <= reorderPoint"

Task 10: Extract EOD report data helper

Files:
  • Create: server/src/lib/eod-report.ts
  • Modify: server/src/routes/expenses.ts
The EOD route builds a large parallel query. Extract it into a shared helper so the scheduled job can use the same logic.
  • Step 1: Create server/src/lib/eod-report.ts
Copy the query logic from expenses.ts lines ~110-360 (the eod-report GET handler body) into a new exported function:
// server/src/lib/eod-report.ts
import { getDatabase } from './db';
import { getDatabaseUrl } from './env';
import * as schema from '../schema';
import { eq, and, gte, lt, desc } from 'drizzle-orm';
import { sql } from 'drizzle-orm';

export interface EODReportData {
  date: string;
  clinicId: string;
  // mirror the shape returned by the existing endpoint
  dayExpenses: any[];
  payrollRuns: any[];
  todayReceipts: any[];
  todayInvoices: any[];
  todayAppointments: any[];
  arData: any[];
}

export async function fetchEODReportData(clinicId: string, date: string): Promise<EODReportData> {
  const databaseUrl = getDatabaseUrl();
  const db = await getDatabase(databaseUrl);

  const dayStart = new Date(`${date}T00:00:00.000Z`);
  const dayEnd   = new Date(`${date}T24:00:00.000Z`);

  // Paste the exact Promise.all query from expenses.ts here (lines ~126-360)
  // Replace currentClinicId with the clinicId parameter
  const [dayExpenses, payrollRuns, todayReceipts, todayInvoices, todayAppointments, arData] =
    await Promise.all([
      // ... exact queries from expenses.ts eod-report handler
    ]);

  return { date, clinicId, dayExpenses, payrollRuns, todayReceipts, todayInvoices, todayAppointments, arData };
}
The exact query bodies should be copy-pasted verbatim from the existing expenses.ts eod-report handler, substituting currentClinicIdclinicId.
  • Step 2: Update expenses.ts to call the helper
In the expenses.get('/eod-report', ...) handler, replace the inline Promise.all with:
import { fetchEODReportData } from '../lib/eod-report';
// ...
const reportData = await fetchEODReportData(currentClinicId, date);
const { dayExpenses, payrollRuns, todayReceipts, todayInvoices, todayAppointments, arData } = reportData;
// rest of the handler unchanged
  • Step 3: Verify TypeScript
cd /Users/ssh/Documents/Beta-App/odontoX/server && npx tsc --noEmit
  • Step 4: Commit
git add server/src/lib/eod-report.ts server/src/routes/expenses.ts
git commit -m "refactor(server): extract EOD report query into shared eod-report.ts helper"

Task 11: EOD report email template + send function

Files:
  • Create: server/src/emails/EODReportEmail.tsx
  • Modify: server/src/lib/email.ts
  • Step 1: Create EODReportEmail.tsx
// server/src/emails/EODReportEmail.tsx
import * as React from 'react';
import { Section, Text, Hr } from '@react-email/components';
import { BaseLayout, ClinicBranding } from './components/BaseLayout';
import { ClinicButton } from './components/ClinicButton';
import * as s from './components/styles';

interface EODReportEmailProps {
  branding: ClinicBranding;
  date: string;           // e.g. "2026-04-29"
  aiSummary: string;      // markdown text from DeepSeek
  totalRevenue: string;   // formatted, e.g. "PKR 45,200"
  totalExpenses: string;
  netAmount: string;
  appointmentsCount: number;
  appUrl: string;
}

export function EODReportEmail({
  branding,
  date,
  aiSummary,
  totalRevenue,
  totalExpenses,
  netAmount,
  appointmentsCount,
  appUrl,
}: EODReportEmailProps) {
  return (
    <BaseLayout branding={branding} previewText={`EOD Report — ${date}`}>
      <Section style={s.section}>
        <Text style={s.h2}>End of Day Report — {date}</Text>
        <Text style={s.bodyMuted}>Daily financial close summary for {branding.clinicName}</Text>
      </Section>

      <Section style={{ ...s.section, background: '#f8fafc', borderRadius: 8, padding: '16px 24px' }}>
        <Text style={s.label}>TODAY AT A GLANCE</Text>
        <table style={{ width: '100%', borderCollapse: 'collapse' }}>
          <tbody>
            <tr>
              <td style={{ padding: '6px 0', color: '#64748b', fontSize: 13 }}>Revenue collected</td>
              <td style={{ padding: '6px 0', textAlign: 'right', fontWeight: 600, fontSize: 13 }}>{totalRevenue}</td>
            </tr>
            <tr>
              <td style={{ padding: '6px 0', color: '#64748b', fontSize: 13 }}>Expenses</td>
              <td style={{ padding: '6px 0', textAlign: 'right', fontWeight: 600, fontSize: 13 }}>{totalExpenses}</td>
            </tr>
            <tr>
              <td style={{ padding: '6px 0', color: '#64748b', fontSize: 13 }}>Net</td>
              <td style={{ padding: '6px 0', textAlign: 'right', fontWeight: 700, fontSize: 14 }}>{netAmount}</td>
            </tr>
            <tr>
              <td style={{ padding: '6px 0', color: '#64748b', fontSize: 13 }}>Appointments seen</td>
              <td style={{ padding: '6px 0', textAlign: 'right', fontWeight: 600, fontSize: 13 }}>{appointmentsCount}</td>
            </tr>
          </tbody>
        </table>
      </Section>

      <Section style={s.section}>
        <Text style={s.label}>AI ANALYSIS</Text>
        {aiSummary.split('\n').filter(Boolean).map((line, i) => (
          <Text key={i} style={s.body}>{line.replace(/^#+\s*/, '').replace(/\*\*(.*?)\*\*/g, '$1')}</Text>
        ))}
      </Section>

      <Section style={s.section}>
        <Text style={s.bodyMuted}>Full report attached as PDF.</Text>
        <ClinicButton href={`${appUrl}?view=eod`} branding={branding}>
          View in OdontoX
        </ClinicButton>
      </Section>
    </BaseLayout>
  );
}
  • Step 2: Add sendEODReportEmail to email.ts
export interface SendEODReportEmailOptions {
  recipients: { email: string; name: string }[];
  branding: ClinicBranding;
  date: string;
  aiSummary: string;
  totalRevenue: string;
  totalExpenses: string;
  netAmount: string;
  appointmentsCount: number;
  appUrl: string;
  pdfBytes: Uint8Array;   // rendered PDF
  customZeptoApiKey?: string;
  customFromEmail?: string;
}

export async function sendEODReportEmail(options: SendEODReportEmailOptions): Promise<void> {
  const { EODReportEmail } = await import('../emails/EODReportEmail');
  const html = await render(
    React.createElement(EODReportEmail, {
      branding: options.branding,
      date: options.date,
      aiSummary: options.aiSummary,
      totalRevenue: options.totalRevenue,
      totalExpenses: options.totalExpenses,
      netAmount: options.netAmount,
      appointmentsCount: options.appointmentsCount,
      appUrl: options.appUrl,
    })
  );
  const pdfBase64 = uint8ToBase64(options.pdfBytes);
  await sendEmailViaZepto(
    options.recipients,
    `EOD Report — ${options.date}`,
    html,
    {
      customApiKey: options.customZeptoApiKey,
      customFromEmail: options.customFromEmail,
      attachments: [{
        name: `EOD-Report-${options.date}.pdf`,
        content: pdfBase64,
        mime_type: 'application/pdf',
      }],
    }
  );
}
Note: uint8ToBase64 is already defined in email.ts. The ZeptoAttachment type already accepts { name, content, mime_type }.
  • Step 3: Verify TypeScript
cd /Users/ssh/Documents/Beta-App/odontoX/server && npx tsc --noEmit
  • Step 4: Commit
git add server/src/emails/EODReportEmail.tsx server/src/lib/email.ts
git commit -m "feat(email): add EODReportEmail template and sendEODReportEmail with PDF attachment"

Task 12: EOD scheduled handler + wrangler cron

Files:
  • Create: server/src/scheduled/eod-email-report.ts
  • Modify: server/src/scheduled.ts
  • Modify: server/wrangler.toml
  • Step 1: Create server/src/scheduled/eod-email-report.ts
// server/src/scheduled/eod-email-report.ts
import { getDatabase } from '../lib/db';
import { getDatabaseUrl, runWithEnv } from '../lib/env';
import { clinics, users, userClinicAssignments } from '../schema';
import { eq, and } from 'drizzle-orm';
import { fetchEODReportData } from '../lib/eod-report';
import { sendEODReportEmail } from '../lib/email';
import { pdf } from '@react-pdf/renderer';
import * as React from 'react';

// PKT = UTC+5. At 16:00 UTC the date in PKT is still the same calendar day.
function getTodayPKT(): string {
  const now = new Date();
  // Shift by +5 hours to get PKT date
  const pkt = new Date(now.getTime() + 5 * 60 * 60 * 1000);
  return pkt.toISOString().slice(0, 10); // YYYY-MM-DD
}

function formatAmount(value: number): string {
  return `PKR ${value.toLocaleString('en-PK', { minimumFractionDigits: 0 })}`;
}

export async function handleEODEmailReport(env: any): Promise<void> {
  console.log('📊 EOD email report job started:', new Date().toISOString());
  const databaseUrl = getDatabaseUrl();
  const db = await getDatabase(databaseUrl);

  // Fetch clinics that opted in
  const allClinics = await db.select({
    id: clinics.id,
    name: clinics.name,
    subdomain: clinics.subdomain,
    notificationPreferences: clinics.notificationPreferences,
  }).from(clinics);

  const opted = allClinics.filter(c => c.notificationPreferences?.eodEmailEnabled === true);
  console.log(`EOD email: ${opted.length} clinic(s) opted in`);

  const date = getTodayPKT();

  // Process each clinic independently — one failure must not block others
  await Promise.allSettled(opted.map(async (clinic) => {
    try {
      // 1. Fetch EOD data
      const report = await fetchEODReportData(clinic.id, date);

      // 2. Compute summary figures
      const totalRevenue = report.todayReceipts.reduce((s: number, r: any) => s + parseFloat(r.amount || '0'), 0);
      const totalExpenses = report.dayExpenses.reduce((s: number, e: any) => s + parseFloat(e.amount || '0'), 0);
      const net = totalRevenue - totalExpenses;
      const appointmentsCount = report.todayAppointments.length;

      // 3. AI summary via DeepSeek (same endpoint used by the interactive module)
      let aiSummary = 'AI summary unavailable.';
      try {
        const aiResponse = await fetch(`${env.APP_URL ?? 'https://api.odontox.io'}/api/v1/protected/expenses/eod-report/ai-summary`, {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            'X-Internal-Cron': env.INTERNAL_CRON_SECRET ?? '',
          },
          body: JSON.stringify({ date, clinicId: clinic.id, reportData: report }),
        });
        if (aiResponse.ok) {
          const aiData = await aiResponse.json() as { summary?: string };
          aiSummary = aiData.summary ?? aiSummary;
        }
      } catch (aiErr) {
        console.warn(`AI summary failed for clinic ${clinic.id}:`, aiErr);
      }

      // 4. Render PDF server-side
      const { EODExpenseReportPdf } = await import('../pdf/EODExpenseReportPdf' as any);
      const pdfDoc = React.createElement(EODExpenseReportPdf, { report, clinicName: clinic.name ?? 'Clinic', date });
      const pdfBlob = await pdf(pdfDoc).toBuffer();
      const pdfBytes = new Uint8Array(pdfBlob);

      // 5. Fetch admin recipients
      const adminRows = await db.select({ email: users.email, firstName: users.firstName, lastName: users.lastName })
        .from(userClinicAssignments)
        .innerJoin(users, eq(userClinicAssignments.userId, users.id))
        .where(and(
          eq(userClinicAssignments.clinicId, clinic.id),
          eq(userClinicAssignments.role, 'admin'),
          eq(userClinicAssignments.status, 'active'),
        ));

      const recipients = adminRows
        .filter(r => r.email)
        .map(r => ({ email: r.email!, name: `${r.firstName ?? ''} ${r.lastName ?? ''}`.trim() || r.email! }));

      if (recipients.length === 0) return;

      const appUrl = clinic.subdomain
        ? `https://${clinic.subdomain}.odontox.io`
        : 'https://app.odontox.io';

      // 6. Send email
      await sendEODReportEmail({
        recipients,
        branding: { clinicName: clinic.name ?? 'Your Clinic' },
        date,
        aiSummary,
        totalRevenue: formatAmount(totalRevenue),
        totalExpenses: formatAmount(totalExpenses),
        netAmount: formatAmount(net),
        appointmentsCount,
        appUrl,
        pdfBytes,
        customZeptoApiKey: env?.ZEPTO_API_KEY,
        customFromEmail: env?.ZEPTO_FROM_EMAIL,
      });

      console.log(`✅ EOD email sent for clinic: ${clinic.name} (${recipients.length} recipient(s))`);
    } catch (err) {
      console.error(`❌ EOD email failed for clinic ${clinic.id}:`, err);
    }
  }));
}
Note on AI summary: The AI summary is currently triggered via the interactive HTTP endpoint. For the cron job, the cleanest approach is to extract the AI call into a shared helper (same pattern as EOD data). If the AI endpoint is not easily callable internally, simplify: call DeepSeek directly using the existing AI client pattern. Check server/src/routes/expenses.ts lines ~370-470 for the DeepSeek call and replicate it here if needed.
  • Step 2: Wire the handler in scheduled.ts
Add the import:
import { handleEODEmailReport } from './scheduled/eod-email-report';
In handleScheduled, add a cron-specific branch. Cloudflare’s ScheduledEvent has a cron property. Add at the beginning of the function body:
// EOD email — only runs on the 9 PM PKT cron (16:00 UTC)
if (event.cron === '0 16 * * *') {
  await handleEODEmailReport(env);
  return; // don't run other tasks on this trigger
}
This sits before the existing runWithEnv block.
  • Step 3: Add cron trigger to wrangler.toml
Find all three occurrences of the crons array and add "0 16 * * *" to each:
# dev (line ~77)
crons = ["0 4 * * *", "0 9 * * *", "0 14 * * *", "0 16 * * *", "0 19 * * *"]

# staging (same section)
crons = ["0 4 * * *", "0 9 * * *", "0 14 * * *", "0 16 * * *", "0 19 * * *"]

# production
crons = ["0 4 * * *", "0 9 * * *", "0 14 * * *", "0 16 * * *", "0 19 * * *"]
  • Step 4: Verify TypeScript
cd /Users/ssh/Documents/Beta-App/odontoX/server && npx tsc --noEmit
  • Step 5: Commit
git add server/src/scheduled/eod-email-report.ts server/src/scheduled.ts server/wrangler.toml
git commit -m "feat(server): EOD email report scheduled at 16:00 UTC (9 PM PKT) — AI summary + PDF attachment"

Self-review notes

  • SWR: The background-refresh path does not go through refreshTokenIfNeeded() — acceptable because: (a) if the token expired, the next foreground request will trigger refresh normally, (b) we just invalidate the stale cache entry on bg-refresh 401 rather than force-logout.
  • 403 guard: Both files use .catch(() => {}) — kept since removing it risks unhandled promise rejection if future code removes the guard.
  • Inventory form: InventoryItemPage.tsx imports useSearchParams from react-router-dom — confirm it’s already imported before adding the edit navigation.
  • EOD AI call in cron: The internal HTTP call approach works only if the AI route doesn’t require a user JWT. Consider extracting the DeepSeek call into a shared server/src/lib/ai/eod-summary.ts if the HTTP approach has auth issues.
  • onConflictDoNothing() in Task 9: Drizzle’s onConflictDoNothing requires a unique constraint — inventory_alerts doesn’t have one on (clinicId, itemId, alertType). Use a plain insert + separate update emailSentAt query instead, or add a unique constraint. Simplest: just insert a new row each time (the dedup check above prevents the insert from being reached more than once per 24h).