chore: add new logic for the quota
This commit is contained in:
@@ -324,6 +324,7 @@ export const enum BusinessMessage {
|
||||
DOMAIN_NOT_CONNECTED = "دامنه به سرور ما متصل نشد",
|
||||
DOMAIN_NOT_FOUND = "دامنه یافت نشد",
|
||||
DOMAIN_VERIFICATION_TOKEN_REQUIRED = "توکن تایید دامنه مورد نیاز است",
|
||||
INSUFFICIENT_QUOTA = "حجم شما برای ساخت ایمیل کافی نمی باشد",
|
||||
}
|
||||
|
||||
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 { 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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] })
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user