import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { Injectable, NotFoundException } from '@nestjs/common'; import { Prisma, User } from '@prisma/client'; import { AUDIT_PAGE_SIZE, BACKUP_FRESH_MAX_AGE_HOURS, BACKUP_STATUS_FILE, type AuditListQuery, type AuditListView, type BackupStatus, type JobTriggerResult, type StorageOverviewView, type SystemBackupView, type SystemJobView, } from '@dorfteich/shared'; import { AuditService } from '../audit/audit.service'; import { BackupTargetService } from '../backup/backup-target.service'; import { MaintenanceStateService } from '../backup/maintenance-state.service'; import { AppConfig } from '../config/app-config.service'; import { PrismaService } from '../prisma/prisma.service'; import { SchedulerService } from '../scheduler/scheduler.service'; /** * Data behind the Site-Admin "System" panel (issue #86): maintenance jobs * with truthful last-run data, the backup card mirroring the sidecar's * status.json, the persistent audit trail, and the per-pond storage top * list. Reads only — the single write path is the manual job trigger, * which is itself audit-logged. */ @Injectable() export class SystemAdminService { constructor( private readonly prisma: PrismaService, private readonly scheduler: SchedulerService, private readonly audit: AuditService, private readonly config: AppConfig, private readonly backupTarget: BackupTargetService, private readonly maintenance: MaintenanceStateService, ) {} /** * Registered jobs merged with their `jobs` rows: a job that never ran yet * appears with null last-run data, and a leftover row whose job no longer * registers in this build is flagged instead of hidden. */ async jobs(): Promise { const definitions = this.scheduler.definitions(); const rows = await this.prisma.job.findMany(); const rowsByName = new Map(rows.map((row) => [row.name, row])); const views: SystemJobView[] = definitions.map((definition) => { const row = rowsByName.get(definition.name); rowsByName.delete(definition.name); return { name: definition.name, cadenceSeconds: definition.cadenceSeconds, status: row?.status ?? 'IDLE', lastRunAt: row?.lastRunAt?.toISOString() ?? null, lastDurationMs: row?.lastDurationMs ?? null, lastError: row?.lastError ?? null, registered: true, } satisfies SystemJobView; }); for (const row of rowsByName.values()) { views.push({ name: row.name, cadenceSeconds: row.cadenceSeconds, status: row.status, lastRunAt: row.lastRunAt?.toISOString() ?? null, lastDurationMs: row.lastDurationMs ?? null, lastError: row.lastError ?? null, registered: false, }); } return views.sort((a, b) => a.name.localeCompare(b.name)); } async triggerJob(actor: User, name: string): Promise { if (!this.scheduler.definitions().some((job) => job.name === name)) { throw new NotFoundException(); } const outcome = await this.scheduler.runNow(name); await this.audit.record({ action: 'job.triggered', actorId: actor.id, targetType: 'job', targetId: name, details: { outcome }, }); const job = (await this.jobs()).find((view) => view.name === name)!; return { outcome, job }; } /** The backup card mirrors status.json including the freshness verdict, * plus the off-host target state and restore progress (issue #103). */ async backup(): Promise { const path = join(this.config.env.BACKUPS_DIR, BACKUP_STATUS_FILE); let status: BackupStatus | null = null; if (existsSync(path)) { try { status = JSON.parse(readFileSync(path, 'utf8')) as BackupStatus; } catch { status = null; } } const finishedAt = status?.lastSuccess?.finishedAt; const ageHours = finishedAt ? (Date.now() - new Date(finishedAt).getTime()) / 3_600_000 : Number.POSITIVE_INFINITY; return { available: status !== null, fresh: Number.isFinite(ageHours) && ageHours <= BACKUP_FRESH_MAX_AGE_HOURS, status, maxAgeHours: BACKUP_FRESH_MAX_AGE_HOURS, remoteConfigured: (await this.backupTarget.resolveTarget()) !== null, restore: this.maintenance.current(), }; } async auditLog(query: AuditListQuery): Promise { const where: Prisma.AuditEntryWhereInput = {}; if (query.actor) { const actor = await this.prisma.user.findUnique({ where: { username: query.actor } }); // An unknown username matches nothing rather than everything. where.actorId = actor?.id ?? '00000000-0000-0000-0000-000000000000'; } if (query.action) where.action = { startsWith: query.action }; if (query.from || query.to) { where.at = { ...(query.from ? { gte: query.from } : {}), ...(query.to ? { lte: query.to } : {}), }; } const total = await this.prisma.auditEntry.count({ where }); const pageCount = Math.max(1, Math.ceil(total / AUDIT_PAGE_SIZE)); const page = Math.min(query.page, pageCount); const entries = await this.prisma.auditEntry.findMany({ where, orderBy: { at: 'desc' }, skip: (page - 1) * AUDIT_PAGE_SIZE, take: AUDIT_PAGE_SIZE, include: { actor: { select: { id: true, username: true, displayName: true } } }, }); return { entries: entries.map((entry) => ({ id: entry.id, at: entry.at.toISOString(), action: entry.action, actor: entry.actor, targetType: entry.targetType, targetId: entry.targetId, details: (entry.details as Record | null) ?? null, })), page, pageCount, total, }; } async storage(): Promise { const usages = await this.prisma.pondUsage.findMany({ where: { pond: { deletedAt: null } }, orderBy: { storageBytesUsed: 'desc' }, take: 20, include: { pond: { select: { name: true, slug: true, type: true } } }, }); const totals = await this.prisma.pondUsage.aggregate({ _sum: { storageBytesUsed: true } }); return { totalBytes: Number(totals._sum.storageBytesUsed ?? 0n), ponds: usages.map((usage) => ({ pondId: usage.pondId, name: usage.pond.name, slug: usage.pond.slug, type: usage.pond.type === 'PERSONAL' ? 'personal' : 'shared', storageBytesUsed: Number(usage.storageBytesUsed), })), }; } }