188 lines
6.4 KiB
TypeScript
188 lines
6.4 KiB
TypeScript
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<INotificationJob, "id">,
|
|
Document {}
|
|
|
|
export class NotificationJobsAdapterMongoose implements INotificationJobAdapter {
|
|
schema: Schema;
|
|
notificationJobList: Model<INotificationJobDocument>;
|
|
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<INotificationJobDocument>(
|
|
"NotificationJob",
|
|
this.schema
|
|
);
|
|
}
|
|
|
|
public async create(data: CreateJobParams): Promise<INotificationJob> {
|
|
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<INotificationJob[]> {
|
|
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<void> {
|
|
const update: Record<string, unknown> = { 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<void> {
|
|
await this.cleanupOldJobsIfDueSafely();
|
|
await this.notificationJobList
|
|
.updateMany(
|
|
{
|
|
appointmentId,
|
|
status: NotificationJobStatus.PENDING,
|
|
},
|
|
{ $set: { status: NotificationJobStatus.CANCELLED } }
|
|
)
|
|
.exec();
|
|
}
|
|
|
|
public async findByAppointment(appointmentId: string): Promise<INotificationJob[]> {
|
|
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<DeleteOldJobsResult> {
|
|
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<void> {
|
|
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<void> {
|
|
try {
|
|
await this.cleanupOldJobsIfDue();
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
console.warn(`Notification jobs cleanup failed: ${message}`);
|
|
}
|
|
}
|
|
}
|