Skip to main content

Denco layer — Maintenance mode & email (design)

Status: draft (approved 2026-05-18) Scope: thread #1 of the Denco layer rollout — maintenance state, gate behavior, and the email side that’s currently broken/missing.

Goal

A working maintenance mode flow. Superadmin can:
  1. Engage maintenance immediately (manual override) — optionally with an outbound staff email.
  2. Schedule maintenance in advance — gate auto-engages at start time, auto-disengages at end. An advance email goes out at schedule time.
  3. End maintenance — optionally with a “back up” email.
Staff (admin/doctor/receptionist) get notified. Patients never get a maintenance email; the in-app maintenance page handles them.

Non-goals

  • Per-event recipient routing — deferred to Denco thread #2. This spec uses a single hardcoded recipient query.
  • Per-tenant maintenance — global only. A single platform-wide flip; we don’t support taking one clinic down.
  • Status page on a separate domain — uses the existing AccountStatusPage rendering; no new domain.
  • Cron-driven enforcement — no new cron. Auto-engage happens on the next request that crosses the scheduled-window boundary (cheap, the cache TTL is already 30s).

State model

Stored in platform_settings (existing K/V table). All values are strings.
KeyPurpose
system.maintenanceMode"true" / "false" — manual override toggle
system.maintenanceMessageUser-facing message body (used in both the maintenance page and emails)
system.maintenanceBannerShort banner text shown above the message
system.maintenanceStartISO datetime, optional — start of scheduled window
system.maintenanceEndISO datetime, optional — end of scheduled window
system.maintenanceLastAnnouncedAt (new)ISO datetime — when the most recent email was sent. Dedup guard.
system.maintenanceLastNotifiedKind (new)scheduled / down / up — last email kind sent. Dedup guard.

Active-state calculation

In loadMaintenanceState() (existing helper in server/src/middleware/maintenance.ts):
active = (maintenanceMode === 'true')
      OR (start && end && now >= start && now < end)
Manual override is a hard ON. Scheduled window is automatic. Either can trigger; both together are idempotent. When neither applies, the gate is open.

Trigger flows

Three explicit superadmin actions, each backed by a dedicated endpoint:

1. Save & engage (manual immediate)

POST /admin/denco/maintenance/save Body: { message?, banner?, startsAt?, endsAt?, engage: true, notify: boolean }
  • Writes message/banner/start/end fields.
  • Flips maintenanceMode = "true".
  • If notify=true, enqueues a down email send via c.executionCtx.waitUntil(...). Subject to the 5-min same-kind dedup guard.

2. Schedule + announce (advance notice)

POST /admin/denco/maintenance/announce Body: { message?, banner?, startsAt, endsAt }startsAt and endsAt are required here.
  • Writes fields.
  • Does NOT engage the manual toggle. The middleware will auto-engage at startsAt via the active-state calculation.
  • Always sends the scheduled email — announcing is the point of this endpoint.
  • Subject to the dedup guard (re-announcing same window within 5min is a no-op).

3. End maintenance (manual disengage)

Reuses POST /admin/denco/maintenance/save with { engage: false, notify: boolean }.
  • Flips maintenanceMode = "false".
  • Clears maintenanceStart and maintenanceEnd (so the scheduled-window calc doesn’t immediately re-engage).
  • If notify=true, enqueues up email.

4. Auto-engage on scheduled window enter

Handled inside loadMaintenanceState():
  • When the active-state calc flips from false → true on a scheduled boundary AND lastNotifiedKind != 'down', write lastNotifiedKind = 'down' + log an audit row action='auto-engaged'. The scheduled email already went out at schedule time — no second email here, just bookkeeping.
  • No email is sent on auto-engage. Recipients were already warned by the scheduled email. Sending again would be noisy.

5. Auto-disengage on scheduled window exit

Symmetric. When active flips true → false due to now >= endsAt:
  • Log audit row action='auto-disengaged'.
  • Do NOT send up email automatically. The superadmin who scheduled it can fire one manually if they want to confirm “we’re back”. This avoids waking 200 inboxes at 3am when the scheduled end-time hits.

Recipient query

Single SELECT at email-send time:
SELECT u.id, u.email, u.firstName,
       c.id AS clinicId, c.name AS clinicName
FROM users u
JOIN clinics c ON u.primary_clinic_id = c.id OR u.clinic_id = c.id
WHERE u.is_active = true
  AND u.role IN ('admin','doctor','receptionist')
  AND c.is_active = true
  AND c.subscription_status != 'suspended'
  AND c.is_test_account = false
  AND u.email IS NOT NULL
  AND u.email != ''
Hardcoded exclusion: [email protected] filtered out client-side after the query, per the explicit “never email” rule. Returned count is reported back in the API response and shown in the UI’s “Last announcement” line.

Email delivery

Transport: Zepto Mail (already wired). One request per batch using Zepto’s to[] multi-recipient send; one Zepto round-trip handles up to 500 staff in OdontoX’s current scale. Batching: group recipients in chunks of 500. Each chunk = one Zepto API call. Fire all chunks via c.executionCtx.waitUntil(Promise.allSettled(...)) so the superadmin sees a fast 200 and emails flow in background. Personalization: Zepto’s merge_info per recipient — single subject + HTML template with {{firstName}} etc., resolved per-to[] entry by Zepto. No N templates rendered server-side. Idempotency: before sending, read system.maintenanceLastAnnouncedAt + system.maintenanceLastNotifiedKind. If now − lastAnnouncedAt < 5 min AND kind matches, skip silently and log [level=info, tag=maintenance.email.skip, reason=duplicate]. Different-kind re-announce within 5min is always allowed. Failure handling:
  • Per-batch try/catch. Failed batch → console.error(JSON.stringify({ level: 'error', tag: 'maintenance.email.failed', kind, batchSize, error })).
  • No retries — the superadmin can re-fire from the UI if Zepto was down.
  • In-app notifications table inserts happen BEFORE email — durable, survive any email failure.

Email content & template

Templates live in server/src/lib/email/templates/maintenance.ts. Three exports, each returning { subject, html, text }. Plain text fallback derived from HTML.
KindSubjectBody shape
scheduled”Scheduled maintenance: Hello , OdontoX will undergo scheduled maintenance from to (PKT). The app at go.odontox.io will be unavailable. — no action required, your data is safe.
down”OdontoX is currently down for maintenance”Hello , OdontoX is now offline. Estimated back online: (or “shortly” if unset). We’ll email you when it’s back.
up”OdontoX is back online”Hello , maintenance is complete. OdontoX at go.odontox.io is available again. Thanks for your patience.
Tokens:
  • {{firstName}} — fall back to “there” if null.
  • {{startLocal}} / {{endLocal}} — formatted in Asia/Karachi, e.g. Sun, May 19 at 2:30 PM PKT. Fall back to "shortly" / null if unset.
  • {{messageBody}}system.maintenanceMessage, trimmed. If empty, the surrounding sentence is omitted entirely (no awkward “We are performing scheduled maintenance.” default).
  • clinicName available in merge_info but not used in the body — maintenance is from “OdontoX Platform”, not from the clinic.
Headers:

In-app rendering

Existing ui/src/pages/AccountStatusPage.tsx already handles the MAINTENANCE_MODE code. The middleware response (503) already includes message, banner, startsAt, endsAt. Action item: verify the page reads all four fields and renders them; polish layout if not already done.

API surface

All endpoints under /api/v1/protected/admin/denco/maintenance/*, superadmin-only, dual-auth + permission-middleware applied.
Method + pathEffect
POST /saveWrite fields + optionally engage/disengage + optionally email. Returns updated state + recipient count if email fired.
POST /announceWrite fields + send scheduled email. Required: startsAt, endsAt.
GET /statusReturns full state including lastAnnouncedAt, lastNotifiedKind, and a count from the recipient query (cached 30s — same TTL as the gate cache).
POST /admin/maintenance/announce (the old endpoint) becomes a deprecated alias that calls the new /announce, returns the same shape. Removed in the next release.

UI

Modify ui/src/components/superadmin/PlatformSettings.tsx. The existing Maintenance Mode card gets restructured:
┌── Maintenance Mode ─────────────────────────────────────┐
│                                                          │
│  Manual override     [○ off]                             │
│                                                          │
│  Message:            [textarea]                          │
│  Banner (optional):  [input]                             │
│  Start (optional):   [datetime]                          │
│  End (optional):     [datetime]                          │
│                                                          │
│  ☑ Notify all clinic staff (admin/doctor/receptionist)  │
│                                                          │
│  [Save & engage]   [Schedule + announce]   [Save only]  │
│                                                          │
│  Last announcement: scheduled · 5 minutes ago · 47 staff │
└──────────────────────────────────────────────────────────┘
  • Save & engage/save with engage: true. Confirm dialog if currently OFF.
  • Schedule + announce/announce. Disabled until both startsAt and endsAt are filled.
  • Save only/save with engage: undefined, notify: false. For tweaking the message mid-window without spamming.
  • Manual override toggle itself is interactive — flipping it is a shortcut that opens an inline confirm with the Notify checkbox, then calls /save.
The “Last announcement” line reads from GET /status.

Audit logging

Every action writes to audit_logs via the existing impersonation-aware middleware. entityType='system', entityId='maintenance'.
Actionmetadata
engaged{ start, end, notify, recipientCount }
scheduled{ start, end, recipientCount }
updated{ fieldsChanged: [...] }
disengaged{ notify, recipientCount }
auto-engaged{ trigger: 'schedule', start, end }
auto-disengaged{ trigger: 'schedule', start, end }
Filterable in /dashboard/audit-logs by entityType=system.

Testing

Unit (vitest):
  • server/src/middleware/maintenance.test.ts
    • loadMaintenanceState() returns active=true within scheduled window
    • Cache invalidated past TTL
    • Superadmin role bypasses gate
    • Path allowlist (/maintenance/status, upgrade-requests) bypasses
    • Auto-engage records lastNotifiedKind once per cycle
  • server/src/lib/email/templates/maintenance.test.ts
    • Each template renders for empty/null tokens without crashing
    • PKT date formatting for fixed cases (no DST in PK)
Integration (vitest, real DB):
  • server/src/routes/admin-maintenance.test.ts
    • /announce writes lastAnnouncedAt and lastNotifiedKind='scheduled'
    • Same-kind announce within 5min is dedup-skipped
    • Different-kind announce within 5min proceeds
    • engage=true notify=true triggers down send (mocked Zepto — assert call count + recipient filter excludes patients, test tenants, [email protected])
    • engage=false notify=true triggers up send
Manual smoke (dev):
  1. Schedule maintenance starting in 2 min → scheduled email arrives.
  2. Wait → middleware auto-engages at start time → next non-superadmin request returns 503 → page locks.
  3. Window ends → next request unlocks the gate.
  4. Manually flip “End maintenance” with notify → up email arrives.

Rollout

  1. Backend deploy first. New endpoints live; old /admin/maintenance/announce keeps working as alias.
  2. UI deploy second. Picks up new layout + endpoints.
  3. No data migration needed — lastAnnouncedAt / lastNotifiedKind rows are lazily created on first write to the K/V platform_settings table.
  4. Reversible by reverting the commit (no destructive schema changes).
  5. Old alias removed in the next release after this one.

Files touched (estimate)

Server:
  • server/src/middleware/maintenance.ts — extend loadMaintenanceState() for scheduled-window calc + auto-engage audit hook
  • server/src/routes/admin.ts — replace existing /maintenance/announce with alias + new endpoints under /denco/maintenance/
  • server/src/lib/email/templates/maintenance.ts (new) — three templates
  • server/src/lib/email/maintenance-recipients.ts (new) — recipient query + filter
  • server/src/lib/email/zepto-batch.ts (new or existing helper) — batched to[] send with merge_info
UI:
  • ui/src/components/superadmin/PlatformSettings.tsx — restructure maintenance card, new buttons, “Last announcement” line
  • ui/src/lib/serverComm.ts — three new functions for the endpoints
  • ui/src/pages/AccountStatusPage.tsx — verify it consumes startsAt/endsAt/banner from the 503 payload (polish if needed)
Tests:
  • server/src/middleware/maintenance.test.ts (new)
  • server/src/lib/email/templates/maintenance.test.ts (new)
  • server/src/routes/admin-maintenance.test.ts (new)

Open questions

None at design time. Implementation may surface follow-ups; capture them as plan deltas.