// 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;