feat: implement comprehensive notification engine with policies, preferences, and automated dispatching services
This commit is contained in:
@@ -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",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
Generated
+4571
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user