chore: add new logic for the quota

This commit is contained in:
mahyargdz
2025-07-12 09:45:07 +03:30
parent a350f8af5a
commit 344bf1de0a
12 changed files with 169 additions and 39 deletions
+1
View File
@@ -324,6 +324,7 @@ export const enum BusinessMessage {
DOMAIN_NOT_CONNECTED = "دامنه به سرور ما متصل نشد", DOMAIN_NOT_CONNECTED = "دامنه به سرور ما متصل نشد",
DOMAIN_NOT_FOUND = "دامنه یافت نشد", DOMAIN_NOT_FOUND = "دامنه یافت نشد",
DOMAIN_VERIFICATION_TOKEN_REQUIRED = "توکن تایید دامنه مورد نیاز است", DOMAIN_VERIFICATION_TOKEN_REQUIRED = "توکن تایید دامنه مورد نیاز است",
INSUFFICIENT_QUOTA = "حجم شما برای ساخت ایمیل کافی نمی باشد",
} }
export const enum DomainMessage { export const enum DomainMessage {
@@ -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;
}
@@ -1,6 +1,7 @@
import { Body, Controller, Get, Param, Patch, UseInterceptors } from "@nestjs/common"; 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 { BusinessSlugParamDto } from "./DTO/business-slug-param.dto";
import { UpdateBusinessSettingsDto } from "./DTO/update-business-settings.dto"; import { UpdateBusinessSettingsDto } from "./DTO/update-business-settings.dto";
import { BusinessesService } from "./services/businesses.service"; import { BusinessesService } from "./services/businesses.service";
@@ -34,4 +35,13 @@ export class BusinessesController {
getBusinessSettings(@BusinessDec("id") businessId: string) { getBusinessSettings(@BusinessDec("id") businessId: string) {
return this.businessesService.getBusinessSettings(businessId); 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);
}
} }
+6
View File
@@ -7,3 +7,9 @@ export const SUBSCRIPTIONS = Object.freeze({
SERVICE_SLUG: "Dmail", SERVICE_SLUG: "Dmail",
SERVICE_ID: "e51afdc3-ea0b-49cf-8f49-2a6f131b024e", 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
});
@@ -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 { BusinessStaff } from "./business-staff.entity";
import { BaseEntity } from "../../../common/entities/base.entity"; import { BaseEntity } from "../../../common/entities/base.entity";
@@ -20,6 +20,15 @@ export class Business extends BaseEntity {
@Property({ type: "varchar", length: 255, nullable: true }) @Property({ type: "varchar", length: 255, nullable: true })
logoUrl?: string; 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] }) @OneToOne(() => Domain, (domain) => domain.business, { nullable: true, cascade: [Cascade.ALL] })
@@ -5,7 +5,7 @@ import { Logger } from "@nestjs/common";
import { Job } from "bullmq"; import { Job } from "bullmq";
import { WorkerProcessor } from "../../../common/queues/worker.processor"; 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 { BusinessStaff } from "../entities/business-staff.entity";
import { Business } from "../entities/business.entity"; import { Business } from "../entities/business.entity";
import { IBusinessProvisioningJob } from "../interfaces/IBusiness"; import { IBusinessProvisioningJob } from "../interfaces/IBusiness";
@@ -45,9 +45,12 @@ export class BusinessProvisioningProcessor extends WorkerProcessor {
danakSubscriptionId: subscriptionId, danakSubscriptionId: subscriptionId,
name: businessName, name: businessName,
slug: slug, slug: slug,
quota: QUOTA_CONSTANTS.INITIAL_BUSINESS_QUOTA,
usedQuota: 0,
remainingQuota: QUOTA_CONSTANTS.INITIAL_BUSINESS_QUOTA,
}); });
await em.persistAndFlush(business); 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 { } else {
this.logger.debug(`Business already exists for subscription ${subscriptionId}`); this.logger.debug(`Business already exists for subscription ${subscriptionId}`);
} }
@@ -85,4 +85,25 @@ export class BusinessesService {
if (!staff) throw new BadRequestException(BusinessMessage.STAFF_NOT_FOUND); if (!staff) throw new BadRequestException(BusinessMessage.STAFF_NOT_FOUND);
return staff; 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),
},
};
}
} }
+1 -1
View File
@@ -282,7 +282,7 @@ export class DnsService {
// Check if the expected value matches either the joined record or any individual part // 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); return joinedRecord === dnsRecord.value || flatRecords.some((record) => record.includes(dnsRecord.value) || record === dnsRecord.value);
} catch (error) { } 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; return false;
} }
} }
@@ -1,7 +1,9 @@
import { EntityManager } from "@mikro-orm/core"; import { EntityManager } from "@mikro-orm/core";
import { Injectable, Logger } from "@nestjs/common"; import { BadRequestException, Injectable, Logger } from "@nestjs/common";
import { firstValueFrom } from "rxjs"; 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 { MailServerService } from "../../mail-server/services/mail-server.service";
import { MailboxEnum } from "../../mailbox/enums/mailbox.enum"; import { MailboxEnum } from "../../mailbox/enums/mailbox.enum";
import { Role } from "../../users/entities/role.entity"; import { Role } from "../../users/entities/role.entity";
@@ -114,9 +116,7 @@ export class DomainAutomationService {
// Get user role // Get user role
const userRole = await this.em.findOne(Role, { name: RoleEnum.USER }); const userRole = await this.em.findOne(Role, { name: RoleEnum.USER });
if (!userRole) { if (!userRole) throw new BadRequestException(RoleMessage.NOT_FOUND);
throw new Error("User role not found in database");
}
// Create user in mail server (WildDuck) // Create user in mail server (WildDuck)
const mailServerUser = await firstValueFrom( const mailServerUser = await firstValueFrom(
@@ -125,14 +125,15 @@ export class DomainAutomationService {
password, password,
address: infoEmail, address: infoEmail,
name: `Info - ${domain.business.name}`, name: `Info - ${domain.business.name}`,
quota: 1073741824, // 1GB default quota quota: QUOTA_CONSTANTS.DEFAULT_EMAIL_QUOTA_BYTES,
language: "fa",
}), }),
); );
if (!mailServerUser.success) { if (!mailServerUser.success) throw new BadRequestException(MailServerMessage.FAILED_TO_CREATE_ACCOUNT);
throw new Error(`Failed to create mail server user`);
} // 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 // Create user in local database
const user = this.em.create(User, { const user = this.em.create(User, {
@@ -141,7 +142,7 @@ export class DomainAutomationService {
password: hashedPassword, password: hashedPassword,
emailAddress: infoEmail, emailAddress: infoEmail,
emailEnabled: true, emailEnabled: true,
emailQuota: 1073741824, // 1GB default emailQuota: QUOTA_CONSTANTS.DEFAULT_EMAIL_QUOTA_BYTES,
wildduckUserId: mailServerUser.id, wildduckUserId: mailServerUser.id,
displayName: `Info - ${domain.business.name}`, displayName: `Info - ${domain.business.name}`,
isActive: true, isActive: true,
@@ -150,7 +151,11 @@ export class DomainAutomationService {
role: userRole, 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) // Create default mailboxes (Archive and Favorite)
await this.createDefaultMailboxes(mailServerUser.id); await this.createDefaultMailboxes(mailServerUser.id);
@@ -216,9 +216,26 @@ export class DomainsService {
const domain = await this.getDomainById(businessDomain.id, businessId); 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 { return {
dnsRecords, dnsRecords,
overallStatus, overallStatus,
@@ -235,17 +252,34 @@ export class DomainsService {
const domain = await this.getDomainById(businessDomain.id, businessId); const domain = await this.getDomainById(businessDomain.id, businessId);
// // Check DNS records for verification // If domain is already verified, skip verification check
// await this.dnsService.verifyDomainDNS(domain); 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); const { dnsRecords } = await this.dnsService.getDomainDnsRecords(domain.id);
// Process DNS records for verification status // Process DNS records for verification status
const { verificationStatuses, statusCounts, recommendations } = await this.processDnsRecordsForVerification(dnsRecords); 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) { if (isVerified && isComplete && !domain.isVerified) {
// Update domain status // Update domain status
+13 -13
View File
@@ -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 { RefreshToken } from "./refresh-token.entity";
import { Role } from "./role.entity"; import { Role } from "./role.entity";
@@ -19,30 +19,30 @@ export class User extends BaseEntity {
@Property({ type: "varchar", length: 150, nullable: false }) @Property({ type: "varchar", length: 150, nullable: false })
password!: string; password!: string;
@Property({ type: "varchar", length: 255, nullable: true }) @Property({ type: "varchar", length: 255, nullable: false })
emailAddress?: string; // Full email address emailAddress!: string;
@Property({ type: "boolean", default: false }) @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 @Property({ type: "bigint", nullable: false, default: 134_217_728 }) // 128MB default
emailQuota?: number; // Email quota in bytes emailQuota!: number;
@Property({ type: "bigint", nullable: true, default: 0 }) @Property({ type: "bigint", nullable: false, default: 0 })
emailQuotaUsed?: number; // Email quota used in bytes emailQuotaUsed!: number & Opt;
// Email account integration fields // Email account integration fields
@Property({ type: "varchar", length: 255, nullable: false }) @Property({ type: "varchar", length: 255, nullable: false })
wildduckUserId: string; // WildDuck user ID wildduckUserId!: string;
@Property({ type: "varchar", length: 255, nullable: true }) @Property({ type: "varchar", length: 255, nullable: true })
displayName?: string; // Display name for email displayName?: string;
@Property({ type: "timestamptz", nullable: true }) @Property({ type: "timestamptz", nullable: true })
lastQuotaSync?: Date; // Last time quota was synced with WildDuck lastQuotaSync?: Date;
@Property({ default: true, nullable: false }) @Property({ default: true, nullable: false })
isActive: boolean = true; isActive: boolean & Opt;
//========================= //=========================
@@ -61,7 +61,7 @@ export class User extends BaseEntity {
business!: Business; business!: Business;
@ManyToOne(() => Domain, { deleteRule: "restrict" }) @ManyToOne(() => Domain, { deleteRule: "restrict" })
domain!: Domain; // The domain this email user belongs to domain!: Domain;
[EntityRepositoryType]?: UserRepository; [EntityRepositoryType]?: UserRepository;
} }
+16 -4
View File
@@ -11,6 +11,7 @@ import {
DANAK_SMTPS_SERVER, DANAK_SMTPS_SERVER,
} from "../../../common/constants"; } from "../../../common/constants";
import { BusinessMessage, DomainMessage, MailServerMessage, RoleMessage, UserMessage } from "../../../common/enums/message.enum"; 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 { Business } from "../../businesses/entities/business.entity";
import { DomainStatus } from "../../domains/enums/domain-status.enum"; import { DomainStatus } from "../../domains/enums/domain-status.enum";
import { DomainAutomationService } from "../../domains/services/domain-automation.service"; import { DomainAutomationService } from "../../domains/services/domain-automation.service";
@@ -75,6 +76,12 @@ export class UsersService {
const business = await this.em.findOne(Business, { id: businessId }); const business = await this.em.findOne(Business, { id: businessId });
if (!business) throw new BadRequestException(BusinessMessage.NOT_FOUND); 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); const domain = await this.domainsService.getDomainById(domainId, businessId);
if (domain.business.id !== businessId) throw new BadRequestException(DomainMessage.NOT_BELONG_TO_BUSINESS); 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); if (!userRole) throw new BadRequestException(RoleMessage.NOT_FOUND);
try { try {
const userQuota = quota || QUOTA_CONSTANTS.DEFAULT_EMAIL_QUOTA_BYTES;
const mailServerUser = await firstValueFrom( const mailServerUser = await firstValueFrom(
this.mailServerService.users.createUser({ this.mailServerService.users.createUser({
username: `${username}-${domain.name}`, username: `${username}-${domain.name}`,
password, password,
address: emailAddress, address: emailAddress,
name: displayName || username, name: displayName || username,
quota: quota || 1073741824, quota: userQuota,
// language: "fa",
}), }),
); );
@@ -111,7 +119,7 @@ export class UsersService {
password: hashedPassword, password: hashedPassword,
emailAddress, emailAddress,
emailEnabled: true, emailEnabled: true,
emailQuota: quota || 1073741824, emailQuota: userQuota,
wildduckUserId: mailServerUser.id, wildduckUserId: mailServerUser.id,
displayName: displayName || username, displayName: displayName || username,
isActive: true, isActive: true,
@@ -120,7 +128,11 @@ export class UsersService {
role: userRole, 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) { if (aliases && aliases.length > 0) {
for (const alias of aliases) { for (const alias of aliases) {