Files
turnosxpress/server/src/Models/NotificationPolicies/NotificationJobService.ts
T

281 lines
8.9 KiB
TypeScript

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<any[]> {
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<any[]> {
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<any[]> {
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<any[]> {
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<void> {
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;
}
}
}