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
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
183 lines
6.1 KiB
TypeScript
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 };
|
|
}
|
|
}
|