191 lines
6.8 KiB
TypeScript
191 lines
6.8 KiB
TypeScript
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' };
|
|
}
|
|
}
|