feat: implement comprehensive notification engine with policies, preferences, and automated dispatching services

This commit is contained in:
2026-07-21 18:53:11 -03:00
parent 40090cdec5
commit 10ca449f88
82 changed files with 10249 additions and 171 deletions
@@ -0,0 +1,71 @@
import axios from "axios";
import { dispatchEmail, dispatchWhatsApp } from "../ChannelDispatchers.js";
jest.mock("axios");
const mockedAxios = axios as jest.Mocked<typeof axios>;
describe("ChannelDispatchers", () => {
beforeEach(() => {
jest.clearAllMocks();
process.env.API_URL = "https://api.example.com/base";
process.env.API_KEY = "system-token";
mockedAxios.post.mockResolvedValue({
data: { success: true },
status: 200,
statusText: "OK",
headers: {},
config: { url: "" },
});
});
it("sends the job type in the email notification payload", async () => {
await dispatchEmail({
appointmentId: "appointment-1",
systemToken: "system-token",
type: "creation",
email: "client@example.com",
subject: "Appointment created",
message: "Creation message",
});
expect(mockedAxios.post).toHaveBeenCalledWith(
"https://api.example.com/notifications/send-email",
{
systemToken: "system-token",
email: "client@example.com",
subject: "Appointment created",
message: "Creation message",
},
{
headers: { "Content-Type": "application/json" },
}
);
});
it("sends the job type in the WhatsApp system notification payload", async () => {
await dispatchWhatsApp({
companyId: "company-1",
appointmentId: "appointment-1",
type: "creation",
systemToken: "system-token",
phoneNumber: "5491112345678",
message: "Creation message",
companyOwnerId: "owner-1",
});
expect(mockedAxios.post).toHaveBeenCalledWith(
"https://api.example.com/notifications/send-wap",
{
systemToken: "system-token",
phoneNumber: "5491112345678",
message: "Creation message",
companyId: "company-1",
sessionUser: "owner-1",
},
{
headers: { "Content-Type": "application/json" },
}
);
});
});
@@ -0,0 +1,252 @@
import { dispatchEmail, dispatchWhatsApp } from "../ChannelDispatchers.js";
import { buildPendingJobQuery, calculateNextRetryAt, JobProcessor } from "../JobProcessor.js";
import { resolveNotificationContent } from "../NotificationContentResolver.js";
jest.mock("../ChannelDispatchers.js", () => ({
dispatchWhatsApp: jest.fn(),
dispatchEmail: jest.fn(),
dispatchSystem: jest.fn(),
}));
jest.mock("../NotificationContentResolver.js", () => ({
resolveNotificationContent: jest.fn(),
}));
describe("JobProcessor", () => {
describe("calculateNextRetryAt", () => {
it("calculates exponential backoff for attempt 1", () => {
const now = new Date("2026-07-21T12:00:00.000Z");
const result = calculateNextRetryAt(1, now);
const minExpected = now.getTime() + 5000;
const maxExpected = now.getTime() + 5000 + 3000;
expect(result.getTime()).toBeGreaterThanOrEqual(minExpected);
expect(result.getTime()).toBeLessThanOrEqual(maxExpected);
});
it("calculates exponential backoff for attempt 2", () => {
const now = new Date("2026-07-21T12:00:00.000Z");
const result = calculateNextRetryAt(2, now);
const minExpected = now.getTime() + 10000;
const maxExpected = now.getTime() + 10000 + 3000;
expect(result.getTime()).toBeGreaterThanOrEqual(minExpected);
expect(result.getTime()).toBeLessThanOrEqual(maxExpected);
});
it("calculates exponential backoff for attempt 3", () => {
const now = new Date("2026-07-21T12:00:00.000Z");
const result = calculateNextRetryAt(3, now);
const minExpected = now.getTime() + 20000;
const maxExpected = now.getTime() + 20000 + 3000;
expect(result.getTime()).toBeGreaterThanOrEqual(minExpected);
expect(result.getTime()).toBeLessThanOrEqual(maxExpected);
});
it("returns a Date in the future", () => {
const now = new Date("2026-07-21T12:00:00.000Z");
const result = calculateNextRetryAt(1, now);
expect(result.getTime()).toBeGreaterThan(now.getTime());
});
it("uses base delay for attempt 0 as fallback", () => {
const now = new Date("2026-07-21T12:00:00.000Z");
const result = calculateNextRetryAt(1, now);
expect(result).toBeInstanceOf(Date);
});
});
describe("backoff math correctness", () => {
it("exponential growth doubles each attempt", () => {
const now = new Date("2026-07-21T12:00:00.000Z");
const r1 = calculateNextRetryAt(1, now).getTime() - now.getTime();
const r2 = calculateNextRetryAt(2, now).getTime() - now.getTime();
const r3 = calculateNextRetryAt(3, now).getTime() - now.getTime();
// Without jitter: 5000, 10000, 20000
// With jitter up to 3000ms, the growth pattern should still hold
// Min possible: 5000, 10000, 20000
expect(r1).toBeGreaterThanOrEqual(5000);
expect(r2).toBeGreaterThanOrEqual(10000);
expect(r3).toBeGreaterThanOrEqual(20000);
});
});
describe("buildPendingJobQuery", () => {
it("only selects due jobs whose retry window is missing, null, or due", () => {
const now = new Date("2026-07-21T12:00:00.000Z");
expect(buildPendingJobQuery(now)).toEqual({
status: "pending",
scheduledAt: { $lte: now },
$or: [
{ nextRetryAt: { $exists: false } },
{ nextRetryAt: null },
{ nextRetryAt: { $lte: now } },
],
});
});
});
describe("WhatsApp dispatch", () => {
beforeEach(() => {
jest.clearAllMocks();
(resolveNotificationContent as jest.Mock).mockResolvedValue({
subject: "Appointment reminder",
message: "Reminder message",
phoneNumber: "5491112345678",
companyOwnerId: "owner-1",
});
});
it("passes the job appointmentId and type to the WhatsApp dispatcher", async () => {
const exec = jest.fn().mockResolvedValue({});
const updateOne = jest.fn(() => ({ exec }));
const recordSent = jest.fn();
(dispatchWhatsApp as jest.Mock).mockResolvedValue({ success: true, providerResponse: "{}" });
await (JobProcessor.prototype as any).processJob.call(
{
jobModel: { notificationJobList: { updateOne } },
throttle: { canSend: jest.fn(() => true), recordSent },
handleFailure: jest.fn(),
},
{
_id: "job-1",
companyId: "company-1",
appointmentId: "appointment-1",
channel: "whatsapp",
type: "creation",
attempts: 0,
maxAttempts: 3,
},
"company-1"
);
expect(dispatchWhatsApp).toHaveBeenCalledWith({
companyId: "company-1",
appointmentId: "appointment-1",
type: "creation",
systemToken: `${process.env.API_KEY}`,
phoneNumber: "5491112345678",
message: "Reminder message",
companyOwnerId: "owner-1",
});
expect(recordSent).toHaveBeenCalledWith("company-1");
});
it("preserves cancellation WhatsApp job types", async () => {
const exec = jest.fn().mockResolvedValue({});
const updateOne = jest.fn(() => ({ exec }));
(dispatchWhatsApp as jest.Mock).mockResolvedValue({ success: true, providerResponse: "{}" });
await (JobProcessor.prototype as any).processJob.call(
{
jobModel: { notificationJobList: { updateOne } },
throttle: { canSend: jest.fn(() => true), recordSent: jest.fn() },
handleFailure: jest.fn(),
},
{
_id: "job-1",
companyId: "company-1",
appointmentId: "appointment-1",
channel: "whatsapp",
type: "cancellation",
attempts: 0,
maxAttempts: 3,
},
"company-1"
);
expect(dispatchWhatsApp).toHaveBeenCalledWith({
companyId: "company-1",
appointmentId: "appointment-1",
type: "cancellation",
systemToken: `${process.env.API_KEY}`,
phoneNumber: "5491112345678",
message: "Reminder message",
companyOwnerId: "owner-1",
});
});
it("does not record a WhatsApp send when dispatch fails", async () => {
const exec = jest.fn().mockResolvedValue({});
const updateOne = jest.fn(() => ({ exec }));
const recordSent = jest.fn();
(dispatchWhatsApp as jest.Mock).mockResolvedValue({ success: false, error: "provider failed" });
await (JobProcessor.prototype as any).processJob.call(
{
jobModel: { notificationJobList: { updateOne } },
throttle: { canSend: jest.fn(() => true), recordSent },
handleFailure: jest.fn(),
},
{
_id: "job-1",
companyId: "company-1",
appointmentId: "appointment-1",
channel: "whatsapp",
type: "reminder",
attempts: 0,
maxAttempts: 3,
},
"company-1"
);
expect(recordSent).not.toHaveBeenCalled();
});
});
describe("Email dispatch", () => {
beforeEach(() => {
jest.clearAllMocks();
(resolveNotificationContent as jest.Mock).mockResolvedValue({
email: "client@example.com",
subject: "Appointment created",
message: "Creation message",
});
(dispatchEmail as jest.Mock).mockResolvedValue({ success: true, providerResponse: "{}" });
});
it("passes creation job type to the email dispatcher", async () => {
const exec = jest.fn().mockResolvedValue({});
const updateOne = jest.fn(() => ({ exec }));
await (JobProcessor.prototype as any).processJob.call(
{
jobModel: { notificationJobList: { updateOne } },
throttle: { canSend: jest.fn(() => true), recordSent: jest.fn() },
handleFailure: jest.fn(),
},
{
_id: "job-1",
companyId: "company-1",
appointmentId: "appointment-1",
channel: "email",
type: "creation",
attempts: 0,
maxAttempts: 3,
},
"company-1"
);
expect(dispatchEmail).toHaveBeenCalledWith({
appointmentId: "appointment-1",
systemToken: `${process.env.API_KEY}`,
type: "creation",
email: "client@example.com",
subject: "Appointment created",
message: "Creation message",
});
});
});
});
@@ -0,0 +1,200 @@
import mongoose from "mongoose";
import { buildNotificationText, resolveNotificationContent } from "../NotificationContentResolver.js";
describe("NotificationContentResolver", () => {
it("builds appointment creation subject and message as Alta de turno", () => {
const result = buildNotificationText({
type: "creation",
appointment: {
_id: "appointment-1",
companyId: "company-1",
start: new Date("2026-07-22T15:30:00.000Z"),
},
service: { _id: "service-1", name: "Corte" },
company: { _id: "company-1", name: "Barber Shop" },
});
expect(result.subject).toContain("Alta de turno");
expect(result.subject).toContain("Barber Shop");
expect(result.message).toContain("Corte");
expect(result.message).toContain("confirmado");
});
it("builds meaningful appointment reminder subject and message", () => {
const result = buildNotificationText({
type: "reminder",
appointment: {
_id: "appointment-1",
companyId: "company-1",
start: new Date("2026-07-22T15:30:00.000Z"),
},
service: { _id: "service-1", name: "Corte" },
company: { _id: "company-1", name: "Barber Shop" },
});
expect(result.subject).toContain("Recordatorio");
expect(result.subject).toContain("Barber Shop");
expect(result.message).toContain("Corte");
expect(result.message).toContain("Barber Shop");
expect(result.subject.trim()).not.toBe("");
expect(result.message.trim()).not.toBe("");
});
it("uses cancellation snapshot payload without requiring appointment lookup", async () => {
const result = await resolveNotificationContent({
appointmentId: "deleted-appointment-1",
clientId: "client-1",
companyId: "company-1",
channel: "email",
type: "cancellation",
payload: {
email: "client@example.com",
phoneNumber: "5491112345678",
userId: "user-1",
companyOwnerId: "owner-1",
subject: "Turno cancelado en Barber Shop",
message: "Tu turno para Corte fue cancelado.",
emailSubject: "Email cancellation subject",
emailMessage: "Email cancellation body.",
wapMessage: "WAP cancellation body.",
systemSubject: "System cancellation subject",
systemMessage: "System cancellation body.",
},
} as any);
expect(result).toEqual({
email: "client@example.com",
phoneNumber: "5491112345678",
userId: "user-1",
companyOwnerId: "owner-1",
subject: "Email cancellation subject",
message: "Email cancellation body.",
});
});
it("uses creation snapshot payload content for email jobs", async () => {
const result = await resolveNotificationContent({
appointmentId: "appointment-1",
clientId: "client-1",
companyId: "company-1",
channel: "email",
type: "creation",
payload: {
email: "client@example.com",
phoneNumber: "5491112345678",
userId: "user-1",
companyOwnerId: "owner-1",
subject: "Turno reservado en Barber Shop",
message: "Generic creation body.",
emailSubject: "Email Alta subject",
emailMessage: "Email Alta body.",
wapMessage: "WAP Alta body.",
systemSubject: "System Alta subject",
systemMessage: "System Alta body.",
},
} as any);
expect(result).toEqual({
email: "client@example.com",
phoneNumber: "5491112345678",
userId: "user-1",
companyOwnerId: "owner-1",
subject: "Email Alta subject",
message: "Email Alta body.",
});
});
it("uses creation snapshot payload content for WhatsApp jobs", async () => {
const result = await resolveNotificationContent({
appointmentId: "appointment-1",
clientId: "client-1",
companyId: "company-1",
channel: "whatsapp",
type: "creation",
payload: {
phoneNumber: "5491112345678",
subject: "Generic subject",
message: "Generic message.",
emailMessage: "Email Alta body.",
wapMessage: "WAP Alta body.",
},
} as any);
expect(result.message).toBe("WAP Alta body.");
});
it("uses reminder payload content without requiring appointment lookup", async () => {
const appointmentLookup = jest.spyOn(mongoose.models.Appointment, "findOne");
const result = await resolveNotificationContent({
appointmentId: "appointment-1",
clientId: "client-1",
companyId: "company-1",
channel: "whatsapp",
type: "reminder",
payload: {
email: "client@example.com",
phoneNumber: "5491112345678",
userId: "user-1",
companyOwnerId: "owner-1",
subject: "Generic reminder subject",
message: "Generic reminder body.",
emailSubject: "Email reminder subject",
emailMessage: "Email reminder body.",
wapMessage: "WAP reminder body.",
systemSubject: "System reminder subject",
systemMessage: "System reminder body.",
},
} as any);
expect(appointmentLookup).not.toHaveBeenCalled();
expect(result).toEqual({
email: "client@example.com",
phoneNumber: "5491112345678",
userId: "user-1",
companyOwnerId: "owner-1",
subject: "Generic reminder subject",
message: "WAP reminder body.",
});
});
it("uses WhatsApp cancellation payload content for WhatsApp jobs", async () => {
const result = await resolveNotificationContent({
appointmentId: "deleted-appointment-1",
clientId: "client-1",
companyId: "company-1",
channel: "whatsapp",
type: "cancellation",
payload: {
phoneNumber: "5491112345678",
companyOwnerId: "owner-1",
subject: "Generic subject",
message: "Generic message.",
emailMessage: "Email cancellation body.",
wapMessage: "WAP cancellation body.",
},
} as any);
expect(result.message).toBe("WAP cancellation body.");
});
it("uses system cancellation payload content for system jobs", async () => {
const result = await resolveNotificationContent({
appointmentId: "deleted-appointment-1",
clientId: "client-1",
companyId: "company-1",
channel: "system",
type: "cancellation",
payload: {
userId: "user-1",
subject: "Generic subject",
message: "Generic message.",
systemSubject: "System cancellation subject",
systemMessage: "System cancellation body.",
},
} as any);
expect(result.subject).toBe("System cancellation subject");
expect(result.message).toBe("System cancellation body.");
});
});
@@ -0,0 +1,93 @@
describe("WhatsAppThrottle", () => {
let WhatsAppThrottle: typeof import("../WhatsAppThrottle.js").WhatsAppThrottleClass;
beforeAll(async () => {
const mod = await import("../WhatsAppThrottle.js");
WhatsAppThrottle = mod.WhatsAppThrottleClass;
});
beforeEach(() => {
jest.restoreAllMocks();
});
it("allows first send for a company immediately", () => {
const throttle = new WhatsAppThrottle(8000);
expect(throttle.canSend("company-1")).toBe(true);
});
it("blocks send within minimum interval", () => {
const throttle = new WhatsAppThrottle(8000);
throttle.recordSent("company-1");
jest.useFakeTimers();
jest.setSystemTime(new Date("2026-07-21T12:00:03.000Z")); // 3s later
expect(throttle.canSend("company-1")).toBe(false);
jest.useRealTimers();
});
it("allows send after maximum possible interval (base + jitter)", () => {
const throttle = new WhatsAppThrottle(8000);
const now = new Date("2026-07-21T12:00:00.000Z");
jest.useFakeTimers();
jest.setSystemTime(now);
throttle.recordSent("company-1");
// After 16s (max = 8s base + 8s jitter), should always be allowed
jest.setSystemTime(new Date("2026-07-21T12:00:16.000Z"));
expect(throttle.canSend("company-1")).toBe(true);
jest.useRealTimers();
});
it("tracks companies independently", () => {
const throttle = new WhatsAppThrottle(8000);
const now = new Date("2026-07-21T12:00:00.000Z");
jest.useFakeTimers();
jest.setSystemTime(now);
throttle.recordSent("company-1");
// company-2 should still be allowed
expect(throttle.canSend("company-2")).toBe(true);
jest.useRealTimers();
});
it("applies jitter to minimum interval", () => {
const throttle = new WhatsAppThrottle(8000);
const now = new Date("2026-07-21T12:00:00.000Z");
jest.useFakeTimers();
jest.setSystemTime(now);
throttle.recordSent("company-1");
// At exactly 8s (base interval), it should still be blocked due to jitter
jest.setSystemTime(new Date("2026-07-21T12:00:08.000Z"));
expect(throttle.canSend("company-1")).toBe(false);
// At 16s (max interval = base + jitter), it should be allowed
jest.setSystemTime(new Date("2026-07-21T12:00:16.000Z"));
expect(throttle.canSend("company-1")).toBe(true);
jest.useRealTimers();
});
it("uses default interval of 8000ms when not specified", () => {
const throttle = new WhatsAppThrottle();
const now = new Date("2026-07-21T12:00:00.000Z");
jest.useFakeTimers();
jest.setSystemTime(now);
throttle.recordSent("company-1");
// 7s should be blocked
jest.setSystemTime(new Date("2026-07-21T12:00:07.000Z"));
expect(throttle.canSend("company-1")).toBe(false);
jest.useRealTimers();
});
});