feat: implement automated job cleanup, update notification presets, and upgrade Baileys library
This commit is contained in:
@@ -8,8 +8,17 @@ const POLL_INTERVAL_MS = 5000;
|
||||
const BASE_RETRY_DELAY_MS = 5000;
|
||||
const MAX_JITTER_MS = 3000;
|
||||
const THROTTLE_RETRY_DELAY_MS = 8000;
|
||||
const DEFAULT_CLEANUP_INTERVAL_MS = 60 * 60 * 1000;
|
||||
const SUPPORTED_APPOINTMENT_NOTIFICATION_TYPES = new Set(["creation", "reminder", "cancellation"]);
|
||||
|
||||
function resolveCleanupIntervalMs(): number {
|
||||
const raw = process.env.NOTIFICATION_JOB_CLEANUP_INTERVAL_MS;
|
||||
if (!raw) return DEFAULT_CLEANUP_INTERVAL_MS;
|
||||
|
||||
const parsed = Number(raw);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_CLEANUP_INTERVAL_MS;
|
||||
}
|
||||
|
||||
function resolveAppointmentNotificationType(type: unknown): string {
|
||||
return typeof type === "string" && SUPPORTED_APPOINTMENT_NOTIFICATION_TYPES.has(type) ? type : "reminder";
|
||||
}
|
||||
@@ -28,6 +37,10 @@ export function calculateNextRetryAt(attempt: number, now: Date): Date {
|
||||
return new Date(now.getTime() + delay + jitter);
|
||||
}
|
||||
|
||||
export function buildStartOfDay(date: Date): Date {
|
||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
}
|
||||
|
||||
function getJobId(job: { id?: unknown; _id?: unknown; appointmentId?: unknown; channel?: unknown; get?: (path: string) => unknown }): string {
|
||||
const value = job.id || job._id || job.get?.("_id");
|
||||
if (value) return String(value);
|
||||
@@ -41,24 +54,53 @@ export class JobProcessor {
|
||||
private jobModel: NotificationJobModel;
|
||||
private throttle: WhatsAppThrottleClass;
|
||||
private polling = false;
|
||||
private cleanupTimeout?: NodeJS.Timeout;
|
||||
private cleanupIntervalMs: number;
|
||||
|
||||
constructor() {
|
||||
this.jobModel = new NotificationJobModel();
|
||||
this.throttle = new WhatsAppThrottleClass();
|
||||
this.cleanupIntervalMs = resolveCleanupIntervalMs();
|
||||
}
|
||||
|
||||
public async startPolling(): Promise<void> {
|
||||
if (this.polling) return;
|
||||
this.polling = true;
|
||||
logIntent("JobProcessor: starting poll loop");
|
||||
await this.cleanupOldJobs();
|
||||
this.scheduleCleanup();
|
||||
this.poll();
|
||||
}
|
||||
|
||||
public stopPolling(): void {
|
||||
this.polling = false;
|
||||
if (this.cleanupTimeout) {
|
||||
clearTimeout(this.cleanupTimeout);
|
||||
this.cleanupTimeout = undefined;
|
||||
}
|
||||
logIntent("JobProcessor: stopping poll loop");
|
||||
}
|
||||
|
||||
private scheduleCleanup(): void {
|
||||
if (!this.polling || this.cleanupIntervalMs <= 0) return;
|
||||
|
||||
this.cleanupTimeout = setTimeout(async () => {
|
||||
await this.cleanupOldJobs();
|
||||
this.scheduleCleanup();
|
||||
}, this.cleanupIntervalMs);
|
||||
}
|
||||
|
||||
private async cleanupOldJobs(now = new Date()): Promise<void> {
|
||||
const cutoff = buildStartOfDay(now);
|
||||
|
||||
try {
|
||||
const deletedCount = await this.jobModel.deleteBeforeScheduledAt(cutoff);
|
||||
logDone(`JobProcessor: cleaned ${deletedCount} old notification jobs before ${cutoff.toISOString()}`);
|
||||
} catch (error) {
|
||||
logError(`JobProcessor: cleanup error before ${cutoff.toISOString()}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
private async poll(): Promise<void> {
|
||||
if (!this.polling) return;
|
||||
|
||||
|
||||
@@ -102,4 +102,12 @@ export class NotificationJobModel {
|
||||
this.schema
|
||||
);
|
||||
}
|
||||
|
||||
async deleteBeforeScheduledAt(cutoff: Date): Promise<number> {
|
||||
const result = await this.notificationJobList.deleteMany({
|
||||
scheduledAt: { $lt: cutoff },
|
||||
}).exec();
|
||||
|
||||
return result.deletedCount || 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { dispatchEmail, dispatchWhatsApp } from "../ChannelDispatchers.js";
|
||||
import { buildPendingJobQuery, calculateNextRetryAt, JobProcessor } from "../JobProcessor.js";
|
||||
import { buildPendingJobQuery, buildStartOfDay, calculateNextRetryAt, JobProcessor } from "../JobProcessor.js";
|
||||
import { resolveNotificationContent } from "../NotificationContentResolver.js";
|
||||
|
||||
jest.mock("../ChannelDispatchers.js", () => ({
|
||||
@@ -94,6 +94,28 @@ describe("JobProcessor", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildStartOfDay", () => {
|
||||
it("returns the start of the local day", () => {
|
||||
const result = buildStartOfDay(new Date(2026, 6, 21, 15, 30, 45, 123));
|
||||
|
||||
expect(result).toEqual(new Date(2026, 6, 21, 0, 0, 0, 0));
|
||||
});
|
||||
});
|
||||
|
||||
describe("old job cleanup", () => {
|
||||
it("deletes jobs scheduled before the start of the current day", async () => {
|
||||
const deleteBeforeScheduledAt = jest.fn().mockResolvedValue(3);
|
||||
const now = new Date(2026, 6, 21, 15, 30, 0, 0);
|
||||
|
||||
await (JobProcessor.prototype as any).cleanupOldJobs.call(
|
||||
{ jobModel: { deleteBeforeScheduledAt } },
|
||||
now
|
||||
);
|
||||
|
||||
expect(deleteBeforeScheduledAt).toHaveBeenCalledWith(new Date(2026, 6, 21, 0, 0, 0, 0));
|
||||
});
|
||||
});
|
||||
|
||||
describe("WhatsApp dispatch", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
Reference in New Issue
Block a user