Skip to main content

Denco Maintenance 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: Ship the maintenance-mode + email flow per docs/superpowers/specs/2026-05-18-denco-maintenance-design.md. Superadmin can flip a manual gate, schedule a window, and notify all clinic staff (admin/doctor/receptionist) — never patients. Gate auto-engages within scheduled windows. Existing AccountStatusPage already renders the MAINTENANCE_MODE state. Architecture: Single source of state is app.platform_settings (K/V). The maintenanceMiddleware reads it (30s cache) and gates traffic; the active calc combines a manual override with the scheduled window. Three superadmin endpoints (/save, /announce, /status) under /admin/denco/maintenance/* are the write surface. Email goes out via sendEmailViaZepto one call per recipient (so staff don’t see each other’s addresses), wrapped in executionCtx.waitUntil(Promise.allSettled(...)). Dedup guard uses two new K/V keys, system.maintenanceLastAnnouncedAt + system.maintenanceLastNotifiedKind. Tech Stack: Hono · Drizzle · Neon HTTP (read) · Zepto (email) · React + Vite (UI) · vitest (test).

File map

Server (new):
  • server/src/lib/email/templates/maintenance.ts — three renderers (renderMaintenanceScheduled, renderMaintenanceDown, renderMaintenanceUp) returning { subject, html, text }.
  • server/src/lib/email/maintenance-recipients.tsgetMaintenanceRecipients() returning { id, email, firstName, clinicId, clinicName }[].
  • server/src/lib/email/maintenance-send.tsannounceMaintenance({ db, kind, state, executionCtx, actorUserId, impersonation, onBehalfOfUserId }) — runs dedup check, gets recipients, fires waitUntil, writes lastAnnouncedAt + lastNotifiedKind, records audit log.
  • server/src/routes/admin-maintenance.ts — three handlers (save, announce, status) mounted under /admin/denco/maintenance/.
Server (modified):
  • server/src/middleware/maintenance.ts — extend loadMaintenanceState() for scheduled-window calc + auto-engage audit hook (writes lastNotifiedKind once per cycle on false → true transition due to schedule).
  • server/src/routes/admin.ts — mount the new route subtree; keep old /maintenance/announce as deprecated alias.
Server (tests, new):
  • server/src/lib/email/templates/maintenance.test.ts
  • server/src/lib/email/maintenance-recipients.test.ts
  • server/src/middleware/maintenance.test.ts
  • server/src/routes/admin-maintenance.test.ts
UI (modified):
  • ui/src/lib/serverComm.ts — three new functions: saveMaintenance(...), announceMaintenance(...), getMaintenanceAdminStatus().
  • ui/src/components/superadmin/PlatformSettings.tsx — rewrite the Maintenance Mode card per the spec mockup.
UI (verified, possibly minor polish):
  • ui/src/pages/AccountStatusPage.tsx — confirm it consumes message / banner / startsAt / endsAt from the 503 payload.

Task 1: Email templates (TDD)

Files:
  • Create: server/src/lib/email/templates/maintenance.ts
  • Test: server/src/lib/email/templates/maintenance.test.ts
  • Step 1: Write the failing tests
// server/src/lib/email/templates/maintenance.test.ts
import { describe, it, expect } from 'vitest';
import {
  renderMaintenanceScheduled,
  renderMaintenanceDown,
  renderMaintenanceUp,
} from './maintenance';

const tokens = {
  firstName: 'Ayesha',
  startsAt: '2026-05-19T14:30:00+05:00',
  endsAt:   '2026-05-19T15:00:00+05:00',
  messageBody: 'Database migration window.',
};

describe('maintenance email templates', () => {
  it('scheduled: subject + html + text are populated', () => {
    const out = renderMaintenanceScheduled(tokens);
    expect(out.subject).toContain('Scheduled maintenance');
    expect(out.html).toContain('Ayesha');
    expect(out.html).toContain('Database migration window.');
    expect(out.text.length).toBeGreaterThan(0);
  });

  it('scheduled: PKT-formatted dates appear in subject + body', () => {
    const out = renderMaintenanceScheduled(tokens);
    // "May 19" should appear regardless of viewer locale because we format in PKT.
    expect(out.html).toMatch(/May\s+19/);
    expect(out.subject).toMatch(/May\s+19/);
  });

  it('falls back to "there" when firstName is null', () => {
    const out = renderMaintenanceScheduled({ ...tokens, firstName: null });
    expect(out.html).toContain('Hello there');
  });

  it('omits messageBody sentence when empty', () => {
    const out = renderMaintenanceDown({ ...tokens, messageBody: '' });
    expect(out.html).not.toContain('undefined');
    expect(out.html).not.toContain('We are performing scheduled maintenance');
  });

  it('down: subject conveys "currently down"', () => {
    const out = renderMaintenanceDown(tokens);
    expect(out.subject.toLowerCase()).toContain('down');
  });

  it('up: subject conveys "back online" and body has no error text', () => {
    const out = renderMaintenanceUp({ firstName: 'Ayesha' });
    expect(out.subject.toLowerCase()).toContain('back online');
    expect(out.html).toContain('Ayesha');
  });

  it('down: falls back to "shortly" when endsAt is null', () => {
    const out = renderMaintenanceDown({ ...tokens, endsAt: null });
    expect(out.html.toLowerCase()).toContain('shortly');
  });
});
  • Step 2: Run tests to verify they fail
Run: cd server && npx vitest run src/lib/email/templates/maintenance.test.ts Expected: FAIL with “Cannot find module ’./maintenance’”.
  • Step 3: Implement the templates
// server/src/lib/email/templates/maintenance.ts

const PKT_TZ = 'Asia/Karachi';

function formatPKT(iso: string | null | undefined): string | null {
  if (!iso) return null;
  const d = new Date(iso);
  if (isNaN(d.getTime())) return null;
  return d.toLocaleString('en-US', {
    timeZone: PKT_TZ,
    weekday: 'short',
    month: 'short',
    day: 'numeric',
    hour: 'numeric',
    minute: '2-digit',
    hour12: true,
  }) + ' PKT';
}

function htmlEscape(s: string): string {
  return s
    .replace(/&/g, '&')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;');
}

function wrap(bodyHtml: string): string {
  return `<!doctype html><html><body style="font-family:-apple-system,Segoe UI,Roboto,sans-serif;color:#0f172a;max-width:560px;margin:24px auto;padding:0 16px;line-height:1.5">${bodyHtml}<p style="color:#64748b;font-size:12px;margin-top:32px">— OdontoX Platform</p></body></html>`;
}

function htmlToText(html: string): string {
  return html.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
}

interface ScheduledTokens {
  firstName: string | null | undefined;
  startsAt: string | null | undefined;
  endsAt: string | null | undefined;
  messageBody?: string | null;
}

export function renderMaintenanceScheduled(t: ScheduledTokens) {
  const name = t.firstName?.trim() || 'there';
  const start = formatPKT(t.startsAt);
  const end = formatPKT(t.endsAt);
  const trimmedBody = (t.messageBody || '').trim();
  const subject = `Scheduled maintenance: ${start || 'upcoming'}`;
  const bodyParts: string[] = [
    `<p>Hello ${htmlEscape(name)},</p>`,
    `<p>OdontoX will be undergoing scheduled maintenance${start ? ` from <strong>${htmlEscape(start)}</strong>` : ''}${end ? ` to <strong>${htmlEscape(end)}</strong>` : ''}.</p>`,
    `<p>During this window the app at <strong>go.odontox.io</strong> will be unavailable.</p>`,
  ];
  if (trimmedBody) bodyParts.push(`<p>${htmlEscape(trimmedBody)}</p>`);
  bodyParts.push(`<p style="color:#64748b">No action required from you — your data is safe.</p>`);
  const html = wrap(bodyParts.join(''));
  return { subject, html, text: htmlToText(html) };
}

export function renderMaintenanceDown(t: ScheduledTokens) {
  const name = t.firstName?.trim() || 'there';
  const end = formatPKT(t.endsAt);
  const trimmedBody = (t.messageBody || '').trim();
  const subject = 'OdontoX is currently down for maintenance';
  const bodyParts: string[] = [
    `<p>Hello ${htmlEscape(name)},</p>`,
    `<p>OdontoX is now offline for maintenance.</p>`,
  ];
  if (trimmedBody) bodyParts.push(`<p>${htmlEscape(trimmedBody)}</p>`);
  bodyParts.push(`<p>Estimated back online: <strong>${htmlEscape(end || 'shortly')}</strong>.</p>`);
  bodyParts.push(`<p style="color:#64748b">We'll email you when it's back.</p>`);
  const html = wrap(bodyParts.join(''));
  return { subject, html, text: htmlToText(html) };
}

export function renderMaintenanceUp(t: { firstName: string | null | undefined }) {
  const name = t.firstName?.trim() || 'there';
  const subject = 'OdontoX is back online';
  const html = wrap(
    `<p>Hello ${htmlEscape(name)},</p>` +
    `<p>Maintenance is complete. OdontoX at <strong>go.odontox.io</strong> is available again.</p>` +
    `<p style="color:#64748b">Thanks for your patience.</p>`
  );
  return { subject, html, text: htmlToText(html) };
}
  • Step 4: Run tests to verify they pass
Run: cd server && npx vitest run src/lib/email/templates/maintenance.test.ts Expected: 7 tests PASS.
  • Step 5: Commit
git add server/src/lib/email/templates/maintenance.ts server/src/lib/email/templates/maintenance.test.ts
git commit -m "feat(denco): maintenance email templates (scheduled/down/up)"

Task 2: Recipient query module (TDD)

Files:
  • Create: server/src/lib/email/maintenance-recipients.ts
  • Test: server/src/lib/email/maintenance-recipients.test.ts
  • Step 1: Write the failing test
// server/src/lib/email/maintenance-recipients.test.ts
import { describe, it, expect, vi } from 'vitest';
import { filterMaintenanceRecipients } from './maintenance-recipients';

const NEVER_EMAIL = '[email protected]';

describe('filterMaintenanceRecipients', () => {
  const base = (over: Partial<any> = {}) => ({
    id: 'u1', email: '[email protected]', firstName: 'A',
    clinicId: 'c1', clinicName: 'Demo', role: 'admin',
    isActive: true, clinicActive: true,
    subscriptionStatus: 'active', isTestAccount: false,
    ...over,
  });

  it('keeps admin/doctor/receptionist on active non-test clinics', () => {
    const rows = [
      base({ role: 'admin' }),
      base({ id: 'u2', email: '[email protected]', role: 'doctor' }),
      base({ id: 'u3', email: '[email protected]', role: 'receptionist' }),
    ];
    expect(filterMaintenanceRecipients(rows)).toHaveLength(3);
  });

  it('drops patients and superadmins', () => {
    const rows = [
      base({ role: 'patient' }),
      base({ id: 'u2', email: '[email protected]', role: 'superadmin' }),
      base({ id: 'u3', email: '[email protected]', role: 'admin' }),
    ];
    const out = filterMaintenanceRecipients(rows);
    expect(out.map(r => r.id)).toEqual(['u3']);
  });

  it('drops test-tenant rows', () => {
    const rows = [
      base({ isTestAccount: true }),
      base({ id: 'u2', email: '[email protected]', isTestAccount: false }),
    ];
    expect(filterMaintenanceRecipients(rows).map(r => r.id)).toEqual(['u2']);
  });

  it('drops suspended clinics and inactive users/clinics', () => {
    const rows = [
      base({ subscriptionStatus: 'suspended' }),
      base({ id: 'u2', email: '[email protected]', isActive: false }),
      base({ id: 'u3', email: '[email protected]', clinicActive: false }),
      base({ id: 'u4', email: '[email protected]' }),
    ];
    expect(filterMaintenanceRecipients(rows).map(r => r.id)).toEqual(['u4']);
  });

  it('drops rows with null/empty email and the no-email blocklist', () => {
    const rows = [
      base({ email: null }),
      base({ id: 'u2', email: '' }),
      base({ id: 'u3', email: NEVER_EMAIL }),
      base({ id: 'u4', email: '[email protected]' }),
    ];
    expect(filterMaintenanceRecipients(rows).map(r => r.id)).toEqual(['u4']);
  });

  it('dedupes by email (one row per address even if user appears twice)', () => {
    const rows = [
      base({ email: '[email protected]' }),
      base({ id: 'u2', email: '[email protected]' }),
    ];
    expect(filterMaintenanceRecipients(rows)).toHaveLength(1);
  });
});
  • Step 2: Run test to verify it fails
Run: cd server && npx vitest run src/lib/email/maintenance-recipients.test.ts Expected: FAIL — module not found.
  • Step 3: Implement the module
// server/src/lib/email/maintenance-recipients.ts
import { eq, and, ne, or, inArray, isNotNull } from 'drizzle-orm';
import { users, clinics } from '../../schema';

export interface MaintenanceRecipient {
  id: string;
  email: string;
  firstName: string | null;
  clinicId: string;
  clinicName: string;
}

interface RawRow {
  id: string;
  email: string | null;
  firstName: string | null;
  clinicId: string;
  clinicName: string;
  role: string;
  isActive: boolean;
  clinicActive: boolean;
  subscriptionStatus: string | null;
  isTestAccount: boolean | null;
}

// Explicit per-memory blocklist — never send any test/auto/maintenance email
// to this address; use synthetic test addresses or ask the user instead.
const NEVER_EMAIL = new Set(['[email protected]']);

const STAFF_ROLES = new Set(['admin', 'doctor', 'receptionist']);

export function filterMaintenanceRecipients(rows: RawRow[]): MaintenanceRecipient[] {
  const seen = new Set<string>();
  const out: MaintenanceRecipient[] = [];
  for (const r of rows) {
    if (!r.email) continue;
    const lower = r.email.toLowerCase().trim();
    if (!lower) continue;
    if (NEVER_EMAIL.has(lower)) continue;
    if (!STAFF_ROLES.has(r.role)) continue;
    if (!r.isActive) continue;
    if (!r.clinicActive) continue;
    if (r.subscriptionStatus === 'suspended') continue;
    if (r.isTestAccount === true) continue;
    if (seen.has(lower)) continue;
    seen.add(lower);
    out.push({
      id: r.id,
      email: r.email,
      firstName: r.firstName,
      clinicId: r.clinicId,
      clinicName: r.clinicName,
    });
  }
  return out;
}

/**
 * Run the recipient SELECT against Drizzle and apply the same filter the
 * unit tests cover. Returns the post-filter list ready for Zepto send.
 */
export async function getMaintenanceRecipients(db: any): Promise<MaintenanceRecipient[]> {
  const rows = await db
    .select({
      id: users.id,
      email: users.email,
      firstName: users.firstName,
      clinicId: clinics.id,
      clinicName: clinics.name,
      role: users.role,
      isActive: users.isActive,
      clinicActive: clinics.isActive,
      subscriptionStatus: clinics.subscriptionStatus,
      isTestAccount: clinics.isTestAccount,
    })
    .from(users)
    .innerJoin(
      clinics,
      or(eq(users.primaryClinicId, clinics.id), eq(users.clinicId, clinics.id)) as any,
    )
    .where(
      and(
        eq(users.isActive, true),
        inArray(users.role, ['admin', 'doctor', 'receptionist']),
        eq(clinics.isActive, true),
        ne(clinics.subscriptionStatus, 'suspended'),
        eq(clinics.isTestAccount, false),
        isNotNull(users.email),
      ),
    );
  return filterMaintenanceRecipients(rows as RawRow[]);
}
  • Step 4: Run tests to verify they pass
Run: cd server && npx vitest run src/lib/email/maintenance-recipients.test.ts Expected: 6 tests PASS.
  • Step 5: Commit
git add server/src/lib/email/maintenance-recipients.ts server/src/lib/email/maintenance-recipients.test.ts
git commit -m "feat(denco): recipient query for maintenance email (staff only, no patients/test tenants/blocklist)"

Task 3: Send orchestrator (dedup, batching, audit)

Files:
  • Create: server/src/lib/email/maintenance-send.ts
This module ties the templates + recipient query together. It is integration-tested in Task 5 (route tests) — no standalone test here because the unit-level behavior is just glue.
  • Step 1: Implement the module
// server/src/lib/email/maintenance-send.ts
import { eq } from 'drizzle-orm';
import { platformSettings } from '../../schema';
import { sendEmailViaZepto } from '../email';
import { recordAuditLog } from '../audit-helper';
import {
  renderMaintenanceScheduled,
  renderMaintenanceDown,
  renderMaintenanceUp,
} from './templates/maintenance';
import { getMaintenanceRecipients } from './maintenance-recipients';

export type MaintenanceEmailKind = 'scheduled' | 'down' | 'up';

const DEDUP_WINDOW_MS = 5 * 60 * 1000;

interface MaintenanceState {
  startsAt: string | null;
  endsAt: string | null;
  messageBody: string | null;
}

interface AnnounceParams {
  db: any;                          // tx-capable db (getTxDb) — caller owns end()
  kind: MaintenanceEmailKind;
  state: MaintenanceState;
  executionCtx?: { waitUntil: (p: Promise<any>) => void };
  // Audit/impersonation context — propagated from the route handler
  actorUserId: string | null;
  onBehalfOfUserId: string | null;
  impersonation: boolean;
  auditAction: 'engaged' | 'scheduled' | 'disengaged' | 'updated';
  auditMetadata?: Record<string, unknown>;
}

interface AnnounceResult {
  emailed: boolean;
  recipientCount: number;
  skippedReason?: 'duplicate-within-5min';
}

async function readDedupKeys(db: any): Promise<{ lastAt: number; lastKind: MaintenanceEmailKind | null }> {
  const rows = await db
    .select()
    .from(platformSettings)
    .where(
      // Inline OR avoids importing or() — quick read of 2 keys
      eq(platformSettings.settingKey, 'system.maintenanceLastAnnouncedAt'),
    );
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  const all = await db.select().from(platformSettings);
  const map = new Map(all.map((r: any) => [r.settingKey, r.settingValue]));
  const lastAtStr = map.get('system.maintenanceLastAnnouncedAt') as string | undefined;
  const lastKind = (map.get('system.maintenanceLastNotifiedKind') as MaintenanceEmailKind | undefined) ?? null;
  const lastAt = lastAtStr ? Date.parse(lastAtStr) : 0;
  return { lastAt, lastKind };
}

async function upsertSetting(db: any, key: string, value: string) {
  await db
    .insert(platformSettings)
    .values({ settingKey: key, settingValue: value })
    .onConflictDoUpdate({
      target: platformSettings.settingKey,
      set: { settingValue: value },
    });
}

function pickTemplate(kind: MaintenanceEmailKind, state: MaintenanceState, firstName: string | null) {
  if (kind === 'scheduled') {
    return renderMaintenanceScheduled({
      firstName,
      startsAt: state.startsAt,
      endsAt: state.endsAt,
      messageBody: state.messageBody,
    });
  }
  if (kind === 'down') {
    return renderMaintenanceDown({
      firstName,
      startsAt: state.startsAt,
      endsAt: state.endsAt,
      messageBody: state.messageBody,
    });
  }
  return renderMaintenanceUp({ firstName });
}

/**
 * Orchestrates a maintenance announcement:
 *   1. Dedup-check (same kind within 5min → skip).
 *   2. Resolve recipients.
 *   3. Fire one Zepto call per recipient inside waitUntil (so the request
 *      returns immediately). One-per-recipient avoids leaking all staff
 *      addresses in a single To: list.
 *   4. Update lastAnnouncedAt + lastNotifiedKind synchronously so subsequent
 *      requests see the dedup flag.
 *   5. Write an audit log row.
 */
export async function announceMaintenance(params: AnnounceParams): Promise<AnnounceResult> {
  const { db, kind, state, executionCtx, auditAction, auditMetadata } = params;

  const { lastAt, lastKind } = await readDedupKeys(db);
  if (kind === lastKind && Date.now() - lastAt < DEDUP_WINDOW_MS) {
    console.log(JSON.stringify({
      level: 'info', tag: 'maintenance.email.skip',
      reason: 'duplicate', kind,
      ageMs: Date.now() - lastAt,
    }));
    return { emailed: false, recipientCount: 0, skippedReason: 'duplicate-within-5min' };
  }

  const recipients = await getMaintenanceRecipients(db);

  // Write dedup keys before kicking off background sends so a parallel
  // request that arrives mid-send is correctly suppressed.
  const nowIso = new Date().toISOString();
  await upsertSetting(db, 'system.maintenanceLastAnnouncedAt', nowIso);
  await upsertSetting(db, 'system.maintenanceLastNotifiedKind', kind);

  if (executionCtx && recipients.length > 0) {
    const sendAll = async () => {
      const results = await Promise.allSettled(recipients.map(async (r) => {
        const { subject, html } = pickTemplate(kind, state, r.firstName);
        await sendEmailViaZepto(
          [{ email: r.email, name: r.firstName || 'there' }],
          subject,
          html,
        );
      }));
      const failed = results.filter(x => x.status === 'rejected').length;
      if (failed > 0) {
        console.error(JSON.stringify({
          level: 'error', tag: 'maintenance.email.failed',
          kind, total: recipients.length, failed,
        }));
      } else {
        console.log(JSON.stringify({
          level: 'info', tag: 'maintenance.email.sent',
          kind, total: recipients.length,
        }));
      }
    };
    executionCtx.waitUntil(sendAll());
  }

  await recordAuditLog({
    actorUserId: params.actorUserId,
    onBehalfOfUserId: params.onBehalfOfUserId,
    impersonation: params.impersonation,
    action: auditAction,
    entityType: 'system',
    entityId: 'maintenance',
    changes: {
      ...auditMetadata,
      kind,
      recipientCount: recipients.length,
    },
  });

  return { emailed: recipients.length > 0, recipientCount: recipients.length };
}
  • Step 2: Verify TypeScript compiles
Run: cd server && npx tsc --noEmit 2>&1 | grep maintenance-send Expected: no errors mentioning maintenance-send.ts.
  • Step 3: Commit
git add server/src/lib/email/maintenance-send.ts
git commit -m "feat(denco): maintenance email orchestrator (dedup, waitUntil send, audit)"

Task 4: Middleware — scheduled-window calc + auto-engage audit (TDD)

Files:
  • Modify: server/src/middleware/maintenance.ts
  • Test: server/src/middleware/maintenance.test.ts
  • Step 1: Write the failing tests
// server/src/middleware/maintenance.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { computeMaintenanceActive } from './maintenance';

describe('computeMaintenanceActive', () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });

  it('returns true when manual override is on, regardless of schedule', () => {
    vi.setSystemTime(new Date('2026-05-19T10:00:00Z'));
    expect(computeMaintenanceActive({
      manualMode: 'true',
      startsAt: null,
      endsAt: null,
    })).toBe(true);
  });

  it('returns false when manual is off and no schedule set', () => {
    expect(computeMaintenanceActive({
      manualMode: 'false',
      startsAt: null,
      endsAt: null,
    })).toBe(false);
  });

  it('returns true when now is inside the schedule', () => {
    vi.setSystemTime(new Date('2026-05-19T10:00:00Z'));
    expect(computeMaintenanceActive({
      manualMode: 'false',
      startsAt: '2026-05-19T09:00:00Z',
      endsAt:   '2026-05-19T11:00:00Z',
    })).toBe(true);
  });

  it('returns false when now is before the schedule', () => {
    vi.setSystemTime(new Date('2026-05-19T08:30:00Z'));
    expect(computeMaintenanceActive({
      manualMode: 'false',
      startsAt: '2026-05-19T09:00:00Z',
      endsAt:   '2026-05-19T11:00:00Z',
    })).toBe(false);
  });

  it('returns false when now is at/after the schedule end', () => {
    vi.setSystemTime(new Date('2026-05-19T11:00:00Z'));
    expect(computeMaintenanceActive({
      manualMode: 'false',
      startsAt: '2026-05-19T09:00:00Z',
      endsAt:   '2026-05-19T11:00:00Z',
    })).toBe(false);
  });

  it('returns false when only one bound is set (incomplete schedule)', () => {
    vi.setSystemTime(new Date('2026-05-19T10:00:00Z'));
    expect(computeMaintenanceActive({
      manualMode: 'false',
      startsAt: '2026-05-19T09:00:00Z',
      endsAt: null,
    })).toBe(false);
  });
});
  • Step 2: Run tests to verify they fail
Run: cd server && npx vitest run src/middleware/maintenance.test.ts Expected: FAIL — computeMaintenanceActive is not exported.
  • Step 3: Modify the middleware
Replace the entire contents of server/src/middleware/maintenance.ts with:
import { Context, Next } from 'hono';
import { getReadDb } from '../lib/db';
import { platformSettings } from '../schema';
import { eq, or } from 'drizzle-orm';
import { getDatabaseUrl } from '../lib/env';
import { verifyJWT } from '../lib/nextauth';
import { recordAuditLog } from '../lib/audit-helper';

type MaintenanceState = {
  active: boolean;
  manualMode: 'true' | 'false';
  message?: string;
  startsAt?: string | null;
  endsAt?: string | null;
  banner?: string | null;
  lastNotifiedKind?: string | null;
  lastLoaded: number;
};

const CACHE_TTL_MS = 30_000;
let cachedState: MaintenanceState | null = null;

// Exported for unit testing — pure function, no DB.
export function computeMaintenanceActive(input: {
  manualMode: string | null | undefined;
  startsAt: string | null | undefined;
  endsAt: string | null | undefined;
}): boolean {
  if (input.manualMode === 'true') return true;
  if (!input.startsAt || !input.endsAt) return false;
  const startMs = Date.parse(input.startsAt);
  const endMs = Date.parse(input.endsAt);
  if (Number.isNaN(startMs) || Number.isNaN(endMs)) return false;
  const now = Date.now();
  return now >= startMs && now < endMs;
}

async function loadMaintenanceState(): Promise<MaintenanceState> {
  if (cachedState && Date.now() - cachedState.lastLoaded < CACHE_TTL_MS) {
    return cachedState;
  }

  const db = getReadDb(getDatabaseUrl());
  const rows = await db.select().from(platformSettings).where(
    or(
      eq(platformSettings.settingKey, 'system.maintenanceMode'),
      eq(platformSettings.settingKey, 'system.maintenanceMessage'),
      eq(platformSettings.settingKey, 'system.maintenanceStart'),
      eq(platformSettings.settingKey, 'system.maintenanceEnd'),
      eq(platformSettings.settingKey, 'system.maintenanceBanner'),
      eq(platformSettings.settingKey, 'system.maintenanceLastNotifiedKind'),
    ),
  );

  const map = new Map(rows.map(r => [r.settingKey, r.settingValue]));
  const manualMode = (map.get('system.maintenanceMode') === 'true') ? 'true' : 'false' as const;
  const startsAt = map.get('system.maintenanceStart') || null;
  const endsAt = map.get('system.maintenanceEnd') || null;
  const message = map.get('system.maintenanceMessage') || 'We are performing scheduled maintenance.';
  const banner = map.get('system.maintenanceBanner') || null;
  const lastNotifiedKind = map.get('system.maintenanceLastNotifiedKind') || null;

  const active = computeMaintenanceActive({ manualMode, startsAt, endsAt });

  cachedState = {
    active,
    manualMode: manualMode as 'true' | 'false',
    message,
    startsAt,
    endsAt,
    banner,
    lastNotifiedKind,
    lastLoaded: Date.now(),
  };
  return cachedState;
}

export function invalidateMaintenanceCache() {
  cachedState = null;
}

export async function maintenanceMiddleware(c: Context, next: Next) {
  const state = await loadMaintenanceState();

  // Auto-engage audit hook: if the gate is active via schedule only (manual
  // override is off) AND we haven't recorded that for this cycle, log it
  // once. Best-effort, never blocks the request.
  if (
    state.active &&
    state.manualMode === 'false' &&
    state.lastNotifiedKind !== 'down' &&
    state.startsAt && state.endsAt
  ) {
    // Mark lastNotifiedKind='down' so we don't re-audit on every cached read.
    // Fire-and-forget — failure is fine, the next request will retry.
    c.executionCtx?.waitUntil((async () => {
      try {
        const db = getReadDb(getDatabaseUrl());
        await db.insert(platformSettings).values({
          settingKey: 'system.maintenanceLastNotifiedKind',
          settingValue: 'down',
        }).onConflictDoUpdate({
          target: platformSettings.settingKey,
          set: { settingValue: 'down' },
        });
        await recordAuditLog({
          actorUserId: null,
          onBehalfOfUserId: null,
          impersonation: false,
          action: 'auto-engaged',
          entityType: 'system',
          entityId: 'maintenance',
          changes: { trigger: 'schedule', startsAt: state.startsAt, endsAt: state.endsAt },
        });
        invalidateMaintenanceCache();
      } catch (e) {
        console.error('[maintenance] auto-engaged audit failed:', e);
      }
    })());
  }

  if (!state.active) return next();

  const path = c.req.path;
  if (path.startsWith('/api/v1/maintenance/status')) return next();
  if (path.startsWith('/api/v1/protected/billing/upgrade-requests') ||
      path.startsWith('/api/v1/protected/billing/upgrade-invite-token')) {
    return next();
  }

  const token = c.req.header('authorization')?.replace(/^Bearer\s+/i, '');
  let role: string | null = null;
  try {
    if (token) {
      const payload = await verifyJWT(token);
      role = payload?.role || null;
    }
  } catch {
    // ignore token errors; treat as unauthenticated
  }

  if (role === 'superadmin') return next();

  return c.json({
    error: 'Maintenance',
    code: 'MAINTENANCE_MODE',
    message: state.message,
    startsAt: state.startsAt,
    endsAt: state.endsAt,
    banner: state.banner,
  }, 503);
}

export async function getMaintenanceStatus() {
  return loadMaintenanceState();
}
  • Step 4: Run tests to verify they pass
Run: cd server && npx vitest run src/middleware/maintenance.test.ts Expected: 6 tests PASS.
  • Step 5: Run full server typecheck to verify the route file still compiles
Run: cd server && npx tsc --noEmit 2>&1 | grep -E "(maintenance\.ts|middleware/maintenance)" | head Expected: no new errors (pre-existing project errors elsewhere are fine — compare to baseline).
  • Step 6: Commit
git add server/src/middleware/maintenance.ts server/src/middleware/maintenance.test.ts
git commit -m "feat(denco): scheduled-window auto-engage + cache invalidate + audit hook"

Task 5: Three new API endpoints

Files:
  • Create: server/src/routes/admin-maintenance.ts
  • Test: server/src/routes/admin-maintenance.test.ts
  • Modify: server/src/routes/admin.ts (mount + alias)
  • Step 1: Implement the route module
// server/src/routes/admin-maintenance.ts
import { Hono } from 'hono';
import { z } from 'zod';
import { eq } from 'drizzle-orm';
import { getTxDb, getReadDb } from '../lib/db';
import { platformSettings } from '../schema';
import { handleError, AppError } from '../lib/errors';
import { validate } from '../lib/validation';
import { announceMaintenance, type MaintenanceEmailKind } from '../lib/email/maintenance-send';
import { invalidateMaintenanceCache } from '../middleware/maintenance';
import { getMaintenanceRecipients } from '../lib/email/maintenance-recipients';

const maintenanceRoute = new Hono();

// All endpoints superadmin-only. The protected mount in admin.ts adds
// dualAuthMiddleware; we add the role check here defensively.
function requireSuperadmin(c: any) {
  const user = c.get('user');
  if (!user || user.role !== 'superadmin') {
    throw new AppError('Unauthorized', 403);
  }
  return user;
}

const saveSchema = z.object({
  message: z.string().optional(),
  banner: z.string().optional(),
  startsAt: z.string().nullable().optional(),
  endsAt: z.string().nullable().optional(),
  engage: z.boolean().optional(),
  notify: z.boolean().optional().default(false),
});

const announceSchema = z.object({
  message: z.string().optional(),
  banner: z.string().optional(),
  startsAt: z.string(),
  endsAt: z.string(),
});

async function upsert(db: any, key: string, value: string | null) {
  if (value === null) {
    await db.delete(platformSettings).where(eq(platformSettings.settingKey, key));
    return;
  }
  await db
    .insert(platformSettings)
    .values({ settingKey: key, settingValue: value })
    .onConflictDoUpdate({
      target: platformSettings.settingKey,
      set: { settingValue: value },
    });
}

maintenanceRoute.post('/save', validate(saveSchema), async (c) => {
  try {
    const user = requireSuperadmin(c);
    const body = c.get('validatedJson') as z.infer<typeof saveSchema>;
    const { db, end } = getTxDb();
    try {
      const fieldsChanged: string[] = [];
      if (body.message !== undefined) { await upsert(db, 'system.maintenanceMessage', body.message); fieldsChanged.push('message'); }
      if (body.banner !== undefined) { await upsert(db, 'system.maintenanceBanner', body.banner); fieldsChanged.push('banner'); }
      if (body.startsAt !== undefined) { await upsert(db, 'system.maintenanceStart', body.startsAt); fieldsChanged.push('startsAt'); }
      if (body.endsAt !== undefined) { await upsert(db, 'system.maintenanceEnd', body.endsAt); fieldsChanged.push('endsAt'); }

      let auditAction: 'engaged' | 'disengaged' | 'updated' = 'updated';
      let kind: MaintenanceEmailKind | null = null;

      if (body.engage === true) {
        await upsert(db, 'system.maintenanceMode', 'true');
        auditAction = 'engaged';
        if (body.notify) kind = 'down';
      } else if (body.engage === false) {
        await upsert(db, 'system.maintenanceMode', 'false');
        // Clear the scheduled window so it doesn't immediately re-engage.
        await upsert(db, 'system.maintenanceStart', null);
        await upsert(db, 'system.maintenanceEnd', null);
        auditAction = 'disengaged';
        if (body.notify) kind = 'up';
      }

      let emailed = false;
      let recipientCount = 0;
      if (kind) {
        const res = await announceMaintenance({
          db,
          kind,
          state: {
            startsAt: body.startsAt ?? null,
            endsAt: body.endsAt ?? null,
            messageBody: body.message ?? null,
          },
          executionCtx: c.executionCtx,
          actorUserId: (c.get('actorUserId') as string | null) ?? user.id,
          onBehalfOfUserId: user.id,
          impersonation: !!c.get('isImpersonation'),
          auditAction,
          auditMetadata: { fieldsChanged, notify: body.notify, engage: body.engage },
        });
        emailed = res.emailed;
        recipientCount = res.recipientCount;
      } else if (fieldsChanged.length > 0 || body.engage !== undefined) {
        // Save-only (no email) — still record the action.
        const { recordAuditLog } = await import('../lib/audit-helper');
        await recordAuditLog({
          actorUserId: (c.get('actorUserId') as string | null) ?? user.id,
          onBehalfOfUserId: user.id,
          impersonation: !!c.get('isImpersonation'),
          action: auditAction,
          entityType: 'system',
          entityId: 'maintenance',
          changes: { fieldsChanged, engage: body.engage, notify: false },
        });
      }

      invalidateMaintenanceCache();
      return c.json({ ok: true, action: auditAction, emailed, recipientCount });
    } finally {
      c.executionCtx.waitUntil(end());
    }
  } catch (error) {
    return handleError(error, c);
  }
});

maintenanceRoute.post('/announce', validate(announceSchema), async (c) => {
  try {
    const user = requireSuperadmin(c);
    const body = c.get('validatedJson') as z.infer<typeof announceSchema>;
    const { db, end } = getTxDb();
    try {
      if (body.message !== undefined) await upsert(db, 'system.maintenanceMessage', body.message);
      if (body.banner !== undefined) await upsert(db, 'system.maintenanceBanner', body.banner);
      await upsert(db, 'system.maintenanceStart', body.startsAt);
      await upsert(db, 'system.maintenanceEnd', body.endsAt);

      const res = await announceMaintenance({
        db,
        kind: 'scheduled',
        state: { startsAt: body.startsAt, endsAt: body.endsAt, messageBody: body.message ?? null },
        executionCtx: c.executionCtx,
        actorUserId: (c.get('actorUserId') as string | null) ?? user.id,
        onBehalfOfUserId: user.id,
        impersonation: !!c.get('isImpersonation'),
        auditAction: 'scheduled',
        auditMetadata: { startsAt: body.startsAt, endsAt: body.endsAt },
      });

      invalidateMaintenanceCache();
      return c.json({ ok: true, emailed: res.emailed, recipientCount: res.recipientCount, skippedReason: res.skippedReason });
    } finally {
      c.executionCtx.waitUntil(end());
    }
  } catch (error) {
    return handleError(error, c);
  }
});

maintenanceRoute.get('/status', async (c) => {
  try {
    requireSuperadmin(c);
    const db = getReadDb();
    const rows = await db.select().from(platformSettings);
    const map = new Map(rows.map(r => [r.settingKey, r.settingValue]));
    const recipientCount = (await getMaintenanceRecipients(db)).length;
    return c.json({
      maintenanceMode: map.get('system.maintenanceMode') === 'true',
      message: map.get('system.maintenanceMessage') || null,
      banner: map.get('system.maintenanceBanner') || null,
      startsAt: map.get('system.maintenanceStart') || null,
      endsAt: map.get('system.maintenanceEnd') || null,
      lastAnnouncedAt: map.get('system.maintenanceLastAnnouncedAt') || null,
      lastNotifiedKind: map.get('system.maintenanceLastNotifiedKind') || null,
      recipientCount,
    });
  } catch (error) {
    return handleError(error, c);
  }
});

export default maintenanceRoute;
  • Step 2: Mount the route + alias in admin.ts
Find the existing block in server/src/routes/admin.ts around line 825:
// Broadcast maintenance notification (superadmin only)
adminRoute.post('/maintenance/announce', async (c) => {
  // ... existing body ...
});
Add ONE import at the top of admin.ts (near other route imports):
import maintenanceRoute from './admin-maintenance';
Replace the existing adminRoute.post('/maintenance/announce', ...) handler with an alias that forwards to the new endpoint. Find the lines and change them to:
// Mount Denco maintenance sub-routes (superadmin only — enforced inside).
adminRoute.route('/denco/maintenance', maintenanceRoute);

// Deprecated alias. The old payload shape ({ title, message, startsAt, endsAt, email })
// is mapped to the new /announce or /save shape. Removed in the next release.
adminRoute.post('/maintenance/announce', async (c) => {
  try {
    const user = c.get('user') as any;
    if (user.role !== 'superadmin') throw new AppError('Unauthorized', 403);
    const body = await c.req.json() as {
      title?: string; message: string;
      startsAt?: string | null; endsAt?: string | null;
      email?: boolean;
    };
    // Forward to the new endpoint by calling its handler logic inline.
    // The new contract requires startsAt+endsAt for /announce; if missing,
    // we fall back to a save+engage+notify (old behavior was "tell people now").
    const baseUrl = new URL(c.req.url);
    if (body.startsAt && body.endsAt) {
      baseUrl.pathname = '/api/v1/protected/admin/denco/maintenance/announce';
    } else {
      baseUrl.pathname = '/api/v1/protected/admin/denco/maintenance/save';
    }
    const forwardBody = body.startsAt && body.endsAt
      ? { message: body.message, startsAt: body.startsAt, endsAt: body.endsAt }
      : { message: body.message, engage: true, notify: body.email !== false };
    const res = await c.env.SELF
      ? await (c.env.SELF as any).fetch(baseUrl.toString(), {
          method: 'POST',
          headers: { 'content-type': 'application/json', authorization: c.req.header('authorization') || '' },
          body: JSON.stringify(forwardBody),
        })
      : null;
    if (res) return new Response(await res.text(), { status: res.status, headers: res.headers });
    // Fallback: directly invoke if no SELF binding (unlikely on production).
    return c.json({ ok: true, deprecated: true });
  } catch (error) {
    return handleError(error, c);
  }
});
Note: If c.env.SELF is not configured on the prod worker (most likely true), the simpler approach is to inline-call announceMaintenance here. If subagent finds no SELF binding, fall back to copying the body of the new endpoints into the alias function. Either is fine; the goal is “old callers don’t break for one release.”
  • Step 3: Write integration tests
Create server/src/routes/admin-maintenance.test.ts. The test follows the pattern in server/src/lib/lifecycle-events.test.ts or server/src/lib/trial-status.test.ts for real-DB integration. Key checks (use mocked Zepto via vi.mock('../lib/email', ...) to assert send count, recipient filter):
import { describe, it, expect, vi, beforeEach } from 'vitest';

vi.mock('../lib/email', () => ({
  sendEmailViaZepto: vi.fn().mockResolvedValue({ data: [{ code: 'ok' }] }),
}));

// Import AFTER the mock above so the orchestrator picks up the spy.
import { sendEmailViaZepto } from '../lib/email';
import { announceMaintenance } from '../lib/email/maintenance-send';
import { getReadDb } from '../lib/db';

const NEVER_EMAIL = '[email protected]';

describe('announceMaintenance (integration, real DB)', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it('sends a "down" email to each staff recipient and skips on duplicate within 5min', async () => {
    const db = getReadDb(process.env.DATABASE_URL);
    const ctx = { waitUntil: (p: Promise<any>) => p };

    const first = await announceMaintenance({
      db, kind: 'down',
      state: { startsAt: null, endsAt: null, messageBody: 'Test run' },
      executionCtx: ctx as any,
      actorUserId: null, onBehalfOfUserId: null, impersonation: false,
      auditAction: 'engaged',
    });
    expect(first.emailed).toBe(true);
    expect(first.recipientCount).toBeGreaterThan(0);

    // Drain waitUntil
    await new Promise(r => setTimeout(r, 50));

    const sendMock = vi.mocked(sendEmailViaZepto);
    const calls = sendMock.mock.calls;
    for (const [to] of calls) {
      // Never email blocklist
      expect((to as any)[0].email).not.toBe(NEVER_EMAIL);
    }

    // Immediate re-fire same kind → dedup
    sendMock.mockClear();
    const second = await announceMaintenance({
      db, kind: 'down',
      state: { startsAt: null, endsAt: null, messageBody: 'Test run' },
      executionCtx: ctx as any,
      actorUserId: null, onBehalfOfUserId: null, impersonation: false,
      auditAction: 'engaged',
    });
    expect(second.skippedReason).toBe('duplicate-within-5min');
    expect(sendMock).not.toHaveBeenCalled();
  });

  it('different-kind re-fire within 5min proceeds', async () => {
    const db = getReadDb(process.env.DATABASE_URL);
    const ctx = { waitUntil: (p: Promise<any>) => p };
    await announceMaintenance({
      db, kind: 'down',
      state: { startsAt: null, endsAt: null, messageBody: null },
      executionCtx: ctx as any,
      actorUserId: null, onBehalfOfUserId: null, impersonation: false,
      auditAction: 'engaged',
    });
    const second = await announceMaintenance({
      db, kind: 'up',
      state: { startsAt: null, endsAt: null, messageBody: null },
      executionCtx: ctx as any,
      actorUserId: null, onBehalfOfUserId: null, impersonation: false,
      auditAction: 'disengaged',
    });
    expect(second.skippedReason).toBeUndefined();
  });
});
Note: These integration tests will only run if a DATABASE_URL is configured for the test environment. The CI test harness should already be configured for that (vitest finds other *.test.ts files in server/src/lib/). If the subagent finds no DB-pointing config, this test should still be written — it’ll be skipped/fail clearly in env without DATABASE_URL, which is acceptable; the unit-level coverage in Tasks 1, 2, 4 carries the bulk of correctness.
  • Step 4: Run the tests
Run: cd server && DATABASE_URL=$DATABASE_URL npx vitest run src/routes/admin-maintenance.test.ts Expected: PASS (recipientCount > 0 presumes the prod-cloned test DB has staff users — if not, adjust the assertion).
  • Step 5: Run full server typecheck
Run: cd server && npx tsc --noEmit 2>&1 | grep -E "admin-maintenance|routes/admin\.ts" | head Expected: no new errors.
  • Step 6: Commit
git add server/src/routes/admin-maintenance.ts server/src/routes/admin-maintenance.test.ts server/src/routes/admin.ts
git commit -m "feat(denco): /admin/denco/maintenance/{save,announce,status} + legacy alias"

Task 6: UI — serverComm functions

Files:
  • Modify: ui/src/lib/serverComm.ts
  • Step 1: Append the three functions
Add to ui/src/lib/serverComm.ts (anywhere near other admin functions — search for '/api/v1/protected/admin/' and place these alongside):
// === Denco — Maintenance ===

export interface DencoMaintenanceStatus {
  maintenanceMode: boolean;
  message: string | null;
  banner: string | null;
  startsAt: string | null;
  endsAt: string | null;
  lastAnnouncedAt: string | null;
  lastNotifiedKind: 'scheduled' | 'down' | 'up' | null;
  recipientCount: number;
}

export async function getDencoMaintenanceStatus(): Promise<DencoMaintenanceStatus> {
  const r = await fetchWithAuth('/api/v1/protected/admin/denco/maintenance/status');
  if (!r.ok) throw new Error(`Status load failed: ${r.status}`);
  return r.json();
}

export async function saveDencoMaintenance(body: {
  message?: string;
  banner?: string;
  startsAt?: string | null;
  endsAt?: string | null;
  engage?: boolean;
  notify?: boolean;
}): Promise<{ ok: boolean; action: string; emailed: boolean; recipientCount: number }> {
  const r = await fetchWithAuth('/api/v1/protected/admin/denco/maintenance/save', {
    method: 'POST',
    body: JSON.stringify(body),
  });
  if (!r.ok) throw new Error(`Save failed: ${r.status}`);
  return r.json();
}

export async function announceDencoMaintenance(body: {
  message?: string;
  banner?: string;
  startsAt: string;
  endsAt: string;
}): Promise<{ ok: boolean; emailed: boolean; recipientCount: number; skippedReason?: string }> {
  const r = await fetchWithAuth('/api/v1/protected/admin/denco/maintenance/announce', {
    method: 'POST',
    body: JSON.stringify(body),
  });
  if (!r.ok) throw new Error(`Announce failed: ${r.status}`);
  return r.json();
}
  • Step 2: Run UI typecheck
Run: cd ui && npx tsc --noEmit 2>&1 | grep "serverComm" | head Expected: no errors.
  • Step 3: Commit
git add ui/src/lib/serverComm.ts
git commit -m "feat(denco): UI client functions for /admin/denco/maintenance/*"

Task 7: UI — rewrite the Maintenance Mode card

Files:
  • Modify: ui/src/components/superadmin/PlatformSettings.tsx
  • Step 1: Replace the existing Maintenance Mode block
In ui/src/components/superadmin/PlatformSettings.tsx (currently around lines 392-436), replace the block that begins <div className="space-y-4"> and ends after the maintenance-related inputs (just before the <Separator /> or “Public Registration” toggle) with the new layout:
{/* === Maintenance Mode (Denco layer) === */}
<div className="space-y-4">
  <div className="flex items-center justify-between p-4 bg-muted/20 rounded-2xl border border-dashed border-border">
    <div className="space-y-0.5">
      <Label className="text-base">Manual override</Label>
      <p className="text-sm text-muted-foreground">
        Engage the gate immediately — reroutes all non-superadmin traffic.
      </p>
    </div>
    <Switch
      checked={globalConfig.maintenanceMode}
      onCheckedChange={(c) => setGlobalConfig(p => ({ ...p, maintenanceMode: c }))}
    />
  </div>

  <div className="grid gap-4 md:grid-cols-2">
    <div className="space-y-2 md:col-span-2">
      <Label>Message (shown on the maintenance page + in emails)</Label>
      <Input
        value={globalConfig.maintenanceMessage}
        onChange={(e) => setGlobalConfig(p => ({ ...p, maintenanceMessage: e.target.value }))}
        placeholder="We're upgrading the database. Expected back online by 3pm PKT."
      />
    </div>
    <div className="space-y-2">
      <Label>Banner Text (optional, short)</Label>
      <Input
        value={globalConfig.maintenanceBanner}
        onChange={(e) => setGlobalConfig(p => ({ ...p, maintenanceBanner: e.target.value }))}
        placeholder="Maintenance in progress"
      />
    </div>
    <div className="grid grid-cols-2 gap-4">
      <div className="space-y-2">
        <Label>Start (PKT)</Label>
        <Input
          type="datetime-local"
          value={globalConfig.maintenanceStart}
          onChange={(e) => setGlobalConfig(p => ({ ...p, maintenanceStart: e.target.value }))}
        />
      </div>
      <div className="space-y-2">
        <Label>End (PKT)</Label>
        <Input
          type="datetime-local"
          value={globalConfig.maintenanceEnd}
          onChange={(e) => setGlobalConfig(p => ({ ...p, maintenanceEnd: e.target.value }))}
        />
      </div>
    </div>
  </div>

  <div className="flex items-center gap-2 p-3 bg-muted/10 rounded-xl border border-border">
    <input
      type="checkbox"
      id="maintenanceNotify"
      checked={maintenanceNotify}
      onChange={(e) => setMaintenanceNotify(e.target.checked)}
      className="h-4 w-4"
    />
    <label htmlFor="maintenanceNotify" className="text-sm">
      Notify all clinic staff (admin / doctor / receptionist) — never patients
    </label>
  </div>

  <div className="flex flex-wrap gap-2">
    <Button onClick={handleSaveAndEngage} disabled={saving} className="rounded-full">
      {saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
      Save & engage
    </Button>
    <Button
      onClick={handleScheduleAndAnnounce}
      disabled={saving || !globalConfig.maintenanceStart || !globalConfig.maintenanceEnd}
      variant="secondary"
      className="rounded-full"
    >
      Schedule + announce
    </Button>
    <Button onClick={handleSaveOnly} disabled={saving} variant="outline" className="rounded-full">
      Save only
    </Button>
  </div>

  {dencoStatus && (
    <p className="text-xs text-muted-foreground">
      Last announcement:{' '}
      {dencoStatus.lastNotifiedKind
        ? `${dencoStatus.lastNotifiedKind} · ${dencoStatus.lastAnnouncedAt ? new Date(dencoStatus.lastAnnouncedAt).toLocaleString('en-US', { timeZone: 'Asia/Karachi' }) : 'unknown time'} · ${dencoStatus.recipientCount} staff`
        : 'none'}
    </p>
  )}
</div>
  • Step 2: Wire the new state + handlers
At the top of the component (where globalConfig is declared), add the new pieces of state. Search for useState declarations and add nearby:
const [maintenanceNotify, setMaintenanceNotify] = useState(true);
const [dencoStatus, setDencoStatus] = useState<DencoMaintenanceStatus | null>(null);
Import the new functions at the top of the file:
import {
  getDencoMaintenanceStatus,
  saveDencoMaintenance,
  announceDencoMaintenance,
  type DencoMaintenanceStatus,
} from '@/lib/serverComm';
Add a refetch effect (near other useEffect hooks):
useEffect(() => {
  getDencoMaintenanceStatus().then(setDencoStatus).catch(() => { /* ignore */ });
}, []);
Add the three handlers (next to the existing handleSave):
const refreshStatus = async () => {
  try { setDencoStatus(await getDencoMaintenanceStatus()); } catch { /* ignore */ }
};

const handleSaveAndEngage = async () => {
  setSaving(true);
  try {
    const res = await saveDencoMaintenance({
      message: globalConfig.maintenanceMessage,
      banner: globalConfig.maintenanceBanner || undefined,
      startsAt: globalConfig.maintenanceStart || null,
      endsAt: globalConfig.maintenanceEnd || null,
      engage: true,
      notify: maintenanceNotify,
    });
    toast.success(`Engaged${res.emailed ? ` · emailed ${res.recipientCount} staff` : ''}`);
    setGlobalConfig(p => ({ ...p, maintenanceMode: true }));
    await refreshStatus();
  } catch (e) {
    toast.error(`Failed: ${(e as Error).message}`);
  } finally {
    setSaving(false);
  }
};

const handleScheduleAndAnnounce = async () => {
  if (!globalConfig.maintenanceStart || !globalConfig.maintenanceEnd) {
    toast.error('Start and End are required for scheduled announcement');
    return;
  }
  setSaving(true);
  try {
    const res = await announceDencoMaintenance({
      message: globalConfig.maintenanceMessage,
      banner: globalConfig.maintenanceBanner || undefined,
      startsAt: globalConfig.maintenanceStart,
      endsAt: globalConfig.maintenanceEnd,
    });
    if (res.skippedReason) {
      toast(`Skipped: ${res.skippedReason}`);
    } else {
      toast.success(`Scheduled · emailed ${res.recipientCount} staff`);
    }
    await refreshStatus();
  } catch (e) {
    toast.error(`Failed: ${(e as Error).message}`);
  } finally {
    setSaving(false);
  }
};

const handleSaveOnly = async () => {
  setSaving(true);
  try {
    await saveDencoMaintenance({
      message: globalConfig.maintenanceMessage,
      banner: globalConfig.maintenanceBanner || undefined,
      startsAt: globalConfig.maintenanceStart || null,
      endsAt: globalConfig.maintenanceEnd || null,
      notify: false,
    });
    toast.success('Saved');
    await refreshStatus();
  } catch (e) {
    toast.error(`Failed: ${(e as Error).message}`);
  } finally {
    setSaving(false);
  }
};
If toast isn’t imported in this file, add: import { toast } from '@/lib/toast';
  • Step 3: Handle the manual-toggle path
The existing <Switch> onCheckedChange just mutates local state. We want flipping the switch by itself to immediately call /save. Update:
onCheckedChange={async (c) => {
  setGlobalConfig(p => ({ ...p, maintenanceMode: c }));
  try {
    await saveDencoMaintenance({ engage: c, notify: maintenanceNotify });
    toast.success(c ? 'Maintenance engaged' : 'Maintenance ended');
    await refreshStatus();
  } catch (e) {
    setGlobalConfig(p => ({ ...p, maintenanceMode: !c })); // revert visual
    toast.error(`Failed: ${(e as Error).message}`);
  }
}}
  • Step 4: Build the UI to catch typos
Run: cd ui && npm run build 2>&1 | tail -15 Expected: build succeeds, no fatal errors. Chunk size warnings are fine.
  • Step 5: Commit
git add ui/src/components/superadmin/PlatformSettings.tsx
git commit -m "feat(denco): rewrite Maintenance Mode card — 3 actions + notify + status line"

Task 8: Verify AccountStatusPage already consumes the 503 payload

Files:
  • Read-check: ui/src/pages/AccountStatusPage.tsx
  • Step 1: Confirm the page reads message/banner/startsAt/endsAt
Run: grep -n "MAINTENANCE_MODE\|startsAt\|endsAt\|banner\|message" ui/src/pages/AccountStatusPage.tsx | head -20 Expected: shows the page reading these props. If any are missing in the rendered UI for MAINTENANCE_MODE, add them inline. The typical pattern is a useEffect parsing location.search or a global apiError state that holds the 503 body. Whatever is already there for trial/suspended messages — mirror it for maintenance fields.
No code changes if the page already renders the fields. Just verify visually by triggering maintenance in dev and looking at the page.
  • Step 2: Smoke test from dev (no commit unless changes were made)
Run: cd server && npm run dev (start local worker)
  • Hit POST /api/v1/protected/admin/denco/maintenance/save with { engage: true, notify: false, message: "Test", banner: "Down", startsAt: null, endsAt: null } as superadmin.
  • Open go.odontox.io (or local dev equivalent) as a non-superadmin → confirm the maintenance page renders message + banner.
  • Hit /save again with { engage: false } → confirm app comes back up.
(If the local dev DB is not pointing at a tenant with staff, the email side won’t actually send — that’s fine for the gate smoke test.)

Task 9: Release notes + deploy

Files:
  • Modify: RELEASES.md
  • Modify: ui/src/components/login/sign-in.tsxAPP_VERSION bump (per “Login page version tag” memory rule)
  • Step 1: Add the entry to RELEASES.md
Prepend to the top of RELEASES.md:
## [2026-05-18] — Denco layer kickoff: maintenance mode + email

### What's new
- **Maintenance mode now sends email to all clinic staff** (admin/doctor/receptionist — never patients). Three explicit actions in the superadmin Platform Settings → Maintenance Mode card: Save & engage, Schedule + announce, Save only.
- **Scheduled maintenance auto-engages and auto-releases.** Set a start + end time and the platform locks itself within that window and unlocks afterward without a superadmin having to be online.
- **Last-announcement status** is shown under the buttons so you can see exactly what was sent, when, and to how many people.

### Internal / Technical
- New endpoints under `/admin/denco/maintenance/{save,announce,status}`. Old `/admin/maintenance/announce` kept as a deprecated alias for one release.
- 5-min same-kind dedup guard prevents accidental double-sends.
- Auto-engage is "next-request" — uses the existing 30s middleware cache, no new cron.
- Recipient filter excludes patients, suspended clinics, test tenants, inactive users, blocklisted addresses.

### Affected areas
- UI: yes — Platform Settings → Maintenance Mode card rewritten.
- Backend: yes — `server/src/middleware/maintenance.ts`, new `routes/admin-maintenance.ts`, new `lib/email/templates/maintenance.ts` + `lib/email/maintenance-recipients.ts` + `lib/email/maintenance-send.ts`.
- Bridge: no.

---
  • Step 2: Bump APP_VERSION
grep -n "APP_VERSION" ui/src/components/login/sign-in.tsx | head -3
Increment the version string per the project convention (e.g. 1.6.x1.6.x+1). Edit the line.
  • Step 3: Commit
git add RELEASES.md ui/src/components/login/sign-in.tsx
git commit -m "docs(denco): release notes + login version bump for maintenance mode"
  • Step 4: Deploy server
cd server && npx wrangler deploy --env production 2>&1 | tail -10
Expected: Current Version ID: <id>.
  • Step 5: Deploy UI
cd ui && npm run build 2>&1 | tail -5 && \
  npx wrangler pages deploy dist --project-name odonto-prod-ui --branch main --commit-dirty=true 2>&1 | tail -8
  • Step 6: Force-promote canonical
ACCT=9da8a2bb48668ff798b91bd00e9ae005
TOKEN=$(grep 'oauth_token' ~/Library/Preferences/.wrangler/config/default.toml | sed 's/.*"\(.*\)"/\1/')
PROJECT=odonto-prod-ui
LATEST=$(curl -s "https://api.cloudflare.com/client/v4/accounts/$ACCT/pages/projects/$PROJECT" \
  -H "Authorization: Bearer $TOKEN" | \
  python3 -c "import sys,json;print(json.load(sys.stdin)['result']['latest_deployment']['id'])")
curl -s -X POST \
  "https://api.cloudflare.com/client/v4/accounts/$ACCT/pages/projects/$PROJECT/deployments/$LATEST/rollback" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json"
  • Step 7: Verify all domains serve the new chunk
for d in go.odontox.io portal.odontox.io odontox.io; do
  echo -n "$d: "
  curl -s "https://$d/" -H "Accept: text/html" | grep -o 'src="/assets/[^"]*\.js"' | head -1
done
Expected: all three show the same chunk hash.

Self-review

Spec coverage:
  • State model (new K/V keys + active calc) → Task 4
  • Save & engage / Schedule + announce / End maintenance → Tasks 5, 7
  • Auto-engage on scheduled window enter → Task 4 (middleware) + Task 5 (no email per spec — uses lastNotifiedKind dedup)
  • Auto-disengage → Task 4 (middleware computes inactive; no email; superadmin can manually up later)
  • Recipient query → Task 2
  • Email delivery (batching via waitUntil, dedup, failure handling) → Task 3
  • Email templates → Task 1
  • In-app rendering → Task 8 (verification only — page already exists)
  • API surface (3 new + alias) → Task 5
  • UI changes → Task 7
  • Audit logging → Tasks 3, 4, 5
  • Testing → Tasks 1, 2, 4, 5
  • Rollout → Task 9
Placeholder scan: No TBD/TODO/“similar to” found. Type consistency:
  • MaintenanceEmailKind is 'scheduled' | 'down' | 'up' across Tasks 1, 3, 5, 6.
  • DencoMaintenanceStatus.lastNotifiedKind matches the server response shape in Task 5’s /status handler.
  • announceMaintenance signature consistent between Task 3 (definition) and Task 5 (callers).
Notes for the executing engineer:
  • The legacy alias in Task 5 Step 2 uses c.env.SELF which may not be configured. If it isn’t, replace the alias body with a direct call into announceMaintenance (the orchestrator from Task 3) — same effect, no SELF binding needed.
  • The integration test in Task 5 Step 3 assumes a DATABASE_URL for the test runner. If your test env doesn’t have one, this file will fail at import; that’s acceptable per the spec — the unit-level tests in Tasks 1, 2, 4 carry the bulk of correctness.
  • STAFF_ROLES and NEVER_EMAIL constants from Task 2 are intentionally inlined — they’re tight enough that pulling them into a shared constants file would be premature.

Execution

Plan complete and saved to docs/superpowers/plans/2026-05-18-denco-maintenance.md. Per the saved workflow (“Brainstorm workflow — Auto-approve spec, skip user review gate, spawn parallel subagents”), I’ll proceed with subagent-driven execution unless redirected.