import { CreateJobParams, NotificationChannel, } from "../NotificationJobs/NotificationJobs.Interface"; import { NotificationJobsAdapterMongoose } from "../NotificationJobs/NotificationJobs.Adapter.Mongoose"; import { PolicyResolver, ResolvedPolicy } from "./PolicyResolver"; import dayjs from "dayjs"; import utc from "dayjs/plugin/utc"; dayjs.extend(utc); export interface CreateJobServiceParams { companyId: string; clientId: string; appointmentId: string; type: CreateJobParams["type"]; scheduledAt?: Date; appointmentStart?: Date; payload?: CreateJobParams["payload"]; reminderPayload?: CreateJobParams["payload"]; } export class NotificationJobService { private jobAdapter: NotificationJobsAdapterMongoose; private policyResolver: PolicyResolver; constructor() { this.jobAdapter = new NotificationJobsAdapterMongoose(); this.policyResolver = new PolicyResolver(); } async createJob(params: CreateJobServiceParams): Promise { const resolvedPolicy = await this.policyResolver.resolve( params.companyId, params.clientId ); if (resolvedPolicy.channels.length === 0) { return []; } const now = new Date(); const baseScheduledAt = params.scheduledAt || now; const adjustedScheduledAt = this.adjustForQuietHours( baseScheduledAt, resolvedPolicy ); const jobs: any[] = []; for (const channel of resolvedPolicy.channels) { const job = await this.jobAdapter.create({ companyId: params.companyId, appointmentId: params.appointmentId, clientId: params.clientId, channel, type: params.type, scheduledAt: adjustedScheduledAt, payload: params.payload, }); jobs.push(job); } if (params.appointmentStart) { const enabledReminderRules = (resolvedPolicy.reminderRules || []).filter( (rule) => rule.enabled ); for (const rule of enabledReminderRules) { const reminderScheduledAt = this.adjustForQuietHours( dayjs(params.appointmentStart).subtract(rule.offset, "minute").toDate(), resolvedPolicy ); if (reminderScheduledAt < now || reminderScheduledAt >= params.appointmentStart) { continue; } for (const channel of resolvedPolicy.channels) { const job = await this.jobAdapter.create({ companyId: params.companyId, appointmentId: params.appointmentId, clientId: params.clientId, channel, type: "reminder", scheduledAt: reminderScheduledAt, payload: params.reminderPayload, }); jobs.push(job); } } } return jobs; } async createImmediateJob(params: CreateJobServiceParams): Promise { const resolvedPolicy = await this.policyResolver.resolve( params.companyId, params.clientId ); if (resolvedPolicy.channels.length === 0) { return []; } const scheduledAt = params.scheduledAt || new Date(); const jobs: any[] = []; for (const channel of resolvedPolicy.channels) { const job = await this.jobAdapter.create({ companyId: params.companyId, appointmentId: params.appointmentId, clientId: params.clientId, channel, type: params.type, scheduledAt, payload: params.payload, }); jobs.push(job); } return jobs; } async createMandatoryCreationJob(params: CreateJobServiceParams): Promise { const payloadChannels = this.getPayloadChannels(params.payload); const channels = await this.policyResolver.resolveAvailableChannels( params.companyId, payloadChannels ); if (channels.length === 0) { return []; } const scheduledAt = params.scheduledAt || new Date(); const jobs: any[] = []; for (const channel of channels) { const job = await this.jobAdapter.create({ companyId: params.companyId, appointmentId: params.appointmentId, clientId: params.clientId, channel, type: "creation", scheduledAt, payload: params.payload, }); jobs.push(job); } return jobs; } async createReminderJobs(params: CreateJobServiceParams): Promise { const resolvedPolicy = await this.policyResolver.resolve( params.companyId, params.clientId ); if (!params.appointmentStart || resolvedPolicy.channels.length === 0) { return []; } const now = new Date(); const jobs: any[] = []; const enabledReminderRules = (resolvedPolicy.reminderRules || []).filter( (rule) => rule.enabled ); for (const rule of enabledReminderRules) { const reminderScheduledAt = this.adjustForQuietHours( dayjs(params.appointmentStart).subtract(rule.offset, "minute").toDate(), resolvedPolicy ); if (reminderScheduledAt < now || reminderScheduledAt >= params.appointmentStart) { continue; } for (const channel of resolvedPolicy.channels) { const job = await this.jobAdapter.create({ companyId: params.companyId, appointmentId: params.appointmentId, clientId: params.clientId, channel, type: "reminder", scheduledAt: reminderScheduledAt, payload: params.reminderPayload, }); jobs.push(job); } } return jobs; } /** * Cancel all pending jobs for an appointment. * Called when an appointment is deleted or rescheduled. */ async cancelByAppointment(appointmentId: string): Promise { return this.jobAdapter.cancelByAppointment(appointmentId); } private adjustForQuietHours(scheduledAt: Date, policy: ResolvedPolicy): Date { if (!policy.quietHours?.from || !policy.quietHours?.to) { return scheduledAt; } const scheduled = dayjs.utc(scheduledAt); const fromHour = parseInt(policy.quietHours.from.split(":")[0], 10); const fromMinute = parseInt(policy.quietHours.from.split(":")[1], 10); const toHour = parseInt(policy.quietHours.to.split(":")[0], 10); const toMinute = parseInt(policy.quietHours.to.split(":")[1], 10); const scheduledHour = scheduled.hour(); const scheduledMinute = scheduled.minute(); const isInsideQuietHours = this.isTimeInQuietHours( scheduledHour, scheduledMinute, fromHour, fromMinute, toHour, toMinute ); if (isInsideQuietHours) { // Shift to quiet hours end let adjusted = scheduled.hour(toHour).minute(toMinute).second(0); // If quiet hours cross midnight, only the pre-midnight segment ends next day. if (toHour < fromHour && scheduledHour >= fromHour) { adjusted = adjusted.add(1, "day"); } return adjusted.toDate(); } return scheduledAt; } private getPayloadChannels(payload?: CreateJobParams["payload"]): NotificationChannel[] { const channels: NotificationChannel[] = []; if (payload?.email) { channels.push("email"); } if (payload?.phoneNumber) { channels.push("whatsapp"); } if (payload?.userId) { channels.push("system"); } return channels; } private isTimeInQuietHours( hour: number, minute: number, fromHour: number, fromMinute: number, toHour: number, toMinute: number ): boolean { const currentMinutes = hour * 60 + minute; const fromMinutes = fromHour * 60 + fromMinute; const toMinutes = toHour * 60 + toMinute; if (fromMinutes < toMinutes) { // Quiet hours within same day (e.g. 01:00-06:00) return currentMinutes >= fromMinutes && currentMinutes < toMinutes; } else { // Quiet hours cross midnight (e.g. 22:00-07:00) return currentMinutes >= fromMinutes || currentMinutes < toMinutes; } } }