From 344bf1de0a0daf495acd79842c9c4e6ff93cc007 Mon Sep 17 00:00:00 2001 From: mahyargdz Date: Sat, 12 Jul 2025 09:45:07 +0330 Subject: [PATCH] chore: add new logic for the quota --- src/common/enums/message.enum.ts | 1 + .../DTO/business-quota-response.dto.ts | 29 ++++++++++++ .../businesses/businesses.controller.ts | 12 ++++- src/modules/businesses/constant/index.ts | 6 +++ .../businesses/entities/business.entity.ts | 11 ++++- .../queue/business-provisioning.processor.ts | 7 ++- .../businesses/services/businesses.service.ts | 21 +++++++++ src/modules/domains/services/dns.service.ts | 2 +- .../services/domain-automation.service.ts | 27 ++++++----- .../domains/services/domains.service.ts | 46 ++++++++++++++++--- src/modules/users/entities/user.entity.ts | 26 +++++------ src/modules/users/services/users.service.ts | 20 ++++++-- 12 files changed, 169 insertions(+), 39 deletions(-) create mode 100644 src/modules/businesses/DTO/business-quota-response.dto.ts diff --git a/src/common/enums/message.enum.ts b/src/common/enums/message.enum.ts index 49feb8c..b4abceb 100755 --- a/src/common/enums/message.enum.ts +++ b/src/common/enums/message.enum.ts @@ -324,6 +324,7 @@ export const enum BusinessMessage { DOMAIN_NOT_CONNECTED = "دامنه به سرور ما متصل نشد", DOMAIN_NOT_FOUND = "دامنه یافت نشد", DOMAIN_VERIFICATION_TOKEN_REQUIRED = "توکن تایید دامنه مورد نیاز است", + INSUFFICIENT_QUOTA = "حجم شما برای ساخت ایمیل کافی نمی باشد", } export const enum DomainMessage { diff --git a/src/modules/businesses/DTO/business-quota-response.dto.ts b/src/modules/businesses/DTO/business-quota-response.dto.ts new file mode 100644 index 0000000..32214af --- /dev/null +++ b/src/modules/businesses/DTO/business-quota-response.dto.ts @@ -0,0 +1,29 @@ +import { ApiProperty } from "@nestjs/swagger"; + +export class QuotaInfoDto { + @ApiProperty({ description: "Total quota in bytes", example: 134217728 }) + total: number; + + @ApiProperty({ description: "Used quota in bytes", example: 67108864 }) + used: number; + + @ApiProperty({ description: "Remaining quota in bytes", example: 67108864 }) + remaining: number; + + @ApiProperty({ description: "Total quota in MB", example: 128 }) + totalInMB: number; + + @ApiProperty({ description: "Used quota in MB", example: 64 }) + usedInMB: number; + + @ApiProperty({ description: "Remaining quota in MB", example: 64 }) + remainingInMB: number; + + @ApiProperty({ description: "Usage percentage", example: 50 }) + usagePercentage: number; +} + +export class BusinessQuotaResponseDto { + @ApiProperty({ description: "Business quota information", type: QuotaInfoDto }) + quota: QuotaInfoDto; +} diff --git a/src/modules/businesses/businesses.controller.ts b/src/modules/businesses/businesses.controller.ts index ddc8f35..73553bf 100644 --- a/src/modules/businesses/businesses.controller.ts +++ b/src/modules/businesses/businesses.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, Get, Param, Patch, UseInterceptors } from "@nestjs/common"; -import { ApiHeader, ApiOperation } from "@nestjs/swagger"; +import { ApiHeader, ApiOperation, ApiResponse } from "@nestjs/swagger"; +import { BusinessQuotaResponseDto } from "./DTO/business-quota-response.dto"; import { BusinessSlugParamDto } from "./DTO/business-slug-param.dto"; import { UpdateBusinessSettingsDto } from "./DTO/update-business-settings.dto"; import { BusinessesService } from "./services/businesses.service"; @@ -34,4 +35,13 @@ export class BusinessesController { getBusinessSettings(@BusinessDec("id") businessId: string) { return this.businessesService.getBusinessSettings(businessId); } + + @Get("quota") + @UseInterceptors(BusinessInterceptor) + @ApiOperation({ summary: "Get business quota information" }) + @ApiResponse({ status: 200, description: "Business quota information retrieved successfully", type: BusinessQuotaResponseDto }) + @AuthGuards() + getBusinessQuota(@BusinessDec("id") businessId: string) { + return this.businessesService.getBusinessQuota(businessId); + } } diff --git a/src/modules/businesses/constant/index.ts b/src/modules/businesses/constant/index.ts index acca555..005cb7d 100644 --- a/src/modules/businesses/constant/index.ts +++ b/src/modules/businesses/constant/index.ts @@ -7,3 +7,9 @@ export const SUBSCRIPTIONS = Object.freeze({ SERVICE_SLUG: "Dmail", SERVICE_ID: "e51afdc3-ea0b-49cf-8f49-2a6f131b024e", }); + +export const QUOTA_CONSTANTS = Object.freeze({ + INITIAL_BUSINESS_QUOTA: 1024 * 1024 * 1024, // 1GB initial quota when purchasing dmail service (allows 1 user) + USER_QUOTA_DEDUCTION: 128 * 1024 * 1024, // 128MB deducted per user creation + DEFAULT_EMAIL_QUOTA_BYTES: 128 * 1024 * 1024, // 128MB per user in bytes +}); diff --git a/src/modules/businesses/entities/business.entity.ts b/src/modules/businesses/entities/business.entity.ts index 7c07e5d..d821f0b 100644 --- a/src/modules/businesses/entities/business.entity.ts +++ b/src/modules/businesses/entities/business.entity.ts @@ -1,4 +1,4 @@ -import { Cascade, Collection, Entity, EntityRepositoryType, OneToMany, OneToOne, Property, Unique } from "@mikro-orm/core"; +import { Cascade, Collection, Entity, EntityRepositoryType, OneToMany, OneToOne, Opt, Property, Unique } from "@mikro-orm/core"; import { BusinessStaff } from "./business-staff.entity"; import { BaseEntity } from "../../../common/entities/base.entity"; @@ -20,6 +20,15 @@ export class Business extends BaseEntity { @Property({ type: "varchar", length: 255, nullable: true }) logoUrl?: string; + @Property({ type: "bigint", default: 1_073_741_824 }) // 1GB default + quota!: number; + + @Property({ type: "bigint", default: 0 }) + usedQuota!: number & Opt; + + @Property({ type: "bigint", default: 1_073_741_824 }) + remainingQuota!: number & Opt; + //========================= @OneToOne(() => Domain, (domain) => domain.business, { nullable: true, cascade: [Cascade.ALL] }) diff --git a/src/modules/businesses/queue/business-provisioning.processor.ts b/src/modules/businesses/queue/business-provisioning.processor.ts index 1a830c5..3627fed 100644 --- a/src/modules/businesses/queue/business-provisioning.processor.ts +++ b/src/modules/businesses/queue/business-provisioning.processor.ts @@ -5,7 +5,7 @@ import { Logger } from "@nestjs/common"; import { Job } from "bullmq"; import { WorkerProcessor } from "../../../common/queues/worker.processor"; -import { SUBSCRIPTIONS } from "../constant"; +import { QUOTA_CONSTANTS, SUBSCRIPTIONS } from "../constant"; import { BusinessStaff } from "../entities/business-staff.entity"; import { Business } from "../entities/business.entity"; import { IBusinessProvisioningJob } from "../interfaces/IBusiness"; @@ -45,9 +45,12 @@ export class BusinessProvisioningProcessor extends WorkerProcessor { danakSubscriptionId: subscriptionId, name: businessName, slug: slug, + quota: QUOTA_CONSTANTS.INITIAL_BUSINESS_QUOTA, + usedQuota: 0, + remainingQuota: QUOTA_CONSTANTS.INITIAL_BUSINESS_QUOTA, }); await em.persistAndFlush(business); - this.logger.log(`Created business for subscription ${subscriptionId}`); + this.logger.log(`Created business for subscription ${subscriptionId} with initial quota: ${QUOTA_CONSTANTS.INITIAL_BUSINESS_QUOTA}`); } else { this.logger.debug(`Business already exists for subscription ${subscriptionId}`); } diff --git a/src/modules/businesses/services/businesses.service.ts b/src/modules/businesses/services/businesses.service.ts index c6e6706..9b44a94 100644 --- a/src/modules/businesses/services/businesses.service.ts +++ b/src/modules/businesses/services/businesses.service.ts @@ -85,4 +85,25 @@ export class BusinessesService { if (!staff) throw new BadRequestException(BusinessMessage.STAFF_NOT_FOUND); return staff; } + + //************************************************** */ + async getBusinessQuota(businessId: string) { + const business = await this.businessRepository.findOne({ id: businessId, deletedAt: null }); + if (!business) throw new BadRequestException(BusinessMessage.NOT_FOUND); + + return { + quota: { + total: Number(business.quota), + used: Number(business.usedQuota), + remaining: Number(business.remainingQuota), + totalInMB: Math.round(Number(business.quota) / (1024 * 1024)), + totalInGB: Math.round(Number(business.quota) / (1024 * 1024 * 1024)), + usedInMB: Math.round(Number(business.usedQuota) / (1024 * 1024)), + usedInGB: Math.round(Number(business.usedQuota) / (1024 * 1024 * 1024)), + remainingInMB: Math.round(Number(business.remainingQuota) / (1024 * 1024)), + remainingInGB: Math.round(Number(business.remainingQuota) / (1024 * 1024 * 1024)), + usagePercentage: Math.round((Number(business.usedQuota) / Number(business.quota)) * 100), + }, + }; + } } diff --git a/src/modules/domains/services/dns.service.ts b/src/modules/domains/services/dns.service.ts index 953eed3..fa886c8 100644 --- a/src/modules/domains/services/dns.service.ts +++ b/src/modules/domains/services/dns.service.ts @@ -282,7 +282,7 @@ export class DnsService { // Check if the expected value matches either the joined record or any individual part return joinedRecord === dnsRecord.value || flatRecords.some((record) => record.includes(dnsRecord.value) || record === dnsRecord.value); } catch (error) { - this.logger.debug(`TXT record verification failed for ${dnsRecord.name}:`, error instanceof Error ? error.message : "Unknown error"); + this.logger.debug(`TXT record verification failed for ${dnsRecord.fullName}:`, error instanceof Error ? error.message : "Unknown error"); return false; } } diff --git a/src/modules/domains/services/domain-automation.service.ts b/src/modules/domains/services/domain-automation.service.ts index 52d28fc..6b4f529 100644 --- a/src/modules/domains/services/domain-automation.service.ts +++ b/src/modules/domains/services/domain-automation.service.ts @@ -1,7 +1,9 @@ import { EntityManager } from "@mikro-orm/core"; -import { Injectable, Logger } from "@nestjs/common"; +import { BadRequestException, Injectable, Logger } from "@nestjs/common"; import { firstValueFrom } from "rxjs"; +import { BusinessMessage, MailServerMessage, RoleMessage } from "../../../common/enums/message.enum"; +import { QUOTA_CONSTANTS } from "../../businesses/constant"; import { MailServerService } from "../../mail-server/services/mail-server.service"; import { MailboxEnum } from "../../mailbox/enums/mailbox.enum"; import { Role } from "../../users/entities/role.entity"; @@ -114,9 +116,7 @@ export class DomainAutomationService { // Get user role const userRole = await this.em.findOne(Role, { name: RoleEnum.USER }); - if (!userRole) { - throw new Error("User role not found in database"); - } + if (!userRole) throw new BadRequestException(RoleMessage.NOT_FOUND); // Create user in mail server (WildDuck) const mailServerUser = await firstValueFrom( @@ -125,14 +125,15 @@ export class DomainAutomationService { password, address: infoEmail, name: `Info - ${domain.business.name}`, - quota: 1073741824, // 1GB default quota - language: "fa", + quota: QUOTA_CONSTANTS.DEFAULT_EMAIL_QUOTA_BYTES, }), ); - if (!mailServerUser.success) { - throw new Error(`Failed to create mail server user`); - } + if (!mailServerUser.success) throw new BadRequestException(MailServerMessage.FAILED_TO_CREATE_ACCOUNT); + + // Check if business has enough quota + const remainingQuota = domain.business.remainingQuota || 0; + if (remainingQuota < QUOTA_CONSTANTS.USER_QUOTA_DEDUCTION) throw new BadRequestException(BusinessMessage.INSUFFICIENT_QUOTA); // Create user in local database const user = this.em.create(User, { @@ -141,7 +142,7 @@ export class DomainAutomationService { password: hashedPassword, emailAddress: infoEmail, emailEnabled: true, - emailQuota: 1073741824, // 1GB default + emailQuota: QUOTA_CONSTANTS.DEFAULT_EMAIL_QUOTA_BYTES, wildduckUserId: mailServerUser.id, displayName: `Info - ${domain.business.name}`, isActive: true, @@ -150,7 +151,11 @@ export class DomainAutomationService { role: userRole, }); - await this.em.persistAndFlush(user); + // Update business quota after successful user creation + domain.business.usedQuota = (domain.business.usedQuota || 0) + QUOTA_CONSTANTS.USER_QUOTA_DEDUCTION; + domain.business.remainingQuota = (domain.business.quota || 0) - domain.business.usedQuota; + + await this.em.persistAndFlush([user, domain.business]); // Create default mailboxes (Archive and Favorite) await this.createDefaultMailboxes(mailServerUser.id); diff --git a/src/modules/domains/services/domains.service.ts b/src/modules/domains/services/domains.service.ts index 7afa18b..525f89c 100644 --- a/src/modules/domains/services/domains.service.ts +++ b/src/modules/domains/services/domains.service.ts @@ -216,9 +216,26 @@ export class DomainsService { const domain = await this.getDomainById(businessDomain.id, businessId); - const { overallStatus, recommendations } = await this.checkDomainVerificationStatus(businessId); + // If domain is already verified, skip verification check and return DNS records directly + if (domain.isVerified && domain.status === DomainStatus.VERIFIED) { + const { dnsRecords } = await this.dnsService.getDomainDnsRecords(domain.id); + return { + dnsRecords, + overallStatus: { + isVerified: true, + isComplete: true, + verified: dnsRecords.length, + pending: 0, + failed: 0, + total: dnsRecords.length, + lastChecked: new Date(), + }, + recommendations: [], + }; + } + + const { dnsRecords, overallStatus, recommendations } = await this.checkDomainVerificationStatus(businessId); - const { dnsRecords } = await this.dnsService.getDomainDnsRecords(domain.id); return { dnsRecords, overallStatus, @@ -235,17 +252,34 @@ export class DomainsService { const domain = await this.getDomainById(businessDomain.id, businessId); - // // Check DNS records for verification - // await this.dnsService.verifyDomainDNS(domain); + // If domain is already verified, skip verification check + if (domain.isVerified && domain.status === DomainStatus.VERIFIED) { + const { dnsRecords } = await this.dnsService.getDomainDnsRecords(domain.id); + + return { + dnsRecords, + overallStatus: { + isVerified: true, + isComplete: true, + verified: dnsRecords.length, + pending: 0, + failed: 0, + total: dnsRecords.length, + lastChecked: new Date(), + }, + recommendations: [], + }; + } const { dnsRecords } = await this.dnsService.getDomainDnsRecords(domain.id); // Process DNS records for verification status const { verificationStatuses, statusCounts, recommendations } = await this.processDnsRecordsForVerification(dnsRecords); - const isVerified = statusCounts.failed === 0 && statusCounts.pending === 0 && statusCounts.verified > 0; + // Only mark as verified if ALL DNS records are verified (no failed, no pending) + const isVerified = statusCounts.failed === 0 && statusCounts.pending === 0 && statusCounts.verified === dnsRecords.length; - const isComplete = statusCounts.failed === 0 && statusCounts.pending === 0 && statusCounts.verified > 0; + const isComplete = statusCounts.failed === 0 && statusCounts.pending === 0 && statusCounts.verified === dnsRecords.length; if (isVerified && isComplete && !domain.isVerified) { // Update domain status diff --git a/src/modules/users/entities/user.entity.ts b/src/modules/users/entities/user.entity.ts index cb794ed..9c22791 100644 --- a/src/modules/users/entities/user.entity.ts +++ b/src/modules/users/entities/user.entity.ts @@ -1,4 +1,4 @@ -import { Collection, Entity, EntityRepositoryType, ManyToOne, OneToMany, OneToOne, Property } from "@mikro-orm/core"; +import { Collection, Entity, EntityRepositoryType, ManyToOne, OneToMany, OneToOne, Opt, Property } from "@mikro-orm/core"; import { RefreshToken } from "./refresh-token.entity"; import { Role } from "./role.entity"; @@ -19,30 +19,30 @@ export class User extends BaseEntity { @Property({ type: "varchar", length: 150, nullable: false }) password!: string; - @Property({ type: "varchar", length: 255, nullable: true }) - emailAddress?: string; // Full email address + @Property({ type: "varchar", length: 255, nullable: false }) + emailAddress!: string; @Property({ type: "boolean", default: false }) - emailEnabled: boolean = false; // Whether email is enabled for this user + emailEnabled: boolean & Opt; - @Property({ type: "bigint", nullable: true, default: 1073741824 }) // 1GB default - emailQuota?: number; // Email quota in bytes + @Property({ type: "bigint", nullable: false, default: 134_217_728 }) // 128MB default + emailQuota!: number; - @Property({ type: "bigint", nullable: true, default: 0 }) - emailQuotaUsed?: number; // Email quota used in bytes + @Property({ type: "bigint", nullable: false, default: 0 }) + emailQuotaUsed!: number & Opt; // Email account integration fields @Property({ type: "varchar", length: 255, nullable: false }) - wildduckUserId: string; // WildDuck user ID + wildduckUserId!: string; @Property({ type: "varchar", length: 255, nullable: true }) - displayName?: string; // Display name for email + displayName?: string; @Property({ type: "timestamptz", nullable: true }) - lastQuotaSync?: Date; // Last time quota was synced with WildDuck + lastQuotaSync?: Date; @Property({ default: true, nullable: false }) - isActive: boolean = true; + isActive: boolean & Opt; //========================= @@ -61,7 +61,7 @@ export class User extends BaseEntity { business!: Business; @ManyToOne(() => Domain, { deleteRule: "restrict" }) - domain!: Domain; // The domain this email user belongs to + domain!: Domain; [EntityRepositoryType]?: UserRepository; } diff --git a/src/modules/users/services/users.service.ts b/src/modules/users/services/users.service.ts index 4156afd..f7a694b 100644 --- a/src/modules/users/services/users.service.ts +++ b/src/modules/users/services/users.service.ts @@ -11,6 +11,7 @@ import { DANAK_SMTPS_SERVER, } from "../../../common/constants"; import { BusinessMessage, DomainMessage, MailServerMessage, RoleMessage, UserMessage } from "../../../common/enums/message.enum"; +import { QUOTA_CONSTANTS } from "../../businesses/constant"; import { Business } from "../../businesses/entities/business.entity"; import { DomainStatus } from "../../domains/enums/domain-status.enum"; import { DomainAutomationService } from "../../domains/services/domain-automation.service"; @@ -75,6 +76,12 @@ export class UsersService { const business = await this.em.findOne(Business, { id: businessId }); if (!business) throw new BadRequestException(BusinessMessage.NOT_FOUND); + // Check if business has enough quota + const remainingQuota = business.remainingQuota || 0; + if (remainingQuota < QUOTA_CONSTANTS.USER_QUOTA_DEDUCTION) { + throw new BadRequestException("Insufficient quota to create user. Please upgrade your plan."); + } + const domain = await this.domainsService.getDomainById(domainId, businessId); if (domain.business.id !== businessId) throw new BadRequestException(DomainMessage.NOT_BELONG_TO_BUSINESS); @@ -92,14 +99,15 @@ export class UsersService { if (!userRole) throw new BadRequestException(RoleMessage.NOT_FOUND); try { + const userQuota = quota || QUOTA_CONSTANTS.DEFAULT_EMAIL_QUOTA_BYTES; + const mailServerUser = await firstValueFrom( this.mailServerService.users.createUser({ username: `${username}-${domain.name}`, password, address: emailAddress, name: displayName || username, - quota: quota || 1073741824, - // language: "fa", + quota: userQuota, }), ); @@ -111,7 +119,7 @@ export class UsersService { password: hashedPassword, emailAddress, emailEnabled: true, - emailQuota: quota || 1073741824, + emailQuota: userQuota, wildduckUserId: mailServerUser.id, displayName: displayName || username, isActive: true, @@ -120,7 +128,11 @@ export class UsersService { role: userRole, }); - await this.em.persistAndFlush(user); + // Update business quota after successful user creation + business.usedQuota = business.usedQuota + QUOTA_CONSTANTS.USER_QUOTA_DEDUCTION; + business.remainingQuota = business.quota - business.usedQuota; + + await this.em.persistAndFlush([user, business]); if (aliases && aliases.length > 0) { for (const alias of aliases) {