dorfteich/apps/api/src/admin/quota-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

183 lines
6.1 KiB
TypeScript

import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import {
QUOTA_KEYS,
QuotaKey,
QuotaLineView,
QuotaSubject,
QuotaSubjectView,
} from '@dorfteich/shared';
import { User } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino';
import { AuditService } from '../audit/audit.service';
import { PrismaService } from '../prisma/prisma.service';
import { QuotaService } from '../quotas/quota.service';
import { UsersService } from '../users/users.service';
/**
* Site-Admin quota override management (issue #58): view/set/clear per-user and
* per-pond overrides on the three-level ladder (pond → user → instance default,
* data-model.md §Quotas). The QuotaService stays the single resolution path —
* this only reads its effective values and writes `quota_overrides` rows, so an
* override takes effect at the next consumption check immediately. Every change
* is audit-logged. Site-Admin gating is the controller's job.
*/
@Injectable()
export class QuotaAdminService {
constructor(
private readonly prisma: PrismaService,
private readonly quotas: QuotaService,
private readonly users: UsersService,
private readonly audit: AuditService,
private readonly logger: PinoLogger,
) {
this.logger.setContext(QuotaAdminService.name);
}
/** Resolve a user (by username/e-mail) or pond (by slug) to id + label. */
async lookup(type: QuotaSubject, q: string): Promise<{ id: string; label: string }> {
if (type === 'user') {
const user = await this.users.findByUsernameOrEmail(q);
if (!user) throw new NotFoundException({ code: 'member_not_found' });
return { id: user.id, label: user.username };
}
const pond = await this.prisma.pond.findFirst({ where: { slug: q.trim(), deletedAt: null } });
if (!pond) throw new NotFoundException();
return { id: pond.id, label: pond.name };
}
async subject(type: QuotaSubject, id: string): Promise<QuotaSubjectView> {
const { label, scope } = await this.subjectScope(type, id);
const overrides = await this.prisma.quotaOverride.findMany({
where: { subjectType: type === 'user' ? 'USER' : 'POND', subjectId: id },
});
const overrideOf = new Map(overrides.map((o) => [o.quotaKey, Number(o.value)]));
const usage = await this.usageFor(type, id);
const lines: QuotaLineView[] = await Promise.all(
QUOTA_KEYS.map(async (key) => ({
key,
override: overrideOf.get(key) ?? null,
instanceDefault: await this.quotas.getEffective(key, {}),
effective: await this.quotas.getEffective(key, scope),
usage: usage[key] ?? null,
})),
);
return { type, id, label, lines };
}
async setOverride(
actor: User,
type: QuotaSubject,
id: string,
key: string,
value: number,
): Promise<QuotaSubjectView> {
const quotaKey = this.assertKey(key);
await this.subjectScope(type, id); // 404s an unknown subject
const subjectType = type === 'user' ? 'USER' : 'POND';
await this.prisma.quotaOverride.upsert({
where: { subjectType_subjectId_quotaKey: { subjectType, subjectId: id, quotaKey } },
create: { subjectType, subjectId: id, quotaKey, value },
update: { value },
});
await this.audit.record({
action: 'quota.override_set',
actorId: actor.id,
targetType: type,
targetId: id,
details: { quotaKey, value },
});
return this.subject(type, id);
}
async clearOverride(
actor: User,
type: QuotaSubject,
id: string,
key: string,
): Promise<QuotaSubjectView> {
const quotaKey = this.assertKey(key);
await this.subjectScope(type, id);
await this.prisma.quotaOverride.deleteMany({
where: { subjectType: type === 'user' ? 'USER' : 'POND', subjectId: id, quotaKey },
});
await this.audit.record({
action: 'quota.override_cleared',
actorId: actor.id,
targetType: type,
targetId: id,
details: { quotaKey },
});
return this.subject(type, id);
}
private assertKey(key: string): QuotaKey {
if (!(QUOTA_KEYS as readonly string[]).includes(key)) {
throw new BadRequestException({ code: 'bad_request' });
}
return key as QuotaKey;
}
/** The subject's label and the ladder scope its effective values resolve on. */
private async subjectScope(
type: QuotaSubject,
id: string,
): Promise<{ label: string; scope: { userId?: string; pondId?: string } }> {
if (type === 'user') {
const user = await this.prisma.user.findUnique({
where: { id },
select: { username: true },
});
if (!user) throw new NotFoundException({ code: 'member_not_found' });
return { label: user.username, scope: { userId: id } };
}
const pond = await this.prisma.pond.findFirst({
where: { id, deletedAt: null },
select: { name: true, ownerId: true },
});
if (!pond) throw new NotFoundException();
// A pond resolves on its own override, then its owner's, then the default.
return { label: pond.name, scope: { pondId: id, userId: pond.ownerId } };
}
/** Current usage for the metered dimensions (soft warning in the UI). */
private async usageFor(
type: QuotaSubject,
id: string,
): Promise<Partial<Record<QuotaKey, number>>> {
if (type === 'pond') {
const [usage, editors, readers] = await Promise.all([
this.prisma.pondUsage.findUnique({ where: { pondId: id } }),
this.prisma.roleGrant.count({
where: {
pondId: id,
role: 'EDITOR',
scopeType: 'POND',
subjectType: 'USER',
effect: 'ALLOW',
},
}),
this.prisma.roleGrant.count({
where: {
pondId: id,
role: 'READER',
scopeType: 'POND',
subjectType: 'USER',
effect: 'ALLOW',
},
}),
]);
return {
storage_bytes: Number(usage?.storageBytesUsed ?? 0),
editors_per_pond: editors,
readers_per_pond: readers,
};
}
const owned = await this.prisma.pond.count({
where: { ownerId: id, type: 'SHARED', deletedAt: null },
});
return { additional_ponds: owned };
}
}