feat(billing): cap each owner's stored file volume

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-02 15:44:36 +03:30
co-authored by Cursor
parent e6e90aabf3
commit 28938e8f77
20 changed files with 647 additions and 22 deletions
+17 -2
View File
@@ -1,12 +1,27 @@
import { Global, Module } from '@nestjs/common';
import { StorageModule } from '../storage/storage.module';
import { BillingExpiryService } from './billing-expiry.service';
import { BillingService } from './billing.service';
import { OrgModuleGuard } from './org-module.guard';
import { PlatformAdminAlertService } from './platform-admin-alert.service';
import { StorageQuotaService } from './storage-quota.service';
@Global()
@Module({
providers: [BillingService, BillingExpiryService, PlatformAdminAlertService, OrgModuleGuard],
exports: [BillingService, BillingExpiryService, PlatformAdminAlertService, OrgModuleGuard],
imports: [StorageModule],
providers: [
BillingService,
BillingExpiryService,
PlatformAdminAlertService,
OrgModuleGuard,
StorageQuotaService,
],
exports: [
BillingService,
BillingExpiryService,
PlatformAdminAlertService,
OrgModuleGuard,
StorageQuotaService,
],
})
export class BillingModule {}
@@ -0,0 +1,190 @@
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<StorageUsageDto> {
const ownerUserId = await this.ownerUserId(organizationId);
if (!ownerUserId) return this.empty();
return this.usageForOwner(ownerUserId);
}
async usageForOwner(ownerUserId: string, refresh = false): Promise<StorageUsageDto> {
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<void> {
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<void> {
await this.add(organizationId, 'fileBytes', delta);
}
async addChat(organizationId: string, bytes: number): Promise<StorageUsageDto> {
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<StorageUsageDto> {
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<void> {
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<string | null> {
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<void> {
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' };
}
}