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
+20
View File
@@ -0,0 +1,20 @@
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
testMatch: ["**/__tests__/**/*.ts", "**/?(*.)+(spec|test).ts"],
moduleNameMapper: {
"^(\\.{1,2}/.*)\\.js$": "$1",
},
transform: {
"^.+\\.tsx?$": [
"ts-jest",
{
useESM: false,
tsconfig: {
module: "CommonJS",
moduleResolution: "node",
},
},
],
},
};
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -6,7 +6,8 @@
"scripts": {
"dev": "NODE_NO_WARNINGS=1 DOTENV_DISABLE_TELEMETRY=1 node --loader ts-node/esm src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
"start": "node dist/index.js",
"test": "jest --config jest.config.cjs"
},
"keywords": [],
"author": "",
@@ -14,8 +15,11 @@
"description": "",
"devDependencies": {
"@types/axios": "^0.9.36",
"@types/jest": "^30.0.0",
"@types/mongoose": "^5.11.96",
"@types/node": "^24.3.3",
"jest": "^30.4.2",
"ts-jest": "^29.4.11",
"ts-node": "^10.9.2",
"typescript": "^5.9.2"
},
@@ -0,0 +1,185 @@
import { logDone, logError, logIntent } from "../../config/logger.js";
export interface DispatchResult {
success: boolean;
providerResponse?: string;
error?: string;
}
function formatResponseFailure(endpoint: string, data: unknown): string {
const serialized = JSON.stringify(data);
return `${endpoint} returned success=false${serialized && serialized !== "{}" ? ` data=${serialized}` : " without error details"}`;
}
function getApiBaseUrl(): string {
const raw = process.env.API_URL || "";
if (!raw.trim()) {
return "";
}
try {
return new URL(raw).origin;
} catch {
return raw.replace(/\/$/, "");
}
}
function buildApiUrl(path: string): string {
return `${getApiBaseUrl()}${path}`;
}
function formatDispatchError(error: unknown): string {
if (typeof error === "object" && error !== null && "isAxiosError" in error) {
const axiosError = error as {
message?: string;
response?: { status?: number; data?: unknown };
};
const details = [
axiosError.response?.status ? `status=${axiosError.response.status}` : undefined,
axiosError.response?.data !== undefined ? `data=${JSON.stringify(axiosError.response.data)}` : undefined,
axiosError.message ? `message=${axiosError.message}` : undefined,
].filter(Boolean);
return details.join(" ") || "Axios request failed without details";
}
return error instanceof Error ? error.message : String(error);
}
export async function dispatchWhatsApp(params: {
companyId: string;
appointmentId: string;
type: string;
systemToken: string;
phoneNumber: string;
message: string;
companyOwnerId: string;
}): Promise<DispatchResult> {
try {
if (!params.phoneNumber.trim() || !params.message.trim() || !params.companyOwnerId.trim()) {
return { success: false, error: "WhatsApp dispatch requires phoneNumber, message, and companyOwnerId" };
}
// Send worker-rendered content directly so cancellation jobs survive appointment deletion.
const axios = (await import("axios")).default;
const response = await axios.post(
buildApiUrl("/notifications/send-wap"),
{
systemToken: params.systemToken,
phoneNumber: params.phoneNumber,
message: params.message,
companyId: params.companyId,
sessionUser: params.companyOwnerId,
},
{
headers: { "Content-Type": "application/json" },
}
);
logDone(`WhatsApp dispatched for company ${params.companyId}`);
return { success: true, providerResponse: JSON.stringify(response.data) };
} catch (error) {
const msg = formatDispatchError(error);
logError(`WhatsApp dispatch failed for company ${params.companyId}:`, msg);
return { success: false, error: msg };
}
}
export async function dispatchEmail(params: {
appointmentId: string;
systemToken: string;
type: string;
email: string;
subject: string;
message: string;
}): Promise<DispatchResult> {
try {
if (!params.email.trim() || !params.subject.trim() || !params.message.trim()) {
return { success: false, error: "Email dispatch requires email, subject, and message" };
}
// Send worker-rendered content directly so cancellation jobs survive appointment deletion.
const axios = (await import("axios")).default;
const response = await axios.post(
buildApiUrl("/notifications/send-email"),
{
systemToken: params.systemToken,
email: params.email,
subject: params.subject,
message: params.message,
},
{
headers: { "Content-Type": "application/json" },
}
);
const responseData = response.data as { success?: boolean };
if (responseData.success === false) {
return {
success: false,
error: formatResponseFailure("notifications/send-email", response.data),
};
}
logDone(`Email dispatched to ${params.email}`);
return { success: true, providerResponse: JSON.stringify(response.data) };
} catch (error) {
const msg = formatDispatchError(error);
logError(`Email dispatch failed for ${params.email}:`, msg);
return { success: false, error: msg };
}
}
export async function dispatchSystem(params: {
systemToken: string;
userId: string;
subject: string;
message: string;
companyId?: string;
serviceId?: number;
conversationId?: string;
type?: string;
code?: string;
}): Promise<DispatchResult> {
try {
if (!params.userId.trim() || !params.subject.trim() || !params.message.trim()) {
return { success: false, error: "System dispatch requires userId, subject, and message" };
}
// Delegate to the server API for system notification
const axios = (await import("axios")).default;
const response = await axios.post(
buildApiUrl("/systemnotifications/create"),
{
userId: params.userId,
subject: params.subject,
message: params.message,
companyId: params.companyId,
serviceId: params.serviceId,
conversationId: params.conversationId,
type: params.type,
code: params.code,
systemToken: params.systemToken,
},
{
headers: { "Content-Type": "application/json" },
}
);
const responseData = response.data as { success?: boolean };
if (responseData.success === false) {
return {
success: false,
error: formatResponseFailure("systemnotifications/create", response.data),
};
}
logDone(`System notification dispatched to user ${params.userId}`);
return { success: true, providerResponse: JSON.stringify(response.data) };
} catch (error) {
const msg = formatDispatchError(error);
logError(`System notification dispatch failed for user ${params.userId}:`, msg);
return { success: false, error: msg };
}
}
@@ -0,0 +1,228 @@
import { logDone, logError, logIntent } from "../../config/logger.js";
import { NotificationJobModel, INotificationJob } from "./NotificationJob.Adapter.Mongoose.js";
import { WhatsAppThrottleClass } from "./WhatsAppThrottle.js";
import { dispatchWhatsApp, dispatchEmail, dispatchSystem } from "./ChannelDispatchers.js";
import { resolveNotificationContent } from "./NotificationContentResolver.js";
const POLL_INTERVAL_MS = 5000;
const BASE_RETRY_DELAY_MS = 5000;
const MAX_JITTER_MS = 3000;
const THROTTLE_RETRY_DELAY_MS = 8000;
const SUPPORTED_APPOINTMENT_NOTIFICATION_TYPES = new Set(["creation", "reminder", "cancellation"]);
function resolveAppointmentNotificationType(type: unknown): string {
return typeof type === "string" && SUPPORTED_APPOINTMENT_NOTIFICATION_TYPES.has(type) ? type : "reminder";
}
export function buildPendingJobQuery(now: Date) {
return {
status: "pending",
scheduledAt: { $lte: now },
$or: [{ nextRetryAt: { $exists: false } }, { nextRetryAt: null }, { nextRetryAt: { $lte: now } }],
};
}
export function calculateNextRetryAt(attempt: number, now: Date): Date {
const delay = BASE_RETRY_DELAY_MS * Math.pow(2, attempt - 1);
const jitter = Math.floor(Math.random() * MAX_JITTER_MS);
return new Date(now.getTime() + delay + jitter);
}
function getJobId(job: { id?: unknown; _id?: unknown; appointmentId?: unknown; channel?: unknown; get?: (path: string) => unknown }): string {
const value = job.id || job._id || job.get?.("_id");
if (value) return String(value);
const appointmentId = job.appointmentId || job.get?.("appointmentId");
const channel = job.channel || job.get?.("channel");
return appointmentId || channel ? `unknown appointment=${String(appointmentId || "n/a")} channel=${String(channel || "n/a")}` : "unknown";
}
export class JobProcessor {
private jobModel: NotificationJobModel;
private throttle: WhatsAppThrottleClass;
private polling = false;
constructor() {
this.jobModel = new NotificationJobModel();
this.throttle = new WhatsAppThrottleClass();
}
public async startPolling(): Promise<void> {
if (this.polling) return;
this.polling = true;
logIntent("JobProcessor: starting poll loop");
this.poll();
}
public stopPolling(): void {
this.polling = false;
logIntent("JobProcessor: stopping poll loop");
}
private async poll(): Promise<void> {
if (!this.polling) return;
try {
const now = new Date();
const pendingJobs = await this.jobModel.notificationJobList
.find(buildPendingJobQuery(now))
.sort({ scheduledAt: 1 })
.exec();
if (pendingJobs.length > 0) {
logIntent(`JobProcessor: found ${pendingJobs.length} pending jobs`);
await this.processJobs(pendingJobs);
}
} catch (error) {
logError("JobProcessor: poll error", error);
}
setTimeout(() => this.poll(), POLL_INTERVAL_MS);
}
private async processJobs(jobs: any[]): Promise<void> {
// Group by companyId
const grouped = new Map<string, any[]>();
for (const job of jobs) {
const companyId = String(job.companyId);
if (!grouped.has(companyId)) {
grouped.set(companyId, []);
}
grouped.get(companyId)!.push(job);
}
for (const [companyId, companyJobs] of grouped) {
for (const job of companyJobs) {
await this.processJob(job, companyId);
}
}
}
private async processJob(job: any, companyId: string): Promise<void> {
const now = new Date();
const jobId = getJobId(job);
try {
// Check throttle for WhatsApp
if (job.channel === "whatsapp") {
if (!this.throttle.canSend(companyId)) {
const nextRetryAt = new Date(now.getTime() + THROTTLE_RETRY_DELAY_MS);
await this.jobModel.notificationJobList.updateOne(
{ _id: job._id },
{ $set: { status: "pending", nextRetryAt } }
).exec();
logIntent(`JobProcessor: job ${jobId} throttled WhatsApp for company ${companyId}; retry at ${nextRetryAt.toISOString()}`);
return;
}
}
const nextAttempt = job.attempts + 1;
await this.jobModel.notificationJobList.updateOne(
{ _id: job._id },
{ $set: { status: "processing", attempts: nextAttempt } }
).exec();
const dispatchJob = {
...job,
_id: job._id || job.get?.("_id"),
appointmentId: job.appointmentId || job.get?.("appointmentId"),
channel: job.channel || job.get?.("channel"),
attempts: nextAttempt,
};
const content = await resolveNotificationContent(job);
const appointmentNotificationType = resolveAppointmentNotificationType(job.type);
let result;
switch (job.channel) {
case "whatsapp":
result = await dispatchWhatsApp({
companyId,
appointmentId: String(job.appointmentId),
type: appointmentNotificationType,
systemToken: `${process.env.API_KEY}`,
phoneNumber: content.phoneNumber || "",
message: content.message,
companyOwnerId: content.companyOwnerId || "",
});
break;
case "email":
result = await dispatchEmail({
appointmentId: String(job.appointmentId),
systemToken: `${process.env.API_KEY}`,
type: appointmentNotificationType,
email: content.email || "",
subject: content.subject,
message: content.message,
});
break;
case "system":
result = await dispatchSystem({
systemToken: `${process.env.API_KEY}`,
userId: content.userId || "",
subject: content.subject,
message: content.message,
companyId: String(job.companyId),
type: job.type,
code: String(job.appointmentId),
});
break;
default:
result = { success: false, error: `Unknown channel: ${job.channel}` };
}
if (result.success) {
if (job.channel === "whatsapp") {
this.throttle.recordSent(companyId);
}
await this.jobModel.notificationJobList.updateOne(
{ _id: job._id },
{
$set: {
status: "sent",
sentAt: new Date(),
providerResponse: result.providerResponse,
},
}
).exec();
logDone(`JobProcessor: job ${jobId} sent via ${job.channel}`);
} else {
await this.handleFailure(dispatchJob, result.error || "Unknown error");
}
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
await this.handleFailure({ ...job, attempts: job.attempts + 1 }, msg);
}
}
private async handleFailure(job: any, errorMessage: string): Promise<void> {
const now = new Date();
const jobId = getJobId(job);
if (job.attempts >= job.maxAttempts) {
await this.jobModel.notificationJobList.updateOne(
{ _id: job._id },
{
$set: {
status: "failed",
lastError: errorMessage,
},
}
).exec();
logError(`JobProcessor: job ${jobId} failed permanently after ${job.attempts} attempts`, errorMessage);
} else {
const nextRetryAt = calculateNextRetryAt(job.attempts, now);
await this.jobModel.notificationJobList.updateOne(
{ _id: job._id },
{
$set: {
status: "pending",
lastError: errorMessage,
nextRetryAt,
},
}
).exec();
logError(`JobProcessor: job ${jobId} failed attempt ${job.attempts}/${job.maxAttempts}; retry at ${nextRetryAt.toISOString()}`, errorMessage);
}
}
}
@@ -0,0 +1,191 @@
import mongoose, { Model } from "mongoose";
import { INotificationJobDocument, NotificationJobType } from "./NotificationJob.Adapter.Mongoose.js";
const { Schema, model, models } = mongoose;
interface AppointmentDocument {
_id: unknown;
companyId: unknown;
serviceId?: unknown;
clientId?: unknown;
userId?: unknown;
start: Date;
}
interface ClientDocument {
_id: unknown;
userId?: unknown;
firstName?: string;
lastName?: string;
email?: string;
}
interface UserDocument {
_id: unknown;
email?: string;
firstName?: string;
lastName?: string;
}
interface ServiceDocument {
_id: unknown;
name?: string;
}
interface CompanyDocument {
_id: unknown;
name?: string;
ownerId?: unknown;
}
export interface ResolvedNotificationContent {
email?: string;
phoneNumber?: string;
userId?: string;
companyOwnerId?: string;
subject: string;
message: string;
}
const appointmentSchema = new Schema(
{
companyId: { type: Schema.Types.ObjectId, required: true, ref: "Companie" },
serviceId: { type: Schema.Types.ObjectId, required: false, ref: "Service" },
clientId: { type: Schema.Types.ObjectId, required: false, ref: "Client" },
userId: { type: Schema.Types.ObjectId, required: false, ref: "User" },
start: { type: Date, required: true },
},
{ strict: false }
);
const clientSchema = new Schema(
{
userId: { type: Schema.Types.ObjectId, required: false, ref: "User" },
firstName: { type: String, required: false },
lastName: { type: String, required: false },
email: { type: String, required: false },
},
{ strict: false }
);
const userSchema = new Schema(
{
email: { type: String, required: false },
firstName: { type: String, required: false },
lastName: { type: String, required: false },
},
{ strict: false }
);
const serviceSchema = new Schema(
{
name: { type: String, required: false },
},
{ strict: false }
);
const companySchema = new Schema(
{
name: { type: String, required: false },
ownerId: { type: Schema.Types.ObjectId, required: false, ref: "User" },
},
{ strict: false }
);
const Appointment = (models.Appointment || model<AppointmentDocument>("Appointment", appointmentSchema)) as Model<AppointmentDocument>;
const Client = (models.Client || model<ClientDocument>("Client", clientSchema)) as Model<ClientDocument>;
const User = (models.User || model<UserDocument>("User", userSchema)) as Model<UserDocument>;
const Service = (models.Service || model<ServiceDocument>("Service", serviceSchema)) as Model<ServiceDocument>;
const Company = (models.Company || model<CompanyDocument>("Company", companySchema)) as Model<CompanyDocument>;
function compactName(...parts: Array<string | undefined>): string {
return parts.filter(Boolean).join(" ").trim();
}
function formatDateTime(value?: Date): string {
if (!value) return "fecha pendiente";
return new Intl.DateTimeFormat("es-AR", {
dateStyle: "short",
timeStyle: "short",
timeZone: "America/Argentina/Buenos_Aires",
}).format(value);
}
export function buildNotificationText(params: {
type: NotificationJobType;
appointment?: AppointmentDocument | null;
client?: ClientDocument | null;
service?: ServiceDocument | null;
company?: CompanyDocument | null;
}): Pick<ResolvedNotificationContent, "subject" | "message"> {
const companyName = params.company?.name || "TurnosXpress";
const serviceName = params.service?.name || "tu turno";
const appointmentDate = formatDateTime(params.appointment?.start);
switch (params.type) {
case "creation":
return {
subject: `Alta de turno en ${companyName}`,
message: `Tu turno para ${serviceName} en ${companyName} fue confirmado para el ${appointmentDate}.`,
};
case "reminder":
return {
subject: `Recordatorio de turno en ${companyName}`,
message: `Te recordamos tu turno para ${serviceName} en ${companyName} el ${appointmentDate}.`,
};
case "update":
return {
subject: `Turno actualizado en ${companyName}`,
message: `Tu turno para ${serviceName} en ${companyName} fue actualizado. Nueva fecha: ${appointmentDate}.`,
};
case "cancellation":
return {
subject: `Turno cancelado en ${companyName}`,
message: `Tu turno para ${serviceName} en ${companyName} del ${appointmentDate} fue cancelado.`,
};
}
}
export async function resolveNotificationContent(job: INotificationJobDocument): Promise<ResolvedNotificationContent> {
if ((job.type === "creation" || job.type === "reminder" || job.type === "cancellation") && job.payload) {
const { email, phoneNumber, userId, companyOwnerId } = job.payload;
const subject = job.channel === "email"
? job.payload.emailSubject
: job.channel === "system"
? job.payload.systemSubject
: job.payload.subject;
const message = job.channel === "whatsapp"
? job.payload.wapMessage
: job.channel === "email"
? job.payload.emailMessage
: job.payload.systemMessage;
const fallbackSubject = subject || job.payload.subject;
const fallbackMessage = message || job.payload.message;
if (fallbackSubject?.trim() && fallbackMessage?.trim()) {
return {
email: email?.trim(),
phoneNumber: phoneNumber?.trim(),
userId: userId ? String(userId) : undefined,
companyOwnerId: companyOwnerId ? String(companyOwnerId) : undefined,
subject: fallbackSubject.trim(),
message: fallbackMessage.trim(),
};
}
}
const appointment = await Appointment.findOne({ _id: job.appointmentId }).lean().exec();
const client = await Client.findOne({ _id: job.clientId }).lean().exec();
const company = await Company.findOne({ _id: job.companyId }).lean().exec();
const service = appointment?.serviceId ? await Service.findOne({ _id: appointment.serviceId }).lean().exec() : null;
const userId = client?.userId || appointment?.userId || company?.ownerId;
const user = userId ? await User.findOne({ _id: userId }).lean().exec() : null;
const text = buildNotificationText({ type: job.type, appointment, client, service, company });
const email = client?.email || user?.email;
return {
email: email?.trim(),
userId: userId ? String(userId) : undefined,
subject: text.subject.trim(),
message: text.message.trim(),
};
}
@@ -0,0 +1,105 @@
import mongoose, { Document, Model, Schema, model } from "mongoose";
export type NotificationChannel = "whatsapp" | "email" | "system";
export type NotificationJobStatus = "pending" | "processing" | "sent" | "failed" | "cancelled";
export type NotificationJobType = "creation" | "reminder" | "update" | "cancellation";
export interface NotificationJobPayload {
email?: string;
phoneNumber?: string;
userId?: string;
companyOwnerId?: string;
subject?: string;
message?: string;
emailSubject?: string;
emailMessage?: string;
wapMessage?: string;
systemSubject?: string;
systemMessage?: string;
}
export interface INotificationJob {
_id: string;
companyId: string;
appointmentId: string;
clientId: string;
channel: NotificationChannel;
type: NotificationJobType;
scheduledAt: Date;
status: NotificationJobStatus;
attempts: number;
maxAttempts: number;
lastError?: string;
nextRetryAt?: Date;
providerResponse?: string;
payload?: NotificationJobPayload;
createdAt: Date;
updatedAt: Date;
sentAt?: Date;
}
export interface INotificationJobDocument extends Omit<INotificationJob, "_id">, Document {}
export class NotificationJobModel {
schema: Schema;
notificationJobList: Model<INotificationJobDocument>;
constructor() {
this.schema = new Schema(
{
companyId: {
type: Schema.Types.ObjectId,
required: true,
ref: "Companie",
},
appointmentId: {
type: Schema.Types.ObjectId,
required: true,
ref: "Appointment",
},
clientId: {
type: Schema.Types.ObjectId,
required: true,
ref: "Client",
},
channel: {
type: String,
required: true,
enum: ["whatsapp", "email", "system"],
},
type: {
type: String,
required: true,
enum: ["creation", "reminder", "update", "cancellation"],
},
scheduledAt: { type: Date, required: true },
status: {
type: String,
required: true,
enum: ["pending", "processing", "sent", "failed", "cancelled"],
default: "pending",
},
attempts: { type: Number, required: true, default: 0 },
maxAttempts: { type: Number, required: true, default: 3 },
lastError: { type: String, required: false },
nextRetryAt: { type: Date, required: false },
providerResponse: { type: String, required: false },
payload: { type: Schema.Types.Mixed, required: false },
sentAt: { type: Date, required: false },
},
{
timestamps: true,
}
);
this.schema.index({ status: 1, scheduledAt: 1 });
this.schema.index({ status: 1, scheduledAt: 1, nextRetryAt: 1 });
this.schema.index({ companyId: 1, status: 1 });
this.schema.index({ appointmentId: 1 });
this.notificationJobList = model<INotificationJobDocument>(
"NotificationJob",
this.schema
);
}
}
@@ -0,0 +1,28 @@
const DEFAULT_MIN_INTERVAL_MS = 8000;
const JITTER_MAX_MS = 8000;
export class WhatsAppThrottleClass {
private lastSentAt: Map<string, number> = new Map();
private minIntervalMs: number;
constructor(minIntervalMs: number = DEFAULT_MIN_INTERVAL_MS) {
this.minIntervalMs = minIntervalMs;
}
canSend(companyId: string): boolean {
const lastSent = this.lastSentAt.get(companyId);
if (!lastSent) {
return true;
}
const elapsed = Date.now() - lastSent;
const jitter = Math.floor(Math.random() * JITTER_MAX_MS);
const requiredInterval = this.minIntervalMs + jitter;
return elapsed >= requiredInterval;
}
recordSent(companyId: string): void {
this.lastSentAt.set(companyId, Date.now());
}
}
@@ -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();
});
});
+2 -1
View File
@@ -42,7 +42,8 @@ export const logDone = (message: string) => {
};
export const logError = (message: string, error: any) => {
logger.info(`${bold}${message}${reset}\n\r`, error);
const details = error instanceof Error ? error.stack || error.message : String(error || "Unknown error");
logger.error(`${bold}${message}${reset}\n${details}`);
};
export const logWarn = (message: string) => {
+4 -4
View File
@@ -1,7 +1,7 @@
import dotenv from "dotenv";
import logger, { logDone, logError, logIntent } from "./config/logger.js";
import mongoose from "mongoose";
import { Notifications } from "./Models/Notifications/Notifications.js";
import { JobProcessor } from "./Models/Jobs/JobProcessor.js";
logIntent("Iniciando Notification Sender.");
@@ -28,10 +28,10 @@ mongoose
.then(() => {
const start = async () => {
logDone("Conexión a MongoDB establecida correctamente.");
logger.info("🚀 Notification Sender running");
logger.info("🚀 Notification Sender running (job-based polling)");
const worker = new Notifications();
await worker.start();
const processor = new JobProcessor();
await processor.startPolling();
};
start();