import { Document, Model, Schema, model } from "mongoose"; import { CreateJobParams, DeleteOldJobsParams, DeleteOldJobsResult, INotificationJob, INotificationJobAdapter, NotificationJobStatus, } from "./NotificationJobs.Interface"; export const NOTIFICATION_JOB_CLEANUP_RETENTION_DAYS = 7; export const NOTIFICATION_JOB_CLEANUP_INTERVAL_MS = 60 * 60 * 1000; export const NOTIFICATION_JOB_CLEANUP_STATUSES = [ NotificationJobStatus.SENT, NotificationJobStatus.FAILED, NotificationJobStatus.CANCELLED, ]; export interface INotificationJobDocument extends Omit, Document {} export class NotificationJobsAdapterMongoose implements INotificationJobAdapter { schema: Schema; notificationJobList: Model; private lastCleanupAt?: number; constructor() { this.schema = new Schema( { companyId: { type: Schema.Types.ObjectId, required: true, ref: "Company", }, appointmentId: { type: Schema.Types.ObjectId, required: true, ref: "Appointment", }, clientId: { type: Schema.Types.ObjectId, required: true, ref: "Client", }, channel: { type: String, required: true, enum: ["whatsapp", "email", "system"], }, type: { type: String, required: true, enum: ["creation", "reminder", "update", "cancellation"], }, scheduledAt: { type: Date, required: true }, status: { type: String, required: true, enum: Object.values(NotificationJobStatus), default: NotificationJobStatus.PENDING, }, attempts: { type: Number, required: true, default: 0 }, maxAttempts: { type: Number, required: true, default: 3 }, lastError: { type: String, required: false }, nextRetryAt: { type: Date, required: false }, providerResponse: { type: String, required: false }, payload: { type: Schema.Types.Mixed, required: false }, sentAt: { type: Date, required: false }, }, { timestamps: true, } ); this.schema.index({ status: 1, scheduledAt: 1 }); this.schema.index({ companyId: 1, status: 1 }); this.schema.index({ appointmentId: 1 }); this.notificationJobList = model( "NotificationJob", this.schema ); } public async create(data: CreateJobParams): Promise { await this.cleanupOldJobsIfDueSafely(); const doc = await this.notificationJobList.create({ ...data, status: NotificationJobStatus.PENDING, attempts: 0, maxAttempts: 3, }); return doc.toObject() as INotificationJob; } public async findPendingDue(now: Date): Promise { const docs = await this.notificationJobList .find({ status: NotificationJobStatus.PENDING, scheduledAt: { $lte: now }, }) .sort({ scheduledAt: 1 }) .exec(); return docs.map((d) => d.toObject() as INotificationJob); } public async updateStatus( jobId: string, status: NotificationJobStatus, data?: { lastError?: string; nextRetryAt?: Date; providerResponse?: string } ): Promise { const update: Record = { status }; if (status === NotificationJobStatus.SENT) { update.sentAt = new Date(); } if (data?.lastError !== undefined) update.lastError = data.lastError; if (data?.nextRetryAt !== undefined) update.nextRetryAt = data.nextRetryAt; if (data?.providerResponse !== undefined) update.providerResponse = data.providerResponse; await this.notificationJobList.updateOne({ _id: jobId }, { $set: update }).exec(); } public async cancelByAppointment(appointmentId: string): Promise { await this.cleanupOldJobsIfDueSafely(); await this.notificationJobList .updateMany( { appointmentId, status: NotificationJobStatus.PENDING, }, { $set: { status: NotificationJobStatus.CANCELLED } } ) .exec(); } public async findByAppointment(appointmentId: string): Promise { await this.cleanupOldJobsIfDueSafely(); const docs = await this.notificationJobList .find({ appointmentId }) .exec(); return docs.map((d) => d.toObject() as INotificationJob); } public async deleteOldJobs( data: DeleteOldJobsParams ): Promise { const result = await this.notificationJobList .deleteMany({ scheduledAt: { $lt: data.scheduledBefore }, status: { $in: data.statuses }, }) .exec(); return { deletedCount: result.deletedCount || 0 }; } public async cleanupOldJobsIfDue(now = new Date()): Promise { if ( this.lastCleanupAt !== undefined && now.getTime() - this.lastCleanupAt < NOTIFICATION_JOB_CLEANUP_INTERVAL_MS ) { return; } const scheduledBefore = new Date(now.getTime()); scheduledBefore.setDate( scheduledBefore.getDate() - NOTIFICATION_JOB_CLEANUP_RETENTION_DAYS ); await this.deleteOldJobs({ scheduledBefore, statuses: NOTIFICATION_JOB_CLEANUP_STATUSES, }); this.lastCleanupAt = now.getTime(); } private async cleanupOldJobsIfDueSafely(): Promise { try { await this.cleanupOldJobsIfDue(); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.warn(`Notification jobs cleanup failed: ${message}`); } } }