429 lines
13 KiB
TypeScript
429 lines
13 KiB
TypeScript
import { EntityManager } from "@mikro-orm/core";
|
|
import { Injectable, Logger } from "@nestjs/common";
|
|
import { Cron, CronExpression } from "@nestjs/schedule";
|
|
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 {
|
|
BusinessQuotaInfoDto,
|
|
BusinessQuotaStatisticsDto,
|
|
ComprehensiveQuotaSyncResultDto,
|
|
QuotaInfoDto,
|
|
QuotaSyncResultDto,
|
|
UserQuotaStatisticsDto,
|
|
} from "../DTO/quota-statistics.dto";
|
|
|
|
@Injectable()
|
|
export class QuotaSyncService {
|
|
private readonly logger = new Logger(QuotaSyncService.name);
|
|
|
|
constructor(
|
|
private readonly wildDuckUsersService: WildDuckUsersService,
|
|
private readonly em: EntityManager,
|
|
) {}
|
|
|
|
/**
|
|
* Sync quota usage every 2 hours
|
|
*/
|
|
@Cron(CronExpression.EVERY_2_HOURS)
|
|
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"}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sync business quota usage every 4 hours
|
|
*/
|
|
@Cron(CronExpression.EVERY_4_HOURS)
|
|
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"}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
},
|
|
};
|
|
}
|
|
}
|