refactor: modularize notification job creation and enforce plan-based channel availability in policy resolver
This commit is contained in:
@@ -529,51 +529,51 @@ 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),
|
||||||
type: APPOINTMENT_NOTIFICATION_TYPE.CREATION,
|
type: APPOINTMENT_NOTIFICATION_TYPE.CREATION,
|
||||||
channel: "email",
|
channel: "email",
|
||||||
});
|
});
|
||||||
const wapContent = await this.tryToSendNotification({
|
const wapContent = await this.tryToSendNotification({
|
||||||
appointmentId: String(newAppointment.id),
|
appointmentId: String(newAppointment.id),
|
||||||
sessionUser: String(companyCheck.ownerId),
|
sessionUser: String(companyCheck.ownerId),
|
||||||
type: APPOINTMENT_NOTIFICATION_TYPE.CREATION,
|
type: APPOINTMENT_NOTIFICATION_TYPE.CREATION,
|
||||||
channel: "whatsapp",
|
channel: "whatsapp",
|
||||||
});
|
});
|
||||||
const clientPhoneNumber = await this.getOptionalClientWapNumber(checkClient);
|
const clientPhoneNumber = await this.getOptionalClientWapNumber(checkClient);
|
||||||
const reminderEmailContent = await this.tryToSendNotification({
|
const reminderEmailContent = await this.tryToSendNotification({
|
||||||
appointmentId: String(newAppointment.id),
|
appointmentId: String(newAppointment.id),
|
||||||
sessionUser: String(companyCheck.ownerId),
|
sessionUser: String(companyCheck.ownerId),
|
||||||
type: APPOINTMENT_NOTIFICATION_TYPE.REMINDER,
|
type: APPOINTMENT_NOTIFICATION_TYPE.REMINDER,
|
||||||
channel: "email",
|
channel: "email",
|
||||||
});
|
});
|
||||||
const reminderWapContent = await this.tryToSendNotification({
|
const reminderWapContent = await this.tryToSendNotification({
|
||||||
appointmentId: String(newAppointment.id),
|
appointmentId: String(newAppointment.id),
|
||||||
sessionUser: String(companyCheck.ownerId),
|
sessionUser: String(companyCheck.ownerId),
|
||||||
type: APPOINTMENT_NOTIFICATION_TYPE.REMINDER,
|
type: APPOINTMENT_NOTIFICATION_TYPE.REMINDER,
|
||||||
channel: "whatsapp",
|
channel: "whatsapp",
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.createCreationNotificationJobs({
|
await this.createCreationNotificationJobs({
|
||||||
appointmentId: String(newAppointment.id),
|
appointmentId: String(newAppointment.id),
|
||||||
companyId: String(companyCheck._id),
|
companyId: String(companyCheck._id),
|
||||||
clientId: String(checkClient._id),
|
clientId: String(checkClient._id),
|
||||||
clientUserId: checkClient.userId ? String(checkClient.userId) : undefined,
|
clientUserId: checkClient.userId ? String(checkClient.userId) : undefined,
|
||||||
clientEmail: checkClient.email,
|
clientEmail: checkClient.email,
|
||||||
clientPhoneNumber,
|
clientPhoneNumber,
|
||||||
companyOwnerId: String(companyCheck.ownerId),
|
companyOwnerId: String(companyCheck.ownerId),
|
||||||
companyName: companyCheck.name,
|
companyName: companyCheck.name,
|
||||||
appointmentStart: new Date(data.start),
|
appointmentStart: new Date(data.start),
|
||||||
emailMessage: emailContent.message,
|
emailMessage: emailContent.message,
|
||||||
wapMessage: wapContent.message,
|
wapMessage: wapContent.message,
|
||||||
reminderEmailMessage: reminderEmailContent.message,
|
reminderEmailMessage: reminderEmailContent.message,
|
||||||
reminderWapMessage: reminderWapContent.message,
|
reminderWapMessage: reminderWapContent.message,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//Envio la notificacion al profesional.
|
//Envio la notificacion al profesional.
|
||||||
@@ -2240,25 +2240,34 @@ 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 = {
|
||||||
|
email: data.clientEmail,
|
||||||
|
phoneNumber: data.clientPhoneNumber,
|
||||||
|
userId: data.clientUserId,
|
||||||
|
companyOwnerId: data.companyOwnerId,
|
||||||
|
subject: systemSubject,
|
||||||
|
message: data.emailMessage,
|
||||||
|
emailSubject: "TurnosXpress :: Alta de turno",
|
||||||
|
emailMessage: data.emailMessage,
|
||||||
|
wapMessage: data.wapMessage,
|
||||||
|
systemSubject,
|
||||||
|
systemMessage: data.emailMessage,
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.jobService.createMandatoryCreationJob({
|
||||||
companyId: data.companyId,
|
companyId: data.companyId,
|
||||||
clientId: data.clientId,
|
clientId: data.clientId,
|
||||||
appointmentId: data.appointmentId,
|
appointmentId: data.appointmentId,
|
||||||
type: "creation",
|
type: "creation",
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.jobService.createReminderJobs({
|
||||||
|
companyId: data.companyId,
|
||||||
|
clientId: data.clientId,
|
||||||
|
appointmentId: data.appointmentId,
|
||||||
|
type: "reminder",
|
||||||
appointmentStart: data.appointmentStart,
|
appointmentStart: data.appointmentStart,
|
||||||
payload: {
|
|
||||||
email: data.clientEmail,
|
|
||||||
phoneNumber: data.clientPhoneNumber,
|
|
||||||
userId: data.clientUserId,
|
|
||||||
companyOwnerId: data.companyOwnerId,
|
|
||||||
subject: systemSubject,
|
|
||||||
message: data.emailMessage,
|
|
||||||
emailSubject: "TurnosXpress :: Alta de turno",
|
|
||||||
emailMessage: data.emailMessage,
|
|
||||||
wapMessage: data.wapMessage,
|
|
||||||
systemSubject,
|
|
||||||
systemMessage: data.emailMessage,
|
|
||||||
},
|
|
||||||
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"]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user