refactor: modularize notification job creation and enforce plan-based channel availability in policy resolver

This commit is contained in:
2026-07-27 22:51:33 -03:00
parent dc020d94d2
commit 68d98e8b40
6 changed files with 333 additions and 58 deletions
+19 -10
View File
@@ -529,9 +529,10 @@ class AppointmentManager implements IAppointmentsManager {
type: NotificationType.APPOINTMENT, type: NotificationType.APPOINTMENT,
code: String((newAppointment as any)._id) code: String((newAppointment as any)._id)
}); });
}
//Create jobs for email and whatsapp notifications //Create jobs for email and whatsapp notifications
if (newAppointment.id) { if (notification && newAppointment.id) {
const emailContent = await this.tryToSendNotification({ const emailContent = await this.tryToSendNotification({
appointmentId: String(newAppointment.id), appointmentId: String(newAppointment.id),
sessionUser: String(companyCheck.ownerId), sessionUser: String(companyCheck.ownerId),
@@ -574,7 +575,6 @@ class AppointmentManager implements IAppointmentsManager {
reminderWapMessage: reminderWapContent.message, reminderWapMessage: reminderWapContent.message,
}); });
} }
}
//Envio la notificacion al profesional. //Envio la notificacion al profesional.
await NotificationsManager.sendSystemNotification({ await NotificationsManager.sendSystemNotification({
@@ -2240,13 +2240,7 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
const systemSubject = `Turno reservado en ${data.companyName}`; const systemSubject = `Turno reservado en ${data.companyName}`;
const reminderSystemSubject = `Recordatorio de turno en ${data.companyName}`; const reminderSystemSubject = `Recordatorio de turno en ${data.companyName}`;
await this.jobService.createJob({ const payload = {
companyId: data.companyId,
clientId: data.clientId,
appointmentId: data.appointmentId,
type: "creation",
appointmentStart: data.appointmentStart,
payload: {
email: data.clientEmail, email: data.clientEmail,
phoneNumber: data.clientPhoneNumber, phoneNumber: data.clientPhoneNumber,
userId: data.clientUserId, userId: data.clientUserId,
@@ -2258,7 +2252,22 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
wapMessage: data.wapMessage, wapMessage: data.wapMessage,
systemSubject, systemSubject,
systemMessage: data.emailMessage, systemMessage: data.emailMessage,
}, };
await this.jobService.createMandatoryCreationJob({
companyId: data.companyId,
clientId: data.clientId,
appointmentId: data.appointmentId,
type: "creation",
payload,
});
await this.jobService.createReminderJobs({
companyId: data.companyId,
clientId: data.clientId,
appointmentId: data.appointmentId,
type: "reminder",
appointmentStart: data.appointmentStart,
reminderPayload: { reminderPayload: {
email: data.clientEmail, email: data.clientEmail,
phoneNumber: data.clientPhoneNumber, phoneNumber: data.clientPhoneNumber,
@@ -421,6 +421,8 @@ describe("cancellation notification jobs", () => {
}; };
(AppointmentsList as any).jobService = { (AppointmentsList as any).jobService = {
createJob: jest.fn().mockResolvedValue([]), createJob: jest.fn().mockResolvedValue([]),
createMandatoryCreationJob: jest.fn().mockResolvedValue([]),
createReminderJobs: jest.fn().mockResolvedValue([]),
}; };
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({ (CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
@@ -563,6 +565,8 @@ describe("creation notification jobs", () => {
}; };
(AppointmentsList as any).jobService = { (AppointmentsList as any).jobService = {
createJob: jest.fn().mockResolvedValue([]), createJob: jest.fn().mockResolvedValue([]),
createMandatoryCreationJob: jest.fn().mockResolvedValue([]),
createReminderJobs: jest.fn().mockResolvedValue([]),
}; };
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({ (CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
@@ -649,7 +653,7 @@ describe("creation notification jobs", () => {
reminderWapMessage: "WAP REMINDER Ada Lovelace", reminderWapMessage: "WAP REMINDER Ada Lovelace",
}); });
expect((AppointmentsList as any).jobService.createJob).toHaveBeenCalledWith( expect((AppointmentsList as any).jobService.createMandatoryCreationJob).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
type: "creation", type: "creation",
payload: expect.objectContaining({ payload: expect.objectContaining({
@@ -659,6 +663,11 @@ describe("creation notification jobs", () => {
systemSubject: "Turno reservado en clases llavallol", systemSubject: "Turno reservado en clases llavallol",
systemMessage: "EMAIL ALTA Ada Lovelace Historia clases llavallol 22/07/2026 08:00hs.", systemMessage: "EMAIL ALTA Ada Lovelace Historia clases llavallol 22/07/2026 08:00hs.",
}), }),
})
);
expect((AppointmentsList as any).jobService.createReminderJobs).toHaveBeenCalledWith(
expect.objectContaining({
type: "reminder",
reminderPayload: expect.objectContaining({ reminderPayload: expect.objectContaining({
email: "ada@example.com", email: "ada@example.com",
phoneNumber: "5491112345678", phoneNumber: "5491112345678",
@@ -1,5 +1,6 @@
import { import {
CreateJobParams, CreateJobParams,
NotificationChannel,
} from "../NotificationJobs/NotificationJobs.Interface"; } from "../NotificationJobs/NotificationJobs.Interface";
import { NotificationJobsAdapterMongoose } from "../NotificationJobs/NotificationJobs.Adapter.Mongoose"; import { NotificationJobsAdapterMongoose } from "../NotificationJobs/NotificationJobs.Adapter.Mongoose";
import { PolicyResolver, ResolvedPolicy } from "./PolicyResolver"; import { PolicyResolver, ResolvedPolicy } from "./PolicyResolver";
@@ -121,6 +122,79 @@ export class NotificationJobService {
return jobs; 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. * Cancel all pending jobs for an appointment.
* Called when an appointment is deleted or rescheduled. * Called when an appointment is deleted or rescheduled.
@@ -165,6 +239,24 @@ export class NotificationJobService {
return scheduledAt; 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( private isTimeInQuietHours(
hour: number, hour: number,
minute: number, minute: number,
@@ -93,6 +93,21 @@ export class PolicyResolver {
}; };
} }
async resolveAvailableChannels(
companyId: string,
channels: NotificationChannel[]
): Promise<NotificationChannel[]> {
const companyPolicy = await this.companyPolicyAdapter.findOne({ companyId });
const enabledChannels = companyPolicy
? companyPolicy.defaultChannels
: SYSTEM_DEFAULT_CHANNELS;
const companyEnabledChannels = channels.filter((channel) =>
enabledChannels.includes(channel)
);
return this.applyPlanLimits(companyId, companyEnabledChannels);
}
/** /**
* Returns muted channels from the winning cascade level only. * Returns muted channels from the winning cascade level only.
* Per spec, muted channels are NOT merged across levels. * Per spec, muted channels are NOT merged across levels.
@@ -10,6 +10,7 @@ jest.mock("../../NotificationJobs/NotificationJobs.Adapter.Mongoose", () => ({
jest.mock("../PolicyResolver", () => ({ jest.mock("../PolicyResolver", () => ({
PolicyResolver: jest.fn().mockImplementation(() => ({ PolicyResolver: jest.fn().mockImplementation(() => ({
resolve: jest.fn(), resolve: jest.fn(),
resolveAvailableChannels: jest.fn(),
})), })),
})); }));
@@ -27,6 +28,7 @@ describe("NotificationJobService.createJob", () => {
let service: NotificationJobService; let service: NotificationJobService;
let mockJobCreate: jest.Mock; let mockJobCreate: jest.Mock;
let mockResolverResolve: jest.Mock; let mockResolverResolve: jest.Mock;
let mockResolveAvailableChannels: jest.Mock;
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
@@ -36,6 +38,7 @@ describe("NotificationJobService.createJob", () => {
mockJobCreate = (service as any).jobAdapter.create as jest.Mock; mockJobCreate = (service as any).jobAdapter.create as jest.Mock;
mockResolverResolve = (service as any).policyResolver.resolve as jest.Mock; mockResolverResolve = (service as any).policyResolver.resolve as jest.Mock;
mockResolveAvailableChannels = (service as any).policyResolver.resolveAvailableChannels as jest.Mock;
}); });
it("creates a job with resolved channels", async () => { it("creates a job with resolved channels", async () => {
@@ -219,6 +222,114 @@ describe("NotificationJobService.createJob", () => {
expect(result).toHaveLength(0); expect(result).toHaveLength(0);
}); });
it("creates mandatory creation jobs from available payload destinations without resolving reminder policy", async () => {
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T14:00:00.000Z"));
mockJobCreate.mockResolvedValue({ id: "job-1" });
mockResolveAvailableChannels.mockResolvedValue(["email", "whatsapp"]);
const payload = {
email: "client@example.com",
phoneNumber: "5491112345678",
companyOwnerId: "owner-1",
emailMessage: "Alta email",
wapMessage: "Alta WAP",
};
const result = await service.createMandatoryCreationJob({
companyId: "company-1",
clientId: "client-1",
appointmentId: "appt-1",
type: "creation",
payload,
});
const createCalls = mockJobCreate.mock.calls.map((call) => call[0]);
expect(mockResolverResolve).not.toHaveBeenCalled();
expect(mockResolveAvailableChannels).toHaveBeenCalledWith("company-1", [
"email",
"whatsapp",
]);
expect(result).toHaveLength(2);
expect(createCalls).toEqual([
expect.objectContaining({
channel: "email",
type: "creation",
scheduledAt: new Date("2026-07-21T14:00:00.000Z"),
payload,
}),
expect.objectContaining({
channel: "whatsapp",
type: "creation",
scheduledAt: new Date("2026-07-21T14:00:00.000Z"),
payload,
}),
]);
});
it("does not create mandatory creation jobs for channels missing destination data", async () => {
mockResolveAvailableChannels.mockResolvedValue([]);
await service.createMandatoryCreationJob({
companyId: "company-1",
clientId: "client-1",
appointmentId: "appt-1",
type: "creation",
payload: {
companyOwnerId: "owner-1",
emailMessage: "Alta email",
wapMessage: "Alta WAP",
},
});
expect(mockResolverResolve).not.toHaveBeenCalled();
expect(mockResolveAvailableChannels).toHaveBeenCalledWith("company-1", []);
expect(mockJobCreate).not.toHaveBeenCalled();
});
it("does not create mandatory creation jobs for unavailable channels", async () => {
mockResolveAvailableChannels.mockResolvedValue(["email"]);
const payload = {
email: "client@example.com",
phoneNumber: "5491112345678",
companyOwnerId: "owner-1",
};
const result = await service.createMandatoryCreationJob({
companyId: "company-1",
clientId: "client-1",
appointmentId: "appt-1",
type: "creation",
payload,
});
const createCalls = mockJobCreate.mock.calls.map((call) => call[0]);
expect(result).toHaveLength(1);
expect(createCalls).toEqual([
expect.objectContaining({ channel: "email", type: "creation", payload }),
]);
});
it("keeps reminder jobs policy-aware when resolved channels are empty", async () => {
const resolvedPolicy: ResolvedPolicy = {
channels: [],
timezone: "America/Argentina/Buenos_Aires",
reminderRules: [{ offset: 30, enabled: true }],
};
mockResolverResolve.mockResolvedValue(resolvedPolicy);
const result = await service.createReminderJobs({
companyId: "company-1",
clientId: "client-1",
appointmentId: "appt-1",
type: "reminder",
appointmentStart: new Date("2026-07-21T15:00:00.000Z"),
reminderPayload: { email: "client@example.com" },
});
expect(mockResolverResolve).toHaveBeenCalledWith("company-1", "client-1");
expect(mockJobCreate).not.toHaveBeenCalled();
expect(result).toHaveLength(0);
});
it("adjusts scheduledAt for quiet hours crossing midnight", async () => { it("adjusts scheduledAt for quiet hours crossing midnight", async () => {
const resolvedPolicy: ResolvedPolicy = { const resolvedPolicy: ResolvedPolicy = {
channels: ["system"], channels: ["system"],
@@ -309,4 +309,43 @@ describe("PolicyResolver", () => {
expect(result.channels).toEqual([]); expect(result.channels).toEqual([]);
}); });
it("resolves available channels from company defaults and plan limits without client preferences", async () => {
mockCompanyPolicyFindOne.mockResolvedValue({
companyId: "company-1",
defaultChannels: ["whatsapp" as NotificationChannel, "email" as NotificationChannel],
mutedChannels: ["email" as NotificationChannel],
timezone: "America/Argentina/Buenos_Aires",
});
(PlanSubscriptionsList.checkFeature as jest.Mock).mockResolvedValue(true);
const result = await resolver.resolveAvailableChannels("company-1", [
"whatsapp",
"email",
"system",
]);
expect(result).toEqual(["whatsapp", "email"]);
expect(mockClientPreferencesFindOne).not.toHaveBeenCalled();
expect(mockOverrideFindOne).not.toHaveBeenCalled();
});
it("removes available channels disallowed by the owner plan", async () => {
mockCompanyPolicyFindOne.mockResolvedValue({
companyId: "company-1",
defaultChannels: ["whatsapp" as NotificationChannel, "email" as NotificationChannel],
timezone: "America/Argentina/Buenos_Aires",
});
(PlanSubscriptionsList.checkFeature as jest.Mock).mockImplementation(
async (params: { feature: string }) =>
params.feature !== PlanFeatures.WAP_NOTIFICATIONS
);
const result = await resolver.resolveAvailableChannels("company-1", [
"whatsapp",
"email",
]);
expect(result).toEqual(["email"]);
});
}); });