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 { 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 { 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 { 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>> { 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 }; } }