281 lines
8.9 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|