dorfteich/apps/api/src/admin/system-admin.service.ts
Claude Fable 5 c8aac13dfb
All checks were successful
CI / Lint, typecheck, test (push) Successful in 3m14s
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m45s
CD / Deploy to Test (push) Successful in 10s
CD / Smoke tests against Test (push) Successful in 1m11s
CD / Promote to Int (push) Successful in 11s
CI / Auth e2e pack (push) Successful in 5m20s
CI / Import/export fidelity gate (push) Successful in 45s
Add Site-Admin system panel with persistent audit trail (#86)
New /admin/system panel (operations.md §Maintenance jobs): the maintenance
job list shows every registered job with truthful last-run data (new
Job.lastDurationMs recorded by the scheduler) and a manual trigger that
respects the run-mutex and is itself audit-logged; a backup card mirrors
the sidecar's status.json including the freshness verdict; an audit-log
viewer filters by actor, action, and time range with pagination; and a
storage overview lists the largest ponds. Auth events and admin actions
(grants, members, user/quota admin, plugins, settings, setup) now land in
a new audit_log table through a central AuditService — which keeps
emitting the established stdout log line — while content activity stays
log-only by design. All endpoints are Site-Admin-only; covered by API DB
tests and a Playwright pack in CI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-11 20:03:05 +02:00

176 lines
6.0 KiB
TypeScript

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 { 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,
) {}
/**
* 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<SystemJobView[]> {
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<JobTriggerResult> {
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. */
backup(): SystemBackupView {
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,
};
}
async auditLog(query: AuditListQuery): Promise<AuditListView> {
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<string, unknown> | null) ?? null,
})),
page,
pageCount,
total,
};
}
async storage(): Promise<StorageOverviewView> {
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),
})),
};
}
}