- Replaced instances of `getErrorMessage` with `extractErrorMessage` in various services to ensure consistent error handling and improve clarity in logging. - Updated error logging in the Whmcs, Salesforce, and subscription services to utilize the new error extraction method, enhancing maintainability and debugging capabilities. - Adjusted ESLint configuration to prevent the use of `console.log` in production code, promoting the use of a centralized logging solution. - Refactored error handling in the Agentforce widget and other components to align with the updated logging practices, ensuring a consistent approach to error management across the application. - Cleaned up unused imports and optimized code structure for better maintainability.
196 lines
6.8 KiB
TypeScript
196 lines
6.8 KiB
TypeScript
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
|
import { Logger } from "nestjs-pino";
|
|
import type { Redis } from "ioredis";
|
|
|
|
export type OperationType = "voice" | "network" | "plan" | "cancellation";
|
|
|
|
interface OperationTimestamps {
|
|
voice?: number;
|
|
network?: number;
|
|
plan?: number;
|
|
cancellation?: number;
|
|
}
|
|
|
|
/**
|
|
* Rate limiter for Freebit API operations.
|
|
*
|
|
* Enforces timing constraints between operations to prevent them from
|
|
* canceling each other:
|
|
* - PA05-06 (voice features): Runs with immediate effect
|
|
* - PA05-38 (contract line): Runs with immediate effect
|
|
* - PA05-21 (plan change): Requires runTime parameter, scheduled for 1st of following month
|
|
* - PA05-21 and PA02-04 (cancellation) cannot coexist
|
|
* - These must run 30 minutes apart to avoid canceling each other
|
|
*/
|
|
@Injectable()
|
|
export class FreebitRateLimiterService {
|
|
private readonly operationTimestamps = new Map<string, OperationTimestamps>();
|
|
private lastCleanupTime = 0;
|
|
private readonly cleanupIntervalMs = 5 * 60 * 1000; // Run cleanup at most every 5 minutes
|
|
private readonly staleThresholdMs = 35 * 60 * 1000; // Remove entries older than 35 minutes
|
|
private readonly windowMs = 30 * 60 * 1000; // 30 minute window between operations
|
|
|
|
constructor(
|
|
@Inject("REDIS_CLIENT") private readonly redis: Redis,
|
|
@Inject(Logger) private readonly logger: Logger
|
|
) {}
|
|
|
|
/**
|
|
* Cleanup stale entries from operationTimestamps to prevent memory leak.
|
|
* Entries are considered stale when all their timestamps are older than staleThresholdMs.
|
|
*/
|
|
private cleanupStaleEntries(): void {
|
|
const now = Date.now();
|
|
|
|
// Rate limit cleanup to avoid performance overhead
|
|
if (now - this.lastCleanupTime < this.cleanupIntervalMs) {
|
|
return;
|
|
}
|
|
this.lastCleanupTime = now;
|
|
|
|
const staleThreshold = now - this.staleThresholdMs;
|
|
const accountsToRemove: string[] = [];
|
|
|
|
for (const [account, entry] of this.operationTimestamps) {
|
|
const timestamps = [entry.voice, entry.network, entry.plan, entry.cancellation].filter(
|
|
(t): t is number => typeof t === "number"
|
|
);
|
|
|
|
// If all timestamps are stale (or entry is empty), mark for removal
|
|
// EXCEPT: Never clean up entries with a cancellation - cancellation blocks plan changes permanently
|
|
// until the account is actually cancelled or the cancellation is reverted externally
|
|
const hasCancellation = typeof entry.cancellation === "number";
|
|
if (
|
|
!hasCancellation &&
|
|
(timestamps.length === 0 || timestamps.every(t => t < staleThreshold))
|
|
) {
|
|
accountsToRemove.push(account);
|
|
}
|
|
}
|
|
|
|
for (const account of accountsToRemove) {
|
|
this.operationTimestamps.delete(account);
|
|
}
|
|
|
|
if (accountsToRemove.length > 0) {
|
|
this.logger.debug(`Cleaned up ${accountsToRemove.length} stale operation timestamp entries`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get operation timestamps for an account, initializing if needed.
|
|
*/
|
|
private getOperationWindowFromMemory(account: string): OperationTimestamps {
|
|
this.cleanupStaleEntries();
|
|
|
|
if (!this.operationTimestamps.has(account)) {
|
|
this.operationTimestamps.set(account, {});
|
|
}
|
|
return this.operationTimestamps.get(account)!;
|
|
}
|
|
|
|
private buildKey(account: string): string {
|
|
return `freebit:ops:${account}`;
|
|
}
|
|
|
|
private async getOperationWindow(account: string): Promise<OperationTimestamps> {
|
|
const key = this.buildKey(account);
|
|
try {
|
|
const raw = await this.redis.hgetall(key);
|
|
const parsed: OperationTimestamps = {};
|
|
for (const [field, value] of Object.entries(raw)) {
|
|
const num = Number(value);
|
|
if (!isNaN(num)) {
|
|
parsed[field as keyof OperationTimestamps] = num;
|
|
}
|
|
}
|
|
return parsed;
|
|
} catch (error) {
|
|
this.logger.warn("Failed to read Freebit rate limit window from Redis", {
|
|
account,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
return this.getOperationWindowFromMemory(account);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Assert that an operation can be performed based on timing constraints.
|
|
* Throws BadRequestException if the operation violates timing rules.
|
|
*/
|
|
async assertOperationSpacing(account: string, op: OperationType): Promise<void> {
|
|
const now = Date.now();
|
|
const entry = await this.getOperationWindow(account);
|
|
|
|
if (op === "voice") {
|
|
if (entry.plan && now - entry.plan < this.windowMs) {
|
|
throw new BadRequestException(
|
|
"Voice feature changes must be at least 30 minutes apart from plan changes. Please try again later."
|
|
);
|
|
}
|
|
if (entry.network && now - entry.network < this.windowMs) {
|
|
throw new BadRequestException(
|
|
"Voice feature changes must be at least 30 minutes apart from network type updates. Please try again later."
|
|
);
|
|
}
|
|
}
|
|
|
|
if (op === "network") {
|
|
if (entry.voice && now - entry.voice < this.windowMs) {
|
|
throw new BadRequestException(
|
|
"Network type updates must be requested 30 minutes after voice option changes. Please try again later."
|
|
);
|
|
}
|
|
if (entry.plan && now - entry.plan < this.windowMs) {
|
|
throw new BadRequestException(
|
|
"Network type updates must be requested at least 30 minutes apart from plan changes. Please try again later."
|
|
);
|
|
}
|
|
}
|
|
|
|
if (op === "plan") {
|
|
if (entry.voice && now - entry.voice < this.windowMs) {
|
|
throw new BadRequestException(
|
|
"Plan changes must be requested 30 minutes after voice option changes. Please try again later."
|
|
);
|
|
}
|
|
if (entry.network && now - entry.network < this.windowMs) {
|
|
throw new BadRequestException(
|
|
"Plan changes must be requested 30 minutes after network type updates. Please try again later."
|
|
);
|
|
}
|
|
if (entry.cancellation) {
|
|
throw new BadRequestException(
|
|
"This subscription has a pending cancellation. Plan changes are no longer permitted."
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Record that an operation was performed for an account.
|
|
*/
|
|
async stampOperation(account: string, op: OperationType): Promise<void> {
|
|
const now = Date.now();
|
|
const entry = this.getOperationWindowFromMemory(account);
|
|
entry[op] = now;
|
|
|
|
const key = this.buildKey(account);
|
|
try {
|
|
await this.redis.hset(key, op, String(now));
|
|
const hasCancellation = op === "cancellation" || entry.cancellation !== undefined;
|
|
if (hasCancellation) {
|
|
await this.redis.persist(key);
|
|
} else {
|
|
await this.redis.expire(key, Math.ceil(this.staleThresholdMs / 1000));
|
|
}
|
|
} catch (error) {
|
|
this.logger.warn("Failed to persist Freebit rate limit window to Redis", {
|
|
account,
|
|
op,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
}
|
|
}
|
|
}
|