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
@@ -19,6 +19,7 @@ import { Prisma } from '@prisma/client';
import { randomUUID } from 'node:crypto';
import * as path from 'node:path';
import { LocalFileStorage } from '../storage/local-file.storage';
import { StorageQuotaService } from '../billing/storage-quota.service';
import { PrismaService } from '../prisma/prisma.service';
import { NotificationsService } from '../notifications/notifications.service';
import { AutomationsService } from '../automations/automations.service';
@@ -128,6 +129,7 @@ export class ActivitiesService {
private readonly types: ActivityTypesService,
private readonly game: GamificationService,
private readonly files: LocalFileStorage,
private readonly storage: StorageQuotaService,
) {}
private async ctx(organizationId: string): Promise<DtoContext> {
@@ -817,8 +819,10 @@ export class ActivitiesService {
await this.ensureExists(organizationId, id, actor);
const images = await this.prisma.activityImage.findMany({
where: { activityId: id, organizationId },
select: { storageKey: true },
select: { storageKey: true, sizeBytes: true },
});
const bytes = images.reduce((sum, image) => sum + image.sizeBytes, 0);
if (bytes) await this.storage.addLocal(organizationId, -bytes);
await this.prisma.activity.delete({ where: { id } });
for (const image of images) {
await this.files.remove(image.storageKey);
@@ -853,6 +857,7 @@ export class ActivitiesService {
const storageKey = path
.join(organizationId, 'activities', activityId, `${imageId}${ext}`)
.replace(/\\/g, '/');
await this.storage.assertCanAdd(organizationId, file.size);
await this.files.save(storageKey, file.buffer);
try {
const row = await this.prisma.activityImage.create({
@@ -867,6 +872,7 @@ export class ActivitiesService {
uploadedById: userId,
},
});
await this.storage.addLocal(organizationId, file.size);
return {
id: row.id,
fileName: row.fileName,
@@ -893,6 +899,7 @@ export class ActivitiesService {
if (!row) throw new NotFoundException('عکس پیدا نشد');
await this.files.remove(row.storageKey);
await this.prisma.activityImage.delete({ where: { id: row.id } });
await this.storage.addLocal(organizationId, -row.sizeBytes);
}
async openImage(
+21 -1
View File
@@ -16,6 +16,7 @@ import type {
LoginResponse,
LoginResult,
OrgModuleKey,
StorageUsageDto,
SubscriptionSummaryDto,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
@@ -26,6 +27,7 @@ import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
import { LocalFileStorage } from '../storage/local-file.storage';
import { MembershipsService } from '../memberships/memberships.service';
import { BillingService } from '../billing/billing.service';
import { StorageQuotaService } from '../billing/storage-quota.service';
import { PlatformAdminAlertService } from '../billing/platform-admin-alert.service';
import { AttendanceService } from '../attendance/attendance.service';
import type { Actor } from '../common/record-scope';
@@ -112,6 +114,7 @@ export class AuthService {
private readonly files: LocalFileStorage,
private readonly memberships: MembershipsService,
private readonly billing: BillingService,
private readonly storageQuota: StorageQuotaService,
private readonly adminAlerts: PlatformAdminAlertService,
private readonly attendance: AttendanceService,
) {}
@@ -621,11 +624,15 @@ export class AuthService {
const storageKey = path
.join(organizationId, 'users', userId, `avatar.${ext}`)
.replace(/\\/g, '/');
const prevBytes = prevKey ? await this.files.sizeOf(prevKey) : 0;
const delta = file.size - prevBytes;
if (delta > 0) await this.storageQuota.assertCanAdd(organizationId, delta);
await this.files.save(storageKey, file.buffer);
if (prevKey && prevKey !== storageKey) {
await this.files.remove(prevKey);
}
await this.writeAvatarKey(userId, storageKey);
if (delta) await this.storageQuota.addLocal(organizationId, delta);
return this.toAuthUserDto(existing, organizationId);
}
@@ -635,7 +642,11 @@ export class AuthService {
throw new UnauthorizedException();
}
const prevKey = await this.readAvatarKey(userId);
if (prevKey) await this.files.remove(prevKey);
if (prevKey) {
const bytes = await this.files.sizeOf(prevKey);
await this.files.remove(prevKey);
if (bytes) await this.storageQuota.addLocal(organizationId, -bytes);
}
await this.writeAvatarKey(userId, null);
return this.toAuthUserDto(existing, organizationId);
}
@@ -829,6 +840,12 @@ export class AuthService {
canOpenTeamSettings: true,
}
: undefined;
let storage: StorageUsageDto | undefined;
try {
storage = await this.storageQuota.usageForOrganization(organizationId);
} catch {
storage = undefined;
}
return this.toAuthUser({
...user,
role,
@@ -839,6 +856,7 @@ export class AuthService {
membership?.organization.enabledModules,
),
subscription: sub,
storage,
isOrgOwner: role === 'owner',
priorArchive: this.priorArchiveFor(membership),
permissions: (Array.isArray(membership?.orgRole?.permissions)
@@ -949,6 +967,7 @@ export class AuthService {
avatarStorageKey?: string | null;
enabledModules?: OrgModuleKey[];
subscription?: SubscriptionSummaryDto | null;
storage?: StorageUsageDto | null;
isOrgOwner?: boolean;
priorArchive?: AuthUserDto['priorArchive'];
viewAs?: AuthUserDto['viewAs'];
@@ -965,6 +984,7 @@ export class AuthService {
...(user.mobile ? { mobile: user.mobile } : {}),
...(user.enabledModules ? { enabledModules: user.enabledModules } : {}),
...(user.subscription ? { subscription: user.subscription } : {}),
...(user.storage ? { storage: user.storage } : {}),
...(user.isOrgOwner !== undefined ? { isOrgOwner: user.isOrgOwner } : {}),
...(user.priorArchive ? { priorArchive: user.priorArchive } : {}),
...(user.viewAs ? { viewAs: user.viewAs } : {}),
+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' };
}
}
@@ -4,6 +4,7 @@ import { CurrentUser } from '../auth/current-user.decorator';
import { RequireOrgModule } from '../billing/org-module.decorator';
import { OrgModuleGuard } from '../billing/org-module.guard';
import {
ConversationFileBytesDto,
CreateConversationDmDto,
CreateConversationGroupDto,
CreateConversationChannelDto,
@@ -19,12 +20,34 @@ import {
ToggleMessageReactionDto,
} from './dto';
import { ConversationsService } from './conversations.service';
import { StorageQuotaService } from '../billing/storage-quota.service';
@Controller('conversations')
@UseGuards(JwtAuthGuard, OrgModuleGuard)
@RequireOrgModule('conversations')
export class ConversationsController {
constructor(private readonly conversations: ConversationsService) {}
constructor(
private readonly conversations: ConversationsService,
private readonly storage: StorageQuotaService,
) {}
@Post('files/reserve')
reserveFile(
@CurrentUser() user: { organizationId: string },
@Body() dto: ConversationFileBytesDto,
) {
return this.storage.assertCanAdd(user.organizationId, dto.bytes).then(() =>
this.storage.usageForOrganization(user.organizationId),
);
}
@Post('files/commit')
commitFile(
@CurrentUser() user: { organizationId: string },
@Body() dto: ConversationFileBytesDto,
) {
return this.storage.addChat(user.organizationId, dto.bytes);
}
@Get('access')
access(@CurrentUser() user: { userId: string; organizationId: string; role: string }) {
+12
View File
@@ -1,9 +1,13 @@
import { Type } from 'class-transformer';
import {
IsArray,
IsBoolean,
IsInt,
IsOptional,
IsString,
Max,
MaxLength,
Min,
MinLength,
ArrayMinSize,
ArrayMaxSize,
@@ -32,6 +36,14 @@ export class UpdateConversationSettingsDto {
downloadUrl?: string;
}
export class ConversationFileBytesDto {
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100 * 1024 * 1024)
bytes!: number;
}
export class CreateConversationDmDto {
@IsString()
@MinLength(1)
+17 -2
View File
@@ -17,6 +17,7 @@ import { isGenderQuestionLabel, parsePersonGenderAnswer } from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { LocalFileStorage } from '../storage/local-file.storage';
import { StorageQuotaService } from '../billing/storage-quota.service';
import { AutomationsService } from '../automations/automations.service';
import { MessageAutomationsService } from '../message-automations/message-automations.service';
import {
@@ -84,6 +85,7 @@ export class LeadFormsService {
constructor(
private readonly prisma: PrismaService,
private readonly files: LocalFileStorage,
private readonly storage: StorageQuotaService,
private readonly automations: AutomationsService,
private readonly messageAutomations: MessageAutomationsService,
private readonly leadStages: LeadStagesService,
@@ -211,7 +213,12 @@ export class LeadFormsService {
}
async remove(organizationId: string, id: string): Promise<void> {
await this.findInOrg(organizationId, id);
const row = await this.findInOrg(organizationId, id);
if (row.coverStorageKey) {
const bytes = await this.files.sizeOf(row.coverStorageKey);
await this.files.remove(row.coverStorageKey);
if (bytes) await this.storage.addLocal(organizationId, -bytes);
}
await this.prisma.leadForm.delete({ where: { id } });
}
@@ -835,6 +842,9 @@ export class LeadFormsService {
throw new BadRequestException('فرمت تصویر کاور باید PNG، JPG یا WEBP باشد');
}
const key = `lead-forms/${id}/cover.${ext}`;
const prevBytes = row.coverStorageKey ? await this.files.sizeOf(row.coverStorageKey) : 0;
const delta = file.buffer.length - prevBytes;
if (delta > 0) await this.storage.assertCanAdd(organizationId, delta);
await this.files.save(key, file.buffer);
if (row.coverStorageKey && row.coverStorageKey !== key) {
await this.files.remove(row.coverStorageKey);
@@ -843,12 +853,17 @@ export class LeadFormsService {
where: { id },
data: { coverStorageKey: key },
});
if (delta) await this.storage.addLocal(organizationId, delta);
return this.get(organizationId, id);
}
async removeCover(organizationId: string, id: string) {
const row = await this.findInOrg(organizationId, id);
if (row.coverStorageKey) await this.files.remove(row.coverStorageKey);
if (row.coverStorageKey) {
const bytes = await this.files.sizeOf(row.coverStorageKey);
await this.files.remove(row.coverStorageKey);
if (bytes) await this.storage.addLocal(organizationId, -bytes);
}
await this.prisma.leadForm.update({
where: { id },
data: { coverStorageKey: null },
+8
View File
@@ -38,6 +38,7 @@ import {
} from '../common/custom-fields';
import { emptyToNull, normalizePhone } from '../contacts/normalize';
import { LocalFileStorage } from '../storage/local-file.storage';
import { StorageQuotaService } from '../billing/storage-quota.service';
import { LeadStagesService } from './lead-stages.service';
import { buildSearchWhere, ci } from '../common/search';
import { GamificationService } from '../gamification/gamification.service';
@@ -91,6 +92,7 @@ export class LeadsService {
private readonly prisma: PrismaService,
private readonly stages: LeadStagesService,
private readonly files: LocalFileStorage,
private readonly storage: StorageQuotaService,
private readonly game: GamificationService,
private readonly sms: SmsGatewayService,
private readonly outboundSms: OutboundSmsLogService,
@@ -1308,6 +1310,9 @@ export class LeadsService {
const storageKey = path
.join(actor.organizationId, 'leads', id, `logo.${ext}`)
.replace(/\\/g, '/');
const prevBytes = prevKey ? await this.files.sizeOf(prevKey) : 0;
const delta = file.size - prevBytes;
if (delta > 0) await this.storage.assertCanAdd(actor.organizationId, delta);
await this.files.save(storageKey, file.buffer);
if (prevKey && prevKey !== storageKey) {
await this.files.remove(prevKey);
@@ -1318,13 +1323,16 @@ export class LeadsService {
include: leadInclude,
});
await this.addEvent(actor, id, 'note', 'لوگو به‌روز شد', null);
if (delta) await this.storage.addLocal(actor.organizationId, delta);
return this.toDto(row);
}
async removeLogo(actor: Actor, id: string): Promise<LeadDto> {
const existing = await this.findAccessible(actor, id);
if (existing.logoStorageKey) {
const bytes = await this.files.sizeOf(existing.logoStorageKey);
await this.files.remove(existing.logoStorageKey);
if (bytes) await this.storage.addLocal(actor.organizationId, -bytes);
}
const row = await this.prisma.lead.update({
where: { id },
+8 -1
View File
@@ -19,6 +19,7 @@ import * as path from 'path';
import { PrismaService } from '../prisma/prisma.service';
import { buildSearchWhere, ci } from '../common/search';
import { LocalFileStorage } from '../storage/local-file.storage';
import { StorageQuotaService } from '../billing/storage-quota.service';
import { GamificationService } from '../gamification/gamification.service';
import { asCustomFields, toJsonCustomFields } from '../common/custom-fields';
import {
@@ -114,6 +115,7 @@ export class NotesService {
private readonly prisma: PrismaService,
private readonly files: LocalFileStorage,
private readonly game: GamificationService,
private readonly storage: StorageQuotaService,
) {}
async list(
@@ -444,10 +446,12 @@ export class NotesService {
): Promise<void> {
const note = await this.prisma.note.findFirst({
where: { id, organizationId, ...noteAccessWhere(actor) },
include: { attachments: { select: { storageKey: true } } },
include: { attachments: { select: { storageKey: true, sizeBytes: true } } },
});
if (!note) throw new NotFoundException('یادداشت پیدا نشد');
const bytes = note.attachments.reduce((sum, file) => sum + file.sizeBytes, 0);
if (bytes) await this.storage.addLocal(organizationId, -bytes);
for (const att of note.attachments) {
await this.files.remove(att.storageKey);
}
@@ -491,6 +495,7 @@ export class NotesService {
.join(organizationId, 'notes', noteId, `${attachmentId}${ext}`)
.replace(/\\/g, '/');
await this.storage.assertCanAdd(organizationId, file.size);
await this.files.save(storageKey, file.buffer);
try {
@@ -506,6 +511,7 @@ export class NotesService {
uploadedById: userId,
},
});
await this.storage.addLocal(organizationId, file.size);
return this.toAttachmentDto(row);
} catch (err) {
await this.files.remove(storageKey);
@@ -526,6 +532,7 @@ export class NotesService {
if (!row) throw new NotFoundException('پیوست پیدا نشد');
await this.files.remove(row.storageKey);
await this.prisma.noteAttachment.delete({ where: { id: row.id } });
await this.storage.addLocal(organizationId, -row.sizeBytes);
}
async openAttachmentDownload(
+11
View File
@@ -4,8 +4,10 @@ import {
IsEmail,
IsIn,
IsInt,
IsNumber,
IsOptional,
IsString,
Max,
MaxLength,
Min,
MinLength,
@@ -146,6 +148,15 @@ export class ApplyBillingPlanDto {
status?: 'active' | 'expired' | 'suspended';
}
export class SetStorageQuotaDto {
/** گیگابایت؛ null = بدون سقف */
@IsNumber()
@Min(0)
@Max(100_000)
@ValidateIf((_, value) => value !== null)
quotaGb!: number | null;
}
export class UpdateOrgModulesDto {
@IsArray()
@IsString({ each: true })
@@ -11,6 +11,7 @@ import {
import { CurrentUser } from '../auth/current-user.decorator';
import {
ApplyBillingPlanDto,
SetStorageQuotaDto,
CreatePlatformAdminDto,
CreatePlatformOrganizationDto,
ResetPlatformAdminPasswordDto,
@@ -122,6 +123,16 @@ export class PlatformController {
return this.platform.applyBillingPlan(id, dto);
}
@Get('users/:id/storage')
storageUsage(@Param('id') id: string, @Query('refresh') refresh?: string) {
return this.platform.storageUsage(id, refresh === '1' || refresh === 'true');
}
@Patch('users/:id/billing/storage')
setStorageQuota(@Param('id') id: string, @Body() dto: SetStorageQuotaDto) {
return this.platform.setStorageQuota(id, dto.quotaGb);
}
@Patch('organizations/:id/modules')
updateOrganizationModules(
@Param('id') id: string,
+22
View File
@@ -19,6 +19,7 @@ import { MembershipsService } from '../memberships/memberships.service';
import { OrgRolesService } from '../org-roles/org-roles.service';
import { PipelinesService } from '../pipelines/pipelines.service';
import { BillingService, toSummary } from '../billing/billing.service';
import { StorageQuotaService } from '../billing/storage-quota.service';
import { PlatformAdminAlertService } from '../billing/platform-admin-alert.service';
import { PrismaService } from '../prisma/prisma.service';
import { buildSearchWhere, ci } from '../common/search';
@@ -51,6 +52,7 @@ export class PlatformService {
private readonly orgRoles: OrgRolesService,
private readonly pipelines: PipelinesService,
private readonly billing: BillingService,
private readonly storage: StorageQuotaService,
private readonly adminAlerts: PlatformAdminAlertService,
) {}
@@ -408,6 +410,26 @@ export class PlatformService {
return this.userToDto(row);
}
async storageUsage(userId: string, refresh = false) {
const owner = await this.prisma.membership.findFirst({
where: { userId, role: 'owner', isActive: true },
select: { id: true },
});
if (!owner) {
throw new BadRequestException('حجم فایل فقط برای مالک محیط معنی دارد');
}
const account = await this.billing.getByOwnerUserId(userId);
if (!account) {
throw new BadRequestException('اول برای این مالک یک پلن اعمال کنید');
}
return this.storage.usageForOwner(userId, refresh);
}
async setStorageQuota(userId: string, quotaGb: number | null) {
await this.storageUsage(userId, false);
return this.storage.setQuotaGb(userId, quotaGb);
}
async applyBillingPlan(userId: string, dto: ApplyBillingPlanDto) {
const existing = await this.prisma.user.findUnique({
where: { id: userId },
@@ -32,6 +32,15 @@ export class LocalFileStorage implements OnModuleInit {
await fs.writeFile(abs, buffer);
}
async sizeOf(storageKey: string): Promise<number> {
try {
const stat = await fs.stat(this.absolutePath(storageKey));
return stat.size;
} catch {
return 0;
}
}
async remove(storageKey: string): Promise<void> {
const abs = this.absolutePath(storageKey);
try {
+22 -11
View File
@@ -15,6 +15,7 @@ import type {
} from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
import { LocalFileStorage } from '../storage/local-file.storage';
import { StorageQuotaService } from '../billing/storage-quota.service';
import type { Actor } from '../common/record-scope';
import { TreasuryReceivablesService } from './treasury-receivables.service';
@@ -61,6 +62,7 @@ export class TreasuryService {
private readonly prisma: PrismaService,
private readonly receivables: TreasuryReceivablesService,
private readonly files: LocalFileStorage,
private readonly storage: StorageQuotaService,
) {}
async ensureCategories(organizationId: string) {
@@ -613,18 +615,27 @@ export class TreasuryService {
if (!doc) throw new NotFoundException('سند پیدا نشد.');
const safeName = file.originalname.replace(/[^\w.\-\u0600-\u06FF]+/g, '_').slice(0, 80);
const storageKey = `treasury/${actor.organizationId}/${doc.id}/${randomUUID()}-${safeName || 'file'}`;
const bytes = file.buffer.length;
await this.storage.assertCanAdd(actor.organizationId, bytes);
await this.files.save(storageKey, file.buffer);
return this.prisma.financialAttachment.create({
data: {
organizationId: actor.organizationId,
documentId: doc.id,
fileName: file.originalname,
mimeType: file.mimetype || 'application/octet-stream',
storageKey,
createdById: actor.userId,
},
select: { id: true, fileName: true, createdAt: true },
});
try {
const row = await this.prisma.financialAttachment.create({
data: {
organizationId: actor.organizationId,
documentId: doc.id,
fileName: file.originalname,
mimeType: file.mimetype || 'application/octet-stream',
storageKey,
createdById: actor.userId,
},
select: { id: true, fileName: true, createdAt: true },
});
await this.storage.addLocal(actor.organizationId, bytes);
return row;
} catch (err) {
await this.files.remove(storageKey);
throw err;
}
}
async dashboard(