import { Injectable, PayloadTooLargeException } from '@nestjs/common'; import type { StorageUsageDto } from '@mento/shared'; import { PrismaService } from '../prisma/prisma.service'; import { LocalFileStorage } from '../storage/local-file.storage'; const GIB = 1024 * 1024 * 1024; function asNumber(value: bigint | number | null | undefined): number { if (value == null) return 0; const n = typeof value === 'bigint' ? Number(value) : value; return Number.isFinite(n) ? Math.max(0, n) : 0; } function levelOf(used: number, quota: number | null): StorageUsageDto['level'] { if (quota == null) return 'unlimited'; if (quota <= 0 || used >= quota) return 'full'; if (used >= quota * 0.8) return 'warn'; return 'ok'; } @Injectable() export class StorageQuotaService { constructor( private readonly prisma: PrismaService, private readonly files: LocalFileStorage, ) {} async usageForOrganization(organizationId: string): Promise { const ownerUserId = await this.ownerUserId(organizationId); if (!ownerUserId) return this.empty(); return this.usageForOwner(ownerUserId); } async usageForOwner(ownerUserId: string, refresh = false): Promise { const orgs = await this.ownerOrgs(ownerUserId); if (refresh) { await Promise.all(orgs.map((org) => this.measureLocal(org.id))); } else { await Promise.all( orgs.filter((org) => !org.fileBytesSyncedAt).map((org) => this.measureLocal(org.id)), ); } const fresh = refresh || orgs.some((org) => !org.fileBytesSyncedAt) ? await this.ownerOrgs(ownerUserId) : orgs; const account = await this.prisma.billingAccount.findUnique({ where: { ownerUserId }, select: { storageQuotaBytes: true }, }); const quota = account?.storageQuotaBytes == null ? null : asNumber(account.storageQuotaBytes); const byOrganization = fresh.map((org) => ({ id: org.id, name: org.name, usedBytes: asNumber(org.fileBytes) + asNumber(org.chatFileBytes), })); const used = byOrganization.reduce((sum, org) => sum + org.usedBytes, 0); const percent = quota == null || quota <= 0 ? null : Math.min(100, Math.round((used / quota) * 100)); return { usedBytes: used, quotaBytes: quota, percent, level: levelOf(used, quota), byOrganization, }; } async assertCanAdd(organizationId: string, bytes: number): Promise { if (bytes <= 0) return; const ownerUserId = await this.ownerUserId(organizationId); if (!ownerUserId) return; const usage = await this.usageForOwner(ownerUserId); if (usage.quotaBytes == null) return; if (usage.usedBytes + bytes > usage.quotaBytes) { throw new PayloadTooLargeException( 'حجم فایل این اشتراک پر شده است. تا شارژ حجم، آپلود جدید ممکن نیست.', ); } } async addLocal(organizationId: string, delta: number): Promise { await this.add(organizationId, 'fileBytes', delta); } async addChat(organizationId: string, bytes: number): Promise { if (bytes > 0) await this.assertCanAdd(organizationId, bytes); if (bytes > 0) await this.add(organizationId, 'chatFileBytes', bytes); return this.usageForOrganization(organizationId); } async setQuotaGb(ownerUserId: string, quotaGb: number | null): Promise { const bytes = quotaGb == null ? null : BigInt(Math.round(quotaGb * GIB)); await this.prisma.billingAccount.update({ where: { ownerUserId }, data: { storageQuotaBytes: bytes }, }); return this.usageForOwner(ownerUserId, true); } private async add( organizationId: string, field: 'fileBytes' | 'chatFileBytes', delta: number, ): Promise { if (!delta) return; const row = await this.prisma.organization.update({ where: { id: organizationId }, data: { [field]: { increment: BigInt(Math.round(delta)) } }, select: { fileBytes: true, chatFileBytes: true }, }); const value = field === 'fileBytes' ? row.fileBytes : row.chatFileBytes; if (value < 0n) { await this.prisma.organization.update({ where: { id: organizationId }, data: { [field]: 0n }, }); } } private async ownerUserId(organizationId: string): Promise { const owner = await this.prisma.membership.findFirst({ where: { organizationId, role: 'owner', isActive: true }, orderBy: { createdAt: 'asc' }, select: { userId: true }, }); return owner?.userId ?? null; } private ownerOrgs(ownerUserId: string) { return this.prisma.membership.findMany({ where: { userId: ownerUserId, role: 'owner', isActive: true }, select: { organization: { select: { id: true, name: true, fileBytes: true, chatFileBytes: true, fileBytesSyncedAt: true, }, }, }, }).then((rows) => rows.map((row) => row.organization)); } private async measureLocal(organizationId: string): Promise { const [imageSum, noteSum, treasury, leads, forms, users] = await Promise.all([ this.prisma.activityImage.aggregate({ where: { organizationId }, _sum: { sizeBytes: true }, }), this.prisma.noteAttachment.aggregate({ where: { organizationId }, _sum: { sizeBytes: true }, }), this.prisma.financialAttachment.findMany({ where: { organizationId }, select: { storageKey: true }, }), this.prisma.lead.findMany({ where: { organizationId, logoStorageKey: { not: null } }, select: { logoStorageKey: true }, }), this.prisma.leadForm.findMany({ where: { organizationId, coverStorageKey: { not: null } }, select: { coverStorageKey: true }, }), this.prisma.user.findMany({ where: { avatarStorageKey: { startsWith: `${organizationId}/` } }, select: { avatarStorageKey: true }, }), ]); const keys = [ ...treasury.map((row) => row.storageKey), ...leads.map((row) => row.logoStorageKey).filter((key): key is string => Boolean(key)), ...forms.map((row) => row.coverStorageKey).filter((key): key is string => Boolean(key)), ...users.map((row) => row.avatarStorageKey).filter((key): key is string => Boolean(key)), ]; let disk = 0; for (const key of keys) disk += await this.files.sizeOf(key); const bytes = (imageSum._sum.sizeBytes ?? 0) + (noteSum._sum.sizeBytes ?? 0) + disk; await this.prisma.organization.update({ where: { id: organizationId }, data: { fileBytes: BigInt(bytes), fileBytesSyncedAt: new Date() }, }); } private empty(): StorageUsageDto { return { usedBytes: 0, quotaBytes: null, percent: null, level: 'unlimited' }; } }