feat: implement comprehensive notification engine with policies, preferences, and automated dispatching services
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
import {
|
||||
CreateJobParams,
|
||||
} 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"];
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (e.g. 22:00-07:00) and end is before start,
|
||||
// the end is on the next day
|
||||
if (toHour < fromHour) {
|
||||
if (scheduledHour >= fromHour || scheduledHour < toHour) {
|
||||
adjusted = adjusted.add(1, "day");
|
||||
}
|
||||
}
|
||||
return adjusted.toDate();
|
||||
}
|
||||
|
||||
return scheduledAt;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user