import { z } from 'zod'; import type { BackupStatus, RestoreStatus } from './backup-status'; /** * Site-Admin system panel (issue #86): maintenance jobs, backup status, * audit trail, and storage overview — the operator's single glance for * instance health (operations.md §Maintenance jobs). */ export interface SystemJobView { name: string; cadenceSeconds: number; status: 'IDLE' | 'RUNNING' | 'FAILED'; lastRunAt: string | null; lastDurationMs: number | null; lastError: string | null; /** False for a database row whose job no longer registers in this build. */ registered: boolean; } export type JobTriggerOutcome = 'succeeded' | 'failed' | 'already_running'; export interface JobTriggerResult { outcome: JobTriggerOutcome; job: SystemJobView; } export interface SystemBackupView { /** False when no status.json exists (sidecar never ran / not deployed). */ available: boolean; /** Freshness verdict mirroring the readyz `backup` check (issue #85). */ fresh: boolean; status: BackupStatus | null; maxAgeHours: number; /** Whether a Nextcloud target is fully configured (issue #103). */ remoteConfigured: boolean; /** Progress/result of the last in-app restore, if any (issue #103). */ restore: RestoreStatus | null; } /** * Backup configuration surface of the admin settings page (issue #103). * The app password is write-only: the view only says whether one is stored. */ export interface BackupSettingsView { localRetentionDays: number | null; remoteRetentionDays: number; /** * Deploy-level target policy (issue #192, ADR 0026): `allowed` is false * when `BACKUP_ALLOWED_TARGETS` is empty — remote targets are then * UNAVAILABLE by policy, which the UI must distinguish from merely * unconfigured. `allowlist` lets the admin see which hosts qualify. */ remoteTargets: { allowed: boolean; allowlist: string[] }; nextcloud: { enabled: boolean; baseUrl: string; username: string; folder: string; uploadSchedule: 'off' | 'daily' | 'weekly'; passwordSet: boolean; }; } export const backupSettingsInputSchema = z.object({ /** `null` = no override; the sidecar's env value stays authoritative. */ localRetentionDays: z.number().int().min(1).max(3650).nullable(), remoteRetentionDays: z.number().int().min(1).max(3650), nextcloud: z.object({ enabled: z.boolean(), baseUrl: z.string().trim().url({ message: 'validation.url' }).or(z.literal('')), username: z.string().trim().max(200), folder: z.string().trim().min(1).max(500), uploadSchedule: z.enum(['off', 'daily', 'weekly']), /** Empty or omitted = keep the stored app password. */ password: z.string().max(500).optional(), }), }); export type BackupSettingsInput = z.infer; export const backupConnectionTestInputSchema = z.object({ baseUrl: z.string().trim().url({ message: 'validation.url' }), username: z.string().trim().min(1).max(200), folder: z.string().trim().min(1).max(500), /** Empty or omitted = test with the stored app password. */ password: z.string().max(500).optional(), }); export type BackupConnectionTestInput = z.infer; export interface BackupConnectionTestResult { ok: boolean; /** Raw transport/server error for the admin, like the SMTP test (#80). */ error?: string; } /** One restorable set as offered in the restore picker (issue #103). */ export interface BackupSetView { backupId: string; /** UTC run start derived from the backup id. */ startedAt: string; /** Bundle size (remote) or dump+archive sum (local); null when unknown. */ sizeBytes: number | null; } export interface BackupSetsView { local: BackupSetView[]; remoteConfigured: boolean; remote: BackupSetView[]; /** Present when a configured remote target could not be listed. */ remoteError?: string; } export const backupIdSchema = z.string().regex(/^\d{8}-\d{6}$/); export const backupRestoreInputSchema = z.object({ source: z.enum(['local', 'remote']), backupId: backupIdSchema, /** Type-to-confirm safety: must repeat the backup id verbatim. */ confirm: z.string(), }); export type BackupRestoreInput = z.infer; /** Response of the public, maintenance-exempt restore status endpoint. */ export type RestoreStatusResponse = { state: 'idle' } | RestoreStatus; export interface AuditActorView { id: string; username: string; displayName: string; } export interface AuditEntryView { id: string; at: string; action: string; actor: AuditActorView | null; targetType: string | null; targetId: string | null; details: Record | null; } export const AUDIT_PAGE_SIZE = 50; export const auditListQuerySchema = z.object({ /** Exact username of the acting user. */ actor: z.string().trim().min(1).optional(), /** Action id or prefix, e.g. `grant.` matches created and deleted. */ action: z.string().trim().min(1).optional(), from: z.coerce.date().optional(), to: z.coerce.date().optional(), page: z.coerce.number().int().min(1).default(1), }); export type AuditListQuery = z.infer; export interface AuditListView { entries: AuditEntryView[]; page: number; pageCount: number; total: number; } /** * Site-Admin query path over the read-access trail (issue #224, ADR 0023) — * evidence nobody can read is not evidence. Answers the two expected * questions: "who read page X" (pageId) and "what did user Y read" (actor), * both within a period. */ export const READ_EVENT_PAGE_SIZE = 50; export const readEventListQuerySchema = z.object({ /** Page id the events belong to. */ pageId: z.string().trim().min(1).optional(), /** Exact username of the reading user. */ actor: z.string().trim().min(1).optional(), channel: z.string().trim().min(1).optional(), from: z.coerce.date().optional(), to: z.coerce.date().optional(), page: z.coerce.number().int().min(1).default(1), }); export type ReadEventListQuery = z.infer; export interface ReadEventView { id: string; occurredAt: string; actor: AuditActorView | null; /** Null for pond-level attachments (no page); the details name the file. */ pageId: string | null; pondId: string; channel: string; classification: string; /** Seconds the event's dedup window spans (#223) — the row represents up * to this much access time, not a single request. */ windowSeconds: number; details: Record | null; } export interface ReadEventListView { entries: ReadEventView[]; page: number; pageCount: number; total: number; } export interface StoragePondView { pondId: string; name: string; slug: string; /** Lowercase like PondView's `type` (the api's wire convention). */ type: 'personal' | 'shared'; storageBytesUsed: number; } export interface StorageOverviewView { totalBytes: number; /** Top ponds by storage use, largest first. */ ponds: StoragePondView[]; }