feat(billing): cap each owner's stored file volume
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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;
|
||||||
@@ -154,6 +154,12 @@ model Organization {
|
|||||||
/// تاریخ قطع بایگانی اطلاعات قبل (شروع روز انتخابشده)؛ null = بدون فیلتر
|
/// تاریخ قطع بایگانی اطلاعات قبل (شروع روز انتخابشده)؛ null = بدون فیلتر
|
||||||
priorArchiveEnabledAt DateTime?
|
priorArchiveEnabledAt DateTime?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
|
/// حجم فایلهای محلی این محیط، به بایت. متن رکوردها (مشتری، فعالیت، فاکتور) داخلش نیست.
|
||||||
|
fileBytes BigInt @default(0)
|
||||||
|
/// حجم فایلهای گفتگو که از این محیط آپلود شدهاند
|
||||||
|
chatFileBytes BigInt @default(0)
|
||||||
|
/// آخرین باری که fileBytes از روی فایلهای واقعی محاسبه شده
|
||||||
|
fileBytesSyncedAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
users User[]
|
users User[]
|
||||||
@@ -457,6 +463,8 @@ model BillingAccount {
|
|||||||
startsAt DateTime @default(now())
|
startsAt DateTime @default(now())
|
||||||
expiresAt DateTime
|
expiresAt DateTime
|
||||||
maxOrganizations Int @default(1)
|
maxOrganizations Int @default(1)
|
||||||
|
/// سقف حجم فایل همهٔ محیطهای این مالک، به بایت. null = بدون سقف
|
||||||
|
storageQuotaBytes BigInt?
|
||||||
/// رزرو برای درگاه/لندینگ بعدی
|
/// رزرو برای درگاه/لندینگ بعدی
|
||||||
externalPaymentRef String?
|
externalPaymentRef String?
|
||||||
notes String?
|
notes String?
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { Prisma } from '@prisma/client';
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import { LocalFileStorage } from '../storage/local-file.storage';
|
import { LocalFileStorage } from '../storage/local-file.storage';
|
||||||
|
import { StorageQuotaService } from '../billing/storage-quota.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
import { AutomationsService } from '../automations/automations.service';
|
import { AutomationsService } from '../automations/automations.service';
|
||||||
@@ -128,6 +129,7 @@ export class ActivitiesService {
|
|||||||
private readonly types: ActivityTypesService,
|
private readonly types: ActivityTypesService,
|
||||||
private readonly game: GamificationService,
|
private readonly game: GamificationService,
|
||||||
private readonly files: LocalFileStorage,
|
private readonly files: LocalFileStorage,
|
||||||
|
private readonly storage: StorageQuotaService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private async ctx(organizationId: string): Promise<DtoContext> {
|
private async ctx(organizationId: string): Promise<DtoContext> {
|
||||||
@@ -817,8 +819,10 @@ export class ActivitiesService {
|
|||||||
await this.ensureExists(organizationId, id, actor);
|
await this.ensureExists(organizationId, id, actor);
|
||||||
const images = await this.prisma.activityImage.findMany({
|
const images = await this.prisma.activityImage.findMany({
|
||||||
where: { activityId: id, organizationId },
|
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 } });
|
await this.prisma.activity.delete({ where: { id } });
|
||||||
for (const image of images) {
|
for (const image of images) {
|
||||||
await this.files.remove(image.storageKey);
|
await this.files.remove(image.storageKey);
|
||||||
@@ -853,6 +857,7 @@ export class ActivitiesService {
|
|||||||
const storageKey = path
|
const storageKey = path
|
||||||
.join(organizationId, 'activities', activityId, `${imageId}${ext}`)
|
.join(organizationId, 'activities', activityId, `${imageId}${ext}`)
|
||||||
.replace(/\\/g, '/');
|
.replace(/\\/g, '/');
|
||||||
|
await this.storage.assertCanAdd(organizationId, file.size);
|
||||||
await this.files.save(storageKey, file.buffer);
|
await this.files.save(storageKey, file.buffer);
|
||||||
try {
|
try {
|
||||||
const row = await this.prisma.activityImage.create({
|
const row = await this.prisma.activityImage.create({
|
||||||
@@ -867,6 +872,7 @@ export class ActivitiesService {
|
|||||||
uploadedById: userId,
|
uploadedById: userId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
await this.storage.addLocal(organizationId, file.size);
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
fileName: row.fileName,
|
fileName: row.fileName,
|
||||||
@@ -893,6 +899,7 @@ export class ActivitiesService {
|
|||||||
if (!row) throw new NotFoundException('عکس پیدا نشد');
|
if (!row) throw new NotFoundException('عکس پیدا نشد');
|
||||||
await this.files.remove(row.storageKey);
|
await this.files.remove(row.storageKey);
|
||||||
await this.prisma.activityImage.delete({ where: { id: row.id } });
|
await this.prisma.activityImage.delete({ where: { id: row.id } });
|
||||||
|
await this.storage.addLocal(organizationId, -row.sizeBytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
async openImage(
|
async openImage(
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import type {
|
|||||||
LoginResponse,
|
LoginResponse,
|
||||||
LoginResult,
|
LoginResult,
|
||||||
OrgModuleKey,
|
OrgModuleKey,
|
||||||
|
StorageUsageDto,
|
||||||
SubscriptionSummaryDto,
|
SubscriptionSummaryDto,
|
||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
import { Prisma } from '@prisma/client';
|
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 { LocalFileStorage } from '../storage/local-file.storage';
|
||||||
import { MembershipsService } from '../memberships/memberships.service';
|
import { MembershipsService } from '../memberships/memberships.service';
|
||||||
import { BillingService } from '../billing/billing.service';
|
import { BillingService } from '../billing/billing.service';
|
||||||
|
import { StorageQuotaService } from '../billing/storage-quota.service';
|
||||||
import { PlatformAdminAlertService } from '../billing/platform-admin-alert.service';
|
import { PlatformAdminAlertService } from '../billing/platform-admin-alert.service';
|
||||||
import { AttendanceService } from '../attendance/attendance.service';
|
import { AttendanceService } from '../attendance/attendance.service';
|
||||||
import type { Actor } from '../common/record-scope';
|
import type { Actor } from '../common/record-scope';
|
||||||
@@ -112,6 +114,7 @@ export class AuthService {
|
|||||||
private readonly files: LocalFileStorage,
|
private readonly files: LocalFileStorage,
|
||||||
private readonly memberships: MembershipsService,
|
private readonly memberships: MembershipsService,
|
||||||
private readonly billing: BillingService,
|
private readonly billing: BillingService,
|
||||||
|
private readonly storageQuota: StorageQuotaService,
|
||||||
private readonly adminAlerts: PlatformAdminAlertService,
|
private readonly adminAlerts: PlatformAdminAlertService,
|
||||||
private readonly attendance: AttendanceService,
|
private readonly attendance: AttendanceService,
|
||||||
) {}
|
) {}
|
||||||
@@ -621,11 +624,15 @@ export class AuthService {
|
|||||||
const storageKey = path
|
const storageKey = path
|
||||||
.join(organizationId, 'users', userId, `avatar.${ext}`)
|
.join(organizationId, 'users', userId, `avatar.${ext}`)
|
||||||
.replace(/\\/g, '/');
|
.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);
|
await this.files.save(storageKey, file.buffer);
|
||||||
if (prevKey && prevKey !== storageKey) {
|
if (prevKey && prevKey !== storageKey) {
|
||||||
await this.files.remove(prevKey);
|
await this.files.remove(prevKey);
|
||||||
}
|
}
|
||||||
await this.writeAvatarKey(userId, storageKey);
|
await this.writeAvatarKey(userId, storageKey);
|
||||||
|
if (delta) await this.storageQuota.addLocal(organizationId, delta);
|
||||||
return this.toAuthUserDto(existing, organizationId);
|
return this.toAuthUserDto(existing, organizationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -635,7 +642,11 @@ export class AuthService {
|
|||||||
throw new UnauthorizedException();
|
throw new UnauthorizedException();
|
||||||
}
|
}
|
||||||
const prevKey = await this.readAvatarKey(userId);
|
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);
|
await this.writeAvatarKey(userId, null);
|
||||||
return this.toAuthUserDto(existing, organizationId);
|
return this.toAuthUserDto(existing, organizationId);
|
||||||
}
|
}
|
||||||
@@ -829,6 +840,12 @@ export class AuthService {
|
|||||||
canOpenTeamSettings: true,
|
canOpenTeamSettings: true,
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
let storage: StorageUsageDto | undefined;
|
||||||
|
try {
|
||||||
|
storage = await this.storageQuota.usageForOrganization(organizationId);
|
||||||
|
} catch {
|
||||||
|
storage = undefined;
|
||||||
|
}
|
||||||
return this.toAuthUser({
|
return this.toAuthUser({
|
||||||
...user,
|
...user,
|
||||||
role,
|
role,
|
||||||
@@ -839,6 +856,7 @@ export class AuthService {
|
|||||||
membership?.organization.enabledModules,
|
membership?.organization.enabledModules,
|
||||||
),
|
),
|
||||||
subscription: sub,
|
subscription: sub,
|
||||||
|
storage,
|
||||||
isOrgOwner: role === 'owner',
|
isOrgOwner: role === 'owner',
|
||||||
priorArchive: this.priorArchiveFor(membership),
|
priorArchive: this.priorArchiveFor(membership),
|
||||||
permissions: (Array.isArray(membership?.orgRole?.permissions)
|
permissions: (Array.isArray(membership?.orgRole?.permissions)
|
||||||
@@ -949,6 +967,7 @@ export class AuthService {
|
|||||||
avatarStorageKey?: string | null;
|
avatarStorageKey?: string | null;
|
||||||
enabledModules?: OrgModuleKey[];
|
enabledModules?: OrgModuleKey[];
|
||||||
subscription?: SubscriptionSummaryDto | null;
|
subscription?: SubscriptionSummaryDto | null;
|
||||||
|
storage?: StorageUsageDto | null;
|
||||||
isOrgOwner?: boolean;
|
isOrgOwner?: boolean;
|
||||||
priorArchive?: AuthUserDto['priorArchive'];
|
priorArchive?: AuthUserDto['priorArchive'];
|
||||||
viewAs?: AuthUserDto['viewAs'];
|
viewAs?: AuthUserDto['viewAs'];
|
||||||
@@ -965,6 +984,7 @@ export class AuthService {
|
|||||||
...(user.mobile ? { mobile: user.mobile } : {}),
|
...(user.mobile ? { mobile: user.mobile } : {}),
|
||||||
...(user.enabledModules ? { enabledModules: user.enabledModules } : {}),
|
...(user.enabledModules ? { enabledModules: user.enabledModules } : {}),
|
||||||
...(user.subscription ? { subscription: user.subscription } : {}),
|
...(user.subscription ? { subscription: user.subscription } : {}),
|
||||||
|
...(user.storage ? { storage: user.storage } : {}),
|
||||||
...(user.isOrgOwner !== undefined ? { isOrgOwner: user.isOrgOwner } : {}),
|
...(user.isOrgOwner !== undefined ? { isOrgOwner: user.isOrgOwner } : {}),
|
||||||
...(user.priorArchive ? { priorArchive: user.priorArchive } : {}),
|
...(user.priorArchive ? { priorArchive: user.priorArchive } : {}),
|
||||||
...(user.viewAs ? { viewAs: user.viewAs } : {}),
|
...(user.viewAs ? { viewAs: user.viewAs } : {}),
|
||||||
|
|||||||
@@ -1,12 +1,27 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { StorageModule } from '../storage/storage.module';
|
||||||
import { BillingExpiryService } from './billing-expiry.service';
|
import { BillingExpiryService } from './billing-expiry.service';
|
||||||
import { BillingService } from './billing.service';
|
import { BillingService } from './billing.service';
|
||||||
import { OrgModuleGuard } from './org-module.guard';
|
import { OrgModuleGuard } from './org-module.guard';
|
||||||
import { PlatformAdminAlertService } from './platform-admin-alert.service';
|
import { PlatformAdminAlertService } from './platform-admin-alert.service';
|
||||||
|
import { StorageQuotaService } from './storage-quota.service';
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
providers: [BillingService, BillingExpiryService, PlatformAdminAlertService, OrgModuleGuard],
|
imports: [StorageModule],
|
||||||
exports: [BillingService, BillingExpiryService, PlatformAdminAlertService, OrgModuleGuard],
|
providers: [
|
||||||
|
BillingService,
|
||||||
|
BillingExpiryService,
|
||||||
|
PlatformAdminAlertService,
|
||||||
|
OrgModuleGuard,
|
||||||
|
StorageQuotaService,
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
BillingService,
|
||||||
|
BillingExpiryService,
|
||||||
|
PlatformAdminAlertService,
|
||||||
|
OrgModuleGuard,
|
||||||
|
StorageQuotaService,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class BillingModule {}
|
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 { RequireOrgModule } from '../billing/org-module.decorator';
|
||||||
import { OrgModuleGuard } from '../billing/org-module.guard';
|
import { OrgModuleGuard } from '../billing/org-module.guard';
|
||||||
import {
|
import {
|
||||||
|
ConversationFileBytesDto,
|
||||||
CreateConversationDmDto,
|
CreateConversationDmDto,
|
||||||
CreateConversationGroupDto,
|
CreateConversationGroupDto,
|
||||||
CreateConversationChannelDto,
|
CreateConversationChannelDto,
|
||||||
@@ -19,12 +20,34 @@ import {
|
|||||||
ToggleMessageReactionDto,
|
ToggleMessageReactionDto,
|
||||||
} from './dto';
|
} from './dto';
|
||||||
import { ConversationsService } from './conversations.service';
|
import { ConversationsService } from './conversations.service';
|
||||||
|
import { StorageQuotaService } from '../billing/storage-quota.service';
|
||||||
|
|
||||||
@Controller('conversations')
|
@Controller('conversations')
|
||||||
@UseGuards(JwtAuthGuard, OrgModuleGuard)
|
@UseGuards(JwtAuthGuard, OrgModuleGuard)
|
||||||
@RequireOrgModule('conversations')
|
@RequireOrgModule('conversations')
|
||||||
export class ConversationsController {
|
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')
|
@Get('access')
|
||||||
access(@CurrentUser() user: { userId: string; organizationId: string; role: string }) {
|
access(@CurrentUser() user: { userId: string; organizationId: string; role: string }) {
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
|
import { Type } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsArray,
|
IsArray,
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
|
IsInt,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
|
Max,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
|
Min,
|
||||||
MinLength,
|
MinLength,
|
||||||
ArrayMinSize,
|
ArrayMinSize,
|
||||||
ArrayMaxSize,
|
ArrayMaxSize,
|
||||||
@@ -32,6 +36,14 @@ export class UpdateConversationSettingsDto {
|
|||||||
downloadUrl?: string;
|
downloadUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class ConversationFileBytesDto {
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100 * 1024 * 1024)
|
||||||
|
bytes!: number;
|
||||||
|
}
|
||||||
|
|
||||||
export class CreateConversationDmDto {
|
export class CreateConversationDmDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(1)
|
@MinLength(1)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { isGenderQuestionLabel, parsePersonGenderAnswer } from '@mento/shared';
|
|||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { LocalFileStorage } from '../storage/local-file.storage';
|
import { LocalFileStorage } from '../storage/local-file.storage';
|
||||||
|
import { StorageQuotaService } from '../billing/storage-quota.service';
|
||||||
import { AutomationsService } from '../automations/automations.service';
|
import { AutomationsService } from '../automations/automations.service';
|
||||||
import { MessageAutomationsService } from '../message-automations/message-automations.service';
|
import { MessageAutomationsService } from '../message-automations/message-automations.service';
|
||||||
import {
|
import {
|
||||||
@@ -84,6 +85,7 @@ export class LeadFormsService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly files: LocalFileStorage,
|
private readonly files: LocalFileStorage,
|
||||||
|
private readonly storage: StorageQuotaService,
|
||||||
private readonly automations: AutomationsService,
|
private readonly automations: AutomationsService,
|
||||||
private readonly messageAutomations: MessageAutomationsService,
|
private readonly messageAutomations: MessageAutomationsService,
|
||||||
private readonly leadStages: LeadStagesService,
|
private readonly leadStages: LeadStagesService,
|
||||||
@@ -211,7 +213,12 @@ export class LeadFormsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async remove(organizationId: string, id: string): Promise<void> {
|
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 } });
|
await this.prisma.leadForm.delete({ where: { id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -835,6 +842,9 @@ export class LeadFormsService {
|
|||||||
throw new BadRequestException('فرمت تصویر کاور باید PNG، JPG یا WEBP باشد');
|
throw new BadRequestException('فرمت تصویر کاور باید PNG، JPG یا WEBP باشد');
|
||||||
}
|
}
|
||||||
const key = `lead-forms/${id}/cover.${ext}`;
|
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);
|
await this.files.save(key, file.buffer);
|
||||||
if (row.coverStorageKey && row.coverStorageKey !== key) {
|
if (row.coverStorageKey && row.coverStorageKey !== key) {
|
||||||
await this.files.remove(row.coverStorageKey);
|
await this.files.remove(row.coverStorageKey);
|
||||||
@@ -843,12 +853,17 @@ export class LeadFormsService {
|
|||||||
where: { id },
|
where: { id },
|
||||||
data: { coverStorageKey: key },
|
data: { coverStorageKey: key },
|
||||||
});
|
});
|
||||||
|
if (delta) await this.storage.addLocal(organizationId, delta);
|
||||||
return this.get(organizationId, id);
|
return this.get(organizationId, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async removeCover(organizationId: string, id: string) {
|
async removeCover(organizationId: string, id: string) {
|
||||||
const row = await this.findInOrg(organizationId, id);
|
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({
|
await this.prisma.leadForm.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { coverStorageKey: null },
|
data: { coverStorageKey: null },
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
} from '../common/custom-fields';
|
} from '../common/custom-fields';
|
||||||
import { emptyToNull, normalizePhone } from '../contacts/normalize';
|
import { emptyToNull, normalizePhone } from '../contacts/normalize';
|
||||||
import { LocalFileStorage } from '../storage/local-file.storage';
|
import { LocalFileStorage } from '../storage/local-file.storage';
|
||||||
|
import { StorageQuotaService } from '../billing/storage-quota.service';
|
||||||
import { LeadStagesService } from './lead-stages.service';
|
import { LeadStagesService } from './lead-stages.service';
|
||||||
import { buildSearchWhere, ci } from '../common/search';
|
import { buildSearchWhere, ci } from '../common/search';
|
||||||
import { GamificationService } from '../gamification/gamification.service';
|
import { GamificationService } from '../gamification/gamification.service';
|
||||||
@@ -91,6 +92,7 @@ export class LeadsService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly stages: LeadStagesService,
|
private readonly stages: LeadStagesService,
|
||||||
private readonly files: LocalFileStorage,
|
private readonly files: LocalFileStorage,
|
||||||
|
private readonly storage: StorageQuotaService,
|
||||||
private readonly game: GamificationService,
|
private readonly game: GamificationService,
|
||||||
private readonly sms: SmsGatewayService,
|
private readonly sms: SmsGatewayService,
|
||||||
private readonly outboundSms: OutboundSmsLogService,
|
private readonly outboundSms: OutboundSmsLogService,
|
||||||
@@ -1308,6 +1310,9 @@ export class LeadsService {
|
|||||||
const storageKey = path
|
const storageKey = path
|
||||||
.join(actor.organizationId, 'leads', id, `logo.${ext}`)
|
.join(actor.organizationId, 'leads', id, `logo.${ext}`)
|
||||||
.replace(/\\/g, '/');
|
.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);
|
await this.files.save(storageKey, file.buffer);
|
||||||
if (prevKey && prevKey !== storageKey) {
|
if (prevKey && prevKey !== storageKey) {
|
||||||
await this.files.remove(prevKey);
|
await this.files.remove(prevKey);
|
||||||
@@ -1318,13 +1323,16 @@ export class LeadsService {
|
|||||||
include: leadInclude,
|
include: leadInclude,
|
||||||
});
|
});
|
||||||
await this.addEvent(actor, id, 'note', 'لوگو بهروز شد', null);
|
await this.addEvent(actor, id, 'note', 'لوگو بهروز شد', null);
|
||||||
|
if (delta) await this.storage.addLocal(actor.organizationId, delta);
|
||||||
return this.toDto(row);
|
return this.toDto(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
async removeLogo(actor: Actor, id: string): Promise<LeadDto> {
|
async removeLogo(actor: Actor, id: string): Promise<LeadDto> {
|
||||||
const existing = await this.findAccessible(actor, id);
|
const existing = await this.findAccessible(actor, id);
|
||||||
if (existing.logoStorageKey) {
|
if (existing.logoStorageKey) {
|
||||||
|
const bytes = await this.files.sizeOf(existing.logoStorageKey);
|
||||||
await this.files.remove(existing.logoStorageKey);
|
await this.files.remove(existing.logoStorageKey);
|
||||||
|
if (bytes) await this.storage.addLocal(actor.organizationId, -bytes);
|
||||||
}
|
}
|
||||||
const row = await this.prisma.lead.update({
|
const row = await this.prisma.lead.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import * as path from 'path';
|
|||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { buildSearchWhere, ci } from '../common/search';
|
import { buildSearchWhere, ci } from '../common/search';
|
||||||
import { LocalFileStorage } from '../storage/local-file.storage';
|
import { LocalFileStorage } from '../storage/local-file.storage';
|
||||||
|
import { StorageQuotaService } from '../billing/storage-quota.service';
|
||||||
import { GamificationService } from '../gamification/gamification.service';
|
import { GamificationService } from '../gamification/gamification.service';
|
||||||
import { asCustomFields, toJsonCustomFields } from '../common/custom-fields';
|
import { asCustomFields, toJsonCustomFields } from '../common/custom-fields';
|
||||||
import {
|
import {
|
||||||
@@ -114,6 +115,7 @@ export class NotesService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly files: LocalFileStorage,
|
private readonly files: LocalFileStorage,
|
||||||
private readonly game: GamificationService,
|
private readonly game: GamificationService,
|
||||||
|
private readonly storage: StorageQuotaService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async list(
|
async list(
|
||||||
@@ -444,10 +446,12 @@ export class NotesService {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const note = await this.prisma.note.findFirst({
|
const note = await this.prisma.note.findFirst({
|
||||||
where: { id, organizationId, ...noteAccessWhere(actor) },
|
where: { id, organizationId, ...noteAccessWhere(actor) },
|
||||||
include: { attachments: { select: { storageKey: true } } },
|
include: { attachments: { select: { storageKey: true, sizeBytes: true } } },
|
||||||
});
|
});
|
||||||
if (!note) throw new NotFoundException('یادداشت پیدا نشد');
|
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) {
|
for (const att of note.attachments) {
|
||||||
await this.files.remove(att.storageKey);
|
await this.files.remove(att.storageKey);
|
||||||
}
|
}
|
||||||
@@ -491,6 +495,7 @@ export class NotesService {
|
|||||||
.join(organizationId, 'notes', noteId, `${attachmentId}${ext}`)
|
.join(organizationId, 'notes', noteId, `${attachmentId}${ext}`)
|
||||||
.replace(/\\/g, '/');
|
.replace(/\\/g, '/');
|
||||||
|
|
||||||
|
await this.storage.assertCanAdd(organizationId, file.size);
|
||||||
await this.files.save(storageKey, file.buffer);
|
await this.files.save(storageKey, file.buffer);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -506,6 +511,7 @@ export class NotesService {
|
|||||||
uploadedById: userId,
|
uploadedById: userId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
await this.storage.addLocal(organizationId, file.size);
|
||||||
return this.toAttachmentDto(row);
|
return this.toAttachmentDto(row);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await this.files.remove(storageKey);
|
await this.files.remove(storageKey);
|
||||||
@@ -526,6 +532,7 @@ export class NotesService {
|
|||||||
if (!row) throw new NotFoundException('پیوست پیدا نشد');
|
if (!row) throw new NotFoundException('پیوست پیدا نشد');
|
||||||
await this.files.remove(row.storageKey);
|
await this.files.remove(row.storageKey);
|
||||||
await this.prisma.noteAttachment.delete({ where: { id: row.id } });
|
await this.prisma.noteAttachment.delete({ where: { id: row.id } });
|
||||||
|
await this.storage.addLocal(organizationId, -row.sizeBytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
async openAttachmentDownload(
|
async openAttachmentDownload(
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import {
|
|||||||
IsEmail,
|
IsEmail,
|
||||||
IsIn,
|
IsIn,
|
||||||
IsInt,
|
IsInt,
|
||||||
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
|
Max,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
Min,
|
Min,
|
||||||
MinLength,
|
MinLength,
|
||||||
@@ -146,6 +148,15 @@ export class ApplyBillingPlanDto {
|
|||||||
status?: 'active' | 'expired' | 'suspended';
|
status?: 'active' | 'expired' | 'suspended';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class SetStorageQuotaDto {
|
||||||
|
/** گیگابایت؛ null = بدون سقف */
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@Max(100_000)
|
||||||
|
@ValidateIf((_, value) => value !== null)
|
||||||
|
quotaGb!: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
export class UpdateOrgModulesDto {
|
export class UpdateOrgModulesDto {
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import { CurrentUser } from '../auth/current-user.decorator';
|
import { CurrentUser } from '../auth/current-user.decorator';
|
||||||
import {
|
import {
|
||||||
ApplyBillingPlanDto,
|
ApplyBillingPlanDto,
|
||||||
|
SetStorageQuotaDto,
|
||||||
CreatePlatformAdminDto,
|
CreatePlatformAdminDto,
|
||||||
CreatePlatformOrganizationDto,
|
CreatePlatformOrganizationDto,
|
||||||
ResetPlatformAdminPasswordDto,
|
ResetPlatformAdminPasswordDto,
|
||||||
@@ -122,6 +123,16 @@ export class PlatformController {
|
|||||||
return this.platform.applyBillingPlan(id, dto);
|
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')
|
@Patch('organizations/:id/modules')
|
||||||
updateOrganizationModules(
|
updateOrganizationModules(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { MembershipsService } from '../memberships/memberships.service';
|
|||||||
import { OrgRolesService } from '../org-roles/org-roles.service';
|
import { OrgRolesService } from '../org-roles/org-roles.service';
|
||||||
import { PipelinesService } from '../pipelines/pipelines.service';
|
import { PipelinesService } from '../pipelines/pipelines.service';
|
||||||
import { BillingService, toSummary } from '../billing/billing.service';
|
import { BillingService, toSummary } from '../billing/billing.service';
|
||||||
|
import { StorageQuotaService } from '../billing/storage-quota.service';
|
||||||
import { PlatformAdminAlertService } from '../billing/platform-admin-alert.service';
|
import { PlatformAdminAlertService } from '../billing/platform-admin-alert.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { buildSearchWhere, ci } from '../common/search';
|
import { buildSearchWhere, ci } from '../common/search';
|
||||||
@@ -51,6 +52,7 @@ export class PlatformService {
|
|||||||
private readonly orgRoles: OrgRolesService,
|
private readonly orgRoles: OrgRolesService,
|
||||||
private readonly pipelines: PipelinesService,
|
private readonly pipelines: PipelinesService,
|
||||||
private readonly billing: BillingService,
|
private readonly billing: BillingService,
|
||||||
|
private readonly storage: StorageQuotaService,
|
||||||
private readonly adminAlerts: PlatformAdminAlertService,
|
private readonly adminAlerts: PlatformAdminAlertService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -408,6 +410,26 @@ export class PlatformService {
|
|||||||
return this.userToDto(row);
|
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) {
|
async applyBillingPlan(userId: string, dto: ApplyBillingPlanDto) {
|
||||||
const existing = await this.prisma.user.findUnique({
|
const existing = await this.prisma.user.findUnique({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
|
|||||||
@@ -32,6 +32,15 @@ export class LocalFileStorage implements OnModuleInit {
|
|||||||
await fs.writeFile(abs, buffer);
|
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> {
|
async remove(storageKey: string): Promise<void> {
|
||||||
const abs = this.absolutePath(storageKey);
|
const abs = this.absolutePath(storageKey);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type {
|
|||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { LocalFileStorage } from '../storage/local-file.storage';
|
import { LocalFileStorage } from '../storage/local-file.storage';
|
||||||
|
import { StorageQuotaService } from '../billing/storage-quota.service';
|
||||||
import type { Actor } from '../common/record-scope';
|
import type { Actor } from '../common/record-scope';
|
||||||
import { TreasuryReceivablesService } from './treasury-receivables.service';
|
import { TreasuryReceivablesService } from './treasury-receivables.service';
|
||||||
|
|
||||||
@@ -61,6 +62,7 @@ export class TreasuryService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly receivables: TreasuryReceivablesService,
|
private readonly receivables: TreasuryReceivablesService,
|
||||||
private readonly files: LocalFileStorage,
|
private readonly files: LocalFileStorage,
|
||||||
|
private readonly storage: StorageQuotaService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async ensureCategories(organizationId: string) {
|
async ensureCategories(organizationId: string) {
|
||||||
@@ -613,18 +615,27 @@ export class TreasuryService {
|
|||||||
if (!doc) throw new NotFoundException('سند پیدا نشد.');
|
if (!doc) throw new NotFoundException('سند پیدا نشد.');
|
||||||
const safeName = file.originalname.replace(/[^\w.\-\u0600-\u06FF]+/g, '_').slice(0, 80);
|
const safeName = file.originalname.replace(/[^\w.\-\u0600-\u06FF]+/g, '_').slice(0, 80);
|
||||||
const storageKey = `treasury/${actor.organizationId}/${doc.id}/${randomUUID()}-${safeName || 'file'}`;
|
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);
|
await this.files.save(storageKey, file.buffer);
|
||||||
return this.prisma.financialAttachment.create({
|
try {
|
||||||
data: {
|
const row = await this.prisma.financialAttachment.create({
|
||||||
organizationId: actor.organizationId,
|
data: {
|
||||||
documentId: doc.id,
|
organizationId: actor.organizationId,
|
||||||
fileName: file.originalname,
|
documentId: doc.id,
|
||||||
mimeType: file.mimetype || 'application/octet-stream',
|
fileName: file.originalname,
|
||||||
storageKey,
|
mimeType: file.mimetype || 'application/octet-stream',
|
||||||
createdById: actor.userId,
|
storageKey,
|
||||||
},
|
createdById: actor.userId,
|
||||||
select: { id: true, fileName: true, createdAt: true },
|
},
|
||||||
});
|
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(
|
async dashboard(
|
||||||
|
|||||||
@@ -46,6 +46,31 @@ const showOwnRecordsBanner = computed(
|
|||||||
);
|
);
|
||||||
const todayLabel = computed(() => formatJalaliShort(new Date()));
|
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';
|
const SIDEBAR_KEY = 'mento_sidebar_collapsed';
|
||||||
|
|
||||||
function readSidebarCollapsed() {
|
function readSidebarCollapsed() {
|
||||||
@@ -914,6 +939,21 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-if="storageBanner"
|
||||||
|
class="storage-banner"
|
||||||
|
:class="storageBanner.level === 'full' ? 'full' : 'warn'"
|
||||||
|
>
|
||||||
|
<template v-if="storageBanner.level === 'full'">
|
||||||
|
سقف حجم فایل پر شده است ({{ formatBytes(storageBanner.usedBytes) }} از
|
||||||
|
{{ formatBytes(storageBanner.quotaBytes ?? 0) }}). تا شارژ حجم، آپلود جدید ممکن نیست.
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
حجم فایل به {{ toFaDigits(storageBanner.percent ?? 80) }}٪ رسیده
|
||||||
|
({{ formatBytes(storageBanner.usedBytes) }} از {{ formatBytes(storageBanner.quotaBytes ?? 0) }}).
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
|
|
||||||
<main class="content">
|
<main class="content">
|
||||||
<RouterView />
|
<RouterView />
|
||||||
<GamificationToaster v-if="moduleAllowed('gamification')" />
|
<GamificationToaster v-if="moduleAllowed('gamification')" />
|
||||||
@@ -1409,6 +1449,23 @@ onUnmounted(() => {
|
|||||||
font-size: 0.75rem;
|
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 {
|
.sidebar-foot {
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import axios from 'axios';
|
||||||
import { computed, ref, shallowRef } from 'vue';
|
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 { api } from '@/lib/api';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
|
||||||
export type ChatDelivery = 'sending' | 'sent' | 'read';
|
export type ChatDelivery = 'sending' | 'sent' | 'read';
|
||||||
|
|
||||||
@@ -1344,8 +1346,14 @@ export async function refreshUnreadTotal() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useOrgChat() {
|
export function useOrgChat() {
|
||||||
|
const auth = useAuthStore();
|
||||||
const loadingMessages = ref(false);
|
const loadingMessages = ref(false);
|
||||||
|
|
||||||
|
function rememberStorage(usage: StorageUsageDto) {
|
||||||
|
if (!auth.user) return;
|
||||||
|
auth.setUser({ ...auth.user, storage: usage });
|
||||||
|
}
|
||||||
|
|
||||||
async function ensureConnected() {
|
async function ensureConnected() {
|
||||||
await connectSocket();
|
await connectSocket();
|
||||||
}
|
}
|
||||||
@@ -1696,10 +1704,15 @@ export function useOrgChat() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
setProgress(12);
|
setProgress(12);
|
||||||
|
await api.post('/conversations/files/reserve', { bytes: fileForUpload.size });
|
||||||
const uploadedPath = await uploadChatFile(session.uploadUrl, fileForUpload, (p) => {
|
const uploadedPath = await uploadChatFile(session.uploadUrl, fileForUpload, (p) => {
|
||||||
setProgress(12 + Math.round(p * 0.86));
|
setProgress(12 + Math.round(p * 0.86));
|
||||||
});
|
});
|
||||||
setProgress(99);
|
setProgress(99);
|
||||||
|
const committed = await api.post<StorageUsageDto>('/conversations/files/commit', {
|
||||||
|
bytes: fileForUpload.size,
|
||||||
|
});
|
||||||
|
rememberStorage(committed.data);
|
||||||
await sendMediaPath(roomId, uploadedPath, type, {
|
await sendMediaPath(roomId, uploadedPath, type, {
|
||||||
reply,
|
reply,
|
||||||
fileName: outboundFileName,
|
fileName: outboundFileName,
|
||||||
@@ -1715,6 +1728,11 @@ export function useOrgChat() {
|
|||||||
// ignore
|
// 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;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { BillingAccountDto, BillingPlanCode } from '@mento/shared';
|
import { onMounted, ref } from 'vue';
|
||||||
|
import axios from 'axios';
|
||||||
|
import type { BillingAccountDto, BillingPlanCode, StorageUsageDto } from '@mento/shared';
|
||||||
import {
|
import {
|
||||||
BILLING_PLAN_CODES,
|
BILLING_PLAN_CODES,
|
||||||
BILLING_PLAN_LABELS,
|
BILLING_PLAN_LABELS,
|
||||||
isPermanentBillingPlan,
|
isPermanentBillingPlan,
|
||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
import { toFaDigits } from '@/lib/jalali';
|
import { toFaDigits } from '@/lib/jalali';
|
||||||
|
import { platformApi } from '@/lib/platform-api';
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
billing?: BillingAccountDto | null;
|
billing?: BillingAccountDto | null;
|
||||||
busyId: string;
|
busyId: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -16,6 +19,90 @@ defineProps<{
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
apply: [planCode: BillingPlanCode];
|
apply: [planCode: BillingPlanCode];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const usage = ref<StorageUsageDto | null>(null);
|
||||||
|
const quotaGb = ref('');
|
||||||
|
const storageBusy = ref(false);
|
||||||
|
const storageError = ref('');
|
||||||
|
|
||||||
|
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))} بایت`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillQuota(next: StorageUsageDto) {
|
||||||
|
usage.value = next;
|
||||||
|
quotaGb.value =
|
||||||
|
next.quotaBytes == null ? '' : String(Math.round((next.quotaBytes / 1024 ** 3) * 10) / 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorText(err: unknown, fallback: string) {
|
||||||
|
if (axios.isAxiosError(err)) {
|
||||||
|
const raw = (err.response?.data as { message?: string | string[] } | undefined)?.message;
|
||||||
|
if (Array.isArray(raw)) return raw.join(' ');
|
||||||
|
if (raw) return raw;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStorage(refresh = false) {
|
||||||
|
storageError.value = '';
|
||||||
|
try {
|
||||||
|
const { data } = await platformApi.get<StorageUsageDto>(
|
||||||
|
`/platform/users/${props.userId}/storage`,
|
||||||
|
{ params: refresh ? { refresh: '1' } : undefined },
|
||||||
|
);
|
||||||
|
fillQuota(data);
|
||||||
|
} catch (err) {
|
||||||
|
storageError.value = errorText(err, 'حجم فایل خوانده نشد');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveQuota(quota: number | null) {
|
||||||
|
storageBusy.value = true;
|
||||||
|
storageError.value = '';
|
||||||
|
try {
|
||||||
|
const { data } = await platformApi.patch<StorageUsageDto>(
|
||||||
|
`/platform/users/${props.userId}/billing/storage`,
|
||||||
|
{ quotaGb: quota },
|
||||||
|
);
|
||||||
|
fillQuota(data);
|
||||||
|
} catch (err) {
|
||||||
|
storageError.value = errorText(err, 'سقف حجم ذخیره نشد');
|
||||||
|
} finally {
|
||||||
|
storageBusy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveTypedQuota() {
|
||||||
|
const raw = quotaGb.value.trim().replace(/,/g, '');
|
||||||
|
if (!raw) {
|
||||||
|
storageError.value = 'سقف را به گیگابایت وارد کنید، یا «بدون سقف» را بزنید';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const value = Number(raw);
|
||||||
|
if (!Number.isFinite(value) || value < 0) {
|
||||||
|
storageError.value = 'سقف حجم نامعتبر است';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void saveQuota(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.billing) void loadStorage();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -40,6 +127,43 @@ const emit = defineEmits<{
|
|||||||
{{ BILLING_PLAN_LABELS[code] }}
|
{{ BILLING_PLAN_LABELS[code] }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="billing" class="storage">
|
||||||
|
<strong>حجم فایل</strong>
|
||||||
|
<p v-if="usage" class="muted">
|
||||||
|
مصرف {{ formatBytes(usage.usedBytes) }}
|
||||||
|
<template v-if="usage.quotaBytes == null"> · بدون سقف</template>
|
||||||
|
<template v-else>
|
||||||
|
از {{ formatBytes(usage.quotaBytes) }}
|
||||||
|
· {{ toFaDigits(usage.percent ?? 0) }}٪
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
|
<p v-else-if="!storageError" class="muted">در حال خواندن مصرف…</p>
|
||||||
|
<ul v-if="usage?.byOrganization?.length" class="orgs">
|
||||||
|
<li v-for="org in usage.byOrganization" :key="org.id">
|
||||||
|
{{ org.name }} · {{ formatBytes(org.usedBytes) }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p class="muted">
|
||||||
|
عکس فعالیت، پیوست یادداشت، لوگو، کاور فرم، تصویر پروفایل، پیوست مالی و فایل گفتگو حساب میشود.
|
||||||
|
متن مشتری، فعالیت و فاکتور حساب نمیشود. فایلهای قدیمی گفتگو که قبلاً روی سرور خارجی رفتهاند داخل این عدد نیستند.
|
||||||
|
</p>
|
||||||
|
<label class="quota">
|
||||||
|
سقف حجم (گیگابایت)
|
||||||
|
<input v-model="quotaGb" type="number" min="0" step="0.5" inputmode="decimal" />
|
||||||
|
</label>
|
||||||
|
<div class="plan-row">
|
||||||
|
<button class="btn" type="button" :disabled="storageBusy" @click="saveTypedQuota">
|
||||||
|
ذخیره سقف
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-ghost" type="button" :disabled="storageBusy" @click="saveQuota(null)">
|
||||||
|
بدون سقف
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-ghost" type="button" :disabled="storageBusy" @click="loadStorage(true)">
|
||||||
|
محاسبه دوباره
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="storageError" class="error">{{ storageError }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -65,4 +189,37 @@ const emit = defineEmits<{
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.4rem;
|
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;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ export interface AuthUserDto {
|
|||||||
enabledModules?: OrgModuleKey[];
|
enabledModules?: OrgModuleKey[];
|
||||||
/** خلاصه اشتراک مالک محیط (برای اعضا فقط جهت نمایش/گیت ورود) */
|
/** خلاصه اشتراک مالک محیط (برای اعضا فقط جهت نمایش/گیت ورود) */
|
||||||
subscription?: SubscriptionSummaryDto | null;
|
subscription?: SubscriptionSummaryDto | null;
|
||||||
|
/** حجم فایل اشتراک مالک همین محیط */
|
||||||
|
storage?: StorageUsageDto | null;
|
||||||
/** اگر باشد، این کاربر سوئیچ «بایگانی اطلاعات قبل» را دارد */
|
/** اگر باشد، این کاربر سوئیچ «بایگانی اطلاعات قبل» را دارد */
|
||||||
priorArchive?: { lens: "current" | "archive"; cutoffAt: string | null } | 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));
|
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 {
|
export interface SubscriptionSummaryDto {
|
||||||
planCode: BillingPlanCode;
|
planCode: BillingPlanCode;
|
||||||
status: BillingAccountStatus;
|
status: BillingAccountStatus;
|
||||||
@@ -355,6 +373,7 @@ export interface BillingAccountDto {
|
|||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
daysRemaining: number;
|
daysRemaining: number;
|
||||||
warn: boolean;
|
warn: boolean;
|
||||||
|
storage?: StorageUsageDto | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApplyBillingPlanRequest {
|
export interface ApplyBillingPlanRequest {
|
||||||
|
|||||||
Reference in New Issue
Block a user