feat: implement automated job cleanup, update notification presets, and upgrade Baileys library

This commit is contained in:
2026-07-21 21:12:29 -03:00
parent aac53e13cc
commit 98f0ec3102
8 changed files with 388 additions and 35 deletions
@@ -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();