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:- Engage maintenance immediately (manual override) — optionally with an outbound staff email.
- Schedule maintenance in advance — gate auto-engages at start time, auto-disengages at end. An advance email goes out at schedule time.
- End maintenance — optionally with a “back up” email.
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
AccountStatusPagerendering; 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 inplatform_settings (existing K/V table). All values are strings.
| Key | Purpose |
|---|---|
system.maintenanceMode | "true" / "false" — manual override toggle |
system.maintenanceMessage | User-facing message body (used in both the maintenance page and emails) |
system.maintenanceBanner | Short banner text shown above the message |
system.maintenanceStart | ISO datetime, optional — start of scheduled window |
system.maintenanceEnd | ISO 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
InloadMaintenanceState() (existing helper in server/src/middleware/maintenance.ts):
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 adownemail send viac.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
startsAtvia the active-state calculation. - Always sends the
scheduledemail — 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)
ReusesPOST /admin/denco/maintenance/save with { engage: false, notify: boolean }.
- Flips
maintenanceMode = "false". - Clears
maintenanceStartandmaintenanceEnd(so the scheduled-window calc doesn’t immediately re-engage). - If
notify=true, enqueuesupemail.
4. Auto-engage on scheduled window enter
Handled insideloadMaintenanceState():
- When the active-state calc flips from
false → trueon a scheduled boundary ANDlastNotifiedKind != 'down', writelastNotifiedKind = 'down'+ log an audit rowaction='auto-engaged'. Thescheduledemail 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
scheduledemail. Sending again would be noisy.
5. Auto-disengage on scheduled window exit
Symmetric. When active flipstrue → false due to now >= endsAt:
- Log audit row
action='auto-disengaged'. - Do NOT send
upemail 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:[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’sto[] 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
notificationstable inserts happen BEFORE email — durable, survive any email failure.
Email content & template
Templates live inserver/src/lib/email/templates/maintenance.ts. Three exports, each returning { subject, html, text }. Plain text fallback derived from HTML.
| Kind | Subject | Body 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. |
{{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"/nullif unset.{{messageBody}}—system.maintenanceMessage, trimmed. If empty, the surrounding sentence is omitted entirely (no awkward “We are performing scheduled maintenance.” default).clinicNameavailable inmerge_infobut not used in the body — maintenance is from “OdontoX Platform”, not from the clinic.
- From:
[email protected](existing ZEPTO_FROM_EMAIL) - Reply-to:
[email protected](existing CONTACT_FORM_EMAIL) - No
[OdontoX]subject prefix — subject is the message.
In-app rendering
Existingui/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 + path | Effect |
|---|---|
POST /save | Write fields + optionally engage/disengage + optionally email. Returns updated state + recipient count if email fired. |
POST /announce | Write fields + send scheduled email. Required: startsAt, endsAt. |
GET /status | Returns 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
Modifyui/src/components/superadmin/PlatformSettings.tsx. The existing Maintenance Mode card gets restructured:
- Save & engage →
/savewithengage: true. Confirm dialog if currently OFF. - Schedule + announce →
/announce. Disabled until bothstartsAtandendsAtare filled. - Save only →
/savewithengage: 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.
GET /status.
Audit logging
Every action writes toaudit_logs via the existing impersonation-aware middleware. entityType='system', entityId='maintenance'.
| Action | metadata |
|---|---|
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 } |
/dashboard/audit-logs by entityType=system.
Testing
Unit (vitest):-
server/src/middleware/maintenance.test.tsloadMaintenanceState()returnsactive=truewithin scheduled window- Cache invalidated past TTL
- Superadmin role bypasses gate
- Path allowlist (
/maintenance/status, upgrade-requests) bypasses - Auto-engage records
lastNotifiedKindonce 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)
server/src/routes/admin-maintenance.test.ts/announcewriteslastAnnouncedAtandlastNotifiedKind='scheduled'- Same-kind announce within 5min is dedup-skipped
- Different-kind announce within 5min proceeds
engage=true notify=truetriggersdownsend (mocked Zepto — assert call count + recipient filter excludes patients, test tenants, [email protected])engage=false notify=truetriggersupsend
- Schedule maintenance starting in 2 min →
scheduledemail arrives. - Wait → middleware auto-engages at start time → next non-superadmin request returns 503 → page locks.
- Window ends → next request unlocks the gate.
- Manually flip “End maintenance” with notify →
upemail arrives.
Rollout
- Backend deploy first. New endpoints live; old
/admin/maintenance/announcekeeps working as alias. - UI deploy second. Picks up new layout + endpoints.
- No data migration needed —
lastAnnouncedAt/lastNotifiedKindrows are lazily created on first write to the K/Vplatform_settingstable. - Reversible by reverting the commit (no destructive schema changes).
- Old alias removed in the next release after this one.
Files touched (estimate)
Server:server/src/middleware/maintenance.ts— extendloadMaintenanceState()for scheduled-window calc + auto-engage audit hookserver/src/routes/admin.ts— replace existing/maintenance/announcewith alias + new endpoints under/denco/maintenance/server/src/lib/email/templates/maintenance.ts(new) — three templatesserver/src/lib/email/maintenance-recipients.ts(new) — recipient query + filterserver/src/lib/email/zepto-batch.ts(new or existing helper) — batchedto[]send with merge_info
ui/src/components/superadmin/PlatformSettings.tsx— restructure maintenance card, new buttons, “Last announcement” lineui/src/lib/serverComm.ts— three new functions for the endpointsui/src/pages/AccountStatusPage.tsx— verify it consumesstartsAt/endsAt/bannerfrom the 503 payload (polish if needed)
server/src/middleware/maintenance.test.ts(new)server/src/lib/email/templates/maintenance.test.ts(new)server/src/routes/admin-maintenance.test.ts(new)

