Files
dmail-api/src/modules/quota-sync/services/quota-sync.service.ts
T
2025-07-20 10:15:13 +03:30

599 lines
18 KiB
TypeScript

import { EntityManager } from "@mikro-orm/core";
import { InjectQueue } from "@nestjs/bullmq";
import { Injectable, Logger } from "@nestjs/common";
import { Queue } from "bullmq";
import { firstValueFrom } from "rxjs";
import { Business } from "../../businesses/entities/business.entity";
import { WildDuckUsersService } from "../../mail-server/services/wildduck-users.service";
import { User } from "../../users/entities/user.entity";
import { QUOTA_SYNC_QUEUE } from "../constants";
import {
BusinessQuotaInfoDto,
BusinessQuotaStatisticsDto,
ComprehensiveQuotaSyncResultDto,
QuotaInfoDto,
QuotaSyncResultDto,
UserQuotaStatisticsDto,
} from "../DTO/quota-statistics.dto";
import { ISyncBusinessQuotaJob, ISyncUserQuotaJob } from "../interfaces/quota-sync-job.interface";
@Injectable()
export class QuotaSyncService {
private readonly logger = new Logger(QuotaSyncService.name);
constructor(
@InjectQueue(QUOTA_SYNC_QUEUE.QUEUE_NAME) private readonly quotaSyncQueue: Queue,
private readonly wildDuckUsersService: WildDuckUsersService,
private readonly em: EntityManager,
) {}
/**
* Setup repeatable cron job to run comprehensive quota sync every hour
*/
async setupCronJob(): Promise<void> {
try {
// Remove any existing cron jobs for this job type
// Get all repeatable jobs and remove the one with matching name
await this.removeCronJob();
// Add new repeatable job
const job = await this.quotaSyncQueue.add(
QUOTA_SYNC_QUEUE.SYNC_COMPREHENSIVE_QUOTA_JOB,
{},
{
removeOnComplete: true,
removeOnFail: false,
attempts: 3,
backoff: {
type: "exponential",
delay: 2000,
},
repeat: {
pattern: "0 * * * *",
},
},
);
this.logger.debug(`Added new cron job with ID: ${job.id}`);
this.logger.log("Quota sync cron job scheduled to run every hour");
} catch (error) {
this.logger.error("Failed to setup quota sync cron job", error);
throw error;
}
}
/**
* Remove the repeatable cron job
*/
async removeCronJob(): Promise<void> {
try {
// Remove any existing cron jobs for this job type
// Get all repeatable jobs and remove the one with matching name
const repeatableJobs = await this.quotaSyncQueue.getJobSchedulers();
const existingJob = repeatableJobs.filter((job) => job.name === QUOTA_SYNC_QUEUE.SYNC_COMPREHENSIVE_QUOTA_JOB);
if (existingJob.length > 0) {
for (const job of existingJob) {
const removed = await this.quotaSyncQueue.removeJobScheduler(job.key);
this.logger.debug(`Removed existing cron job with key ${job.key}: ${removed}`);
}
} else {
this.logger.debug("No existing cron job found to remove");
}
await this.quotaSyncQueue.clean(0, 100, "completed");
await this.quotaSyncQueue.clean(0, 100, "failed");
} catch (error) {
this.logger.error("Failed to remove quota sync cron job", error);
}
}
/**
* Add a job to sync quota usage for all users
*/
async scheduleAllUsersQuotaSync(): Promise<void> {
await this.quotaSyncQueue.add(
QUOTA_SYNC_QUEUE.SYNC_ALL_USERS_QUOTA_JOB,
{},
{
removeOnComplete: true,
removeOnFail: false,
attempts: 3,
backoff: {
type: "exponential",
delay: 2000,
},
},
);
this.logger.log("Scheduled all users quota sync job");
}
/**
* Add a job to sync business quota usage for all businesses
*/
async scheduleAllBusinessQuotasSync(): Promise<void> {
await this.quotaSyncQueue.add(
QUOTA_SYNC_QUEUE.SYNC_ALL_BUSINESSES_QUOTA_JOB,
{},
{
removeOnComplete: true,
removeOnFail: false,
attempts: 3,
backoff: {
type: "exponential",
delay: 2000,
},
},
);
this.logger.log("Scheduled all businesses quota sync job");
}
/**
* Add a job to sync quota for a specific user
*/
async scheduleUserQuotaSync(userId: string, businessId: string, wildduckUserId: string): Promise<void> {
const jobData: ISyncUserQuotaJob = {
userId,
businessId,
wildduckUserId,
};
await this.quotaSyncQueue.add(QUOTA_SYNC_QUEUE.SYNC_USER_QUOTA_JOB, jobData, {
removeOnComplete: true,
removeOnFail: false,
attempts: 3,
backoff: {
type: "exponential",
delay: 2000,
},
});
this.logger.log(`Scheduled user quota sync job for user: ${userId}`);
}
/**
* Add a job to sync quota for a specific business
*/
async scheduleBusinessQuotaSync(businessId: string, businessName: string): Promise<void> {
const jobData: ISyncBusinessQuotaJob = {
businessId,
businessName,
};
await this.quotaSyncQueue.add(QUOTA_SYNC_QUEUE.SYNC_BUSINESS_QUOTA_JOB, jobData, {
removeOnComplete: true,
removeOnFail: false,
attempts: 3,
backoff: {
type: "exponential",
delay: 2000,
},
});
this.logger.log(`Scheduled business quota sync job for business: ${businessName}`);
}
/**
* Add a job for comprehensive quota sync
*/
async scheduleComprehensiveQuotaSync(): Promise<void> {
await this.quotaSyncQueue.add(
QUOTA_SYNC_QUEUE.SYNC_COMPREHENSIVE_QUOTA_JOB,
{},
{
removeOnComplete: true,
removeOnFail: false,
attempts: 3,
backoff: {
type: "exponential",
delay: 2000,
},
},
);
this.logger.log("Scheduled comprehensive quota sync job");
}
// The following methods remain the same but without cron decorators
// They are now called by the queue processor
/**
* Sync quota usage for all users (called by processor)
*/
async syncAllUsersQuota(): Promise<void> {
const em = this.em.fork();
this.logger.log("Starting quota synchronization for all users");
try {
const users = await em.find(User, {
emailEnabled: true,
deletedAt: null,
wildduckUserId: { $ne: null },
});
this.logger.log(`Found ${users.length} users to sync quota for`);
let successCount = 0;
let errorCount = 0;
for (const user of users) {
try {
await this.syncUserQuotaWithEntityManager(user, em);
successCount++;
} catch (error) {
errorCount++;
this.logger.error(`Failed to sync quota for user ${user.id}: ${error instanceof Error ? error.message : "Unknown error"}`);
}
}
// Flush all changes at once
await em.flush();
this.logger.log(`Quota sync completed: ${successCount} success, ${errorCount} errors`);
} catch (error) {
this.logger.error("Failed to sync quota for users", `${error instanceof Error ? error.message : "Unknown error"}`);
throw error;
}
}
/**
* Sync business quota usage for all businesses (called by processor)
*/
async syncAllBusinessQuotas(): Promise<void> {
const em = this.em.fork();
this.logger.log("Starting business quota synchronization");
try {
const businesses = await em.find(Business, {
deletedAt: null,
});
this.logger.log(`Found ${businesses.length} businesses to sync quota for`);
let successCount = 0;
let errorCount = 0;
for (const business of businesses) {
try {
await this.syncBusinessQuotaWithEntityManager(business, em);
successCount++;
} catch (error) {
errorCount++;
this.logger.error(`Failed to sync quota for business ${business.id}: ${error instanceof Error ? error.message : "Unknown error"}`);
}
}
// Flush all changes at once
await em.flush();
this.logger.log(`Business quota sync completed: ${successCount} success, ${errorCount} errors`);
} catch (error) {
this.logger.error("Failed to sync quota for businesses", `${error instanceof Error ? error.message : "Unknown error"}`);
throw error;
}
}
/**
* Sync quota for a specific user with provided entity manager
*/
private async syncUserQuotaWithEntityManager(user: User, _em: EntityManager): Promise<void> {
if (!user.wildduckUserId) {
this.logger.warn(`User ${user.id} has no WildDuck user ID, skipping quota sync`);
return;
}
try {
const wildduckUser = await firstValueFrom(this.wildDuckUsersService.getUser(user.wildduckUserId));
if (!wildduckUser || !wildduckUser.success) {
this.logger.warn(`Failed to fetch WildDuck user data for user ${user.id}`);
return;
}
const quotaInfo: QuotaInfoDto = {
used: wildduckUser.limits?.quota?.used || 0,
allowed: wildduckUser.limits?.quota?.allowed || 0,
};
// Update local user quota info
user.emailQuotaUsed = quotaInfo.used;
user.emailQuota = quotaInfo.allowed;
user.lastQuotaSync = new Date();
this.logger.debug(`Synced quota for user ${user.id}: ${quotaInfo.used}/${quotaInfo.allowed} bytes`);
} catch (error) {
this.logger.error(`Error syncing quota for user ${user.id}:`, `${error instanceof Error ? error.message : "Unknown error"}`);
throw error;
}
}
/**
* Sync quota for a specific user
*/
async syncUserQuota(user: User): Promise<void> {
const em = this.em.fork();
// Re-fetch the user with the current entity manager
const managedUser = await em.findOne(User, { id: user.id });
if (!managedUser) {
throw new Error(`User ${user.id} not found`);
}
await this.syncUserQuotaWithEntityManager(managedUser, em);
await em.flush();
}
/**
* Manually trigger quota sync for a specific user
*/
async syncUserQuotaById(userId: string): Promise<QuotaInfoDto> {
const em = this.em.fork();
const user = await em.findOne(User, {
id: userId,
emailEnabled: true,
deletedAt: null,
});
if (!user) {
throw new Error(`User ${userId} not found or not email enabled`);
}
await this.syncUserQuotaWithEntityManager(user, em);
await em.flush();
return {
used: user.emailQuotaUsed || 0,
allowed: user.emailQuota || 0,
};
}
/**
* Get quota statistics for all users
*/
async getQuotaStatistics(): Promise<UserQuotaStatisticsDto> {
const em = this.em.fork();
const users = await em.find(User, {
emailEnabled: true,
deletedAt: null,
emailQuotaUsed: { $ne: null },
});
const totalQuotaUsed = users.reduce((sum, user) => sum + (user.emailQuotaUsed || 0), 0);
const totalQuotaAllowed = users.reduce((sum, user) => sum + (user.emailQuota || 0), 0);
const averageUsagePercent = totalQuotaAllowed > 0 ? (totalQuotaUsed / totalQuotaAllowed) * 100 : 0;
// Find the most recent sync time
const lastSyncUser = await em.findOne(
User,
{
emailEnabled: true,
deletedAt: null,
lastQuotaSync: { $ne: null },
},
{
orderBy: { lastQuotaSync: "DESC" },
},
);
return {
totalUsers: users.length,
totalQuotaUsed,
totalQuotaAllowed,
averageUsagePercent: Math.round(averageUsagePercent * 100) / 100,
lastSyncTime: lastSyncUser?.lastQuotaSync || null,
};
}
/**
* Sync quota for a specific business with provided entity manager
*/
private async syncBusinessQuotaWithEntityManager(business: Business, em: EntityManager): Promise<void> {
try {
// Get all active users for this business
const users = await em.find(User, {
business: business.id,
emailEnabled: true,
deletedAt: null,
emailQuotaUsed: { $ne: null },
});
// Calculate total used quota by summing all users' usage
const totalUsedQuota = users.reduce((sum, user) => sum + Number(user.emailQuotaUsed || 0), 0);
// Convert business quota to number for calculations
const businessQuota = Number(business.quota);
// Update business quota usage
business.usedQuota = totalUsedQuota;
business.remainingQuota = Math.max(0, businessQuota - totalUsedQuota);
this.logger.debug(`Synced business quota for ${business.name}: ${totalUsedQuota}/${businessQuota} bytes (${users.length} users)`);
} catch (error) {
this.logger.error(`Error syncing business quota for ${business.id}:`, `${error instanceof Error ? error.message : "Unknown error"}`);
throw error;
}
}
/**
* Sync quota for a specific business
*/
async syncBusinessQuota(business: Business): Promise<void> {
const em = this.em.fork();
// Re-fetch the business with the current entity manager
const managedBusiness = await em.findOne(Business, { id: business.id });
if (!managedBusiness) {
throw new Error(`Business ${business.id} not found`);
}
await this.syncBusinessQuotaWithEntityManager(managedBusiness, em);
await em.flush();
}
/**
* Manually trigger quota sync for a specific business
*/
async syncBusinessQuotaById(businessId: string): Promise<BusinessQuotaInfoDto> {
const em = this.em.fork();
const business = await em.findOne(Business, {
id: businessId,
deletedAt: null,
});
if (!business) {
throw new Error(`Business ${businessId} not found`);
}
await this.syncBusinessQuotaWithEntityManager(business, em);
await em.flush();
// Get user count for this business
const userCount = await em.count(User, {
business: businessId,
emailEnabled: true,
deletedAt: null,
});
return {
businessId: business.id,
businessName: business.name,
totalQuota: Number(business.quota),
usedQuota: Number(business.usedQuota),
remainingQuota: Number(business.remainingQuota),
userCount,
};
}
/**
* Get quota statistics for all businesses
*/
async getBusinessQuotaStatistics(): Promise<BusinessQuotaStatisticsDto> {
const em = this.em.fork();
const businesses = await em.find(Business, {
deletedAt: null,
});
const totalQuotaAllocated = businesses.reduce((sum, business) => sum + Number(business.quota), 0);
const totalQuotaUsed = businesses.reduce((sum, business) => sum + Number(business.usedQuota), 0);
const averageUsagePercent = totalQuotaAllocated > 0 ? (totalQuotaUsed / totalQuotaAllocated) * 100 : 0;
// Get detailed info for each business
const businessInfos: BusinessQuotaInfoDto[] = [];
for (const business of businesses) {
const userCount = await em.count(User, {
business: business.id,
emailEnabled: true,
deletedAt: null,
});
businessInfos.push({
businessId: business.id,
businessName: business.name,
totalQuota: Number(business.quota),
usedQuota: Number(business.usedQuota),
remainingQuota: Number(business.remainingQuota),
userCount,
});
}
return {
totalBusinesses: businesses.length,
totalQuotaAllocated,
totalQuotaUsed,
averageUsagePercent: Math.round(averageUsagePercent * 100) / 100,
businesses: businessInfos,
};
}
/**
* Manual sync for all users (useful for admin endpoints)
*/
async forceSyncAllUsers(): Promise<QuotaSyncResultDto> {
this.logger.log("Manual quota sync triggered");
await this.syncAllUsersQuota();
// Return basic statistics
const em = this.em.fork();
const users = await em.find(User, {
emailEnabled: true,
deletedAt: null,
wildduckUserId: { $ne: null },
});
const recentSyncs = await em.count(User, {
emailEnabled: true,
deletedAt: null,
lastQuotaSync: { $gte: new Date(Date.now() - 60000) }, // Last minute
});
return {
success: recentSyncs,
errors: users.length - recentSyncs,
};
}
/**
* Manual sync for all businesses (useful for admin endpoints)
*/
async forceSyncAllBusinesses(): Promise<QuotaSyncResultDto> {
this.logger.log("Manual business quota sync triggered");
await this.syncAllBusinessQuotas();
// Return basic statistics
const em = this.em.fork();
const businesses = await em.find(Business, {
deletedAt: null,
});
// Count businesses that were recently updated (assuming sync was successful)
const recentSyncs = businesses.filter((business) => business.updatedAt && business.updatedAt > new Date(Date.now() - 300000)).length; // Last 5 minutes
return {
success: recentSyncs,
errors: businesses.length - recentSyncs,
};
}
/**
* Comprehensive sync for both users and businesses
*/
async syncAllQuotas(): Promise<ComprehensiveQuotaSyncResultDto> {
this.logger.log("Starting comprehensive quota sync for users and businesses");
// First sync all users
await this.syncAllUsersQuota();
// Then sync all businesses based on updated user data
await this.syncAllBusinessQuotas();
// Get statistics for both
const em = this.em.fork();
const users = await em.find(User, {
emailEnabled: true,
deletedAt: null,
wildduckUserId: { $ne: null },
});
const businesses = await em.find(Business, {
deletedAt: null,
});
const recentUserSyncs = await em.count(User, {
emailEnabled: true,
deletedAt: null,
lastQuotaSync: { $gte: new Date(Date.now() - 60000) }, // Last minute
});
const recentBusinessSyncs = businesses.filter((business) => business.updatedAt && business.updatedAt > new Date(Date.now() - 300000)).length; // Last 5 minutes
return {
userSync: {
success: recentUserSyncs,
errors: users.length - recentUserSyncs,
},
businessSync: {
success: recentBusinessSyncs,
errors: businesses.length - recentBusinessSyncs,
},
};
}
}