From 28938e8f77c537dce8df907ace86c7065bb7a836 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Mon, 2 Nov 2026 15:44:36 +0330 Subject: [PATCH] feat(billing): cap each owner's stored file volume Co-authored-by: Cursor --- .../migration.sql | 5 + apps/api/prisma/schema.prisma | 8 + apps/api/src/activities/activities.service.ts | 9 +- apps/api/src/auth/auth.service.ts | 22 +- apps/api/src/billing/billing.module.ts | 19 +- apps/api/src/billing/storage-quota.service.ts | 190 ++++++++++++++++++ .../conversations/conversations.controller.ts | 25 ++- apps/api/src/conversations/dto.ts | 12 ++ apps/api/src/lead-forms/lead-forms.service.ts | 19 +- apps/api/src/leads/leads.service.ts | 8 + apps/api/src/notes/notes.service.ts | 9 +- apps/api/src/platform/dto.ts | 11 + apps/api/src/platform/platform.controller.ts | 11 + apps/api/src/platform/platform.service.ts | 22 ++ apps/api/src/storage/local-file.storage.ts | 9 + apps/api/src/treasury/treasury.service.ts | 33 ++- apps/web/src/layouts/AppLayout.vue | 57 ++++++ apps/web/src/lib/useOrgChat.ts | 20 +- apps/web/src/views/adm/AdmBillingPanel.vue | 161 ++++++++++++++- packages/shared/src/index.ts | 19 ++ 20 files changed, 647 insertions(+), 22 deletions(-) create mode 100644 apps/api/prisma/migrations/20261102150000_owner_storage_quota/migration.sql create mode 100644 apps/api/src/billing/storage-quota.service.ts diff --git a/apps/api/prisma/migrations/20261102150000_owner_storage_quota/migration.sql b/apps/api/prisma/migrations/20261102150000_owner_storage_quota/migration.sql new file mode 100644 index 0000000..e61f0e2 --- /dev/null +++ b/apps/api/prisma/migrations/20261102150000_owner_storage_quota/migration.sql @@ -0,0 +1,5 @@ +ALTER TABLE "Organization" ADD COLUMN "fileBytes" BIGINT NOT NULL DEFAULT 0; +ALTER TABLE "Organization" ADD COLUMN "chatFileBytes" BIGINT NOT NULL DEFAULT 0; +ALTER TABLE "Organization" ADD COLUMN "fileBytesSyncedAt" TIMESTAMP(3); + +ALTER TABLE "BillingAccount" ADD COLUMN "storageQuotaBytes" BIGINT; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 46b7276..6caf0bb 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -154,6 +154,12 @@ model Organization { /// تاریخ قطع بایگانی اطلاعات قبل (شروع روز انتخاب‌شده)؛ null = بدون فیلتر priorArchiveEnabledAt DateTime? isActive Boolean @default(true) + /// حجم فایل‌های محلی این محیط، به بایت. متن رکوردها (مشتری، فعالیت، فاکتور) داخلش نیست. + fileBytes BigInt @default(0) + /// حجم فایل‌های گفتگو که از این محیط آپلود شده‌اند + chatFileBytes BigInt @default(0) + /// آخرین باری که fileBytes از روی فایل‌های واقعی محاسبه شده + fileBytesSyncedAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt users User[] @@ -457,6 +463,8 @@ model BillingAccount { startsAt DateTime @default(now()) expiresAt DateTime maxOrganizations Int @default(1) + /// سقف حجم فایل همهٔ محیط‌های این مالک، به بایت. null = بدون سقف + storageQuotaBytes BigInt? /// رزرو برای درگاه/لندینگ بعدی externalPaymentRef String? notes String? diff --git a/apps/api/src/activities/activities.service.ts b/apps/api/src/activities/activities.service.ts index cf62a79..7bfede1 100644 --- a/apps/api/src/activities/activities.service.ts +++ b/apps/api/src/activities/activities.service.ts @@ -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 { @@ -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( diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index 32b186d..c6194aa 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -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 } : {}), diff --git a/apps/api/src/billing/billing.module.ts b/apps/api/src/billing/billing.module.ts index f187b92..f28020b 100644 --- a/apps/api/src/billing/billing.module.ts +++ b/apps/api/src/billing/billing.module.ts @@ -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 {} diff --git a/apps/api/src/billing/storage-quota.service.ts b/apps/api/src/billing/storage-quota.service.ts new file mode 100644 index 0000000..dc9c827 --- /dev/null +++ b/apps/api/src/billing/storage-quota.service.ts @@ -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 { + 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' }; + } +} diff --git a/apps/api/src/conversations/conversations.controller.ts b/apps/api/src/conversations/conversations.controller.ts index 406e398..315d2c7 100644 --- a/apps/api/src/conversations/conversations.controller.ts +++ b/apps/api/src/conversations/conversations.controller.ts @@ -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 }) { diff --git a/apps/api/src/conversations/dto.ts b/apps/api/src/conversations/dto.ts index e3dadfa..42a82b8 100644 --- a/apps/api/src/conversations/dto.ts +++ b/apps/api/src/conversations/dto.ts @@ -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) diff --git a/apps/api/src/lead-forms/lead-forms.service.ts b/apps/api/src/lead-forms/lead-forms.service.ts index abb46cd..9dfb411 100644 --- a/apps/api/src/lead-forms/lead-forms.service.ts +++ b/apps/api/src/lead-forms/lead-forms.service.ts @@ -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 { - 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 }, diff --git a/apps/api/src/leads/leads.service.ts b/apps/api/src/leads/leads.service.ts index 3879374..fc39e75 100644 --- a/apps/api/src/leads/leads.service.ts +++ b/apps/api/src/leads/leads.service.ts @@ -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 { 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 }, diff --git a/apps/api/src/notes/notes.service.ts b/apps/api/src/notes/notes.service.ts index ac3a88d..7ec92f1 100644 --- a/apps/api/src/notes/notes.service.ts +++ b/apps/api/src/notes/notes.service.ts @@ -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 { 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( diff --git a/apps/api/src/platform/dto.ts b/apps/api/src/platform/dto.ts index 8a4fc35..01e1bfc 100644 --- a/apps/api/src/platform/dto.ts +++ b/apps/api/src/platform/dto.ts @@ -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 }) diff --git a/apps/api/src/platform/platform.controller.ts b/apps/api/src/platform/platform.controller.ts index 9a3d7fe..58a7af5 100644 --- a/apps/api/src/platform/platform.controller.ts +++ b/apps/api/src/platform/platform.controller.ts @@ -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, diff --git a/apps/api/src/platform/platform.service.ts b/apps/api/src/platform/platform.service.ts index 425205f..ca86c0f 100644 --- a/apps/api/src/platform/platform.service.ts +++ b/apps/api/src/platform/platform.service.ts @@ -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 }, diff --git a/apps/api/src/storage/local-file.storage.ts b/apps/api/src/storage/local-file.storage.ts index 274982e..fb348cf 100644 --- a/apps/api/src/storage/local-file.storage.ts +++ b/apps/api/src/storage/local-file.storage.ts @@ -32,6 +32,15 @@ export class LocalFileStorage implements OnModuleInit { await fs.writeFile(abs, buffer); } + async sizeOf(storageKey: string): Promise { + try { + const stat = await fs.stat(this.absolutePath(storageKey)); + return stat.size; + } catch { + return 0; + } + } + async remove(storageKey: string): Promise { const abs = this.absolutePath(storageKey); try { diff --git a/apps/api/src/treasury/treasury.service.ts b/apps/api/src/treasury/treasury.service.ts index 374b9be..fe94167 100644 --- a/apps/api/src/treasury/treasury.service.ts +++ b/apps/api/src/treasury/treasury.service.ts @@ -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( diff --git a/apps/web/src/layouts/AppLayout.vue b/apps/web/src/layouts/AppLayout.vue index 0e98a85..e2ee51d 100644 --- a/apps/web/src/layouts/AppLayout.vue +++ b/apps/web/src/layouts/AppLayout.vue @@ -46,6 +46,31 @@ const showOwnRecordsBanner = computed( ); const todayLabel = computed(() => formatJalaliShort(new Date())); +function formatBytes(bytes: number): string { + const n = Math.max(0, bytes); + const units: Array<[number, string]> = [ + [1024 ** 3, 'گیگابایت'], + [1024 ** 2, 'مگابایت'], + [1024, 'کیلوبایت'], + ]; + for (const [size, label] of units) { + if (n >= size) { + const value = n / size; + const text = value >= 10 ? value.toFixed(0) : value.toFixed(1); + return `${toFaDigits(text)} ${label}`; + } + } + return `${toFaDigits(Math.round(n))} بایت`; +} + +const storageBanner = computed(() => { + const storage = auth.user?.storage; + if (!storage || storage.level === 'ok' || storage.level === 'unlimited' || storage.quotaBytes == null) { + return null; + } + return storage; +}); + const SIDEBAR_KEY = 'mento_sidebar_collapsed'; function readSidebarCollapsed() { @@ -914,6 +939,21 @@ onUnmounted(() => { +

+ + +

+
@@ -1409,6 +1449,23 @@ onUnmounted(() => { font-size: 0.75rem; } +.storage-banner { + margin: 0 0 0.75rem; + padding: 0.7rem 0.9rem; + border-radius: 12px; + font-size: 0.88rem; +} + +.storage-banner.warn { + background: #fff7ed; + color: #9a3412; +} + +.storage-banner.full { + background: #fef2f2; + color: #991b1b; +} + .sidebar-foot { margin-top: auto; display: grid; diff --git a/apps/web/src/lib/useOrgChat.ts b/apps/web/src/lib/useOrgChat.ts index 7521fde..7619544 100644 --- a/apps/web/src/lib/useOrgChat.ts +++ b/apps/web/src/lib/useOrgChat.ts @@ -1,6 +1,8 @@ +import axios from 'axios'; import { computed, ref, shallowRef } from 'vue'; -import type { ConversationRoomDto, ConversationSessionDto } from '@mento/shared'; +import type { ConversationRoomDto, ConversationSessionDto, StorageUsageDto } from '@mento/shared'; import { api } from '@/lib/api'; +import { useAuthStore } from '@/stores/auth'; export type ChatDelivery = 'sending' | 'sent' | 'read'; @@ -1344,8 +1346,14 @@ export async function refreshUnreadTotal() { } export function useOrgChat() { + const auth = useAuthStore(); const loadingMessages = ref(false); + function rememberStorage(usage: StorageUsageDto) { + if (!auth.user) return; + auth.setUser({ ...auth.user, storage: usage }); + } + async function ensureConnected() { await connectSocket(); } @@ -1696,10 +1704,15 @@ export function useOrgChat() { } } setProgress(12); + await api.post('/conversations/files/reserve', { bytes: fileForUpload.size }); const uploadedPath = await uploadChatFile(session.uploadUrl, fileForUpload, (p) => { setProgress(12 + Math.round(p * 0.86)); }); setProgress(99); + const committed = await api.post('/conversations/files/commit', { + bytes: fileForUpload.size, + }); + rememberStorage(committed.data); await sendMediaPath(roomId, uploadedPath, type, { reply, fileName: outboundFileName, @@ -1715,6 +1728,11 @@ export function useOrgChat() { // ignore } } + if (axios.isAxiosError(e)) { + const raw = (e.response?.data as { message?: string | string[] } | undefined)?.message; + const text = Array.isArray(raw) ? raw.join(' ') : raw; + if (text) throw new Error(text); + } throw e; } } diff --git a/apps/web/src/views/adm/AdmBillingPanel.vue b/apps/web/src/views/adm/AdmBillingPanel.vue index bdeffd5..d9ad3f2 100644 --- a/apps/web/src/views/adm/AdmBillingPanel.vue +++ b/apps/web/src/views/adm/AdmBillingPanel.vue @@ -1,13 +1,16 @@ @@ -65,4 +189,37 @@ const emit = defineEmits<{ flex-wrap: wrap; gap: 0.4rem; } + +.storage { + display: grid; + gap: 0.45rem; + padding-top: 0.35rem; + border-top: 1px solid var(--line); +} + +.orgs { + margin: 0; + padding: 0 1.1rem 0 0; + color: var(--muted); + font-size: 0.84rem; +} + +.quota { + display: grid; + gap: 0.3rem; + font-size: 0.84rem; +} + +.quota input { + width: 8rem; + padding: 0.4rem 0.55rem; + border: 1px solid var(--line); + border-radius: 8px; +} + +.error { + margin: 0; + color: #b91c1c; + font-size: 0.84rem; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 0ef7067..9938afd 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -54,6 +54,8 @@ export interface AuthUserDto { enabledModules?: OrgModuleKey[]; /** خلاصه اشتراک مالک محیط (برای اعضا فقط جهت نمایش/گیت ورود) */ subscription?: SubscriptionSummaryDto | null; + /** حجم فایل اشتراک مالک همین محیط */ + storage?: StorageUsageDto | null; /** اگر باشد، این کاربر سوئیچ «بایگانی اطلاعات قبل» را دارد */ priorArchive?: { lens: "current" | "archive"; cutoffAt: string | null } | null; /** @@ -333,6 +335,22 @@ export function defaultEnabledModules(): OrgModuleKey[] { return ORG_MODULE_KEYS.filter((k) => !OPT_IN_ORG_MODULES.includes(k)); } +export type StorageUsageLevel = "ok" | "warn" | "full" | "unlimited"; + +/** حجم فایل اشتراک مالک. quotaBytes خالی یعنی سقفی گذاشته نشده. */ +export interface StorageUsageDto { + usedBytes: number; + quotaBytes: number | null; + percent: number | null; + level: StorageUsageLevel; + byOrganization?: Array<{ id: string; name: string; usedBytes: number }>; +} + +export interface SetStorageQuotaRequest { + /** گیگابایت؛ null یعنی بدون سقف */ + quotaGb: number | null; +} + export interface SubscriptionSummaryDto { planCode: BillingPlanCode; status: BillingAccountStatus; @@ -355,6 +373,7 @@ export interface BillingAccountDto { notes?: string | null; daysRemaining: number; warn: boolean; + storage?: StorageUsageDto | null; } export interface ApplyBillingPlanRequest {