import { ForbiddenException, Injectable } from '@nestjs/common'; import { QuotaKey } from '@dorfteich/shared'; import { Prisma, QuotaSubjectType } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { InstanceSettingKey, InstanceSettingsService } from '../settings/instance-settings.service'; const SETTING_FOR_KEY: Record = { editors_per_pond: 'quota.editorsPerPond', readers_per_pond: 'quota.readersPerPond', additional_ponds: 'quota.additionalPonds', storage_bytes: 'quota.storageBytes', max_file_bytes: 'quota.maxFileBytes', }; /** 403 with the key and the limit so the client can name both (i18n). */ export function quotaExceeded(quotaKey: QuotaKey, limit: number): ForbiddenException { return new ForbiddenException({ code: 'quota_exceeded', details: { quotaKey, limit } }); } /** * Quota resolution and race-safe consumption (issue #22, ADR 0011). * Every quota question goes through `getEffective`; every guarded write * consumes inside the same transaction as the write it protects. */ @Injectable() export class QuotaService { constructor( private readonly prisma: PrismaService, private readonly settings: InstanceSettingsService, ) {} /** Pond override → user override → instance default; most specific wins. */ async getEffective(key: QuotaKey, scope: { userId?: string; pondId?: string }): Promise { if (scope.pondId) { const pondValue = await this.findOverride('POND', scope.pondId, key); if (pondValue !== null) return pondValue; } if (scope.userId) { const userValue = await this.findOverride('USER', scope.userId, key); if (userValue !== null) return userValue; } return (await this.settings.get(SETTING_FOR_KEY[key])) as number; } /** * Guards creating another shared pond. Must run inside the transaction * that creates the pond; the advisory lock serializes competing * creations by the same user so the count cannot race. */ async assertCanCreateSharedPond(tx: Prisma.TransactionClient, userId: string): Promise { // ::text because Prisma cannot deserialize the function's void result. await tx.$queryRaw`SELECT pg_advisory_xact_lock(hashtext(${`quota:additional_ponds:${userId}`}))::text`; const limit = await this.getEffective('additional_ponds', { userId }); const owned = await tx.pond.count({ // The personal pond never counts against `additional_ponds`. where: { ownerId: userId, type: 'SHARED', deletedAt: null }, }); if (owned >= limit) throw quotaExceeded('additional_ponds', limit); } /** * Consumes storage budget for a pond, atomically against the effective * limit. Throws `quota_exceeded` without changing the counter when the * delta would not fit. */ async checkAndConsume(pondId: string, ownerUserId: string, deltaBytes: number): Promise { const limit = await this.getEffective('storage_bytes', { userId: ownerUserId, pondId }); await this.prisma.$transaction(async (tx) => { await tx.$queryRaw`SELECT pg_advisory_xact_lock(hashtext(${`quota:storage:${pondId}`}))::text`; const usage = await tx.pondUsage.upsert({ where: { pondId }, create: { pondId }, update: {}, }); if (Number(usage.storageBytesUsed) + deltaBytes > limit) { throw quotaExceeded('storage_bytes', limit); } await tx.pondUsage.update({ where: { pondId }, data: { storageBytesUsed: { increment: deltaBytes } }, }); }); } /** Returns storage budget (file deleted/purged); the counter never goes below zero. */ async release(pondId: string, deltaBytes: number): Promise { await this.prisma.$executeRaw` UPDATE pond_usage SET storage_bytes_used = GREATEST(storage_bytes_used - ${deltaBytes}, 0), updated_at = now() WHERE pond_id = ${pondId}`; } private async findOverride( subjectType: QuotaSubjectType, subjectId: string, quotaKey: QuotaKey, ): Promise { const row = await this.prisma.quotaOverride.findUnique({ where: { subjectType_subjectId_quotaKey: { subjectType, subjectId, quotaKey } }, }); return row === null ? null : Number(row.value); } }