20 Commits

Author SHA1 Message Date
horacio b26c3e58d1 feat: implement notification dropdown in header with centralized navigation logic 2026-07-21 21:51:13 -03:00
horacio 98f0ec3102 feat: implement automated job cleanup, update notification presets, and upgrade Baileys library 2026-07-21 21:12:29 -03:00
horacio aac53e13cc feat: integrate reminder payloads into appointment creation and update notification jobs and adjust quiet hours logic 2026-07-21 19:39:38 -03:00
horacio 10ca449f88 feat: implement comprehensive notification engine with policies, preferences, and automated dispatching services 2026-07-21 18:53:11 -03:00
horacio 40090cdec5 feat: add collaboratorUserId to appointment model and update dashboard filtering logic 2026-07-21 10:34:52 -03:00
horacio a9afb13d15 feat: update collaborator invitation flow to handle existing users and pending invitations in StepEngine 2026-07-20 14:46:14 -03:00
horacio b01149a19e feat: introduce service-based availability filtering for collaborator schedules and overrides 2026-07-19 23:36:18 -03:00
horacio f017f7e9aa feat: redesign CategoriesExplorer component with a modern card-based grid layout and dynamic palettes 2026-07-19 20:11:01 -03:00
horacio 10ec32139c refactor: replace client deletion with user detachment and update client schema to support optional user association 2026-07-19 19:52:45 -03:00
horacio 74d6ee6706 feat: implement collaborator reactivation logic, add access denied handling, and improve metric filtering 2026-07-19 18:56:39 -03:00
horacio af6c87acb6 feat: add manual subscription finalization functionality and support for expired plan notifications 2026-07-19 13:20:33 -03:00
horacio 78ee86ac48 feat: integrate consumption metrics into plan usage cycle tracking and UI display 2026-07-18 22:02:48 -03:00
horacio 0f76ef3118 feat: expand dashboard with navigation cards for companies, plans, and active WAP server management 2026-07-18 21:36:39 -03:00
horacio 4f3b56784e feat: implement sysadmin subscription management and plan usage cycle recalculation tools 2026-07-18 21:32:47 -03:00
horacio 1c8c47cf76 refactor: update error message for subscription cancellation to clarify user options 2026-07-18 15:50:30 -03:00
horacio 8d74ae95c8 feat: implement plan upgrade logic with prorated pricing and MercadoPago integration 2026-07-18 15:39:07 -03:00
horacio f6e2bb8372 refactor: redesign current plan UI with features panel and expandable capacity limits view 2026-07-18 11:05:26 -03:00
horacio 81616c7c1c feat: add collapsible state and item count to dashboard agenda component 2026-07-18 10:54:27 -03:00
horacio 8d9a03cd23 feat: plan management 2026-07-17 21:28:41 -03:00
horacio d6a4aab52b primer commit hacia el nuevo servidor de git 2026-07-17 18:22:52 -03:00
209 changed files with 23818 additions and 1973 deletions
+5
View File
@@ -0,0 +1,5 @@
# CodeGraph data files — local to each machine, not for committing.
# Ignore everything in .codegraph/ except this file itself, so transient
# files (the database, daemon.pid, sockets, logs) never show up in git.
*
!.gitignore
+3
View File
@@ -34,3 +34,6 @@ server/build
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
# AI tooling
.atl/
+1
View File
@@ -1,2 +1,3 @@
# turnosxpress # turnosxpress
ngrok http 3000
+1 -1
View File
@@ -1,4 +1,4 @@
DATABASE_CONNECTION = mongodb://horacio:frx8us2w@192.168.1.50:20215/turnosxpress DATABASE_CONNECTION = mongodb://horacio:frx8us2w@localhost:20215/turnosxpress
DATABASE_CONNECTION_ATLAS = mongodb+srv://horacio:frx8us2w@reservar.a2cgd.mongodb.net/reservar DATABASE_CONNECTION_ATLAS = mongodb+srv://horacio:frx8us2w@reservar.a2cgd.mongodb.net/reservar
DATABASE_CONNECTION_LOCAL = mongodb://horacio:frx8us2w@192.168.1.50:20215/turnosxpress DATABASE_CONNECTION_LOCAL = mongodb://horacio:frx8us2w@192.168.1.50:20215/turnosxpress
+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": { "scripts": {
"dev": "NODE_NO_WARNINGS=1 DOTENV_DISABLE_TELEMETRY=1 node --loader ts-node/esm src/index.ts", "dev": "NODE_NO_WARNINGS=1 DOTENV_DISABLE_TELEMETRY=1 node --loader ts-node/esm src/index.ts",
"build": "tsc", "build": "tsc",
"start": "node dist/index.js" "start": "node dist/index.js",
"test": "jest --config jest.config.cjs"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",
@@ -14,8 +15,11 @@
"description": "", "description": "",
"devDependencies": { "devDependencies": {
"@types/axios": "^0.9.36", "@types/axios": "^0.9.36",
"@types/jest": "^30.0.0",
"@types/mongoose": "^5.11.96", "@types/mongoose": "^5.11.96",
"@types/node": "^24.3.3", "@types/node": "^24.3.3",
"jest": "^30.4.2",
"ts-jest": "^29.4.11",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "^5.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,270 @@
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 DEFAULT_CLEANUP_INTERVAL_MS = 60 * 60 * 1000;
const SUPPORTED_APPOINTMENT_NOTIFICATION_TYPES = new Set(["creation", "reminder", "cancellation"]);
function resolveCleanupIntervalMs(): number {
const raw = process.env.NOTIFICATION_JOB_CLEANUP_INTERVAL_MS;
if (!raw) return DEFAULT_CLEANUP_INTERVAL_MS;
const parsed = Number(raw);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_CLEANUP_INTERVAL_MS;
}
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);
}
export function buildStartOfDay(date: Date): Date {
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
}
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;
private cleanupTimeout?: NodeJS.Timeout;
private cleanupIntervalMs: number;
constructor() {
this.jobModel = new NotificationJobModel();
this.throttle = new WhatsAppThrottleClass();
this.cleanupIntervalMs = resolveCleanupIntervalMs();
}
public async startPolling(): Promise<void> {
if (this.polling) return;
this.polling = true;
logIntent("JobProcessor: starting poll loop");
await this.cleanupOldJobs();
this.scheduleCleanup();
this.poll();
}
public stopPolling(): void {
this.polling = false;
if (this.cleanupTimeout) {
clearTimeout(this.cleanupTimeout);
this.cleanupTimeout = undefined;
}
logIntent("JobProcessor: stopping poll loop");
}
private scheduleCleanup(): void {
if (!this.polling || this.cleanupIntervalMs <= 0) return;
this.cleanupTimeout = setTimeout(async () => {
await this.cleanupOldJobs();
this.scheduleCleanup();
}, this.cleanupIntervalMs);
}
private async cleanupOldJobs(now = new Date()): Promise<void> {
const cutoff = buildStartOfDay(now);
try {
const deletedCount = await this.jobModel.deleteBeforeScheduledAt(cutoff);
logDone(`JobProcessor: cleaned ${deletedCount} old notification jobs before ${cutoff.toISOString()}`);
} catch (error) {
logError(`JobProcessor: cleanup error before ${cutoff.toISOString()}`, error);
}
}
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,113 @@
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
);
}
async deleteBeforeScheduledAt(cutoff: Date): Promise<number> {
const result = await this.notificationJobList.deleteMany({
scheduledAt: { $lt: cutoff },
}).exec();
return result.deletedCount || 0;
}
}
@@ -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,274 @@
import { dispatchEmail, dispatchWhatsApp } from "../ChannelDispatchers.js";
import { buildPendingJobQuery, buildStartOfDay, 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("buildStartOfDay", () => {
it("returns the start of the local day", () => {
const result = buildStartOfDay(new Date(2026, 6, 21, 15, 30, 45, 123));
expect(result).toEqual(new Date(2026, 6, 21, 0, 0, 0, 0));
});
});
describe("old job cleanup", () => {
it("deletes jobs scheduled before the start of the current day", async () => {
const deleteBeforeScheduledAt = jest.fn().mockResolvedValue(3);
const now = new Date(2026, 6, 21, 15, 30, 0, 0);
await (JobProcessor.prototype as any).cleanupOldJobs.call(
{ jobModel: { deleteBeforeScheduledAt } },
now
);
expect(deleteBeforeScheduledAt).toHaveBeenCalledWith(new Date(2026, 6, 21, 0, 0, 0, 0));
});
});
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) => { 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) => { export const logWarn = (message: string) => {
+4 -4
View File
@@ -1,7 +1,7 @@
import dotenv from "dotenv"; import dotenv from "dotenv";
import logger, { logDone, logError, logIntent } from "./config/logger.js"; import logger, { logDone, logError, logIntent } from "./config/logger.js";
import mongoose from "mongoose"; import mongoose from "mongoose";
import { Notifications } from "./Models/Notifications/Notifications.js"; import { JobProcessor } from "./Models/Jobs/JobProcessor.js";
logIntent("Iniciando Notification Sender."); logIntent("Iniciando Notification Sender.");
@@ -28,10 +28,10 @@ mongoose
.then(() => { .then(() => {
const start = async () => { const start = async () => {
logDone("Conexión a MongoDB establecida correctamente."); logDone("Conexión a MongoDB establecida correctamente.");
logger.info("🚀 Notification Sender running"); logger.info("🚀 Notification Sender running (job-based polling)");
const worker = new Notifications(); const processor = new JobProcessor();
await worker.start(); await processor.startPolling();
}; };
start(); start();
@@ -0,0 +1,62 @@
# Tasks: Notification Preferences UI
## Review Workload Forecast
| Field | Value |
|-------|-------|
| Estimated changed lines | 1000-1200 |
| 400-line budget risk | High |
| Chained PRs recommended | Yes |
| Suggested split | PR 1 → PR 2 → PR 3 → PR 4 |
| Delivery strategy | ask-on-risk |
| Chain strategy | pending |
Decision needed before apply: Yes
Chained PRs recommended: Yes
Chain strategy: pending
400-line budget risk: High
### Suggested Work Units
| Unit | Goal | Likely PR | Focused test command | Runtime harness | Rollback boundary |
|------|------|-----------|----------------------|-----------------|-------------------|
| 1 | Foundation: models, API service, store, validation | PR 1 | `npm run typecheck` | N/A — types only | Remove service, store, model files |
| 2 | Org policy page + navigation | PR 2 | Navigate to `/admin/org/profile/[id]/notifications` | Org profile page loads, toggles render | Remove org notifications page and layout |
| 3 | Client override page + navigation | PR 3 | Navigate to `/admin/org/[oid]/client/[clientId]/notifications` | Client profile page loads, toggles render | Remove client notifications page and layout |
| 4 | Client-facing page + tests | PR 4 | Navigate to `/user/profile/notifications` | Landing profile page loads, toggles render | Remove landing notifications page and tests |
## Phase 1: Foundation
- [x] 1.1 Create `txclient/src/Models/NotificationPreferences.model.ts` — TypeScript interfaces: NotificationChannel, QuietHours, ReminderRule, CompanyNotificationPolicy, ClientNotificationPreferences, ClientCompanyNotificationOverride, upsert params
- [x] 1.2 Create `txclient/src/services/Notifications.Service.ts` — 6 API functions using `ApiRequest.post<T>()`: getCompanyPolicy, upsertCompanyPolicy, getClientPreferences, upsertClientPreferences, getClientCompanyOverride, upsertClientCompanyOverride
- [x] 1.3 Create `txclient/src/Store/NotificationPreferences.Store.ts` — Zustand store with mode, form data, dirty flag; actions: setMode, updateFormData, resetForm
- [x] 1.4 Create `txclient/src/Models/NotificationPreferences.validation.ts` — Yup schemas: companyPolicySchema (channels min 1, timezone required, quietHours optional HH:mm, reminderRules max 5), clientPrefsSchema (preferredChannels, mutedChannels)
## Phase 2: Org Policy Page
- [x] 2.1 Create `txclient/src/app/admin/(organization-profile)/org/profile/[id]/notifications/layout.tsx` — Saveable wrapper loading company policy on mount, dispatching NEED_SAVE/SAVE events
- [x] 2.2 Create `txclient/src/app/admin/(organization-profile)/org/profile/[id]/notifications/page.tsx` — Org policy form: channel toggles (3x Switch), quiet hours (2x input time), timezone Textbox, reminder rules dynamic list (max 5) with add/remove buttons
- [x] 2.3 Modify `txclient/src/app/components/OrganizationHeader/OrganizationHeader.tsx` — Add "Notificaciones" menu item linking to `/admin/org/profile/[id]/notifications` with NotificationsIcon
## Phase 3: Client Override Page
- [x] 3.1 Create `txclient/src/app/admin/(client-profile)/org/[oid]/client/[clientId]/notifications/layout.tsx` — Saveable wrapper loading client company override on mount
- [x] 3.2 Create `txclient/src/app/admin/(client-profile)/org/[oid]/client/[clientId]/notifications/page.tsx` — Client override form: preferred channels (3x Switch), muted channels (3x Switch)
- [x] 3.3 Modify `txclient/src/app/admin/(client-profile)/org/[oid]/client/[clientId]/layout.tsx` — Add "Notificaciones" navigation link to client profile menu
## Phase 4: Client-Facing Page
- [x] 4.1 Create `txclient/src/app/user/profile/notifications-preferences/page.tsx` — Client preferences form: preferred channels (3x Switch), muted channels (3x Switch); wrapped in Secure component for auth gate
- [x] 4.2 Modify `txclient/src/app/user/profile/page.tsx` — Add "Preferencias de notificación" navigation link to user profile menu
## Phase 5: Testing
- [ ] 5.1 Create `txclient/src/Models/__tests__/NotificationPreferences.validation.test.ts` — Test Yup schemas: valid inputs pass, invalid channels rejected, timezone required, quietHours format validated, reminderRules max 5 enforced (BLOCKED: no test runner in txclient)
- [ ] 5.2 Create `txclient/src/services/__tests__/Notifications.Service.test.ts` — Mock ApiRequest.post, verify 6 functions call correct endpoints with correct params (BLOCKED: no test runner in txclient)
- [ ] 5.3 Create `txclient/src/Store/__tests__/NotificationPreferences.Store.test.ts` — Test store actions: setMode, updateFormData, resetForm, dirty flag transitions (BLOCKED: no test runner in txclient)
## Phase 6: Cleanup (if needed)
- [x] 6.1 Remove any temporary console.log statements added during development
- [x] 6.2 Verify all TypeScript types compile without errors
- [x] 6.3 Confirm navigation links work in all three contexts (org, client, landing)
@@ -0,0 +1,90 @@
# Proposal: Notification System Redesign
## Intent
The current notification system has three critical limitations: (1) notifications are sent synchronously during appointment lifecycle operations, blocking the API response; (2) the notification-sender worker only handles today's appointments with a global delay (no per-company throttling for WhatsApp rate limits); and (3) there are no per-company or per-client notification preferences — notifications are either on or off for the entire company. This redesign decouples notification delivery from business operations into a job-based, multi-channel, policy-driven system.
## Scope
### In Scope
- New `NotificationJob` model: per-notification job records with status, channel, payload, retry state
- New `CompanyNotificationPolicy` model: org-level defaults for channels, scheduling windows, WhatsApp throttle config
- New `ClientNotificationPreferences` model: per-client opt-in/out per channel
- New `ClientCompanyNotificationOverride` model: per-client per-org overrides
- Policy resolution engine: Plan limits → Org override → Client prefs → Org default → System default
- Redesigned notification-sender worker: MongoDB polling, per-company WhatsApp throttle (816s), multi-channel dispatch
- Server integration: replace inline notification calls in `Appointments` with job creation
- Multi-channel support: WhatsApp (Baileys), Email (DonWeb), System (in-app + Socket.IO)
### Out of Scope
- SMS channel (plan feature exists but not implemented today)
- Notification templates / content builder
- Client-facing notification preferences UI
- Notification analytics / delivery reports
- Push notifications (mobile)
- Batch notification operations (e.g., "notify all clients about holiday")
## Capabilities
### New Capabilities
- `notification-jobs`: Job creation, status tracking, retry logic, and lifecycle management for notification delivery
- `notification-policies`: Company and client notification preferences, policy resolution hierarchy, channel configuration
- `notification-worker`: Multi-channel job processor with per-company throttling and MongoDB polling
- `notification-integration`: Server-side hooks that create notification jobs from appointment lifecycle events
### Modified Capabilities
None — no existing specs in `openspec/specs/`.
## Approach
**Server (job creation):** Replace inline `NotificationsManager.send*()` calls in `Appointments.ts` with `NotificationJobService.createJob()`. The service resolves the effective policy, checks plan limits, and persists a `NotificationJob` document. This makes API calls non-blocking.
**notification-sender (worker):** Poll MongoDB for `status: "pending"` jobs. Process per-company with configurable WhatsApp throttle (default 816s between messages for same company). Email and System channels have no throttle. Failed jobs retry up to 3x with exponential backoff.
**Policy resolution:** Resolve in order: Plan feature flags → `ClientCompanyNotificationOverride` (per-client per-org) → `ClientNotificationPreferences` (client global) → `CompanyNotificationPolicy` (org default) → System defaults (all channels on).
**Migration:** No data migration needed — no pending notifications exist. New models are additive.
## Affected Areas
| Area | Impact | Description |
|------|--------|-------------|
| `notification-sender/src/` | New | Complete rewrite: job polling, multi-channel dispatch, throttle |
| `server/src/Models/Notifications/` | Modified | Add job creation service; existing adapters remain for direct sends |
| `server/src/Models/Appointments/Appointments.ts` | Modified | Replace inline sends with job creation calls |
| `server/src/Models/Plans/Plans.interface.ts` | Unchanged | Plan features already define channel support |
| New: `server/src/Models/NotificationJobs/` | New | NotificationJob model + adapter |
| New: `server/src/Models/NotificationPolicies/` | New | Company/Client policy models + resolution engine |
| New: `notification-sender/src/Models/Jobs/` | New | Job polling model for worker |
## Risks
| Risk | Likelihood | Mitigation |
|------|------------|------------|
| WhatsApp rate limit hit during high-volume periods | High | Per-company throttle (816s), exponential backoff on failures |
| Job queue grows faster than worker processes | Medium | Monitor queue depth; add alerting on backlog threshold |
| Policy resolution adds latency to appointment creation | Low | Resolution is a simple DB query cascade; cache per-request |
| Breaking existing notification behavior during transition | Medium | Feature flag: keep inline sends as fallback during rollout |
## Rollback Plan
1. Disable job creation in server via feature flag (reverts to inline sends)
2. Stop notification-sender worker
3. Existing inline notification paths remain unchanged — no code removal needed until stable
4. Job collection can be truncated without data loss (jobs are ephemeral)
## Dependencies
- MongoDB (shared across all modules) — already available
- Baileys (txbot) — existing WhatsApp integration, no changes needed
- DonWeb email API — existing integration, no changes needed
- Socket.IO — existing in server, no changes needed
## Success Criteria
- [ ] Appointment creation/update/delete returns within normal latency (< 200ms)
- [ ] WhatsApp messages for same company spaced ≥ 8s apart
- [ ] Failed notifications retry up to 3x with exponential backoff
- [ ] Policy resolution correctly cascades through hierarchy
- [ ] Zero missed notifications during 24h soak test
- [ ] notification-sender processes backlog within 5 minutes under normal load
+34
View File
@@ -0,0 +1,34 @@
# SDD Configuration — turnosxpress
# Generated: 2026-07-21
context: |
Multi-module monorepo: Express+TypeScript server (tsoa, Mongoose, JWT),
Next.js 15 client (MUI, Zustand), WhatsApp bot (Baileys),
admin/sysadmin microservices, Android companion app.
MongoDB (Mongoose 8.x), OCI cloud storage, multi-region Docker deployment.
No workspace manager — independent modules.
strict_tdd: true
testing:
runner: jest
framework: Jest 30.x + ts-jest 29.x
command: "cd server && npx jest"
coverage: false
layers:
unit: true
integration: false
e2e: false
quality:
linter: "next lint (txclient), oxlint (sysadmin-cli)"
type_checker: tsc
formatter: none
phases:
proposal: mandatory
spec: mandatory
design: mandatory
tasks: mandatory
apply: mandatory
verify: mandatory
archive: mandatory
+3 -2
View File
@@ -45,7 +45,7 @@ MP_SUBSCRIPTION_FIND_URL = https://api.mercadopago.com/preapproval/
MP_ACCESS_TOKEN = APP_USR-8753424836448507-121412-731599daea1ffa0b278b5dcc78aa1ec8-1611134648 MP_ACCESS_TOKEN = APP_USR-8753424836448507-121412-731599daea1ffa0b278b5dcc78aa1ec8-1611134648
MP_BACK_URL = https://turnosxpress.com.ar/landing/dashboard MP_BACK_URL = https://turnosxpress.com.ar/landing/dashboard
MP_CHECK_PAYMENT_MINUTES = 1 MP_CHECK_PAYMENT_MINUTES = 1
MP_WEBHOOK_URL = https://5747-186-132-168-62.ngrok-free.app/mercadopago/webhook MP_WEBHOOK_URL = https://9300-186-132-200-176.ngrok-free.app/mercadopago/webhook
DEFAULT_BOT_ADMIN_PORT = 3005 DEFAULT_BOT_ADMIN_PORT = 3005
@@ -54,6 +54,7 @@ DONWEB_MAIL_API_KEY = eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpYXQiOjE3NDYxMTc3N
WAP_TIME_LIMIT = 5000 WAP_TIME_LIMIT = 5000
WAP_REMINDER = 🔔Hola <---CLIENTE---> <---BR---> 📌Te recuerdo que el <---FECHA---> te esperamos a las <---HORARIO---> ⏰ <---BR---> 🏫 En <---DIRECCION---> <---BR--->Con <---PROFESIONAL---> para <---SERVICIO---> <---BR--->Precio: <---PRECIO---> <---BR---> *<---ORGANIZACION--->* WAP_REMINDER = 🔔Hola <---CLIENTE---> <---BR---> 📌Te recuerdo que el <---FECHA---> te esperamos a las <---HORARIO---> ⏰ <---BR---> 🏫 En <---DIRECCION---> <---BR--->Con <---PROFESIONAL---> para <---SERVICIO---> <---BR--->Precio: <---PRECIO---> <---BR---> *<---ORGANIZACION--->*
WAP_ALTA = 🔔Hola <---CLIENTE---> <---BR---> 📌Has realizado una reserva para el día <---FECHA---> a las <---HORARIO---> ⏰ <---BR---> 🏫 En <---DIRECCION---> <---BR--->Con <---PROFESIONAL---> para <---SERVICIO---> <---BR--->Precio: <---PRECIO---> <---BR---> *<---ORGANIZACION--->* WAP_ALTA = 🔔Hola <---CLIENTE---> <---BR---> 📌Has realizado una reserva para el día <---FECHA---> a las <---HORARIO---> ⏰ <---BR---> 🏫 En <---DIRECCION---> <---BR--->Con <---PROFESIONAL---> para <---SERVICIO---> <---BR--->Precio: <---PRECIO---> <---BR---> *<---ORGANIZACION--->*
WAP_CANCELACION = 🔔Hola <---CLIENTE---> <---BR---> 📌Tu reserva para el día <---FECHA---> a las <---HORARIO---> fue cancelada. <---BR---> 🏫 En <---DIRECCION---> <---BR--->Con <---PROFESIONAL---> para <---SERVICIO---> <---BR---> *<---ORGANIZACION--->*
GOOGLE_CLIENT_ID = 252287289364-bu8mquhttsaa282egh8su2sumvkjqe58.apps.googleusercontent.com GOOGLE_CLIENT_ID = 252287289364-bu8mquhttsaa282egh8su2sumvkjqe58.apps.googleusercontent.com
@@ -67,4 +68,4 @@ SYSTEM_KEY = 753a2sd1du64-c412-3c3a-9a4hdr9-ac419213957e
FILE_SERVER_URL = http://localhost:4000/ FILE_SERVER_URL = http://localhost:4000/
FILE_SERVER_URL_prod = https://files.turnosxpress.com.ar/ FILE_SERVER_URL_prod = https://files.turnosxpress.com.ar/
SYS_ADMIN_API_PUBLIC_KEY = keys/tx-sysadmin-public.key SYS_ADMIN_API_PUBLIC_KEY = keys/tx-sysadmin-public.key
+2 -1
View File
@@ -53,6 +53,7 @@ DONWEB_MAIL_API_KEY = eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpYXQiOjE3NDYxMTc3N
WAP_TIME_LIMIT = 5000 WAP_TIME_LIMIT = 5000
WAP_REMINDER = 🔔Hola <---CLIENTE---> <---BR---> 📌Te recuerdo que el <---FECHA---> te esperamos a las <---HORARIO---> ⏰ <---BR---> 🏫 En <---DIRECCION---> <---BR--->Con <---PROFESIONAL---> para <---SERVICIO---> <---BR--->Precio: <---PRECIO---> <---BR---> *<---ORGANIZACION--->* WAP_REMINDER = 🔔Hola <---CLIENTE---> <---BR---> 📌Te recuerdo que el <---FECHA---> te esperamos a las <---HORARIO---> ⏰ <---BR---> 🏫 En <---DIRECCION---> <---BR--->Con <---PROFESIONAL---> para <---SERVICIO---> <---BR--->Precio: <---PRECIO---> <---BR---> *<---ORGANIZACION--->*
WAP_ALTA = 🔔Hola <---CLIENTE---> <---BR---> 📌Has realizado una reserva para el día <---FECHA---> a las <---HORARIO---> ⏰ <---BR---> 🏫 En <---DIRECCION---> <---BR--->Con <---PROFESIONAL---> para <---SERVICIO---> <---BR--->Precio: <---PRECIO---> <---BR---> *<---ORGANIZACION--->* WAP_ALTA = 🔔Hola <---CLIENTE---> <---BR---> 📌Has realizado una reserva para el día <---FECHA---> a las <---HORARIO---> ⏰ <---BR---> 🏫 En <---DIRECCION---> <---BR--->Con <---PROFESIONAL---> para <---SERVICIO---> <---BR--->Precio: <---PRECIO---> <---BR---> *<---ORGANIZACION--->*
WAP_CANCELACION = 🔔Hola <---CLIENTE---> <---BR---> 📌Tu reserva para el día <---FECHA---> a las <---HORARIO---> fue cancelada. <---BR---> 🏫 En <---DIRECCION---> <---BR--->Con <---PROFESIONAL---> para <---SERVICIO---> <---BR---> *<---ORGANIZACION--->*
GOOGLE_CLIENT_ID = 252287289364-bu8mquhttsaa282egh8su2sumvkjqe58.apps.googleusercontent.com GOOGLE_CLIENT_ID = 252287289364-bu8mquhttsaa282egh8su2sumvkjqe58.apps.googleusercontent.com
@@ -65,4 +66,4 @@ SYSTEM_KEY = 753a2sd1du64-c412-3c3a-9a4hdr9-ac419213957e
FILE_SERVER_URL = http://10.0.2.2:4000/ FILE_SERVER_URL = http://10.0.2.2:4000/
SYS_ADMIN_API_PUBLIC_KEY = keys/tx-sysadmin-public.key SYS_ADMIN_API_PUBLIC_KEY = keys/tx-sysadmin-public.key
+2 -1
View File
@@ -53,6 +53,7 @@ DONWEB_MAIL_API_KEY = eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpYXQiOjE3NDYxMTc3N
WAP_TIME_LIMIT = 5000 WAP_TIME_LIMIT = 5000
WAP_REMINDER = 🔔Hola <---CLIENTE---> <---BR---> 📌Te recuerdo que el <---FECHA---> te esperamos a las <---HORARIO---> ⏰ <---BR---> 🏫 En <---DIRECCION---> <---BR--->Con <---PROFESIONAL---> para <---SERVICIO---> <---BR--->Precio: <---PRECIO---> <---BR---> *<---ORGANIZACION--->* WAP_REMINDER = 🔔Hola <---CLIENTE---> <---BR---> 📌Te recuerdo que el <---FECHA---> te esperamos a las <---HORARIO---> ⏰ <---BR---> 🏫 En <---DIRECCION---> <---BR--->Con <---PROFESIONAL---> para <---SERVICIO---> <---BR--->Precio: <---PRECIO---> <---BR---> *<---ORGANIZACION--->*
WAP_ALTA = 🔔Hola <---CLIENTE---> <---BR---> 📌Has realizado una reserva para el día <---FECHA---> a las <---HORARIO---> ⏰ <---BR---> 🏫 En <---DIRECCION---> <---BR--->Con <---PROFESIONAL---> para <---SERVICIO---> <---BR--->Precio: <---PRECIO---> <---BR---> *<---ORGANIZACION--->* WAP_ALTA = 🔔Hola <---CLIENTE---> <---BR---> 📌Has realizado una reserva para el día <---FECHA---> a las <---HORARIO---> ⏰ <---BR---> 🏫 En <---DIRECCION---> <---BR--->Con <---PROFESIONAL---> para <---SERVICIO---> <---BR--->Precio: <---PRECIO---> <---BR---> *<---ORGANIZACION--->*
WAP_CANCELACION = 🔔Hola <---CLIENTE---> <---BR---> 📌Tu reserva para el día <---FECHA---> a las <---HORARIO---> fue cancelada. <---BR---> 🏫 En <---DIRECCION---> <---BR--->Con <---PROFESIONAL---> para <---SERVICIO---> <---BR---> *<---ORGANIZACION--->*
GOOGLE_CLIENT_ID = 252287289364-bu8mquhttsaa282egh8su2sumvkjqe58.apps.googleusercontent.com GOOGLE_CLIENT_ID = 252287289364-bu8mquhttsaa282egh8su2sumvkjqe58.apps.googleusercontent.com
@@ -65,4 +66,4 @@ SYSTEM_KEY = 753a2sd1du64-c412-3c3a-9a4hdr9-ac419213957e
FILE_SERVER_URL = http://localhost:4000/ FILE_SERVER_URL = http://localhost:4000/
SYS_ADMIN_API_PUBLIC_KEY = keys/tx-sysadmin-public.key SYS_ADMIN_API_PUBLIC_KEY = keys/tx-sysadmin-public.key
+2 -1
View File
@@ -53,6 +53,7 @@ WAP_TIME_LIMIT = 5000
WAP_REMINDER = 🔔Hola <---CLIENTE---> <---BR---> 📌Te recuerdo que el <---FECHA---> te esperamos a las <---HORARIO---> ⏰ <---BR---> 🏫 En <---DIRECCION---> <---BR--->Con <---PROFESIONAL---> para <---SERVICIO---> <---BR--->Precio: <---PRECIO---> <---BR---> *<---ORGANIZACION--->* WAP_REMINDER = 🔔Hola <---CLIENTE---> <---BR---> 📌Te recuerdo que el <---FECHA---> te esperamos a las <---HORARIO---> ⏰ <---BR---> 🏫 En <---DIRECCION---> <---BR--->Con <---PROFESIONAL---> para <---SERVICIO---> <---BR--->Precio: <---PRECIO---> <---BR---> *<---ORGANIZACION--->*
WAP_ALTA = 🔔Hola <---CLIENTE---> <---BR---> 📌Has realizado una reserva para el día <---FECHA---> a las <---HORARIO---> ⏰ <---BR---> 🏫 En <---DIRECCION---> <---BR--->Con <---PROFESIONAL---> para <---SERVICIO---> <---BR--->Precio: <---PRECIO---> <---BR---> *<---ORGANIZACION--->* WAP_ALTA = 🔔Hola <---CLIENTE---> <---BR---> 📌Has realizado una reserva para el día <---FECHA---> a las <---HORARIO---> ⏰ <---BR---> 🏫 En <---DIRECCION---> <---BR--->Con <---PROFESIONAL---> para <---SERVICIO---> <---BR--->Precio: <---PRECIO---> <---BR---> *<---ORGANIZACION--->*
WAP_CANCELACION = 🔔Hola <---CLIENTE---> <---BR---> 📌Tu reserva para el día <---FECHA---> a las <---HORARIO---> fue cancelada. <---BR---> 🏫 En <---DIRECCION---> <---BR--->Con <---PROFESIONAL---> para <---SERVICIO---> <---BR---> *<---ORGANIZACION--->*
GOOGLE_CLIENT_ID = 252287289364-bu8mquhttsaa282egh8su2sumvkjqe58.apps.googleusercontent.com GOOGLE_CLIENT_ID = 252287289364-bu8mquhttsaa282egh8su2sumvkjqe58.apps.googleusercontent.com
@@ -65,4 +66,4 @@ SYSTEM_KEY = ad7c956a-76bf-45hdr60-8a50-2ca7b2180997
FILE_SERVER_URL = https://files.turnosxpress.com.ar/ FILE_SERVER_URL = https://files.turnosxpress.com.ar/
SYS_ADMIN_API_PUBLIC_KEY = keys/tx-sysadmin-public.key SYS_ADMIN_API_PUBLIC_KEY = keys/tx-sysadmin-public.key
+822 -488
View File
File diff suppressed because it is too large Load Diff
+25 -25
View File
@@ -14,39 +14,39 @@
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"devDependencies": { "devDependencies": {
"@types/bcryptjs": "^2.4.2", "@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.13", "@types/cors": "^2.8.19",
"@types/express": "4.17.17", "@types/express": "^4.17.25",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/jsonwebtoken": "^9.0.2", "@types/jsonwebtoken": "^9.0.10",
"@types/sanitize-html": "^2.16.0", "@types/nodemailer": "^6.4.24",
"@types/socket.io": "^3.0.1", "@types/sanitize-html": "^2.16.1",
"@types/swagger-ui-express": "^4.1.3", "@types/socket.io": "^3.0.2",
"concurrently": "^8.2.0", "@types/swagger-ui-express": "^4.1.8",
"csv-parse": "^5.4.0", "concurrently": "^8.2.2",
"csv-parse": "^5.6.0",
"jest": "^30.4.2", "jest": "^30.4.2",
"ts-jest": "^29.4.11", "ts-jest": "^29.4.11",
"ts-node-dev": "^2.0.0", "ts-node-dev": "^2.0.0",
"typescript": "5.0.4" "typescript": "^5.9.3"
}, },
"dependencies": { "dependencies": {
"@types/nodemailer": "^6.4.16", "axios": "^1.18.1",
"axios": "^1.7.7",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"cors": "^2.8.5", "cors": "^2.8.6",
"dayjs": "^1.11.13", "dayjs": "^1.11.21",
"dotenv": "^16.0.3", "dotenv": "^16.6.1",
"express": "^4.21.1", "express": "^4.22.2",
"google-auth-library": "^10.1.0", "google-auth-library": "^10.9.0",
"joi": "^17.9.2", "joi": "^17.13.4",
"jsonwebtoken": "^9.0.1", "jsonwebtoken": "^9.0.3",
"mongoose": "^8.8.3", "mongoose": "8.8.3",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.2",
"nodemailer": "^6.9.15", "nodemailer": "^6.10.1",
"sanitize-html": "^2.17.0", "sanitize-html": "^2.17.6",
"sharp": "^0.33.5", "sharp": "^0.33.5",
"socket.io": "^4.8.1", "socket.io": "^4.8.3",
"swagger-ui-express": "^5.0.0", "swagger-ui-express": "^5.0.1",
"tsoa": "^5.1.1" "tsoa": "^5.1.1"
} }
} }
@@ -12,6 +12,7 @@ export type FindAppointmentsParams = {
clientId?: string; clientId?: string;
start?: Date; start?: Date;
dateDay?: Date; dateDay?: Date;
sessionUser?: string;
}; };
export type FindAppointmentsByUserParams = { export type FindAppointmentsByUserParams = {
@@ -35,9 +36,19 @@ export type FindAppointmentsByCollaboratorPaginatedParams = FindAppointmentsByCo
export enum APPOINTMENT_NOTIFICATION_TYPE { export enum APPOINTMENT_NOTIFICATION_TYPE {
"CREATION" = "creation", "CREATION" = "creation",
"REMINDER" = "reminder", "REMINDER" = "reminder",
"UPDATE" = "update",
"CANCELLATION" = "cancellation",
} }
export type SendAppointmentNotificationParams = { export type SendAppointmentNotificationParams = {
appointmentId: string;
sessionUser?: string;
systemToken?: string;
type: APPOINTMENT_NOTIFICATION_TYPE;
channel?: "whatsapp" | "email";
};
export type CreateImmediateAppointmentNotificationJobsParams = {
appointmentId: string; appointmentId: string;
sessionUser: string; sessionUser: string;
type: APPOINTMENT_NOTIFICATION_TYPE; type: APPOINTMENT_NOTIFICATION_TYPE;
@@ -62,6 +73,7 @@ export type ChangeServiceParams = {
export type SendWapSystemNotificationParams = { export type SendWapSystemNotificationParams = {
appointmentId: string; appointmentId: string;
systemToken: string; systemToken: string;
type: APPOINTMENT_NOTIFICATION_TYPE;
}; };
export type HeatMapConfig = { export type HeatMapConfig = {
@@ -120,6 +132,7 @@ export type UpdateAppointmentParams = {
export type DeleteAppointmentParams = { export type DeleteAppointmentParams = {
id: string; id: string;
validation?: boolean; validation?: boolean;
notification?: boolean;
sessionUser: string; sessionUser: string;
}; };
@@ -244,6 +257,7 @@ export interface AppointmentEventByClient {
serviceName: string; serviceName: string;
serviceDescription: string; serviceDescription: string;
collaboratorId: string; collaboratorId: string;
collaboratorUserId: string;
collaboratorName: string; collaboratorName: string;
collaboratorAvatar: string; collaboratorAvatar: string;
appointmentDate: string; appointmentDate: string;
@@ -313,6 +327,7 @@ export interface IAppointmentsManager {
getAvailableDates(data: GetAvailableDatesParams): Promise<GetAvailableDatesResult>; getAvailableDates(data: GetAvailableDatesParams): Promise<GetAvailableDatesResult>;
sendWapNotification(data: SendAppointmentNotificationParams): Promise<void>; sendWapNotification(data: SendAppointmentNotificationParams): Promise<void>;
sendEmailNotification(data: SendAppointmentNotificationParams): Promise<void>; sendEmailNotification(data: SendAppointmentNotificationParams): Promise<void>;
createImmediateNotificationJobs(data: CreateImmediateAppointmentNotificationJobsParams): Promise<void>;
sendWapSystemNotification(data: SendWapSystemNotificationParams): Promise<void>; sendWapSystemNotification(data: SendWapSystemNotificationParams): Promise<void>;
deleteAppointmentsByCompany(data: DeleteAppointmentsByCompanyParams): Promise<void>; deleteAppointmentsByCompany(data: DeleteAppointmentsByCompanyParams): Promise<void>;
changeEmployeeToOwner(data: ChangeEmployeeToOwnerParams): Promise<void>; changeEmployeeToOwner(data: ChangeEmployeeToOwnerParams): Promise<void>;
+516 -115
View File
@@ -10,6 +10,7 @@ import {
ChangeEmployeeToOwnerParams, ChangeEmployeeToOwnerParams,
CountAppointmentsByMonthParams, CountAppointmentsByMonthParams,
CreateAppointmentParams, CreateAppointmentParams,
CreateImmediateAppointmentNotificationJobsParams,
DeleteAppointmentDiscountParams, DeleteAppointmentDiscountParams,
DeleteAppointmentParams, DeleteAppointmentParams,
DeleteAppointmentsByCompanyParams, DeleteAppointmentsByCompanyParams,
@@ -70,12 +71,13 @@ import { DiscountType } from "../Discounts/Discounts.Interface";
import Discounts from "../Discounts/Discounts"; import Discounts from "../Discounts/Discounts";
import { validatePermissionsByCompany, validateSessionUser } from "../../helpers/check"; import { validatePermissionsByCompany, validateSessionUser } from "../../helpers/check";
import Templates from "../Templates/Templates"; import Templates from "../Templates/Templates";
import { CollaboratorSchedulesView } from "../Schedules/Schedules.Interface"; import { CollaboratorSchedulesView, ScheduleItem } from "../Schedules/Schedules.Interface";
import ClientAccount from "../ClientAccounts/ClientAccount"; import ClientAccount from "../ClientAccounts/ClientAccount";
import SchedulesDisabledList from "../SchedulesDisabled/SchedulesDisabled"; import SchedulesDisabledList from "../SchedulesDisabled/SchedulesDisabled";
import DiscountsEmail from "../DiscountsEmail/DiscountsEmail"; import DiscountsEmail from "../DiscountsEmail/DiscountsEmail";
import SchedulesOverridesList from "../SchedulesOverrides/SchedulesOverrides"; import SchedulesOverridesList from "../SchedulesOverrides/SchedulesOverrides";
import { NotificationType } from "../../Models/SystemNotifications/SystemNotification.Interface"; import { NotificationType } from "../../Models/SystemNotifications/SystemNotification.Interface";
import { NotificationJobService } from "../NotificationPolicies/NotificationJobService";
dayjs.locale("es"); dayjs.locale("es");
dayjs.extend(isSameOrAfter); dayjs.extend(isSameOrAfter);
@@ -84,9 +86,19 @@ dayjs.extend(weekday);
class AppointmentManager implements IAppointmentsManager { class AppointmentManager implements IAppointmentsManager {
Appointments: AppointmentsAdapterMongoose; Appointments: AppointmentsAdapterMongoose;
private jobService: NotificationJobService;
constructor() { constructor() {
this.Appointments = new AppointmentsAdapterMongoose(); this.Appointments = new AppointmentsAdapterMongoose();
this.jobService = new NotificationJobService();
}
private isScheduleAvailableForService(schedule: ScheduleItem, serviceId?: string): boolean {
if (!serviceId || !schedule.serviceScope || schedule.serviceScope === "all") {
return true;
}
return schedule.serviceScope === "specific" && (schedule.serviceIds || []).includes(serviceId);
} }
public async setPaymentStatus(data: SetPaymentStatusParams): Promise<void> { public async setPaymentStatus(data: SetPaymentStatusParams): Promise<void> {
@@ -305,12 +317,6 @@ class AppointmentManager implements IAppointmentsManager {
throw new Error("La compañia no existe"); throw new Error("La compañia no existe");
} }
const canAdd = await MetricsList.canAddAppointment(companyCheck.ownerId);
if (!canAdd) {
throw new Error("Ha alcanzado el limite de turnos permitidos de acuerdo a su plan.");
}
const serviceCheck = await ServicesManager.services.findOne({ const serviceCheck = await ServicesManager.services.findOne({
_id: data.serviceId, _id: data.serviceId,
companyId: data.companyId, companyId: data.companyId,
@@ -443,6 +449,18 @@ class AppointmentManager implements IAppointmentsManager {
data.comments = isNull<string>(data.comments, ""); data.comments = isNull<string>(data.comments, "");
const appointmentReserved = await MetricsList.reserveAppointment({
userId: String(companyCheck.ownerId),
companyId: data.companyId,
quantity: 1,
});
if (!appointmentReserved) {
throw new Error("Ha alcanzado el limite de turnos permitidos de acuerdo a su plan.");
}
let appointmentCreated = false;
try { try {
const start = dayjs(data.start).clone(); const start = dayjs(data.start).clone();
const end = dayjs(data.start) const end = dayjs(data.start)
@@ -467,6 +485,7 @@ class AppointmentManager implements IAppointmentsManager {
comments: data.comments, comments: data.comments,
sessionUser: data.sessionUser, sessionUser: data.sessionUser,
}); });
appointmentCreated = true;
if (data.repeatId) { if (data.repeatId) {
const repeat = await RepeatsList.repeats.findOne({ const repeat = await RepeatsList.repeats.findOne({
@@ -489,8 +508,9 @@ class AppointmentManager implements IAppointmentsManager {
} }
} }
await MetricsList.addAppointment({ await MetricsList.metrics.addAppointment({
userId: companyCheck.ownerId, userId: companyCheck.ownerId,
companyId: data.companyId,
quantity: 1, quantity: 1,
}); });
@@ -510,36 +530,49 @@ class AppointmentManager implements IAppointmentsManager {
code: String((newAppointment as any)._id) code: String((newAppointment as any)._id)
}); });
//Intento enviar notificacion al cliente por Email //Create jobs for email and whatsapp notifications
try { if (newAppointment.id) {
if (newAppointment.id) { const emailContent = await this.tryToSendNotification({
await this.sendEmailNotification({ appointmentId: String(newAppointment.id),
appointmentId: String(newAppointment.id), sessionUser: String(companyCheck.ownerId),
type: APPOINTMENT_NOTIFICATION_TYPE.CREATION, type: APPOINTMENT_NOTIFICATION_TYPE.CREATION,
sessionUser: String(companyCheck.ownerId), channel: "email",
}); });
} const wapContent = await this.tryToSendNotification({
} catch (err) { appointmentId: String(newAppointment.id),
console.log( sessionUser: String(companyCheck.ownerId),
`Error al enviar la notificacion por Email para ${companyCheck.name}(${companyCheck._id}):`, type: APPOINTMENT_NOTIFICATION_TYPE.CREATION,
err channel: "whatsapp",
); });
} const clientPhoneNumber = await this.getOptionalClientWapNumber(checkClient);
const reminderEmailContent = await this.tryToSendNotification({
appointmentId: String(newAppointment.id),
sessionUser: String(companyCheck.ownerId),
type: APPOINTMENT_NOTIFICATION_TYPE.REMINDER,
channel: "email",
});
const reminderWapContent = await this.tryToSendNotification({
appointmentId: String(newAppointment.id),
sessionUser: String(companyCheck.ownerId),
type: APPOINTMENT_NOTIFICATION_TYPE.REMINDER,
channel: "whatsapp",
});
//Intento enviar notificacion al cliente por Whatsapp await this.createCreationNotificationJobs({
try { appointmentId: String(newAppointment.id),
if (newAppointment.id) { companyId: String(companyCheck._id),
await this.sendWapNotification({ clientId: String(checkClient._id),
appointmentId: String(newAppointment.id), clientUserId: checkClient.userId ? String(checkClient.userId) : undefined,
type: APPOINTMENT_NOTIFICATION_TYPE.CREATION, clientEmail: checkClient.email,
sessionUser: String(companyCheck.ownerId), clientPhoneNumber,
}); companyOwnerId: String(companyCheck.ownerId),
} companyName: companyCheck.name,
} catch (err) { appointmentStart: new Date(data.start),
console.log( emailMessage: emailContent.message,
`Error al enviar la notificacion por Whatsapp para ${companyCheck.name}(${companyCheck._id}):`, wapMessage: wapContent.message,
err reminderEmailMessage: reminderEmailContent.message,
); reminderWapMessage: reminderWapContent.message,
});
} }
} }
@@ -559,6 +592,13 @@ class AppointmentManager implements IAppointmentsManager {
return newAppointment; return newAppointment;
} catch (err) { } catch (err) {
console.log(err); console.log(err);
if (!appointmentCreated) {
await MetricsList.releaseAppointment({
userId: String(companyCheck.ownerId),
companyId: data.companyId,
quantity: 1,
});
}
throw new Error("Ha ocurrido un error al crear el turno."); throw new Error("Ha ocurrido un error al crear el turno.");
} }
} }
@@ -920,6 +960,7 @@ class AppointmentManager implements IAppointmentsManager {
dateDay: dayjs(data.start).startOf("day").toDate(), dateDay: dayjs(data.start).startOf("day").toDate(),
from: checkFrom, from: checkFrom,
to: checkTo, to: checkTo,
serviceId: data.serviceId,
}); });
} else { } else {
//chequear que el colaborador este disponible. //chequear que el colaborador este disponible.
@@ -929,6 +970,7 @@ class AppointmentManager implements IAppointmentsManager {
weekDay: weekDay, weekDay: weekDay,
from: checkFrom, from: checkFrom,
to: checkTo, to: checkTo,
serviceId: data.serviceId,
}); });
} }
@@ -1037,6 +1079,9 @@ class AppointmentManager implements IAppointmentsManager {
continue; continue;
} }
// Historical read: Employee lookup includes removed employees (historical reads allow them)
// No snapshot fallback needed here as employee.calendarColor is used directly
const clientName = joinStrings([client.firstName, client.lastName], " "); const clientName = joinStrings([client.firstName, client.lastName], " ");
const clientUserName = userClient const clientUserName = userClient
? joinStrings([userClient.firstName, userClient.lastName], clientName) ? joinStrings([userClient.firstName, userClient.lastName], clientName)
@@ -1236,9 +1281,17 @@ class AppointmentManager implements IAppointmentsManager {
_id: String(employee.userId), _id: String(employee.userId),
}); });
if (!userEmployee) { // Historical read snapshot fallback: use Employee snapshot if User is deleted
throw new Error("No se ha encontrado el usuario del colaborador"); const collaboratorFirstName = userEmployee
} ? isNull<string>(userEmployee.firstName, "")
: isNull<string>(employee.profileSnapshot?.firstName, "");
const collaboratorLastName = userEmployee
? isNull<string>(userEmployee.lastName, "")
: isNull<string>(employee.profileSnapshot?.lastName, "");
const collaboratorFullName = joinStrings([collaboratorFirstName, collaboratorLastName], " ");
const collaboratorAvatar = userEmployee
? getAvatar(employee.userId, userEmployee.avatar, collaboratorFullName)
: (employee.profileSnapshot?.avatar || collaboratorFullName);
const service = await ServiceList.services.findOne({ const service = await ServiceList.services.findOne({
_id: String(appointment.serviceId), _id: String(appointment.serviceId),
@@ -1256,14 +1309,6 @@ class AppointmentManager implements IAppointmentsManager {
? getAvatar(client.userId, userClient.avatar, clientUserName) ? getAvatar(client.userId, userClient.avatar, clientUserName)
: clientUserName; : clientUserName;
const employeeFirstName = isNull<string>(userEmployee.firstName, "");
const employeeLastName = isNull<string>(userEmployee.lastName, "");
const employeeFullName = joinStrings([employeeFirstName, employeeLastName], " ");
const employeeAvatar = userEmployee
? getAvatar(employee.userId, userEmployee.avatar, employeeFullName)
: clientUserName;
const discountData = await Discounts.getDiscountData(appointment.discountId, appointment.companyId); const discountData = await Discounts.getDiscountData(appointment.discountId, appointment.companyId);
let ctaBalance = 0; let ctaBalance = 0;
@@ -1285,8 +1330,9 @@ class AppointmentManager implements IAppointmentsManager {
serviceName: service.name, serviceName: service.name,
serviceDescription: service.description, serviceDescription: service.description,
collaboratorId: employee.id, collaboratorId: employee.id,
collaboratorName: employeeFullName, collaboratorUserId: String(employee.userId),
collaboratorAvatar: employeeAvatar, collaboratorName: collaboratorFullName,
collaboratorAvatar: collaboratorAvatar,
appointmentDate: dayjs(appointment.start).toISOString(), appointmentDate: dayjs(appointment.start).toISOString(),
startTime: appointment.startHour, startTime: appointment.startHour,
endTime: appointment.endHour, endTime: appointment.endHour,
@@ -1394,6 +1440,7 @@ class AppointmentManager implements IAppointmentsManager {
serviceName: service.name, serviceName: service.name,
serviceDescription: service.description, serviceDescription: service.description,
collaboratorId: employee.id, collaboratorId: employee.id,
collaboratorUserId: String(employee.userId),
collaboratorName: employeeFullName, collaboratorName: employeeFullName,
collaboratorAvatar: employeeAvatar, collaboratorAvatar: employeeAvatar,
appointmentDate: dayjs(appointment.start).toISOString(), appointmentDate: dayjs(appointment.start).toISOString(),
@@ -1503,6 +1550,7 @@ class AppointmentManager implements IAppointmentsManager {
serviceName: service.name, serviceName: service.name,
serviceDescription: service.description, serviceDescription: service.description,
collaboratorId: employee.id, collaboratorId: employee.id,
collaboratorUserId: String(employee.userId),
collaboratorName: employeeFullName, collaboratorName: employeeFullName,
collaboratorAvatar: employeeAvatar, collaboratorAvatar: employeeAvatar,
appointmentDate: dayjs(appointment.start).toISOString(), appointmentDate: dayjs(appointment.start).toISOString(),
@@ -1621,6 +1669,7 @@ class AppointmentManager implements IAppointmentsManager {
serviceName: service.name, serviceName: service.name,
serviceDescription: service.description, serviceDescription: service.description,
collaboratorId: String(employee.id || (employee as any)._id), collaboratorId: String(employee.id || (employee as any)._id),
collaboratorUserId: String(employee.userId),
collaboratorName: employeeFullName, collaboratorName: employeeFullName,
collaboratorAvatar: employeeAvatar, collaboratorAvatar: employeeAvatar,
appointmentDate: dayjs(appointment.start).toISOString(), appointmentDate: dayjs(appointment.start).toISOString(),
@@ -1738,6 +1787,7 @@ class AppointmentManager implements IAppointmentsManager {
serviceName: service.name, serviceName: service.name,
serviceDescription: service.description, serviceDescription: service.description,
collaboratorId: employee.id, collaboratorId: employee.id,
collaboratorUserId: String(employee.userId),
collaboratorName: employeeFullName, collaboratorName: employeeFullName,
collaboratorAvatar: employeeAvatar, collaboratorAvatar: employeeAvatar,
appointmentDate: dayjs(appointment.start).toISOString(), appointmentDate: dayjs(appointment.start).toISOString(),
@@ -1865,6 +1915,7 @@ public async findAppointmentsByCollaboratorPaginated(
serviceName: service.name, serviceName: service.name,
serviceDescription: service.description, serviceDescription: service.description,
collaboratorId: String(employee.id || (employee as any)._id), collaboratorId: String(employee.id || (employee as any)._id),
collaboratorUserId: String(employee.userId),
collaboratorName: employeeFullName, collaboratorName: employeeFullName,
collaboratorAvatar: employeeAvatar, collaboratorAvatar: employeeAvatar,
appointmentDate: dayjs(appointment.start).toISOString(), appointmentDate: dayjs(appointment.start).toISOString(),
@@ -2036,36 +2087,50 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
code: String(checkAppointment._id) code: String(checkAppointment._id)
}); });
//Intento enviar notificacion al cliente por Email //Cancel existing pending jobs before creating new ones for the rescheduled appointment
try { if (checkAppointment.id) {
if (checkAppointment.id) { await this.jobService.cancelByAppointment(String(checkAppointment.id));
await this.sendEmailNotification({
appointmentId: String(checkAppointment.id),
type: APPOINTMENT_NOTIFICATION_TYPE.REMINDER,
sessionUser: String(companyCheck.ownerId),
});
}
} catch (err) {
console.log(
`Error al enviar la notificacion por Email para ${companyCheck.name}(${companyCheck._id}):`,
err
);
}
//Intento enviar notificacion al cliente por Whatsapp const emailContent = await this.tryToSendNotification({
try { appointmentId: String(checkAppointment.id),
if (checkAppointment.id) { sessionUser: String(companyCheck.ownerId),
await this.sendWapNotification({ type: APPOINTMENT_NOTIFICATION_TYPE.UPDATE,
appointmentId: String(checkAppointment.id), channel: "email",
type: APPOINTMENT_NOTIFICATION_TYPE.REMINDER, });
sessionUser: String(companyCheck.ownerId), const wapContent = await this.tryToSendNotification({
}); appointmentId: String(checkAppointment.id),
} sessionUser: String(companyCheck.ownerId),
} catch (err) { type: APPOINTMENT_NOTIFICATION_TYPE.UPDATE,
console.log( channel: "whatsapp",
`Error al enviar la notificacion por Whatsapp para ${companyCheck.name}(${companyCheck._id}):`, });
err const reminderEmailContent = await this.tryToSendNotification({
); appointmentId: String(checkAppointment.id),
sessionUser: String(companyCheck.ownerId),
type: APPOINTMENT_NOTIFICATION_TYPE.REMINDER,
channel: "email",
});
const reminderWapContent = await this.tryToSendNotification({
appointmentId: String(checkAppointment.id),
sessionUser: String(companyCheck.ownerId),
type: APPOINTMENT_NOTIFICATION_TYPE.REMINDER,
channel: "whatsapp",
});
await this.createUpdateNotificationJobs({
appointmentId: String(checkAppointment.id),
companyId: String(companyCheck._id),
clientId: String(checkClient._id),
clientUserId: checkClient.userId ? String(checkClient.userId) : undefined,
clientEmail: checkClient.email,
clientPhoneNumber: await this.getOptionalClientWapNumber(checkClient),
companyOwnerId: String(companyCheck.ownerId),
companyName: companyCheck.name,
appointmentStart: new Date(data.start),
emailMessage: emailContent.message,
wapMessage: wapContent.message,
reminderEmailMessage: reminderEmailContent.message,
reminderWapMessage: reminderWapContent.message,
});
} }
} }
@@ -2121,8 +2186,158 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
return config; return config;
} }
private async createCancellationNotificationJobs(data: {
appointmentId: string;
companyId: string;
clientId: string;
clientUserId?: string;
clientEmail?: string;
clientPhoneNumber?: string;
companyOwnerId: string;
companyName: string;
emailMessage: string;
wapMessage: string;
}): Promise<void> {
const systemSubject = `Turno cancelado en ${data.companyName}`;
await this.jobService.createJob({
companyId: data.companyId,
clientId: data.clientId,
appointmentId: data.appointmentId,
type: "cancellation",
scheduledAt: new Date(),
payload: {
email: data.clientEmail,
phoneNumber: data.clientPhoneNumber,
userId: data.clientUserId,
companyOwnerId: data.companyOwnerId,
subject: systemSubject,
message: data.emailMessage,
emailSubject: "TurnosXpress :: Turno cancelado",
emailMessage: data.emailMessage,
wapMessage: data.wapMessage,
systemSubject,
systemMessage: data.emailMessage,
},
});
}
private async createCreationNotificationJobs(data: {
appointmentId: string;
companyId: string;
clientId: string;
clientUserId?: string;
clientEmail?: string;
clientPhoneNumber?: string;
companyOwnerId: string;
companyName: string;
appointmentStart: Date;
emailMessage: string;
wapMessage: string;
reminderEmailMessage: string;
reminderWapMessage: string;
}): Promise<void> {
const systemSubject = `Turno reservado en ${data.companyName}`;
const reminderSystemSubject = `Recordatorio de turno en ${data.companyName}`;
await this.jobService.createJob({
companyId: data.companyId,
clientId: data.clientId,
appointmentId: data.appointmentId,
type: "creation",
appointmentStart: data.appointmentStart,
payload: {
email: data.clientEmail,
phoneNumber: data.clientPhoneNumber,
userId: data.clientUserId,
companyOwnerId: data.companyOwnerId,
subject: systemSubject,
message: data.emailMessage,
emailSubject: "TurnosXpress :: Alta de turno",
emailMessage: data.emailMessage,
wapMessage: data.wapMessage,
systemSubject,
systemMessage: data.emailMessage,
},
reminderPayload: {
email: data.clientEmail,
phoneNumber: data.clientPhoneNumber,
userId: data.clientUserId,
companyOwnerId: data.companyOwnerId,
subject: reminderSystemSubject,
message: data.reminderEmailMessage,
emailSubject: "TurnosXpress :: Recordatorio",
emailMessage: data.reminderEmailMessage,
wapMessage: data.reminderWapMessage,
systemSubject: reminderSystemSubject,
systemMessage: data.reminderEmailMessage,
},
});
}
private async createUpdateNotificationJobs(data: {
appointmentId: string;
companyId: string;
clientId: string;
clientUserId?: string;
clientEmail?: string;
clientPhoneNumber?: string;
companyOwnerId: string;
companyName: string;
appointmentStart: Date;
emailMessage: string;
wapMessage: string;
reminderEmailMessage: string;
reminderWapMessage: string;
}): Promise<void> {
const systemSubject = `Turno actualizado en ${data.companyName}`;
const reminderSystemSubject = `Recordatorio de turno en ${data.companyName}`;
await this.jobService.createJob({
companyId: data.companyId,
clientId: data.clientId,
appointmentId: data.appointmentId,
type: "update",
appointmentStart: data.appointmentStart,
payload: {
email: data.clientEmail,
phoneNumber: data.clientPhoneNumber,
userId: data.clientUserId,
companyOwnerId: data.companyOwnerId,
subject: systemSubject,
message: data.emailMessage,
emailSubject: "TurnosXpress :: Turno actualizado",
emailMessage: data.emailMessage,
wapMessage: data.wapMessage,
systemSubject,
systemMessage: data.emailMessage,
},
reminderPayload: {
email: data.clientEmail,
phoneNumber: data.clientPhoneNumber,
userId: data.clientUserId,
companyOwnerId: data.companyOwnerId,
subject: reminderSystemSubject,
message: data.reminderEmailMessage,
emailSubject: "TurnosXpress :: Recordatorio",
emailMessage: data.reminderEmailMessage,
wapMessage: data.reminderWapMessage,
systemSubject: reminderSystemSubject,
systemMessage: data.reminderEmailMessage,
},
});
}
private async getOptionalClientWapNumber(client: IClientDocument): Promise<string | undefined> {
try {
return await ClientsManager.getClientWapNumber(client);
} catch {
return undefined;
}
}
public async deleteAppointment(data: DeleteAppointmentParams): Promise<void> { public async deleteAppointment(data: DeleteAppointmentParams): Promise<void> {
const { validation = false } = data; const { validation = false, notification = false } = data;
const sessionUser = await UsersManager.users.findOne({ const sessionUser = await UsersManager.users.findOne({
_id: data.sessionUser, _id: data.sessionUser,
}); });
@@ -2200,23 +2415,41 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
try { try {
const config = this.getHeatMapConfig(checkAppointment, serviceCheck.length); const config = this.getHeatMapConfig(checkAppointment, serviceCheck.length);
// Cancel pending notification jobs for this appointment before deleting it
await this.jobService.cancelByAppointment(String(checkAppointment._id));
if (notification) {
const emailContent = await this.tryToSendNotification({
appointmentId: String(checkAppointment._id),
sessionUser: data.sessionUser,
type: APPOINTMENT_NOTIFICATION_TYPE.CANCELLATION,
channel: "email",
});
const wapContent = await this.tryToSendNotification({
appointmentId: String(checkAppointment._id),
sessionUser: data.sessionUser,
type: APPOINTMENT_NOTIFICATION_TYPE.CANCELLATION,
channel: "whatsapp",
});
await this.createCancellationNotificationJobs({
companyId: String(checkAppointment.companyId),
clientId: String(checkAppointment.clientId),
appointmentId: String(checkAppointment._id),
clientUserId: checkClient.userId ? String(checkClient.userId) : undefined,
clientEmail: checkClient.email,
clientPhoneNumber: await this.getOptionalClientWapNumber(checkClient),
companyOwnerId: String(companyCheck.ownerId),
companyName: companyCheck.name,
emailMessage: emailContent.message,
wapMessage: wapContent.message,
});
}
await this.Appointments.AppointmentList.deleteOne({ _id: data.id }); await this.Appointments.AppointmentList.deleteOne({ _id: data.id });
//Actualizar el heatmap. //Actualizar el heatmap.
await this.updateHeatMap(config); await this.updateHeatMap(config);
if (checkClient.userId) {
//Enviamos la notificacion de cancelacion al cliente
await NotificationsManager.sendSystemNotification({
userId: String(checkClient.userId),
subject: "Turno Cancelado",
message: `Se ha cancelado el turno para el día ${dayjs(checkAppointment.start).format(
"DD/MM/YYYY"
)} a las ${dayjs(checkAppointment.start).format("HH:mm")}`,
type: NotificationType.APPOINTMENT,
code: String(checkAppointment._id)
});
}
//Enviamos la notificacion de cancelacion al profesional //Enviamos la notificacion de cancelacion al profesional
await NotificationsManager.sendSystemNotification({ await NotificationsManager.sendSystemNotification({
userId: String(employeeCheck.userId), userId: String(employeeCheck.userId),
@@ -2271,6 +2504,10 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
continue; continue;
} }
if (!this.isScheduleAvailableForService(schedule, data.serviceId)) {
continue;
}
const scheduleDataFrom = schedule.from.split(":"); const scheduleDataFrom = schedule.from.split(":");
const scheduleDataTo = schedule.to.split(":"); const scheduleDataTo = schedule.to.split(":");
const hourFrom = parseInt(scheduleDataFrom[0]); const hourFrom = parseInt(scheduleDataFrom[0]);
@@ -2346,8 +2583,16 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
throw new Error("La compañia no existe"); throw new Error("La compañia no existe");
} }
if (!(await EmployeesList.checkPermission(companyCheck, data.sessionUser, EmployeeRoles.ADMIN))) { const hasSystemToken = this.hasValidSystemToken(data.systemToken);
throw new Error(NoPermissionMessage());
if (!hasSystemToken) {
if (!data.sessionUser) {
throw new Error("No se ha encontrado el usuario o no tiene permisos para realizar esta acción");
}
if (!(await EmployeesList.checkPermission(companyCheck, data.sessionUser, EmployeeRoles.ADMIN))) {
throw new Error(NoPermissionMessage());
}
} }
const checkClient = await ClientsManager.clients.findOne({ const checkClient = await ClientsManager.clients.findOne({
@@ -2427,9 +2672,13 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
wapMessage = wapMessage.replaceAll("<---ORGANIZACION--->", companyCheck.name); wapMessage = wapMessage.replaceAll("<---ORGANIZACION--->", companyCheck.name);
wapMessage = wapMessage.replaceAll("<---BR--->", "\r\n"); wapMessage = wapMessage.replaceAll("<---BR--->", "\r\n");
} else if (data.type == APPOINTMENT_NOTIFICATION_TYPE.CREATION) { } else if (data.type == APPOINTMENT_NOTIFICATION_TYPE.CREATION) {
if (companyCheck.templateWapAltaId) { const templateId = data.channel === "email"
? companyCheck.templateEmailAltaId || companyCheck.templateWapAltaId
: companyCheck.templateWapAltaId;
if (templateId) {
const template = await Templates.templates.findOne({ const template = await Templates.templates.findOne({
id: String(companyCheck.templateWapAltaId), id: String(templateId),
companyId: String(companyCheck._id), companyId: String(companyCheck._id),
}); });
@@ -2439,7 +2688,9 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
} }
if (!wapMessage) { if (!wapMessage) {
wapMessage = process.env.WAP_ALTA ? process.env.WAP_ALTA : ""; wapMessage = data.channel === "email"
? process.env.EMAIL_NEW_APPOINTMENT || process.env.EMAIL_ALTA || process.env.WAP_ALTA || ""
: process.env.WAP_ALTA || "";
} }
wapMessage = wapMessage.replaceAll( wapMessage = wapMessage.replaceAll(
@@ -2467,6 +2718,61 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
wapMessage = wapMessage.replaceAll("<---SERVICIO--->", checkService.name); wapMessage = wapMessage.replaceAll("<---SERVICIO--->", checkService.name);
wapMessage = wapMessage.replaceAll("<---PRECIO--->", formatCurrency(checkAppointment.price)); wapMessage = wapMessage.replaceAll("<---PRECIO--->", formatCurrency(checkAppointment.price));
wapMessage = wapMessage.replaceAll("<---ORGANIZACION--->", companyCheck.name);
wapMessage = wapMessage.replaceAll("<---BR--->", "\r\n");
} else if (data.type == APPOINTMENT_NOTIFICATION_TYPE.CANCELLATION || data.type == APPOINTMENT_NOTIFICATION_TYPE.UPDATE) {
const isUpdate = data.type == APPOINTMENT_NOTIFICATION_TYPE.UPDATE;
const templateId = data.channel === "email"
? isUpdate
? companyCheck.templateEmailAltaId || companyCheck.templateWapAltaId
: companyCheck.templateEmailCancellationId || companyCheck.templateWapCancellationId
: isUpdate
? companyCheck.templateWapAltaId
: companyCheck.templateWapCancellationId;
if (templateId) {
const template = await Templates.templates.findOne({
id: String(templateId),
companyId: String(companyCheck._id),
});
if (template) {
wapMessage = template.template;
}
}
if (!wapMessage) {
wapMessage = data.channel === "email"
? isUpdate
? process.env.EMAIL_NEW_APPOINTMENT || process.env.EMAIL_ALTA || process.env.WAP_ALTA || ""
: process.env.EMAIL_CANCELLATION || process.env.EMAIL_CANCELACION || process.env.WAP_CANCELLATION || process.env.WAP_CANCELACION || ""
: isUpdate
? process.env.WAP_ALTA || ""
: process.env.WAP_CANCELLATION || process.env.WAP_CANCELACION || "";
}
wapMessage = wapMessage.replaceAll(
"<---CLIENTE--->",
ClientsManager.getClientFullName(checkClient)
);
wapMessage = wapMessage.replaceAll(
"<---FECHA--->",
dayjs(checkAppointment.start).format("DD/MM/YYYY")
);
wapMessage = wapMessage.replaceAll(
"<---HORARIO--->",
dayjs(checkAppointment.start).format("HH:mm") + "hs."
);
wapMessage = wapMessage.replaceAll(
"<---DIRECCION--->",
CompaniesManager.getCompanyAddress(companyCheck)
);
wapMessage = wapMessage.replaceAll(
"<---PROFESIONAL--->",
UsersManager.getUserFullName(checkEmployeeUser)
);
wapMessage = wapMessage.replaceAll("<---SERVICIO--->", checkService.name);
wapMessage = wapMessage.replaceAll("<---PRECIO--->", formatCurrency(checkAppointment.price));
wapMessage = wapMessage.replaceAll("<---ORGANIZACION--->", companyCheck.name); wapMessage = wapMessage.replaceAll("<---ORGANIZACION--->", companyCheck.name);
wapMessage = wapMessage.replaceAll("<---BR--->", "\r\n"); wapMessage = wapMessage.replaceAll("<---BR--->", "\r\n");
} }
@@ -2479,15 +2785,24 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
} }
public async sendEmailNotification(data: SendAppointmentNotificationParams): Promise<void> { public async sendEmailNotification(data: SendAppointmentNotificationParams): Promise<void> {
const sessionUser = await UsersManager.users.findOne({ if (!this.hasValidSystemToken(data.systemToken)) {
_id: data.sessionUser, if (!data.sessionUser) {
}); throw new Error("No se ha encontrado el usuario o no tiene permisos para realizar esta acción");
}
if (!sessionUser) { const sessionUser = await UsersManager.users.findOne({
throw new Error("No se ha encontrado el usuario o no tiene permisos para realizar esta acción"); _id: data.sessionUser,
});
if (!sessionUser) {
throw new Error("No se ha encontrado el usuario o no tiene permisos para realizar esta acción");
}
} }
const { message, checkClient, companyCheck } = await this.tryToSendNotification(data); const { message, checkClient, companyCheck } = await this.tryToSendNotification({
...data,
channel: "email",
});
const canSend = await PlanSubscriptionsList.checkFeature({ const canSend = await PlanSubscriptionsList.checkFeature({
userId: String(companyCheck.ownerId), userId: String(companyCheck.ownerId),
@@ -2510,6 +2825,10 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
subjectEmail = "TurnosXpress :: Recordatorio"; subjectEmail = "TurnosXpress :: Recordatorio";
} else if (data.type == APPOINTMENT_NOTIFICATION_TYPE.CREATION) { } else if (data.type == APPOINTMENT_NOTIFICATION_TYPE.CREATION) {
subjectEmail = "TurnosXpress :: Alta de turno"; subjectEmail = "TurnosXpress :: Alta de turno";
} else if (data.type == APPOINTMENT_NOTIFICATION_TYPE.CANCELLATION) {
subjectEmail = "TurnosXpress :: Turno cancelado";
} else if (data.type == APPOINTMENT_NOTIFICATION_TYPE.UPDATE) {
subjectEmail = "TurnosXpress :: Turno actualizado";
} }
//Enviar email... //Enviar email...
@@ -2520,7 +2839,66 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
}); });
} }
public async createImmediateNotificationJobs(data: CreateImmediateAppointmentNotificationJobsParams): Promise<void> {
if (![APPOINTMENT_NOTIFICATION_TYPE.CREATION, APPOINTMENT_NOTIFICATION_TYPE.REMINDER].includes(data.type)) {
throw new Error("El tipo de notificación no es válido para esta acción");
}
const emailContent = await this.tryToSendNotification({
appointmentId: data.appointmentId,
sessionUser: data.sessionUser,
type: data.type,
channel: "email",
});
const wapContent = await this.tryToSendNotification({
appointmentId: data.appointmentId,
sessionUser: data.sessionUser,
type: data.type,
channel: "whatsapp",
});
const checkAppointment = await this.Appointments.findOne({
_id: data.appointmentId,
});
if (!checkAppointment) {
throw new Error("El turno no existe");
}
const checkClient = emailContent.checkClient;
const companyCheck = emailContent.companyCheck;
const isCreation = data.type === APPOINTMENT_NOTIFICATION_TYPE.CREATION;
const systemSubject = isCreation
? `Turno reservado en ${companyCheck.name}`
: `Recordatorio de turno en ${companyCheck.name}`;
await this.jobService.createImmediateJob({
companyId: String(checkAppointment.companyId),
clientId: String(checkAppointment.clientId),
appointmentId: String(checkAppointment._id),
type: data.type,
scheduledAt: new Date(),
payload: {
email: checkClient.email,
phoneNumber: await this.getOptionalClientWapNumber(checkClient),
userId: checkClient.userId ? String(checkClient.userId) : undefined,
companyOwnerId: String(companyCheck.ownerId),
subject: systemSubject,
message: emailContent.message,
emailSubject: isCreation ? "TurnosXpress :: Alta de turno" : "TurnosXpress :: Recordatorio",
emailMessage: emailContent.message,
wapMessage: wapContent.message,
systemSubject,
systemMessage: emailContent.message,
},
});
}
public async sendWapNotification(data: SendAppointmentNotificationParams): Promise<void> { public async sendWapNotification(data: SendAppointmentNotificationParams): Promise<void> {
if (!data.sessionUser) {
throw new Error("No se ha encontrado el usuario o no tiene permisos para realizar esta acción");
}
const sessionUser = await UsersManager.users.findOne({ const sessionUser = await UsersManager.users.findOne({
_id: data.sessionUser, _id: data.sessionUser,
}); });
@@ -2529,7 +2907,10 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
throw new Error("No se ha encontrado el usuario o no tiene permisos para realizar esta acción"); throw new Error("No se ha encontrado el usuario o no tiene permisos para realizar esta acción");
} }
const { message, checkClient, companyCheck } = await this.tryToSendNotification(data); const { message, checkClient, companyCheck } = await this.tryToSendNotification({
...data,
channel: "whatsapp",
});
const canSend = await PlanSubscriptionsList.checkFeature({ const canSend = await PlanSubscriptionsList.checkFeature({
userId: String(companyCheck.ownerId), userId: String(companyCheck.ownerId),
@@ -2555,13 +2936,7 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
} }
public async sendWapSystemNotification(data: SendWapSystemNotificationParams): Promise<void> { public async sendWapSystemNotification(data: SendWapSystemNotificationParams): Promise<void> {
if (!data.systemToken || data.systemToken == "") { if (!this.hasValidSystemToken(data.systemToken)) {
throw new Error("No tiene permisos para realizar esta acción");
}
const systemToken = `${process.env.SYSTEM_KEY}`;
if (systemToken != data.systemToken) {
throw new Error("No tiene permisos para realizar esta acción"); throw new Error("No tiene permisos para realizar esta acción");
} }
@@ -2584,7 +2959,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
const { message, checkClient, companyCheck } = await this.tryToSendNotification({ const { message, checkClient, companyCheck } = await this.tryToSendNotification({
appointmentId: data.appointmentId, appointmentId: data.appointmentId,
sessionUser: String(company.ownerId), sessionUser: String(company.ownerId),
type: APPOINTMENT_NOTIFICATION_TYPE.REMINDER, systemToken: data.systemToken,
type: data.type,
}); });
const canSend = await PlanSubscriptionsList.checkFeature({ const canSend = await PlanSubscriptionsList.checkFeature({
@@ -2743,7 +3119,17 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
date: currentDate.toDate(), date: currentDate.toDate(),
}); });
if (hasOverride && hasOverride.schedules.length > 0) { if (
hasOverride &&
hasOverride.schedules.some(
(schedule) =>
!schedule.disabled &&
(!schedule.serviceScope ||
schedule.serviceScope === "all" ||
(schedule.serviceScope === "specific" &&
(schedule.serviceIds || []).includes(data.serviceId)))
)
) {
returnAvailableDates.push(currentDate.toDate()); returnAvailableDates.push(currentDate.toDate());
} else if (!hasOverride) { } else if (!hasOverride) {
// Check if the current date is within any enabled schedule range // Check if the current date is within any enabled schedule range
@@ -2766,7 +3152,17 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
weekDay: currentDate.day(), weekDay: currentDate.day(),
}); });
if (schedule && schedule.schedules.length > 0) { if (
schedule &&
schedule.schedules.some(
(scheduleItem) =>
!scheduleItem.disabled &&
(!scheduleItem.serviceScope ||
scheduleItem.serviceScope === "all" ||
(scheduleItem.serviceScope === "specific" &&
(scheduleItem.serviceIds || []).includes(data.serviceId)))
)
) {
returnAvailableDates.push(currentDate.toDate()); returnAvailableDates.push(currentDate.toDate());
} }
} }
@@ -2780,6 +3176,11 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
inRangeDates: [], inRangeDates: [],
} as GetAvailableDatesResult; } as GetAvailableDatesResult;
} }
private hasValidSystemToken(systemToken?: string): boolean {
const expectedSystemToken = process.env.SYSTEM_KEY || process.env.API_KEY;
return Boolean(systemToken && expectedSystemToken && systemToken === expectedSystemToken);
}
} }
const AppointmentList = new AppointmentManager(); const AppointmentList = new AppointmentManager();
@@ -0,0 +1,751 @@
/**
* Tests for Appointments.getAppointmentEvent snapshot fallback
* when a removed employee's User account has been deleted.
*
* The fallback uses Employee.profileSnapshot fields instead of crashing.
*/
// Mock all external dependencies
jest.mock("../../Users/Users", () => ({
__esModule: true,
default: {
users: {
findOne: jest.fn(),
},
getUserFullName: jest.fn(),
},
}));
jest.mock("../../Clients/Clients", () => ({
__esModule: true,
default: {
clients: {
findOne: jest.fn(),
},
getClientFullName: jest.fn(),
},
}));
jest.mock("../../Companies/Companies", () => ({
__esModule: true,
default: {
companies: {
findOne: jest.fn(),
},
getCompanyAddress: jest.fn(),
},
}));
jest.mock("../../Employees/Employee", () => ({
__esModule: true,
default: {
employees: {
findOne: jest.fn(),
},
checkPermission: jest.fn(),
},
}));
jest.mock("../../Services/Service", () => ({
__esModule: true,
default: {
services: {
find: jest.fn(),
findOne: jest.fn(),
},
},
}));
jest.mock("../../HeatMap/HeatMap", () => ({
__esModule: true,
default: {
getHeatMap: jest.fn(),
addHeatMap: jest.fn(),
},
}));
jest.mock("../../Schedules/Schedules", () => ({
__esModule: true,
default: {
find: jest.fn(),
},
}));
jest.mock("../../SchedulesEnabled/SchedulesEnabled", () => ({
__esModule: true,
default: {
findOne: jest.fn(),
},
}));
jest.mock("../../SchedulesDisabled/SchedulesDisabled", () => ({
__esModule: true,
default: {
find: jest.fn(),
},
}));
jest.mock("../../SchedulesOverrides/SchedulesOverrides", () => ({
__esModule: true,
default: {
find: jest.fn(),
},
}));
jest.mock("../../Metrics/Metrics", () => ({
__esModule: true,
default: {
addAppointment: jest.fn(),
canAddAppointment: jest.fn(),
},
}));
jest.mock("../../Notifications/Notifications", () => ({
__esModule: true,
NotificationsManager: {
sendSystemNotification: jest.fn(),
sendPushNotification: jest.fn(),
sendEmail: jest.fn(),
},
}));
jest.mock("../../SystemNotifications/SystemNotification", () => ({
__esModule: true,
default: {
create: jest.fn(),
},
}));
jest.mock("../../PlanSubscriptions/PlanSubscriptons", () => ({
__esModule: true,
default: {
findOne: jest.fn(),
checkFeature: jest.fn(),
},
}));
jest.mock("../../Repeats/Repeats", () => ({
__esModule: true,
default: {
repeats: {
find: jest.fn().mockResolvedValue([]),
findOne: jest.fn(),
},
existDisabledDate: jest.fn(),
},
}));
jest.mock("../../Discounts/Discounts", () => ({
__esModule: true,
default: {
getDiscountData: jest.fn().mockResolvedValue({
name: "",
value: 0,
type: "PERCENTAGE",
code: "",
}),
},
}));
jest.mock("../../../helpers/check", () => ({
validatePermissionsByCompany: jest.fn(),
validateSessionUser: jest.fn(),
}));
jest.mock("../../Templates/Templates", () => ({
__esModule: true,
default: {
templates: {
findOne: jest.fn(),
},
sendEmail: jest.fn(),
},
}));
jest.mock("../../ClientAccounts/ClientAccount", () => ({
__esModule: true,
default: {
getAvailableAmount: jest.fn().mockResolvedValue(0),
},
}));
jest.mock("../../DiscountsEmail/DiscountsEmail", () => ({
__esModule: true,
default: {
findOne: jest.fn(),
},
}));
jest.mock("../../../index", () => ({
io: {
to: jest.fn(() => ({ emit: jest.fn() })),
},
}));
import AppointmentsList from "../Appointments";
import UsersManager from "../../Users/Users";
import EmployeesList from "../../Employees/Employee";
import ClientsManager from "../../Clients/Clients";
import ServiceList from "../../Services/Service";
import CompaniesManager from "../../Companies/Companies";
import Templates from "../../Templates/Templates";
import PlanSubscriptionsList from "../../PlanSubscriptions/PlanSubscriptons";
import { NotificationsManager } from "../../Notifications/Notifications";
import { APPOINTMENT_NOTIFICATION_TYPE } from "../Appointments.Interface";
describe("getAppointmentEvent — historical read snapshot fallback", () => {
let originalAdapter: any;
beforeEach(() => {
jest.clearAllMocks();
// Save and replace adapter
originalAdapter = AppointmentsList.Appointments;
const mockAppointmentFindOne = jest.fn();
(AppointmentsList as any).Appointments = {
find: jest.fn(),
findOne: mockAppointmentFindOne,
AppointmentList: {
findOne: mockAppointmentFindOne,
find: jest.fn(),
},
};
});
afterEach(() => {
(AppointmentsList as any).Appointments = originalAdapter;
});
it("falls back to Employee snapshot when User account is deleted", async () => {
const mockAppointment = {
_id: "appt-001",
id: "appt-001",
companyId: "company-001",
clientId: "client-001",
employeeId: "emp-removed",
serviceId: "svc-001",
start: new Date("2026-07-20T10:00:00Z"),
startHour: 10,
endHour: 11,
dateDay: new Date("2026-07-20"),
price: 100,
length: 60,
payment: false,
comments: "",
discountId: undefined,
};
const mockClient = {
_id: "client-001",
id: "client-001",
userId: "client-user-001",
firstName: "Client",
lastName: "Smith",
};
const mockClientUser = {
_id: "client-user-001",
id: "client-user-001",
firstName: "Client",
lastName: "Smith",
avatar: "client-avatar",
};
// Employee exists (removed, with snapshot)
const mockRemovedEmployee = {
_id: "emp-removed",
id: "emp-removed",
companyId: "company-001",
userId: "deleted-user-id",
calendarColor: "#ff0000",
removed: true,
profileSnapshot: {
firstName: "John",
lastName: "Doe",
email: "john@example.com",
avatar: "old-avatar-url",
},
};
// Mock adapter.findOne for the appointment
const mockAppointmentFindOne = (AppointmentsList as any).Appointments.AppointmentList.findOne;
mockAppointmentFindOne.mockResolvedValue(mockAppointment);
// Mock lookups
(ClientsManager.clients.findOne as jest.Mock).mockResolvedValue(mockClient);
(ServiceList.services.findOne as jest.Mock).mockResolvedValue({
_id: "svc-001",
name: "Haircut",
description: "Professional haircut",
});
// Call order for UsersManager.users.findOne (same mock as UserList):
// 1. Line 1217: UserList.users.findOne (NOT awaited, but consumes a value)
// 2. Line 1241: UsersManager.users.findOne for userClient
// 3. Line 1253: UsersManager.users.findOne for userEmployee
(UsersManager.users.findOne as jest.Mock)
.mockResolvedValueOnce({ _id: "session-user" }) // line 1217: not awaited, just needs truthy
.mockResolvedValueOnce(mockClientUser) // line 1241: client user
.mockResolvedValueOnce(null); // line 1253: employee user DELETED → triggers fallback
(EmployeesList.employees.findOne as jest.Mock).mockResolvedValue(mockRemovedEmployee);
(EmployeesList as any).findById = jest.fn().mockResolvedValue({
id: "emp-removed",
fullName: "John Doe",
avatar: "old-avatar-url",
});
const result = await AppointmentsList.getAppointmentEvent({
appointmentId: "appt-001",
sessionUser: "session-user",
});
// Verify snapshot fields are used for collaboratorName
expect(result.collaboratorName).toBe("John Doe");
// Verify snapshot avatar is used
expect(result.collaboratorAvatar).toBe("old-avatar-url");
// Verify collaboratorId comes from employee, not user
expect(result.collaboratorId).toBe("emp-removed");
});
it("uses live User data when User account still exists", async () => {
const mockAppointment = {
_id: "appt-002",
id: "appt-002",
companyId: "company-001",
clientId: "client-001",
employeeId: "emp-active",
serviceId: "svc-001",
start: new Date("2026-07-20T10:00:00Z"),
startHour: 10,
endHour: 11,
dateDay: new Date("2026-07-20"),
price: 100,
length: 60,
payment: false,
comments: "",
discountId: undefined,
};
const mockClient = {
_id: "client-001",
id: "client-001",
userId: "client-user-001",
firstName: "Client",
lastName: "Smith",
};
const mockClientUser = {
_id: "client-user-001",
id: "client-user-001",
firstName: "Client",
lastName: "Smith",
avatar: "client-avatar",
};
const mockActiveEmployee = {
_id: "emp-active",
id: "emp-active",
companyId: "company-001",
userId: "active-user-id",
calendarColor: "#00ff00",
removed: false,
profileSnapshot: undefined,
};
const mockActiveUser = {
_id: "active-user-id",
id: "active-user-id",
firstName: "Jane",
lastName: "Roe",
avatar: "live-avatar",
email: "jane@example.com",
};
const mockAppointmentFindOne = (AppointmentsList as any).Appointments.AppointmentList.findOne;
mockAppointmentFindOne.mockResolvedValue(mockAppointment);
(ClientsManager.clients.findOne as jest.Mock).mockResolvedValue(mockClient);
(ServiceList.services.findOne as jest.Mock).mockResolvedValue({
_id: "svc-001",
name: "Haircut",
description: "Professional haircut",
});
// Call order for UsersManager.users.findOne (same mock as UserList):
// 1. Line 1217: UserList.users.findOne (NOT awaited, but consumes a value)
// 2. Line 1241: UsersManager.users.findOne for userClient
// 3. Line 1253: UsersManager.users.findOne for userEmployee
(UsersManager.users.findOne as jest.Mock)
.mockResolvedValueOnce({ _id: "session-user" }) // line 1217: not awaited
.mockResolvedValueOnce(mockClientUser) // line 1241: client user
.mockResolvedValueOnce(mockActiveUser); // line 1253: live user exists
(EmployeesList.employees.findOne as jest.Mock).mockResolvedValue(mockActiveEmployee);
(EmployeesList as any).findById = jest.fn().mockResolvedValue({
id: "emp-active",
fullName: "Jane Roe",
avatar: "live-avatar",
});
const result = await AppointmentsList.getAppointmentEvent({
appointmentId: "appt-002",
sessionUser: "session-user",
});
// Live user data is used, not snapshot
expect(result.collaboratorName).toBe("Jane Roe");
});
});
describe("cancellation notification jobs", () => {
let originalAdapter: any;
let originalJobService: any;
beforeEach(() => {
jest.clearAllMocks();
originalAdapter = AppointmentsList.Appointments;
originalJobService = (AppointmentsList as any).jobService;
(AppointmentsList as any).Appointments = {
findOne: jest.fn().mockResolvedValue({
_id: "appt-001",
companyId: "company-001",
clientId: "client-001",
employeeId: "employee-001",
serviceId: "service-001",
start: new Date("2026-07-22T11:00:00.000Z"),
price: 1500,
}),
};
(AppointmentsList as any).jobService = {
createJob: jest.fn().mockResolvedValue([]),
};
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
_id: "company-001",
name: "clases llavallol",
ownerId: "owner-001",
templateEmailCancellationId: "email-template-001",
templateWapCancellationId: "wap-template-001",
address: "Av. Siempre Viva 123",
});
(CompaniesManager.getCompanyAddress as jest.Mock).mockReturnValue("Av. Siempre Viva 123");
(ClientsManager.clients.findOne as jest.Mock).mockResolvedValue({
_id: "client-001",
userId: "client-user-001",
firstName: "Ada",
lastName: "Lovelace",
email: "ada@example.com",
});
(ClientsManager.getClientFullName as jest.Mock).mockReturnValue("Ada Lovelace");
(EmployeesList.employees.findOne as jest.Mock).mockResolvedValue({
_id: "employee-001",
userId: "employee-user-001",
});
(EmployeesList.checkPermission as jest.Mock | undefined)?.mockResolvedValue?.(true);
(UsersManager.users.findOne as jest.Mock).mockResolvedValue({
_id: "employee-user-001",
firstName: "Grace",
lastName: "Hopper",
});
(UsersManager.getUserFullName as jest.Mock).mockReturnValue("Grace Hopper");
(ServiceList.services.findOne as jest.Mock).mockResolvedValue({
_id: "service-001",
name: "Contabilidad",
});
(Templates.templates.findOne as jest.Mock).mockImplementation(({ id }) => {
if (id === "email-template-001") {
return Promise.resolve({
template: "EMAIL CANCEL <---CLIENTE---> <---SERVICIO---> <---ORGANIZACION---> <---FECHA---> <---HORARIO--->",
});
}
if (id === "wap-template-001") {
return Promise.resolve({
template: "WAP CANCEL <---CLIENTE---> <---SERVICIO---> <---ORGANIZACION---> <---FECHA---> <---HORARIO--->",
});
}
return Promise.resolve(null);
});
});
afterEach(() => {
(AppointmentsList as any).Appointments = originalAdapter;
(AppointmentsList as any).jobService = originalJobService;
});
it("stores organization-rendered cancellation template content per channel", async () => {
const emailContent = await (AppointmentsList as any).tryToSendNotification({
appointmentId: "appt-001",
sessionUser: "owner-001",
type: "cancellation",
channel: "email",
});
const wapContent = await (AppointmentsList as any).tryToSendNotification({
appointmentId: "appt-001",
sessionUser: "owner-001",
type: "cancellation",
channel: "whatsapp",
});
await (AppointmentsList as any).createCancellationNotificationJobs({
appointmentId: "appt-001",
companyId: "company-001",
clientId: "client-001",
clientUserId: "client-user-001",
clientEmail: "ada@example.com",
clientPhoneNumber: "5491112345678",
companyOwnerId: "owner-001",
companyName: "clases llavallol",
emailMessage: emailContent.message,
wapMessage: wapContent.message,
});
expect((AppointmentsList as any).jobService.createJob).toHaveBeenCalledWith(
expect.objectContaining({
type: "cancellation",
payload: expect.objectContaining({
email: "ada@example.com",
phoneNumber: "5491112345678",
userId: "client-user-001",
companyOwnerId: "owner-001",
emailSubject: "TurnosXpress :: Turno cancelado",
emailMessage: "EMAIL CANCEL Ada Lovelace Contabilidad clases llavallol 22/07/2026 08:00hs.",
wapMessage: "WAP CANCEL Ada Lovelace Contabilidad clases llavallol 22/07/2026 08:00hs.",
systemSubject: "Turno cancelado en clases llavallol",
systemMessage: "EMAIL CANCEL Ada Lovelace Contabilidad clases llavallol 22/07/2026 08:00hs.",
}),
})
);
});
it("sends update email notifications with a non-empty subject", async () => {
(PlanSubscriptionsList.checkFeature as jest.Mock).mockResolvedValue(true);
await (AppointmentsList as any).sendEmailNotification({
appointmentId: "appt-001",
sessionUser: "owner-001",
type: APPOINTMENT_NOTIFICATION_TYPE.UPDATE,
});
expect(NotificationsManager.sendEmail).toHaveBeenCalledWith(
expect.objectContaining({
email: "ada@example.com",
subject: "TurnosXpress :: Turno actualizado",
})
);
});
});
describe("creation notification jobs", () => {
let originalAdapter: any;
let originalJobService: any;
beforeEach(() => {
jest.clearAllMocks();
originalAdapter = AppointmentsList.Appointments;
originalJobService = (AppointmentsList as any).jobService;
(AppointmentsList as any).Appointments = {
findOne: jest.fn().mockResolvedValue({
_id: "appt-001",
companyId: "company-001",
clientId: "client-001",
employeeId: "employee-001",
serviceId: "service-001",
start: new Date("2026-07-22T11:00:00.000Z"),
price: 1500,
}),
};
(AppointmentsList as any).jobService = {
createJob: jest.fn().mockResolvedValue([]),
};
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
_id: "company-001",
name: "clases llavallol",
ownerId: "owner-001",
templateEmailAltaId: "email-alta-template-001",
templateWapAltaId: "wap-alta-template-001",
address: "Av. Siempre Viva 123",
});
(CompaniesManager.getCompanyAddress as jest.Mock).mockReturnValue("Av. Siempre Viva 123");
(ClientsManager.clients.findOne as jest.Mock).mockResolvedValue({
_id: "client-001",
userId: "client-user-001",
firstName: "Ada",
lastName: "Lovelace",
email: "ada@example.com",
});
(ClientsManager.getClientFullName as jest.Mock).mockReturnValue("Ada Lovelace");
(EmployeesList.employees.findOne as jest.Mock).mockResolvedValue({
_id: "employee-001",
userId: "employee-user-001",
});
(EmployeesList.checkPermission as jest.Mock | undefined)?.mockResolvedValue?.(true);
(UsersManager.users.findOne as jest.Mock).mockResolvedValue({
_id: "employee-user-001",
firstName: "Grace",
lastName: "Hopper",
});
(UsersManager.getUserFullName as jest.Mock).mockReturnValue("Grace Hopper");
(ServiceList.services.findOne as jest.Mock).mockResolvedValue({
_id: "service-001",
name: "Historia",
});
(Templates.templates.findOne as jest.Mock).mockImplementation(({ id }) => {
if (id === "email-alta-template-001") {
return Promise.resolve({
template: "EMAIL ALTA <---CLIENTE---> <---SERVICIO---> <---ORGANIZACION---> <---FECHA---> <---HORARIO--->",
});
}
if (id === "wap-alta-template-001") {
return Promise.resolve({
template: "WAP ALTA <---CLIENTE---> <---SERVICIO---> <---ORGANIZACION---> <---FECHA---> <---HORARIO--->",
});
}
return Promise.resolve(null);
});
});
afterEach(() => {
(AppointmentsList as any).Appointments = originalAdapter;
(AppointmentsList as any).jobService = originalJobService;
});
it("stores organization-rendered Alta template content per channel", async () => {
const emailContent = await (AppointmentsList as any).tryToSendNotification({
appointmentId: "appt-001",
sessionUser: "owner-001",
type: "creation",
channel: "email",
});
const wapContent = await (AppointmentsList as any).tryToSendNotification({
appointmentId: "appt-001",
sessionUser: "owner-001",
type: "creation",
channel: "whatsapp",
});
await (AppointmentsList as any).createCreationNotificationJobs({
appointmentId: "appt-001",
companyId: "company-001",
clientId: "client-001",
clientUserId: "client-user-001",
clientEmail: "ada@example.com",
clientPhoneNumber: "5491112345678",
companyOwnerId: "owner-001",
companyName: "clases llavallol",
appointmentStart: new Date("2026-07-22T11:00:00.000Z"),
emailMessage: emailContent.message,
wapMessage: wapContent.message,
reminderEmailMessage: "EMAIL REMINDER Ada Lovelace",
reminderWapMessage: "WAP REMINDER Ada Lovelace",
});
expect((AppointmentsList as any).jobService.createJob).toHaveBeenCalledWith(
expect.objectContaining({
type: "creation",
payload: expect.objectContaining({
emailSubject: "TurnosXpress :: Alta de turno",
emailMessage: "EMAIL ALTA Ada Lovelace Historia clases llavallol 22/07/2026 08:00hs.",
wapMessage: "WAP ALTA Ada Lovelace Historia clases llavallol 22/07/2026 08:00hs.",
systemSubject: "Turno reservado en clases llavallol",
systemMessage: "EMAIL ALTA Ada Lovelace Historia clases llavallol 22/07/2026 08:00hs.",
}),
reminderPayload: expect.objectContaining({
email: "ada@example.com",
phoneNumber: "5491112345678",
userId: "client-user-001",
companyOwnerId: "owner-001",
emailSubject: "TurnosXpress :: Recordatorio",
emailMessage: "EMAIL REMINDER Ada Lovelace",
wapMessage: "WAP REMINDER Ada Lovelace",
}),
})
);
});
it("falls back to organization WAP Alta template for email when email Alta is absent", async () => {
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
_id: "company-001",
name: "clases llavallol",
ownerId: "owner-001",
templateWapAltaId: "wap-alta-template-001",
address: "Av. Siempre Viva 123",
});
const emailContent = await (AppointmentsList as any).tryToSendNotification({
appointmentId: "appt-001",
sessionUser: "owner-001",
type: "creation",
channel: "email",
});
expect(emailContent.message).toBe("WAP ALTA Ada Lovelace Historia clases llavallol 22/07/2026 08:00hs.");
});
it("stores organization-rendered update template content with contact snapshot fields", async () => {
const emailContent = await (AppointmentsList as any).tryToSendNotification({
appointmentId: "appt-001",
sessionUser: "owner-001",
type: "update",
channel: "email",
});
const wapContent = await (AppointmentsList as any).tryToSendNotification({
appointmentId: "appt-001",
sessionUser: "owner-001",
type: "update",
channel: "whatsapp",
});
await (AppointmentsList as any).createUpdateNotificationJobs({
appointmentId: "appt-001",
companyId: "company-001",
clientId: "client-001",
clientUserId: "client-user-001",
clientEmail: "ada@example.com",
clientPhoneNumber: "5491112345678",
companyOwnerId: "owner-001",
companyName: "clases llavallol",
appointmentStart: new Date("2026-07-22T11:00:00.000Z"),
emailMessage: emailContent.message,
wapMessage: wapContent.message,
reminderEmailMessage: "EMAIL REMINDER Ada Lovelace",
reminderWapMessage: "WAP REMINDER Ada Lovelace",
});
expect((AppointmentsList as any).jobService.createJob).toHaveBeenCalledWith(
expect.objectContaining({
type: "update",
appointmentStart: new Date("2026-07-22T11:00:00.000Z"),
payload: expect.objectContaining({
email: "ada@example.com",
phoneNumber: "5491112345678",
userId: "client-user-001",
companyOwnerId: "owner-001",
emailSubject: "TurnosXpress :: Turno actualizado",
emailMessage: "EMAIL ALTA Ada Lovelace Historia clases llavallol 22/07/2026 08:00hs.",
wapMessage: "WAP ALTA Ada Lovelace Historia clases llavallol 22/07/2026 08:00hs.",
systemSubject: "Turno actualizado en clases llavallol",
systemMessage: "EMAIL ALTA Ada Lovelace Historia clases llavallol 22/07/2026 08:00hs.",
}),
reminderPayload: expect.objectContaining({
email: "ada@example.com",
phoneNumber: "5491112345678",
userId: "client-user-001",
companyOwnerId: "owner-001",
emailSubject: "TurnosXpress :: Recordatorio",
emailMessage: "EMAIL REMINDER Ada Lovelace",
wapMessage: "WAP REMINDER Ada Lovelace",
}),
})
);
});
});
@@ -6,6 +6,7 @@ import {
CreateClientByUserParams, CreateClientByUserParams,
PaginateClientsParams, PaginateClientsParams,
PaginateClientsResults, PaginateClientsResults,
DetachClientUserParams,
} from "./Clients.Interface"; } from "./Clients.Interface";
import { Document, FilterQuery, Model, Schema, model } from "mongoose"; import { Document, FilterQuery, Model, Schema, model } from "mongoose";
@@ -96,4 +97,8 @@ export class ClientsAdapterMongoose implements IClientsAdapter {
pages: Math.ceil(count / filters.limit), pages: Math.ceil(count / filters.limit),
}; };
} }
public async detachUser(data: DetachClientUserParams): Promise<void> {
await this.clientList.updateOne({ _id: data.clientId }, { $unset: { userId: "" } }).exec();
}
} }
@@ -14,6 +14,10 @@ export type FindClientsParams = {
status?: boolean; status?: boolean;
}; };
export type DetachClientUserParams = {
clientId: string;
};
export type FindClientByIdParams = { export type FindClientByIdParams = {
id: string; id: string;
companyId: string; companyId: string;
@@ -77,7 +81,7 @@ export type DeleteClientParams = {
export interface IClient { export interface IClient {
id?: string; id?: string;
companyId: string; companyId: string;
userId: string; userId?: string;
fromClientId: string; fromClientId: string;
status: boolean; status: boolean;
firstName: string; firstName: string;
@@ -116,6 +120,7 @@ export interface IClientsAdapter {
find(filters: FindClientsParams): Promise<IClient[]>; find(filters: FindClientsParams): Promise<IClient[]>;
findOne(filters: FindClientsParams): Promise<IClientDocument | null>; findOne(filters: FindClientsParams): Promise<IClientDocument | null>;
paginate(filters: PaginateClientsParams): Promise<PaginateClientsResults>; paginate(filters: PaginateClientsParams): Promise<PaginateClientsResults>;
detachUser(data: DetachClientUserParams): Promise<void>;
} }
export interface IClientsManager { export interface IClientsManager {
+3 -1
View File
@@ -195,7 +195,9 @@ class ClientManager implements IClientsManager {
if (appointment) { if (appointment) {
appointment.clientId = data.toClientId; appointment.clientId = data.toClientId;
appointment.userId = clientTo.userId; if (clientTo.userId) {
appointment.userId = clientTo.userId;
}
await appointment.save(); await appointment.save();
} }
} }
@@ -58,8 +58,10 @@ export class CompaniesAdapterMongoose implements ICompaniesAdapter {
templateWapNotifId: { type: Schema.Types.ObjectId, required: false, ref: "Template" }, templateWapNotifId: { type: Schema.Types.ObjectId, required: false, ref: "Template" },
templateWapAltaId: { type: Schema.Types.ObjectId, required: false, ref: "Template" }, templateWapAltaId: { type: Schema.Types.ObjectId, required: false, ref: "Template" },
templateWapCancellationId: { type: Schema.Types.ObjectId, required: false, ref: "Template" },
templateEmailNotifId: { type: Schema.Types.ObjectId, required: false, ref: "Template" }, templateEmailNotifId: { type: Schema.Types.ObjectId, required: false, ref: "Template" },
templateEmailAltaId: { type: Schema.Types.ObjectId, required: false, ref: "Template" }, templateEmailAltaId: { type: Schema.Types.ObjectId, required: false, ref: "Template" },
templateEmailCancellationId: { type: Schema.Types.ObjectId, required: false, ref: "Template" },
onboardingStep: { type: Number, required: false, default: 0 }, onboardingStep: { type: Number, required: false, default: 0 },
onboardingCompleted: { type: Boolean, required: false, default: false }, onboardingCompleted: { type: Boolean, required: false, default: false },
@@ -205,6 +207,10 @@ export class CompaniesAdapterMongoose implements ICompaniesAdapter {
updateCompany.templateWapAltaId = data.templateWapAltaId; updateCompany.templateWapAltaId = data.templateWapAltaId;
} }
if (data.templateWapCancellationId) {
updateCompany.templateWapCancellationId = data.templateWapCancellationId;
}
if (data.templateEmailNotifId) { if (data.templateEmailNotifId) {
updateCompany.templateEmailNotifId = data.templateEmailNotifId; updateCompany.templateEmailNotifId = data.templateEmailNotifId;
} }
@@ -213,6 +219,10 @@ export class CompaniesAdapterMongoose implements ICompaniesAdapter {
updateCompany.templateEmailAltaId = data.templateEmailAltaId; updateCompany.templateEmailAltaId = data.templateEmailAltaId;
} }
if (data.templateEmailCancellationId) {
updateCompany.templateEmailCancellationId = data.templateEmailCancellationId;
}
if (data.onboardingStep !== undefined) { if (data.onboardingStep !== undefined) {
updateCompany.onboardingStep = data.onboardingStep; updateCompany.onboardingStep = data.onboardingStep;
} }
@@ -47,8 +47,10 @@ export type UpdateCompanyParams = {
sessionUser: string; sessionUser: string;
templateWapNotifId?: string; templateWapNotifId?: string;
templateWapAltaId?: string; templateWapAltaId?: string;
templateWapCancellationId?: string;
templateEmailNotifId?: string; templateEmailNotifId?: string;
templateEmailAltaId?: string; templateEmailAltaId?: string;
templateEmailCancellationId?: string;
onboardingStep?: number; onboardingStep?: number;
onboardingCompleted?: boolean; onboardingCompleted?: boolean;
banned?: boolean; banned?: boolean;
@@ -144,8 +146,10 @@ export interface ICompany {
automaticNotifications?: boolean; automaticNotifications?: boolean;
templateWapNotifId?: string; templateWapNotifId?: string;
templateWapAltaId?: string; templateWapAltaId?: string;
templateWapCancellationId?: string;
templateEmailNotifId?: string; templateEmailNotifId?: string;
templateEmailAltaId?: string; templateEmailAltaId?: string;
templateEmailCancellationId?: string;
onboardingStep?: number; onboardingStep?: number;
onboardingCompleted?: boolean; onboardingCompleted?: boolean;
fixedPostIds?: Array<string>; fixedPostIds?: Array<string>;
@@ -192,8 +196,10 @@ export interface MyOranizationsView {
appointmentAlert?: string; appointmentAlert?: string;
templateWapNotifId?: string; templateWapNotifId?: string;
templateWapAltaId?: string; templateWapAltaId?: string;
templateWapCancellationId?: string;
templateEmailNotifId?: string; templateEmailNotifId?: string;
templateEmailAltaId?: string; templateEmailAltaId?: string;
templateEmailCancellationId?: string;
onboardingStep?: number; onboardingStep?: number;
onboardingCompleted?: boolean; onboardingCompleted?: boolean;
banned?: boolean; banned?: boolean;
+4 -2
View File
@@ -536,7 +536,8 @@ class CompaniesManager implements ICompaniesManager {
const employ = await EmployeesList.employees.findOne({ const employ = await EmployeesList.employees.findOne({
companyId: data.id, companyId: data.id,
userId: String(data.sessionUser), userId: String(data.sessionUser),
}); removed: { $ne: true },
} as any);
if (!employ) { if (!employ) {
throw new Error("El usuario no participa de la organización"); throw new Error("El usuario no participa de la organización");
@@ -721,7 +722,8 @@ class CompaniesManager implements ICompaniesManager {
//Organizaciones en las que participa como colaborador. //Organizaciones en las que participa como colaborador.
const orgCollaborator = await EmployeesList.employees.find({ const orgCollaborator = await EmployeesList.employees.find({
userId: data.sessionUser, userId: data.sessionUser,
}); removed: { $ne: true },
} as any);
for (const item of orgCollaborator) { for (const item of orgCollaborator) {
if (item.companyId) { if (item.companyId) {
+205 -4
View File
@@ -14,6 +14,7 @@ import {
UpdateEmployeeParams, UpdateEmployeeParams,
DeleteEmployeesByCompany, DeleteEmployeesByCompany,
DeleteEmployeeParams, DeleteEmployeeParams,
RemoveCollaboratorParams,
} from "./Employees.Interface"; } from "./Employees.Interface";
import UsersManager from "../Users/Users"; import UsersManager from "../Users/Users";
import { ICompanyDocument } from "../Companies/Companies.Adapter.Mongoose"; import { ICompanyDocument } from "../Companies/Companies.Adapter.Mongoose";
@@ -26,7 +27,9 @@ import { TextObjectFilterResult } from "../../Models/TextObjectFilter.model";
import MetricsList from "../../Models/Metrics/Metrics"; import MetricsList from "../../Models/Metrics/Metrics";
import { NotificationsManager } from "../Notifications/Notifications"; import { NotificationsManager } from "../Notifications/Notifications";
import EmployeesServicesList from "../EmployeesServices/EmployeesServices"; import EmployeesServicesList from "../EmployeesServices/EmployeesServices";
import HeatMapList from "../HeatMap/HeatMap";
import SchedulesList from "../Schedules/Schedules"; import SchedulesList from "../Schedules/Schedules";
import AppointmentList from "../Appointments/Appointments";
import { IncompleteCollaboratorView } from "./Employees.Interface"; import { IncompleteCollaboratorView } from "./Employees.Interface";
import { NotificationType } from "../../Models/SystemNotifications/SystemNotification.Interface"; import { NotificationType } from "../../Models/SystemNotifications/SystemNotification.Interface";
@@ -51,7 +54,11 @@ class EmployeeManager implements IEmployeesManager {
role: EmployeeRoles role: EmployeeRoles
): Promise<boolean> { ): Promise<boolean> {
//Check if role exist in roles array. //Check if role exist in roles array.
const check = await this.employees.findOne({ companyId: companyId, userId: employeeId }); const check = await this.employees.findOne({
companyId: companyId,
userId: employeeId,
removed: { $ne: true },
} as any);
if (!check) { if (!check) {
return false; return false;
@@ -112,8 +119,59 @@ class EmployeeManager implements IEmployeesManager {
userId: data.userId, userId: data.userId,
}); });
// Reactivation: if employee exists and is removed, reactivate instead of creating duplicate
if (employeeCheck && employeeCheck.removed === true) {
// Check quota before reactivation
const canAdd = await MetricsList.canAddEmployee(companyCheck.ownerId);
if (!canAdd) {
throw new Error(
"Ha alcanzado el limite de colaboradores permitidos de acuerdo a su plan."
);
}
// Clear removal metadata
employeeCheck.removed = false;
employeeCheck.removedAt = undefined;
employeeCheck.removedBy = undefined;
// Refresh profileSnapshot from current User
employeeCheck.profileSnapshot = {
firstName: userCheck.firstName || undefined,
lastName: userCheck.lastName || undefined,
email: userCheck.email || undefined,
avatar: userCheck.avatar || undefined,
};
// Reset invitation state for full re-acceptance flow
employeeCheck.hostOk = false;
employeeCheck.guestOk = false;
await employeeCheck.save();
// Increment metrics
await MetricsList.addEmployee({
userId: companyCheck.ownerId,
quantity: 1,
});
// Send invitation notification
await NotificationsManager.sendSystemNotification({
userId: String(data.userId),
subject: "Su usuario ha sido vinculado a una organización",
message: `La compañía ${companyCheck.name} lo ha vinculado como colaborador. Es necesario que acepte la invitación para poder formar parte de esta organización.`,
type: NotificationType.EMPLOYEE,
code: String(employeeCheck._id),
});
return employeeCheck;
}
if (employeeCheck) { if (employeeCheck) {
throw new Error("El usuario ya pertenece a la compañia"); if (!employeeCheck.guestOk) {
throw new Error("La invitación para este usuario ya está pendiente");
}
throw new Error("El usuario ya pertenece a la compañía");
} }
const canAdd = await MetricsList.canAddEmployee(companyCheck.ownerId); const canAdd = await MetricsList.canAddEmployee(companyCheck.ownerId);
@@ -346,7 +404,7 @@ class EmployeeManager implements IEmployeesManager {
} }
public async findByCompanyId(data: FindEmployeesParams): Promise<CompanyEmployeesView[]> { public async findByCompanyId(data: FindEmployeesParams): Promise<CompanyEmployeesView[]> {
const employees = await this.employees.find(data); const employees = await this.employees.find({ ...data, removed: { $ne: true } } as any);
const view = await Promise.all( const view = await Promise.all(
employees.map(async (employee) => { employees.map(async (employee) => {
@@ -422,7 +480,7 @@ class EmployeeManager implements IEmployeesManager {
} }
public async textObjectFilter(data: FindEmployeesParams): Promise<TextObjectFilterResult[]> { public async textObjectFilter(data: FindEmployeesParams): Promise<TextObjectFilterResult[]> {
const employees = await this.employees.find(data); const employees = await this.employees.find({ ...data, removed: { $ne: true } } as any);
const formattedResult: TextObjectFilterResult[] = []; const formattedResult: TextObjectFilterResult[] = [];
for (const colaborador of employees) { for (const colaborador of employees) {
@@ -522,8 +580,151 @@ class EmployeeManager implements IEmployeesManager {
} }
return incompleteCollaborators; return incompleteCollaborators;
} }
public async removeCollaboratorFromCompany(data: RemoveCollaboratorParams): Promise<void> {
// Validate session user
const sessionUser = await UsersManager.users.findOne({ _id: data.sessionUser });
if (!sessionUser) {
throw new Error(
"No se ha encontrado el usuario o no tiene permisos para realizar esta acción"
);
}
// Validate company exists
const companyCheck = await CompaniesManager.companies.findOne({ _id: data.companyId });
if (!companyCheck) {
throw new Error("La compañia no existe");
}
// Validate target employee exists
const employee = await this.employees.findOne({
_id: data.employeeId,
});
if (!employee) {
throw new Error("El colaborador no existe");
}
// Check if already removed
if (employee.removed) {
throw new Error("El colaborador ya fue eliminado de la organización");
}
// Validate permission (admin or owner)
if (
!(await this.checkPermission(companyCheck, data.sessionUser, EmployeeRoles.ADMIN))
) {
throw new Error(NoPermissionMessage());
}
// Self-removal prevention
if (String(employee.userId) === data.sessionUser) {
throw new Error("No puedes eliminarte a ti mismo de la organización");
}
// Owner protection
if (employee.roles && employee.roles.includes(EmployeeRoles.OWNER)) {
throw new Error("No se puede eliminar al propietario de la organización");
}
// Query future appointments for this employee
const futureAppointments = await AppointmentList.Appointments.AppointmentList.find({
companyId: data.companyId,
employeeId: data.employeeId,
start: { $gt: new Date() },
});
// If there are future appointments, require a replacement
if (futureAppointments && futureAppointments.length > 0) {
if (!data.replacementEmployeeId) {
throw new Error(
"El colaborador tiene turnos futuros asignados. Se requiere un empleado de reemplazo."
);
}
// Validate replacement employee
const replacementEmployee = await this.employees.findOne({
_id: data.replacementEmployeeId,
});
if (!replacementEmployee) {
throw new Error("El empleado de reemplazo no existe");
}
if (String(replacementEmployee.companyId) !== data.companyId) {
throw new Error("El empleado de reemplazo no pertenece a esta organización");
}
if (replacementEmployee.removed) {
throw new Error("El empleado de reemplazo fue eliminado de la organización");
}
if (String(replacementEmployee.userId) === data.sessionUser) {
throw new Error(
"No puedes designarte como reemplazo de ti mismo"
);
}
// Reassign future appointments
for (const appointment of futureAppointments) {
appointment.employeeId = data.replacementEmployeeId;
await appointment.save();
}
// Reassign active repeats
const RepeatsList = (await import("../Repeats/Repeats")).default;
const activeRepeats = await RepeatsList.repeats.find({
companyId: data.companyId,
employeeId: data.employeeId,
});
for (const repeat of activeRepeats) {
// Access the underlying Mongoose model to get save() capability
await RepeatsList.repeats.findOne({ _id: repeat.id }).then(async (doc) => {
if (doc) {
doc.employeeId = data.replacementEmployeeId!;
await doc.save();
}
});
}
}
// Snapshot user profile
const targetUser = await UsersManager.users.findOne({ _id: employee.userId });
if (targetUser) {
employee.profileSnapshot = {
firstName: targetUser.firstName || undefined,
lastName: targetUser.lastName || undefined,
email: targetUser.email || undefined,
avatar: targetUser.avatar || undefined,
};
}
// Mark employee as removed
employee.removed = true;
employee.removedAt = new Date();
employee.removedBy = data.sessionUser;
await employee.save();
// Cleanup: delete employee services and heatmap data
await EmployeesServicesList.deleteEmployeeServiceByEmployee({
employeeId: data.employeeId,
});
await HeatMapList.deleteHeatMapByEmployee({
employeeId: data.employeeId,
});
// Decrement metrics
await MetricsList.addEmployee({
userId: companyCheck.ownerId,
quantity: -1,
});
}
} }
export { EmployeeManager };
const EmployeesList = new EmployeeManager(); const EmployeesList = new EmployeeManager();
export default EmployeesList; export default EmployeesList;
@@ -23,6 +23,18 @@ export class EmployeesAdapterMongoose implements IEmployeesAdapter {
limit: { type: Number, required: true, default: -1 }, limit: { type: Number, required: true, default: -1 },
hostOk: { type: Boolean, required: true, default: false }, hostOk: { type: Boolean, required: true, default: false },
guestOk: { type: Boolean, required: true, default: false }, guestOk: { type: Boolean, required: true, default: false },
removed: { type: Boolean, required: false, default: false },
removedAt: { type: Date, required: false },
removedBy: { type: Schema.Types.ObjectId, required: false, ref: "User" },
profileSnapshot: {
type: {
firstName: { type: String, required: false },
lastName: { type: String, required: false },
email: { type: String, required: false },
avatar: { type: String, required: false },
},
required: false,
},
creationDate: { type: Date, required: true, default: Date.now }, creationDate: { type: Date, required: true, default: Date.now },
}); });
@@ -68,6 +68,13 @@ export type DeleteEmployeeParams = {
employeeId: string; employeeId: string;
}; };
export type RemoveCollaboratorParams = {
employeeId: string;
companyId: string;
replacementEmployeeId?: string;
sessionUser: string;
};
export interface IEmployee { export interface IEmployee {
id?: string; id?: string;
companyId: string; companyId: string;
@@ -77,6 +84,15 @@ export interface IEmployee {
limit?: number; limit?: number;
guestOk?: boolean; guestOk?: boolean;
hostOk?: boolean; hostOk?: boolean;
removed?: boolean;
removedAt?: Date;
removedBy?: string;
profileSnapshot?: {
firstName?: string;
lastName?: string;
email?: string;
avatar?: string;
};
} }
export interface CompanyEmployeesView { export interface CompanyEmployeesView {
@@ -93,6 +109,14 @@ export interface CompanyEmployeesView {
hostOk: boolean; hostOk: boolean;
fullOk: boolean; fullOk: boolean;
calendarColor: string; calendarColor: string;
removed?: boolean;
removedAt?: Date;
profileSnapshot?: {
firstName?: string;
lastName?: string;
email?: string;
avatar?: string;
};
} }
export interface IncompleteCollaboratorView { export interface IncompleteCollaboratorView {
@@ -125,4 +149,5 @@ export interface IEmployeesManager {
deleteEmployee(data: DeleteEmployeeParams): Promise<void>; deleteEmployee(data: DeleteEmployeeParams): Promise<void>;
rejectGuest(data: ValidateEmployeeParams): Promise<void>; rejectGuest(data: ValidateEmployeeParams): Promise<void>;
getIncompleteSetup(sessionUser: string): Promise<IncompleteCollaboratorView[]>; getIncompleteSetup(sessionUser: string): Promise<IncompleteCollaboratorView[]>;
removeCollaboratorFromCompany(data: RemoveCollaboratorParams): Promise<void>;
} }
@@ -0,0 +1,233 @@
import { EmployeeRoles } from "../Employees.Interface";
// Mock all external dependencies before importing the module under test
jest.mock("../../Users/Users", () => ({
__esModule: true,
default: {
users: {
findOne: jest.fn(),
},
getUserFullName: jest.fn(),
},
}));
jest.mock("../../Companies/Companies", () => ({
__esModule: true,
default: {
companies: {
findOne: jest.fn(),
},
},
}));
jest.mock("../../Metrics/Metrics", () => ({
__esModule: true,
default: {
addEmployee: jest.fn(),
canAddEmployee: jest.fn(),
},
}));
jest.mock("../../Notifications/Notifications", () => ({
__esModule: true,
NotificationsManager: {
sendSystemNotification: jest.fn(),
},
}));
jest.mock("../../EmployeesServices/EmployeesServices", () => ({
__esModule: true,
default: {
deleteEmployeeServiceByEmployee: jest.fn(),
},
}));
jest.mock("../../HeatMap/HeatMap", () => ({
__esModule: true,
default: {
deleteHeatMapByEmployee: jest.fn(),
},
}));
jest.mock("../../Repeats/Repeats", () => ({
__esModule: true,
default: {
repeats: {
find: jest.fn().mockResolvedValue([]),
findOne: jest.fn(),
},
},
}));
jest.mock("../../Schedules/Schedules", () => ({
__esModule: true,
default: {
find: jest.fn().mockResolvedValue([]),
},
}));
jest.mock("../../Services/Service", () => ({
__esModule: true,
default: {
services: {
find: jest.fn().mockResolvedValue([]),
findOne: jest.fn(),
},
},
}));
jest.mock("../../../index", () => ({
io: {
to: jest.fn(() => ({ emit: jest.fn() })),
},
}));
import EmployeesList from "../Employee";
import UsersManager from "../../Users/Users";
describe("Query filtering — removed employees excluded", () => {
let originalAdapter: any;
let mockFind: jest.Mock;
let mockFindOne: jest.Mock;
beforeEach(() => {
jest.clearAllMocks();
originalAdapter = EmployeesList.employees;
mockFind = jest.fn();
mockFindOne = jest.fn();
(EmployeesList as any).employees = {
findOne: mockFindOne,
find: mockFind,
create: jest.fn(),
employeeList: {
findOne: mockFindOne,
find: mockFind,
create: jest.fn(),
deleteMany: jest.fn(),
},
};
});
afterEach(() => {
(EmployeesList as any).employees = originalAdapter;
});
// --- findByCompanyId excludes removed ---
describe("findByCompanyId", () => {
it("excludes removed employees from results", async () => {
const activeEmployee = {
_id: "emp-active",
id: "emp-active",
companyId: "company-001",
userId: "user-active",
roles: [EmployeeRoles.EMPLOYEE],
guestOk: true,
hostOk: true,
calendarColor: "#ff9900",
removed: false,
};
// findByCompanyId calls find with removed: { $ne: true }
// The adapter find should return only non-removed employees
mockFind.mockResolvedValue([activeEmployee]);
(UsersManager.users.findOne as jest.Mock).mockResolvedValue({
_id: "user-active",
firstName: "Active",
lastName: "User",
email: "active@example.com",
avatar: "",
});
const result = await EmployeesList.findByCompanyId({
companyId: "company-001",
});
// Verify the query included the removed filter
expect(mockFind).toHaveBeenCalledWith(
expect.objectContaining({
companyId: "company-001",
removed: { $ne: true },
})
);
// Verify only the active employee is in the result
expect(result).toHaveLength(1);
expect(result[0].id).toBe("emp-active");
});
it("returns empty array when all employees are removed", async () => {
// Adapter returns empty because filter excluded all removed
mockFind.mockResolvedValue([]);
const result = await EmployeesList.findByCompanyId({
companyId: "company-001",
});
expect(result).toEqual([]);
expect(mockFind).toHaveBeenCalledWith(
expect.objectContaining({
removed: { $ne: true },
})
);
});
});
// --- textObjectFilter excludes removed ---
describe("textObjectFilter", () => {
it("excludes removed employees from results", async () => {
const activeEmployee = {
_id: "emp-active",
id: "emp-active",
companyId: "company-001",
userId: "user-active",
removed: false,
};
// Only active employees returned after filter
mockFind.mockResolvedValue([activeEmployee]);
(UsersManager.users.findOne as jest.Mock).mockResolvedValue({
_id: "user-active",
firstName: "John",
lastName: "Doe",
avatar: "",
});
const result = await EmployeesList.textObjectFilter({
companyId: "company-001",
});
expect(mockFind).toHaveBeenCalledWith(
expect.objectContaining({
removed: { $ne: true },
})
);
expect(result).toHaveLength(1);
expect(result[0].name).toBe("John Doe");
});
it("returns empty when only removed employees exist", async () => {
mockFind.mockResolvedValue([]);
const result = await EmployeesList.textObjectFilter({
companyId: "company-001",
});
// Verify the removed filter was applied to the query
expect(mockFind).toHaveBeenCalledWith(
expect.objectContaining({
removed: { $ne: true },
})
);
expect(result).toEqual([]);
expect(result).toHaveLength(0);
});
});
});
@@ -0,0 +1,343 @@
import { EmployeeRoles } from "../Employees.Interface";
// Mock all external dependencies before importing the module under test
jest.mock("../../Users/Users", () => ({
__esModule: true,
default: {
users: {
findOne: jest.fn(),
},
getUserFullName: jest.fn(),
},
}));
jest.mock("../../Companies/Companies", () => ({
__esModule: true,
default: {
companies: {
findOne: jest.fn(),
},
},
}));
jest.mock("../../Metrics/Metrics", () => ({
__esModule: true,
default: {
addEmployee: jest.fn(),
canAddEmployee: jest.fn(),
},
}));
jest.mock("../../Notifications/Notifications", () => ({
__esModule: true,
NotificationsManager: {
sendSystemNotification: jest.fn(),
},
}));
jest.mock("../../EmployeesServices/EmployeesServices", () => ({
__esModule: true,
default: {
deleteEmployeeServiceByEmployee: jest.fn(),
},
}));
jest.mock("../../HeatMap/HeatMap", () => ({
__esModule: true,
default: {
deleteHeatMapByEmployee: jest.fn(),
},
}));
jest.mock("../../Repeats/Repeats", () => ({
__esModule: true,
default: {
repeats: {
find: jest.fn().mockResolvedValue([]),
findOne: jest.fn(),
},
},
}));
jest.mock("../../../index", () => ({
io: {
to: jest.fn(() => ({ emit: jest.fn() })),
},
}));
import EmployeesList from "../Employee";
import UsersManager from "../../Users/Users";
import CompaniesManager from "../../Companies/Companies";
import MetricsList from "../../Metrics/Metrics";
describe("createEmployee — reactivation of removed collaborator", () => {
let originalAdapter: any;
let mockFindOne: jest.Mock;
let mockCreate: jest.Mock;
beforeEach(() => {
jest.clearAllMocks();
originalAdapter = EmployeesList.employees;
mockFindOne = jest.fn();
mockCreate = jest.fn();
(EmployeesList as any).employees = {
findOne: mockFindOne,
find: jest.fn(),
create: mockCreate,
employeeList: {
findOne: mockFindOne,
find: jest.fn(),
create: mockCreate,
},
};
});
afterEach(() => {
(EmployeesList as any).employees = originalAdapter;
});
/**
* Call order in createEmployee:
* 1. UsersManager.users.findOne session user
* 2. CompaniesManager.companies.findOne company
* 3. checkPermission checkRoleById this.employees.findOne admin employee record
* 4. UsersManager.users.findOne user being invited
* 5. this.employees.findOne existing employee lookup by (companyId, userId)
*/
it("reactivates a removed employee instead of creating a duplicate", async () => {
const adminEmployee = {
_id: "emp-admin",
companyId: "company-001",
userId: "admin-user",
roles: [EmployeeRoles.ADMIN],
removed: false,
};
const removedEmployee = {
_id: "emp-removed",
id: "emp-removed",
companyId: "company-001",
userId: "user-002",
roles: [EmployeeRoles.EMPLOYEE],
removed: true,
removedAt: new Date(),
removedBy: "admin-user",
hostOk: true,
guestOk: true,
profileSnapshot: { firstName: "Old", lastName: "Name" },
save: jest.fn().mockResolvedValue(true),
};
const userToInvite = {
_id: "user-002",
firstName: "John",
lastName: "Doe",
email: "john@example.com",
avatar: "new-avatar",
};
// Call 1: session user → Call 2: user being invited
(UsersManager.users.findOne as jest.Mock)
.mockResolvedValueOnce({ _id: "admin-user" })
.mockResolvedValueOnce(userToInvite);
// CompaniesManager.companies.findOne → company
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
_id: "company-001",
ownerId: "owner-user",
name: "Test Company",
});
// this.employees.findOne:
// Call 1: checkRoleById → admin employee record
// Call 2: existing employee lookup → found + removed
mockFindOne
.mockResolvedValueOnce(adminEmployee)
.mockResolvedValueOnce(removedEmployee);
(MetricsList.canAddEmployee as jest.Mock).mockResolvedValue(true);
const result = await EmployeesList.createEmployee({
companyId: "company-001",
userId: "user-002",
sessionUser: "admin-user",
});
// Verify employee was reactivated
expect(removedEmployee.removed).toBe(false);
expect(removedEmployee.removedAt).toBeUndefined();
expect(removedEmployee.removedBy).toBeUndefined();
// Verify profileSnapshot was refreshed from current User
expect(removedEmployee.profileSnapshot).toEqual({
firstName: "John",
lastName: "Doe",
email: "john@example.com",
avatar: "new-avatar",
});
// Verify invitation state was reset
expect(removedEmployee.hostOk).toBe(false);
expect(removedEmployee.guestOk).toBe(false);
// Verify save was called
expect(removedEmployee.save).toHaveBeenCalled();
// Verify metrics were incremented
expect(MetricsList.addEmployee).toHaveBeenCalledWith({
userId: "owner-user",
quantity: 1,
});
// Verify no new employee was created
expect(mockCreate).not.toHaveBeenCalled();
// Verify same employee returned
expect(result.id).toBe("emp-removed");
});
it("throws quota error when reactivation would exceed plan limit", async () => {
const adminEmployee = {
_id: "emp-admin",
companyId: "company-001",
userId: "admin-user",
roles: [EmployeeRoles.ADMIN],
removed: false,
};
const removedEmployee = {
_id: "emp-removed",
companyId: "company-001",
userId: "user-002",
removed: true,
save: jest.fn(),
};
// Call 1: session user → Call 2: user being invited
(UsersManager.users.findOne as jest.Mock)
.mockResolvedValueOnce({ _id: "admin-user" })
.mockResolvedValueOnce({ _id: "user-002" });
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
_id: "company-001",
ownerId: "owner-user",
});
// this.employees.findOne: admin check → removed employee lookup
mockFindOne
.mockResolvedValueOnce(adminEmployee)
.mockResolvedValueOnce(removedEmployee);
// Quota is full
(MetricsList.canAddEmployee as jest.Mock).mockResolvedValue(false);
await expect(
EmployeesList.createEmployee({
companyId: "company-001",
userId: "user-002",
sessionUser: "admin-user",
})
).rejects.toThrow("limite de colaboradores");
// Verify employee was NOT reactivated
expect(removedEmployee.save).not.toHaveBeenCalled();
});
it("throws 'El usuario no existe' when reinviting a removed employee whose User record was deleted", async () => {
// Scenario: Employee record exists and is removed, but the underlying User
// was deleted (e.g. admin cleanup). createEmployee must reject the invitation
// BEFORE attempting reactivation because User validation gates the entire flow.
const removedEmployee = {
_id: "emp-removed",
id: "emp-removed",
companyId: "company-001",
userId: "user-deleted",
removed: true,
save: jest.fn().mockResolvedValue(true),
};
// Call 1: session user (owner, so checkPermission short-circuits)
(UsersManager.users.findOne as jest.Mock)
.mockResolvedValueOnce({ _id: "owner-user" })
.mockResolvedValueOnce(null); // invited user was DELETED
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
_id: "company-001",
ownerId: "owner-user",
});
// checkPermission short-circuits for owner — no employees.findOne needed.
// But createEmployee still calls employees.findOne AFTER userCheck.
// Since userCheck is null, the method should throw before reaching employees.findOne.
await expect(
EmployeesList.createEmployee({
companyId: "company-001",
userId: "user-deleted",
sessionUser: "owner-user",
})
).rejects.toThrow("El usuario no existe");
// Critical: reactivation must NOT have been attempted
expect(removedEmployee.save).not.toHaveBeenCalled();
expect(MetricsList.addEmployee).not.toHaveBeenCalled();
});
it("preserves same employeeId across removal and reactivation", async () => {
const adminEmployee = {
_id: "emp-admin",
companyId: "company-001",
userId: "admin-user",
roles: [EmployeeRoles.ADMIN],
removed: false,
};
const removedEmployee = {
_id: "emp-original-id",
id: "emp-original-id",
companyId: "company-001",
userId: "user-002",
removed: true,
save: jest.fn().mockResolvedValue(true),
};
const userToInvite = {
_id: "user-002",
firstName: "Jane",
lastName: "Smith",
email: "jane@example.com",
avatar: "",
};
// Call 1: session user → Call 2: user being invited
(UsersManager.users.findOne as jest.Mock)
.mockResolvedValueOnce({ _id: "admin-user" })
.mockResolvedValueOnce(userToInvite);
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
_id: "company-001",
ownerId: "owner-user",
});
// this.employees.findOne: admin check → removed employee lookup
mockFindOne
.mockResolvedValueOnce(adminEmployee)
.mockResolvedValueOnce(removedEmployee);
(MetricsList.canAddEmployee as jest.Mock).mockResolvedValue(true);
const result = await EmployeesList.createEmployee({
companyId: "company-001",
userId: "user-002",
sessionUser: "admin-user",
});
// Same _id preserved
expect(result.id).toBe("emp-original-id");
// No new employee created
expect(mockCreate).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,632 @@
import { EmployeeRoles } from "../Employees.Interface";
// Mock all external dependencies before importing the module under test
jest.mock("../../Users/Users", () => ({
__esModule: true,
default: {
users: {
findOne: jest.fn(),
},
getUserFullName: jest.fn(),
},
}));
jest.mock("../../Companies/Companies", () => ({
__esModule: true,
default: {
companies: {
findOne: jest.fn(),
},
},
}));
jest.mock("../../Metrics/Metrics", () => ({
__esModule: true,
default: {
addEmployee: jest.fn(),
canAddEmployee: jest.fn(),
},
}));
jest.mock("../../Notifications/Notifications", () => ({
__esModule: true,
NotificationsManager: {
sendSystemNotification: jest.fn(),
},
}));
jest.mock("../../EmployeesServices/EmployeesServices", () => ({
__esModule: true,
default: {
deleteEmployeeServiceByEmployee: jest.fn(),
},
}));
jest.mock("../../HeatMap/HeatMap", () => ({
__esModule: true,
default: {
deleteHeatMapByEmployee: jest.fn(),
},
}));
jest.mock("../../Repeats/Repeats", () => ({
__esModule: true,
default: {
repeats: {
find: jest.fn().mockResolvedValue([]),
findOne: jest.fn(),
},
},
}));
jest.mock("../../Appointments/Appointments", () => ({
__esModule: true,
default: {
Appointments: {
AppointmentList: {
find: jest.fn().mockResolvedValue([]),
},
},
},
}));
jest.mock("../../../index", () => ({
io: {
to: jest.fn(() => ({ emit: jest.fn() })),
},
}));
import EmployeesList from "../Employee";
import UsersManager from "../../Users/Users";
import CompaniesManager from "../../Companies/Companies";
import MetricsList from "../../Metrics/Metrics";
import EmployeesServicesList from "../../EmployeesServices/EmployeesServices";
import HeatMapList from "../../HeatMap/HeatMap";
import AppointmentList from "../../Appointments/Appointments";
// Helper to build a mock employee document with save()
function buildEmployeeDoc(overrides: Record<string, any> = {}) {
const doc = {
_id: "emp-001",
id: "emp-001",
companyId: "company-001",
userId: "user-002",
roles: [EmployeeRoles.EMPLOYEE],
calendarColor: "#ff9900",
hostOk: true,
guestOk: true,
removed: false,
removedAt: undefined,
removedBy: undefined,
profileSnapshot: undefined,
save: jest.fn().mockResolvedValue(true),
...overrides,
};
return doc;
}
describe("removeCollaboratorFromCompany", () => {
let originalAdapter: any;
let mockFindOne: jest.Mock;
let mockAppointmentFind: jest.Mock;
beforeEach(() => {
jest.clearAllMocks();
// Save original adapter reference
originalAdapter = EmployeesList.employees;
// Create mock adapter
mockFindOne = jest.fn();
const mockFind = jest.fn();
const mockCreate = jest.fn();
const mockDeleteMany = jest.fn();
(EmployeesList as any).employees = {
findOne: mockFindOne,
find: mockFind,
create: mockCreate,
employeeList: {
findOne: mockFindOne,
find: mockFind,
deleteMany: mockDeleteMany,
create: mockCreate,
},
};
mockAppointmentFind = (AppointmentList.Appointments.AppointmentList.find as jest.Mock).mockResolvedValue([]);
});
afterEach(() => {
// Restore original adapter
(EmployeesList as any).employees = originalAdapter;
});
// --- Session User Validation ---
it("throws when session user does not exist", async () => {
(UsersManager.users.findOne as jest.Mock).mockResolvedValue(null);
await expect(
EmployeesList.removeCollaboratorFromCompany({
employeeId: "emp-001",
companyId: "company-001",
sessionUser: "nonexistent-user",
})
).rejects.toThrow("No se ha encontrado el usuario");
});
// --- Company Validation ---
it("throws when company does not exist", async () => {
(UsersManager.users.findOne as jest.Mock).mockResolvedValue({ _id: "admin-user" });
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue(null);
await expect(
EmployeesList.removeCollaboratorFromCompany({
employeeId: "emp-001",
companyId: "nonexistent-company",
sessionUser: "admin-user",
})
).rejects.toThrow("La compañia no existe");
});
// --- Employee Not Found ---
it("throws when target employee does not exist", async () => {
(UsersManager.users.findOne as jest.Mock).mockResolvedValue({ _id: "admin-user" });
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
_id: "company-001",
ownerId: "owner-user",
});
mockFindOne.mockResolvedValueOnce(null);
await expect(
EmployeesList.removeCollaboratorFromCompany({
employeeId: "emp-nonexistent",
companyId: "company-001",
sessionUser: "admin-user",
})
).rejects.toThrow("El colaborador no existe");
});
// --- Already Removed ---
it("throws when employee is already removed", async () => {
(UsersManager.users.findOne as jest.Mock).mockResolvedValue({ _id: "admin-user" });
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
_id: "company-001",
ownerId: "owner-user",
});
mockFindOne.mockResolvedValueOnce(buildEmployeeDoc({ removed: true }));
await expect(
EmployeesList.removeCollaboratorFromCompany({
employeeId: "emp-001",
companyId: "company-001",
sessionUser: "admin-user",
})
).rejects.toThrow("El colaborador ya fue eliminado");
});
// --- Permission Check ---
it("throws when non-admin attempts removal", async () => {
const regularEmployee = buildEmployeeDoc({
_id: "emp-regular",
userId: "regular-user",
roles: [EmployeeRoles.EMPLOYEE],
});
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
_id: "company-001",
ownerId: "owner-user",
});
(UsersManager.users.findOne as jest.Mock).mockResolvedValue({ _id: "regular-user" });
mockFindOne
.mockResolvedValueOnce(buildEmployeeDoc({ userId: "target-user" })) // target
.mockResolvedValueOnce(regularEmployee); // session user's employee
await expect(
EmployeesList.removeCollaboratorFromCompany({
employeeId: "emp-001",
companyId: "company-001",
sessionUser: "regular-user",
})
).rejects.toThrow(/No tienes permisos/);
});
// --- Self-Removal Prevention ---
it("throws when admin tries to remove themselves", async () => {
const adminEmployee = buildEmployeeDoc({
_id: "emp-admin",
userId: "admin-user",
roles: [EmployeeRoles.ADMIN],
});
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
_id: "company-001",
ownerId: "owner-user",
});
(UsersManager.users.findOne as jest.Mock).mockResolvedValue({ _id: "admin-user" });
// First findOne: target employee (the admin themselves)
// Second findOne: session user's employee record
mockFindOne
.mockResolvedValueOnce(adminEmployee)
.mockResolvedValueOnce(adminEmployee);
await expect(
EmployeesList.removeCollaboratorFromCompany({
employeeId: "emp-admin",
companyId: "company-001",
sessionUser: "admin-user",
})
).rejects.toThrow("No puedes eliminarte a ti mismo");
});
// --- Owner Protection ---
it("throws when admin tries to remove the owner", async () => {
const ownerEmployee = buildEmployeeDoc({
_id: "emp-owner",
userId: "owner-user",
roles: [EmployeeRoles.OWNER],
});
const adminEmployee = buildEmployeeDoc({
_id: "emp-admin",
userId: "admin-user",
roles: [EmployeeRoles.ADMIN],
});
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
_id: "company-001",
ownerId: "owner-user",
});
(UsersManager.users.findOne as jest.Mock).mockResolvedValue({ _id: "admin-user" });
mockFindOne
.mockResolvedValueOnce(ownerEmployee)
.mockResolvedValueOnce(adminEmployee);
await expect(
EmployeesList.removeCollaboratorFromCompany({
employeeId: "emp-owner",
companyId: "company-001",
sessionUser: "admin-user",
})
).rejects.toThrow("No se puede eliminar al propietario");
});
// --- Happy Path: No Future Appointments ---
it("successfully removes employee with no future appointments", async () => {
const targetEmployee = buildEmployeeDoc({
userId: "target-user",
removed: false,
});
const adminEmployee = buildEmployeeDoc({
userId: "admin-user",
roles: [EmployeeRoles.ADMIN],
});
const targetUser = {
_id: "target-user",
firstName: "John",
lastName: "Doe",
email: "john@example.com",
avatar: "avatar-url",
};
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
_id: "company-001",
ownerId: "owner-user",
});
// First call: session user validation
// Second call: target user lookup (for snapshot)
(UsersManager.users.findOne as jest.Mock)
.mockResolvedValueOnce({ _id: "admin-user" })
.mockResolvedValueOnce(targetUser);
mockFindOne
.mockResolvedValueOnce(targetEmployee) // target
.mockResolvedValueOnce(adminEmployee); // session user
mockAppointmentFind.mockResolvedValue([]);
await EmployeesList.removeCollaboratorFromCompany({
employeeId: "emp-001",
companyId: "company-001",
sessionUser: "admin-user",
});
expect(targetEmployee.save).toHaveBeenCalled();
expect(targetEmployee.removed).toBe(true);
expect(targetEmployee.removedBy).toBe("admin-user");
expect(targetEmployee.removedAt).toBeDefined();
expect(targetEmployee.profileSnapshot).toEqual({
firstName: "John",
lastName: "Doe",
email: "john@example.com",
avatar: "avatar-url",
});
expect(EmployeesServicesList.deleteEmployeeServiceByEmployee).toHaveBeenCalledWith({
employeeId: "emp-001",
});
expect(
HeatMapList.deleteHeatMapByEmployee
).toHaveBeenCalledWith({
employeeId: "emp-001",
});
expect(MetricsList.addEmployee).toHaveBeenCalledWith({
userId: "owner-user",
quantity: -1,
});
});
// --- Owner Can Remove (via company.ownerId check) ---
it("owner can remove a collaborator", async () => {
const targetEmployee = buildEmployeeDoc({
userId: "target-user",
});
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
_id: "company-001",
ownerId: "owner-user",
});
(UsersManager.users.findOne as jest.Mock)
.mockResolvedValueOnce({ _id: "owner-user" }) // session user
.mockResolvedValueOnce({
_id: "target-user",
firstName: "Jane",
lastName: "Smith",
email: "jane@example.com",
avatar: "",
});
mockFindOne
.mockResolvedValueOnce(targetEmployee)
.mockResolvedValueOnce(null); // owner has no employee record
mockAppointmentFind.mockResolvedValue([]);
await EmployeesList.removeCollaboratorFromCompany({
employeeId: "emp-001",
companyId: "company-001",
sessionUser: "owner-user",
});
expect(targetEmployee.removed).toBe(true);
expect(targetEmployee.removedBy).toBe("owner-user");
expect(EmployeesServicesList.deleteEmployeeServiceByEmployee).toHaveBeenCalledWith({
employeeId: "emp-001",
});
expect(HeatMapList.deleteHeatMapByEmployee).toHaveBeenCalledWith({
employeeId: "emp-001",
});
});
// --- Admin with future appointments but no replacement → error ---
it("throws when employee has future appointments but no replacement provided", async () => {
const targetEmployee = buildEmployeeDoc({
userId: "target-user",
});
const adminEmployee = buildEmployeeDoc({
userId: "admin-user",
roles: [EmployeeRoles.ADMIN],
});
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
_id: "company-001",
ownerId: "owner-user",
});
(UsersManager.users.findOne as jest.Mock).mockResolvedValue({ _id: "admin-user" });
mockFindOne
.mockResolvedValueOnce(targetEmployee)
.mockResolvedValueOnce(adminEmployee);
// Simulate future appointments exist
mockAppointmentFind.mockResolvedValue([{ _id: "appt-1" }]);
await expect(
EmployeesList.removeCollaboratorFromCompany({
employeeId: "emp-001",
companyId: "company-001",
sessionUser: "admin-user",
})
).rejects.toThrow("turnos futuros asignados");
});
// --- Replacement from different company → error ---
it("throws when replacement employee belongs to a different company", async () => {
const targetEmployee = buildEmployeeDoc({
userId: "target-user",
});
const adminEmployee = buildEmployeeDoc({
userId: "admin-user",
roles: [EmployeeRoles.ADMIN],
});
const replacementFromOtherCompany = buildEmployeeDoc({
_id: "emp-replacement",
companyId: "other-company",
userId: "replacement-user",
removed: false,
});
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
_id: "company-001",
ownerId: "owner-user",
});
(UsersManager.users.findOne as jest.Mock).mockResolvedValue({ _id: "admin-user" });
mockFindOne
.mockResolvedValueOnce(targetEmployee)
.mockResolvedValueOnce(adminEmployee)
.mockResolvedValueOnce(replacementFromOtherCompany);
mockAppointmentFind.mockResolvedValue([{ _id: "appt-1" }]);
await expect(
EmployeesList.removeCollaboratorFromCompany({
employeeId: "emp-001",
companyId: "company-001",
replacementEmployeeId: "emp-replacement",
sessionUser: "admin-user",
})
).rejects.toThrow("no pertenece a esta organización");
});
// --- Replacement is self → error ---
it("throws when admin tries to designate themselves as replacement", async () => {
const targetEmployee = buildEmployeeDoc({
userId: "target-user",
});
const adminEmployee = buildEmployeeDoc({
_id: "emp-admin",
userId: "admin-user",
roles: [EmployeeRoles.ADMIN],
});
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
_id: "company-001",
ownerId: "owner-user",
});
(UsersManager.users.findOne as jest.Mock).mockResolvedValue({ _id: "admin-user" });
mockFindOne
.mockResolvedValueOnce(targetEmployee)
.mockResolvedValueOnce(adminEmployee)
.mockResolvedValueOnce(adminEmployee); // replacement is the admin themselves
mockAppointmentFind.mockResolvedValue([{ _id: "appt-1" }]);
await expect(
EmployeesList.removeCollaboratorFromCompany({
employeeId: "emp-001",
companyId: "company-001",
replacementEmployeeId: "emp-admin",
sessionUser: "admin-user",
})
).rejects.toThrow("designarte como reemplazo");
});
// --- Replacement does not exist → error ---
it("throws when replacement employee does not exist", async () => {
const targetEmployee = buildEmployeeDoc({
userId: "target-user",
});
const adminEmployee = buildEmployeeDoc({
userId: "admin-user",
roles: [EmployeeRoles.ADMIN],
});
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
_id: "company-001",
ownerId: "owner-user",
});
(UsersManager.users.findOne as jest.Mock).mockResolvedValue({ _id: "admin-user" });
mockFindOne
.mockResolvedValueOnce(targetEmployee)
.mockResolvedValueOnce(adminEmployee)
.mockResolvedValueOnce(null); // replacement not found
mockAppointmentFind.mockResolvedValue([{ _id: "appt-1" }]);
await expect(
EmployeesList.removeCollaboratorFromCompany({
employeeId: "emp-001",
companyId: "company-001",
replacementEmployeeId: "emp-nonexistent",
sessionUser: "admin-user",
})
).rejects.toThrow("empleado de reemplazo no existe");
});
// --- Removal with replacement proceeds when future appointments exist ---
it("successfully removes employee with replacement when future appointments exist", async () => {
const targetEmployee = buildEmployeeDoc({
userId: "target-user",
});
const adminEmployee = buildEmployeeDoc({
userId: "admin-user",
roles: [EmployeeRoles.ADMIN],
});
const replacementEmployee = buildEmployeeDoc({
_id: "emp-replacement",
companyId: "company-001",
userId: "replacement-user",
removed: false,
});
const targetUser = {
_id: "target-user",
firstName: "John",
lastName: "Doe",
email: "john@example.com",
avatar: "avatar-url",
};
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
_id: "company-001",
ownerId: "owner-user",
});
(UsersManager.users.findOne as jest.Mock)
.mockResolvedValueOnce({ _id: "admin-user" })
.mockResolvedValueOnce(targetUser);
mockFindOne
.mockResolvedValueOnce(targetEmployee)
.mockResolvedValueOnce(adminEmployee)
.mockResolvedValueOnce(replacementEmployee);
// Future appointments exist
const mockAppointment = { employeeId: "emp-001", save: jest.fn().mockResolvedValue(true) };
mockAppointmentFind.mockResolvedValue([mockAppointment]);
await EmployeesList.removeCollaboratorFromCompany({
employeeId: "emp-001",
companyId: "company-001",
replacementEmployeeId: "emp-replacement",
sessionUser: "admin-user",
});
expect(targetEmployee.removed).toBe(true);
expect(mockAppointment.employeeId).toBe("emp-replacement");
expect(mockAppointment.save).toHaveBeenCalled();
expect(EmployeesServicesList.deleteEmployeeServiceByEmployee).toHaveBeenCalledWith({
employeeId: "emp-001",
});
expect(HeatMapList.deleteHeatMapByEmployee).toHaveBeenCalledWith({
employeeId: "emp-001",
});
});
});
@@ -94,7 +94,11 @@ class EmployeesServicesManager implements IEmployeesServicesManager {
}); });
if (!employeeCheck) { if (!employeeCheck) {
throw new Error("El empleado no existe o no pertenece a la compaia."); throw new Error("El empleado no existe o no pertenece a la compañia.");
}
if (employeeCheck.removed) {
throw new Error("El empleado no existe o no pertenece a la compañia.");
} }
const employeeServiceCheck = await this.employeesServices.findOne({ const employeeServiceCheck = await this.employeesServices.findOne({
@@ -160,7 +164,11 @@ class EmployeesServicesManager implements IEmployeesServicesManager {
}); });
if (!employeeCheck) { if (!employeeCheck) {
throw new Error("El empleado no existe o no pertenece a la compaia."); throw new Error("El empleado no existe o no pertenece a la compañia.");
}
if (employeeCheck.removed) {
throw new Error("El empleado no existe o no pertenece a la compañia.");
} }
await this.employeesServices.update(data); await this.employeesServices.update(data);
@@ -253,7 +261,11 @@ class EmployeesServicesManager implements IEmployeesServicesManager {
}); });
if (!employeeCheck) { if (!employeeCheck) {
throw new Error("El empleado no existe o no pertenece a la compaia."); throw new Error("El empleado no existe o no pertenece a la compañia.");
}
if (employeeCheck.removed) {
throw new Error("El empleado no existe o no pertenece a la compañia.");
} }
const userCheck = await UsersManager.users.findOne({ const userCheck = await UsersManager.users.findOne({
@@ -330,7 +342,11 @@ class EmployeesServicesManager implements IEmployeesServicesManager {
_id: data.employeeId, _id: data.employeeId,
}); });
if (!employeeCheck) { if (!employeeCheck) {
throw new Error("El empleado no existe o no pertenece a la compaia."); throw new Error("El empleado no existe o no pertenece a la compañia.");
}
if (employeeCheck.removed) {
throw new Error("El empleado no existe o no pertenece a la compañia.");
} }
const serviceCheck = await ServicesManager.services.findOne({ const serviceCheck = await ServicesManager.services.findOne({
@@ -371,7 +387,11 @@ class EmployeesServicesManager implements IEmployeesServicesManager {
}); });
if (!employeeCheck) { if (!employeeCheck) {
throw new Error("El empleado no existe o no pertenece a la compaia."); throw new Error("El empleado no existe o no pertenece a la compañia.");
}
if (employeeCheck.removed) {
throw new Error("El empleado no existe o no pertenece a la compañia.");
} }
const userCheck = await UsersManager.users.findOne({ const userCheck = await UsersManager.users.findOne({
@@ -416,6 +436,15 @@ class EmployeesServicesManager implements IEmployeesServicesManager {
}); });
for (const service of servicesByEmployee) { for (const service of servicesByEmployee) {
const employeeDoc = await EmployeesList.employees.findOne({
_id: service.employeeId,
});
// Skip removed employees from public-facing results
if (!employeeDoc || employeeDoc.removed) {
continue;
}
const employeeData = await EmployeesList.findById({ id: service.employeeId }); const employeeData = await EmployeesList.findById({ id: service.employeeId });
returnData.push({ returnData.push({
@@ -0,0 +1,318 @@
// Mock dependencies
jest.mock("../../Users/Users", () => ({
__esModule: true,
default: {
users: {
findOne: jest.fn(),
},
getUserFullName: jest.fn(),
},
}));
jest.mock("../../Companies/Companies", () => ({
__esModule: true,
default: {
companies: {
findOne: jest.fn(),
},
},
}));
jest.mock("../../Services/Service", () => ({
__esModule: true,
default: {
services: {
find: jest.fn(),
findOne: jest.fn(),
},
},
}));
jest.mock("../../Notifications/Notifications", () => ({
__esModule: true,
NotificationsManager: {
sendSystemNotification: jest.fn(),
},
}));
jest.mock("../../Employees/Employee", () => {
const mockFindOne = jest.fn();
const mockFind = jest.fn();
const mockFindById = jest.fn();
return {
__esModule: true,
default: {
employees: {
findOne: mockFindOne,
find: mockFind,
employeeList: {
findOne: mockFindOne,
find: mockFind,
},
},
findById: mockFindById,
checkPermission: jest.fn().mockResolvedValue(true),
_mocks: { findOne: mockFindOne, find: mockFind, findById: mockFindById },
},
};
});
import EmployeesServicesList from "../EmployeesServices";
import EmployeesList from "../../Employees/Employee";
describe("EmployeesServices.findByService — removed employee exclusion", () => {
let mockEmployeesFindOne: jest.Mock;
let mockEmployeesFindById: jest.Mock;
let originalEmployeesServicesAdapter: any;
beforeEach(() => {
jest.clearAllMocks();
mockEmployeesFindOne = (EmployeesList as any)._mocks.findOne;
mockEmployeesFindById = (EmployeesList as any)._mocks.findById;
// Save and replace the employeesServices adapter
originalEmployeesServicesAdapter = EmployeesServicesList.employeesServices;
const mockESFindOne = jest.fn();
const mockESFind = jest.fn();
(EmployeesServicesList as any).employeesServices = {
findOne: mockESFindOne,
find: mockESFind,
create: jest.fn(),
delete: jest.fn(),
update: jest.fn(),
paginate: jest.fn(),
employeesServicesList: {
findOne: mockESFindOne,
find: mockESFind,
deleteMany: jest.fn(),
},
};
});
afterEach(() => {
(EmployeesServicesList as any).employeesServices = originalEmployeesServicesAdapter;
});
it("excludes removed employees from findByService results", async () => {
// Service assignments include one for an active employee and one for a removed employee
const activeServiceAssignment = {
_id: "es-001",
employeeId: "emp-active",
serviceId: "svc-001",
companyId: "company-001",
};
const removedServiceAssignment = {
_id: "es-002",
employeeId: "emp-removed",
serviceId: "svc-001",
companyId: "company-001",
};
const activeEmployeeDoc = {
_id: "emp-active",
id: "emp-active",
companyId: "company-001",
userId: "user-active",
removed: false,
};
const removedEmployeeDoc = {
_id: "emp-removed",
id: "emp-removed",
companyId: "company-001",
userId: "user-removed",
removed: true,
};
// employeesServices.find returns both assignments
const mockESFind = (EmployeesServicesList as any).employeesServices.find;
mockESFind.mockResolvedValue([activeServiceAssignment, removedServiceAssignment]);
// EmployeesList.employees.findOne returns the employee docs
mockEmployeesFindOne
.mockResolvedValueOnce(activeEmployeeDoc)
.mockResolvedValueOnce(removedEmployeeDoc);
// EmployeesList.findById for the active employee
mockEmployeesFindById.mockResolvedValue({
id: "emp-active",
fullName: "Active User",
avatar: "",
});
const result = await EmployeesServicesList.findByService({
serviceId: "svc-001",
});
// Only the active employee should appear in results
expect(result).toHaveLength(1);
expect(result[0].id).toBe("emp-active");
expect(result[0].name).toBe("Active User");
});
it("returns empty when all assigned employees are removed", async () => {
const removedServiceAssignment = {
_id: "es-001",
employeeId: "emp-removed",
serviceId: "svc-001",
companyId: "company-001",
};
const removedEmployeeDoc = {
_id: "emp-removed",
id: "emp-removed",
companyId: "company-001",
userId: "user-removed",
removed: true,
};
const mockESFind = (EmployeesServicesList as any).employeesServices.find;
mockESFind.mockResolvedValue([removedServiceAssignment]);
mockEmployeesFindOne.mockResolvedValueOnce(removedEmployeeDoc);
const result = await EmployeesServicesList.findByService({
serviceId: "svc-001",
});
expect(result).toEqual([]);
expect(result).toHaveLength(0);
// Prove the guard stopped processing: employee was found but removed,
// so findById should NEVER have been called for enrichment.
expect((EmployeesList as any)._mocks.findById).not.toHaveBeenCalled();
});
it("excludes employees whose Employee document is null (hard-deleted User)", async () => {
// Scenario: service assignment exists but the Employee record was hard-deleted
// (e.g. cascade cleanup). findByService must skip gracefully.
const activeServiceAssignment = {
_id: "es-001",
employeeId: "emp-active",
serviceId: "svc-001",
companyId: "company-001",
};
const orphanServiceAssignment = {
_id: "es-002",
employeeId: "emp-orphan",
serviceId: "svc-001",
companyId: "company-001",
};
const activeEmployeeDoc = {
_id: "emp-active",
id: "emp-active",
companyId: "company-001",
userId: "user-active",
removed: false,
};
const mockESFind = (EmployeesServicesList as any).employeesServices.find;
mockESFind.mockResolvedValue([activeServiceAssignment, orphanServiceAssignment]);
// First findOne: active employee → found; second: orphan → null (deleted)
mockEmployeesFindOne
.mockResolvedValueOnce(activeEmployeeDoc)
.mockResolvedValueOnce(null);
mockEmployeesFindById.mockResolvedValue({
id: "emp-active",
fullName: "Active User",
avatar: "",
});
const result = await EmployeesServicesList.findByService({
serviceId: "svc-001",
});
// Only the active employee should appear
expect(result).toHaveLength(1);
expect(result[0].id).toBe("emp-active");
// findById was called once for the active employee only
expect(mockEmployeesFindById).toHaveBeenCalledTimes(1);
expect(mockEmployeesFindById).toHaveBeenCalledWith({ id: "emp-active" });
});
it("does not call findById for removed employees, only for active ones", async () => {
// Proves the continue guard prevents enrichment calls for removed employees.
const activeAssignment = {
_id: "es-001",
employeeId: "emp-active",
serviceId: "svc-001",
companyId: "company-001",
};
const removedAssignment = {
_id: "es-002",
employeeId: "emp-removed",
serviceId: "svc-001",
companyId: "company-001",
};
const activeDoc = {
_id: "emp-active",
id: "emp-active",
removed: false,
};
const removedDoc = {
_id: "emp-removed",
id: "emp-removed",
removed: true,
};
const mockESFind = (EmployeesServicesList as any).employeesServices.find;
mockESFind.mockResolvedValue([activeAssignment, removedAssignment]);
mockEmployeesFindOne
.mockResolvedValueOnce(activeDoc)
.mockResolvedValueOnce(removedDoc);
mockEmployeesFindById.mockResolvedValue({
id: "emp-active",
fullName: "Active User",
avatar: "",
});
const result = await EmployeesServicesList.findByService({
serviceId: "svc-001",
});
// findById called ONCE (active only), never for the removed employee
expect(mockEmployeesFindById).toHaveBeenCalledTimes(1);
expect(mockEmployeesFindById).toHaveBeenCalledWith({ id: "emp-active" });
expect(result).toHaveLength(1);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Public Booking Path — Limitation Documentation
// ─────────────────────────────────────────────────────────────────────────────
//
// The actual public booking flow for collaborator resolution is:
//
// Frontend (Appointment.tsx)
// → POST /employees/employees-by-service
// → FindEmployeesByServiceController.findEmployeesByService()
// → new EmployeesService().findByService(requestBody) [thin wrapper: connect() + delegate]
// → EmployeesServicesList.findByService(data) [model — tested above]
//
// The model layer (EmployeesServicesManager.findByService) is the GATEKEEPER
// that applies the `removed` guard. The controller and service classes are thin
// pass-through wrappers with no additional filtering logic.
//
// Full-path integration testing (controller → service → model → DB) is not
// practically unit-testable because:
// 1. The service layer calls mongoose.connect() which requires a real DB
// 2. The controller instantiates the service class internally
// 3. 5+ collections would need seeding (Employees, EmployeesServices, Users, Companies)
//
// The model-level tests above provide the STRONGEST feasible coverage because:
// - They exercise the exact `removed` guard logic (line 444 of EmployeesServices.ts)
// - They prove findById enrichment is skipped for removed/null employees
// - They prove the correct filter is applied to service assignment queries
// - The controller/service layers add no filtering — they are pure delegation
// ─────────────────────────────────────────────────────────────────────────────
@@ -2,7 +2,9 @@ import { IMetricsDocument } from "./Metrics.Adapter.Mongoose";
export type MetricsParams = { export type MetricsParams = {
userId?: string; userId?: string;
companyId?: string;
quantity?: number; quantity?: number;
limit?: number;
}; };
export type DeleteMetricsByCompany = { export type DeleteMetricsByCompany = {
@@ -45,6 +47,8 @@ export interface IMetricsManager {
addEmployee(data: MetricsParams): Promise<void>; addEmployee(data: MetricsParams): Promise<void>;
addService(data: MetricsParams): Promise<void>; addService(data: MetricsParams): Promise<void>;
addAppointment(data: MetricsParams): Promise<void>; addAppointment(data: MetricsParams): Promise<void>;
reserveAppointment(data: MetricsParams): Promise<boolean>;
releaseAppointment(data: MetricsParams): Promise<void>;
addClient(data: MetricsParams): Promise<void>; addClient(data: MetricsParams): Promise<void>;
canAddOrganization(userId: string): Promise<boolean>; canAddOrganization(userId: string): Promise<boolean>;
canAddEmployee(userId: string): Promise<boolean>; canAddEmployee(userId: string): Promise<boolean>;
+52 -3
View File
@@ -14,6 +14,7 @@ import AppointmentList from "../Appointments/Appointments";
import ClientsList from "../Clients/Clients"; import ClientsList from "../Clients/Clients";
import RepeatsList from "../Repeats/Repeats"; import RepeatsList from "../Repeats/Repeats";
import { isNull } from "../../helpers/IsNull"; import { isNull } from "../../helpers/IsNull";
import PlanUsageCycleList from "../PlanUsageCycle/PlanUsageCycle";
class MetricsManager implements IMetricsManager { class MetricsManager implements IMetricsManager {
metrics: MetricsAdapterMongoose; metrics: MetricsAdapterMongoose;
@@ -39,7 +40,8 @@ class MetricsManager implements IMetricsManager {
for (const org of organizations) { for (const org of organizations) {
const employees = await EmployeesList.employees.find({ const employees = await EmployeesList.employees.find({
companyId: String(org.id), companyId: String(org.id),
}); removed: { $ne: true },
} as any);
employeesLength += employees.length; employeesLength += employees.length;
const services = await ServiceList.services.find({ const services = await ServiceList.services.find({
@@ -231,17 +233,64 @@ class MetricsManager implements IMetricsManager {
if (plan.limitAppointments < 0) { if (plan.limitAppointments < 0) {
return true; return true;
} }
const metrics = await this.getMetrics(userId); const appointmentsCount = await PlanUsageCycleList.getAppointmentsCount({ userId });
if (metrics.appointmentsCount >= plan.limitAppointments) { if (appointmentsCount >= plan.limitAppointments) {
return false; return false;
} }
return true; return true;
} }
public async addAppointment(data: MetricsParams): Promise<void> { public async addAppointment(data: MetricsParams): Promise<void> {
if (data.userId) {
await PlanUsageCycleList.addAppointment({
userId: data.userId,
quantity: data.quantity,
});
}
await this.metrics.addAppointment(data); await this.metrics.addAppointment(data);
} }
public async reserveAppointment(data: MetricsParams): Promise<boolean> {
if (!data.userId) {
return false;
}
const subscription = await PlanSubscriptionsList.findOne({
sessionUser: data.userId,
});
if (!subscription) {
return false;
}
const plan = await PlansList.plans.findOne({
_id: subscription.planId,
});
if (!plan) {
return false;
}
if (plan.limitAppointments < 0) {
await PlanUsageCycleList.addAppointment({
userId: data.userId,
quantity: data.quantity,
});
return true;
}
return await PlanUsageCycleList.reserveAppointment({
userId: data.userId,
quantity: data.quantity,
limit: plan.limitAppointments,
});
}
public async releaseAppointment(data: MetricsParams): Promise<void> {
if (data.userId) {
await PlanUsageCycleList.releaseAppointment({
userId: data.userId,
quantity: data.quantity,
});
}
}
public async addClient(data: MetricsParams): Promise<void> { public async addClient(data: MetricsParams): Promise<void> {
await this.metrics.addClient(data); await this.metrics.addClient(data);
} }
@@ -0,0 +1,220 @@
/**
* Tests for Metrics.calculateMetrics excludes removed employees from count.
*/
jest.mock("../../Companies/Companies", () => ({
__esModule: true,
default: {
companies: {
find: jest.fn(),
findOne: jest.fn(),
},
},
}));
jest.mock("../../Employees/Employee", () => ({
__esModule: true,
default: {
employees: {
find: jest.fn(),
},
},
}));
jest.mock("../../Services/Service", () => ({
__esModule: true,
default: {
services: {
find: jest.fn(),
},
},
}));
jest.mock("../../Appointments/Appointments", () => ({
__esModule: true,
default: {
countAppointmentsByMonth: jest.fn(),
},
}));
jest.mock("../../Clients/Clients", () => ({
__esModule: true,
default: {
clients: {
find: jest.fn(),
},
},
}));
jest.mock("../../Repeats/Repeats", () => ({
__esModule: true,
default: {
repeats: {
find: jest.fn(),
},
},
}));
jest.mock("../../PlanSubscriptions/PlanSubscriptons", () => ({
__esModule: true,
default: {
findOne: jest.fn(),
},
}));
jest.mock("../../Plans/Plans", () => ({
__esModule: true,
default: {
plans: {
findOne: jest.fn(),
},
},
}));
jest.mock("../../PlanUsageCycle/PlanUsageCycle", () => ({
__esModule: true,
default: {
getAppointmentsCount: jest.fn(),
},
}));
import MetricsList from "../Metrics";
import CompaniesList from "../../Companies/Companies";
import EmployeesList from "../../Employees/Employee";
import ServiceList from "../../Services/Service";
import AppointmentList from "../../Appointments/Appointments";
import ClientsList from "../../Clients/Clients";
import RepeatsList from "../../Repeats/Repeats";
describe("calculateMetrics — excludes removed employees", () => {
let originalAdapter: any;
beforeEach(() => {
jest.clearAllMocks();
originalAdapter = MetricsList.metrics;
const mockSave = jest.fn().mockResolvedValue(true);
const mockMetricsDoc = {
userId: "owner-user",
organizationsCount: 0,
employeesCount: 0,
servicesCount: 0,
appointmentsCount: 0,
clientsCount: 0,
repeatsCount: 0,
month: 6,
year: 2026,
save: mockSave,
};
(MetricsList as any).metrics = {
getMetrics: jest.fn().mockResolvedValue(mockMetricsDoc),
reset: jest.fn().mockResolvedValue(mockMetricsDoc),
};
});
afterEach(() => {
(MetricsList as any).metrics = originalAdapter;
});
it("counts only non-removed employees across companies", async () => {
// Two companies owned by the same user
(CompaniesList.companies.find as jest.Mock).mockResolvedValue([
{ _id: "company-001", id: "company-001" },
{ _id: "company-002", id: "company-002" },
]);
// Company 001: 2 active + 1 removed → should count 2
// Company 002: 1 active → should count 1
// Total: 3 employees (not 4)
(EmployeesList.employees.find as jest.Mock)
.mockResolvedValueOnce([
{ _id: "emp-1", removed: false },
{ _id: "emp-2", removed: false },
{ _id: "emp-3", removed: true }, // filtered by query but just in case
])
.mockResolvedValueOnce([
{ _id: "emp-4", removed: false },
]);
// Services: 2 in company-001, 1 in company-002
(ServiceList.services.find as jest.Mock)
.mockResolvedValueOnce([{ _id: "svc-1" }, { _id: "svc-2" }])
.mockResolvedValueOnce([{ _id: "svc-3" }]);
// Appointments count per company
(AppointmentList.countAppointmentsByMonth as jest.Mock)
.mockResolvedValueOnce(5)
.mockResolvedValueOnce(3);
// Clients per company
(ClientsList.clients.find as jest.Mock)
.mockResolvedValueOnce([{ _id: "c-1" }])
.mockResolvedValueOnce([{ _id: "c-2" }, { _id: "c-3" }]);
// Repeats per company
(RepeatsList.repeats.find as jest.Mock)
.mockResolvedValueOnce([])
.mockResolvedValueOnce([]);
await MetricsList.calculateMetrics({ userId: "owner-user" });
// Verify employee find was called with removed filter for BOTH companies
expect(EmployeesList.employees.find).toHaveBeenCalledWith(
expect.objectContaining({
companyId: "company-001",
removed: { $ne: true },
})
);
expect(EmployeesList.employees.find).toHaveBeenCalledWith(
expect.objectContaining({
companyId: "company-002",
removed: { $ne: true },
})
);
// Verify the metrics document was saved
const metricsDoc = await (MetricsList as any).metrics.getMetrics();
expect(metricsDoc.save).toHaveBeenCalled();
});
it("produces zero employee count when all employees are removed", async () => {
(CompaniesList.companies.find as jest.Mock).mockResolvedValue([
{ _id: "company-001", id: "company-001" },
]);
// All employees in this company are removed
(EmployeesList.employees.find as jest.Mock).mockResolvedValue([]);
(ServiceList.services.find as jest.Mock).mockResolvedValue([]);
(AppointmentList.countAppointmentsByMonth as jest.Mock).mockResolvedValue(0);
(ClientsList.clients.find as jest.Mock).mockResolvedValue([]);
(RepeatsList.repeats.find as jest.Mock).mockResolvedValue([]);
const mockSave = jest.fn().mockResolvedValue(true);
const mockMetricsDoc = {
userId: "owner-user",
organizationsCount: 0,
employeesCount: 5, // pre-existing count
servicesCount: 0,
appointmentsCount: 0,
clientsCount: 0,
repeatsCount: 0,
month: 6,
year: 2026,
save: mockSave,
};
(MetricsList as any).metrics = {
getMetrics: jest.fn().mockResolvedValue(mockMetricsDoc),
reset: jest.fn().mockResolvedValue(mockMetricsDoc),
};
await MetricsList.calculateMetrics({ userId: "owner-user" });
// employeesCount should be set to 0 (no non-removed employees)
expect(mockMetricsDoc.employeesCount).toBe(0);
expect(mockSave).toHaveBeenCalled();
});
});
@@ -0,0 +1,131 @@
import { Document, Model, Schema, model } from "mongoose";
import {
CreateJobParams,
INotificationJob,
INotificationJobAdapter,
NotificationJobStatus,
} from "./NotificationJobs.Interface";
export interface INotificationJobDocument
extends Omit<INotificationJob, "id">,
Document {}
export class NotificationJobsAdapterMongoose implements INotificationJobAdapter {
schema: Schema;
notificationJobList: Model<INotificationJobDocument>;
constructor() {
this.schema = new Schema(
{
companyId: {
type: Schema.Types.ObjectId,
required: true,
ref: "Company",
},
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: Object.values(NotificationJobStatus),
default: NotificationJobStatus.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({ companyId: 1, status: 1 });
this.schema.index({ appointmentId: 1 });
this.notificationJobList = model<INotificationJobDocument>(
"NotificationJob",
this.schema
);
}
public async create(data: CreateJobParams): Promise<INotificationJob> {
const doc = await this.notificationJobList.create({
...data,
status: NotificationJobStatus.PENDING,
attempts: 0,
maxAttempts: 3,
});
return doc.toObject() as INotificationJob;
}
public async findPendingDue(now: Date): Promise<INotificationJob[]> {
const docs = await this.notificationJobList
.find({
status: NotificationJobStatus.PENDING,
scheduledAt: { $lte: now },
})
.sort({ scheduledAt: 1 })
.exec();
return docs.map((d) => d.toObject() as INotificationJob);
}
public async updateStatus(
jobId: string,
status: NotificationJobStatus,
data?: { lastError?: string; nextRetryAt?: Date; providerResponse?: string }
): Promise<void> {
const update: Record<string, unknown> = { status };
if (status === NotificationJobStatus.SENT) {
update.sentAt = new Date();
}
if (data?.lastError !== undefined) update.lastError = data.lastError;
if (data?.nextRetryAt !== undefined) update.nextRetryAt = data.nextRetryAt;
if (data?.providerResponse !== undefined)
update.providerResponse = data.providerResponse;
await this.notificationJobList.updateOne({ _id: jobId }, { $set: update }).exec();
}
public async cancelByAppointment(appointmentId: string): Promise<void> {
await this.notificationJobList
.updateMany(
{
appointmentId,
status: NotificationJobStatus.PENDING,
},
{ $set: { status: NotificationJobStatus.CANCELLED } }
)
.exec();
}
public async findByAppointment(appointmentId: string): Promise<INotificationJob[]> {
const docs = await this.notificationJobList
.find({ appointmentId })
.exec();
return docs.map((d) => d.toObject() as INotificationJob);
}
}
@@ -0,0 +1,71 @@
export enum NotificationJobStatus {
PENDING = "pending",
PROCESSING = "processing",
SENT = "sent",
FAILED = "failed",
CANCELLED = "cancelled",
}
export type NotificationChannel = "whatsapp" | "email" | "system";
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 CreateJobParams {
companyId: string;
appointmentId: string;
clientId: string;
channel: NotificationChannel;
type: NotificationJobType;
scheduledAt: Date;
payload?: NotificationJobPayload;
}
export interface INotificationJobAdapter {
create(data: CreateJobParams): Promise<INotificationJob>;
findPendingDue(now: Date): Promise<INotificationJob[]>;
updateStatus(
jobId: string,
status: NotificationJobStatus,
data?: { lastError?: string; nextRetryAt?: Date; providerResponse?: string }
): Promise<void>;
cancelByAppointment(appointmentId: string): Promise<void>;
findByAppointment(appointmentId: string): Promise<INotificationJob[]>;
}
@@ -0,0 +1,28 @@
import {
CreateJobParams,
INotificationJob,
} from "./NotificationJobs.Interface";
import { NotificationJobsAdapterMongoose } from "./NotificationJobs.Adapter.Mongoose";
class NotificationJobManager {
adapter: NotificationJobsAdapterMongoose;
constructor() {
this.adapter = new NotificationJobsAdapterMongoose();
}
public async createJob(data: CreateJobParams): Promise<INotificationJob> {
return this.adapter.create(data);
}
public async cancelByAppointment(appointmentId: string): Promise<void> {
return this.adapter.cancelByAppointment(appointmentId);
}
public async findByAppointment(appointmentId: string): Promise<INotificationJob[]> {
return this.adapter.findByAppointment(appointmentId);
}
}
const NotificationJobsList = new NotificationJobManager();
export default NotificationJobsList;
@@ -0,0 +1,84 @@
import { Document, Model, Schema, model, models } from "mongoose";
import {
FindClientCompanyOverrideParams,
IClientCompanyNotificationOverride,
IClientCompanyNotificationOverrideAdapter,
} from "./ClientCompanyNotificationOverride.Interface";
export interface IClientCompanyNotificationOverrideDocument
extends Omit<IClientCompanyNotificationOverride, "id">,
Document {}
export class ClientCompanyNotificationOverrideAdapterMongoose
implements IClientCompanyNotificationOverrideAdapter
{
schema: Schema;
overrideList: Model<IClientCompanyNotificationOverrideDocument>;
constructor() {
this.schema = new Schema(
{
clientId: {
type: Schema.Types.ObjectId,
required: true,
ref: "Client",
},
companyId: {
type: Schema.Types.ObjectId,
required: true,
ref: "Company",
},
preferredChannels: {
type: [String],
required: true,
default: ["whatsapp", "email", "system"],
enum: ["whatsapp", "email", "system"],
},
mutedChannels: {
type: [String],
required: true,
default: [],
enum: ["whatsapp", "email", "system"],
},
},
{
timestamps: true,
}
);
this.schema.index({ clientId: 1, companyId: 1 }, { unique: true });
this.overrideList = models.ClientCompanyNotificationOverride ||
model<IClientCompanyNotificationOverrideDocument>(
"ClientCompanyNotificationOverride",
this.schema
);
}
public async findOne(
filters: FindClientCompanyOverrideParams
): Promise<IClientCompanyNotificationOverride | null> {
const doc = await this.overrideList.findOne(filters).exec();
return doc ? (doc.toObject() as IClientCompanyNotificationOverride) : null;
}
public async upsert(
clientId: string,
companyId: string,
data: Partial<
Omit<
IClientCompanyNotificationOverride,
"id" | "clientId" | "companyId" | "createdAt" | "updatedAt"
>
>
): Promise<IClientCompanyNotificationOverride> {
const doc = await this.overrideList
.findOneAndUpdate(
{ clientId, companyId },
{ $set: data },
{ upsert: true, new: true }
)
.exec();
return doc.toObject() as IClientCompanyNotificationOverride;
}
}
@@ -0,0 +1,32 @@
import { NotificationChannel } from "../NotificationJobs/NotificationJobs.Interface";
export interface IClientCompanyNotificationOverride {
id?: string;
clientId: string;
companyId: string;
preferredChannels: NotificationChannel[];
mutedChannels: NotificationChannel[];
createdAt: Date;
updatedAt: Date;
}
export interface FindClientCompanyOverrideParams {
clientId?: string;
companyId?: string;
}
export interface IClientCompanyNotificationOverrideAdapter {
findOne(
filters: FindClientCompanyOverrideParams
): Promise<IClientCompanyNotificationOverride | null>;
upsert(
clientId: string,
companyId: string,
data: Partial<
Omit<
IClientCompanyNotificationOverride,
"id" | "clientId" | "companyId" | "createdAt" | "updatedAt"
>
>
): Promise<IClientCompanyNotificationOverride>;
}
@@ -0,0 +1,71 @@
import { ClientCompanyNotificationOverrideAdapterMongoose } from "./ClientCompanyNotificationOverride.Adapter.Mongoose";
import {
IClientCompanyNotificationOverride,
IClientCompanyNotificationOverrideAdapter,
} from "./ClientCompanyNotificationOverride.Interface";
import ClientsList from "../Clients/Clients";
import { validatePermissionsByCompany, validateSessionUser } from "../../helpers/check";
type FindClientCompanyOverrideParams = {
clientId: string;
companyId: string;
sessionUser: string;
};
type UpsertClientCompanyOverrideParams = FindClientCompanyOverrideParams & {
data: Partial<
Omit<
IClientCompanyNotificationOverride,
"id" | "clientId" | "companyId" | "createdAt" | "updatedAt"
>
>;
};
class ClientCompanyNotificationOverrideManager {
overrides: IClientCompanyNotificationOverrideAdapter;
constructor() {
this.overrides = new ClientCompanyNotificationOverrideAdapterMongoose();
}
public async findOne(
params: FindClientCompanyOverrideParams
): Promise<IClientCompanyNotificationOverride | null> {
await this.validateAccess(params);
return this.overrides.findOne({
clientId: params.clientId,
companyId: params.companyId,
});
}
public async upsert(
params: UpsertClientCompanyOverrideParams
): Promise<IClientCompanyNotificationOverride> {
await this.validateAccess(params);
return this.overrides.upsert(params.clientId, params.companyId, params.data);
}
private async validateAccess(params: FindClientCompanyOverrideParams): Promise<void> {
await validateSessionUser({ sessionUser: params.sessionUser });
await validatePermissionsByCompany({
companyId: params.companyId,
sessionUser: params.sessionUser,
});
const client = await ClientsList.clients.findOne({ _id: params.clientId });
if (!client) {
throw new Error("El cliente no existe");
}
if (String(client.companyId) !== String(params.companyId)) {
throw new Error("El cliente no pertenece a la organización");
}
}
}
const ClientCompanyNotificationOverride = new ClientCompanyNotificationOverrideManager();
export default ClientCompanyNotificationOverride;
@@ -0,0 +1,70 @@
import { Document, Model, Schema, model, models } from "mongoose";
import {
FindClientPreferencesParams,
IClientNotificationPreferences,
IClientNotificationPreferencesAdapter,
} from "./ClientNotificationPreferences.Interface";
export interface IClientNotificationPreferencesDocument
extends Omit<IClientNotificationPreferences, "id">,
Document {}
export class ClientNotificationPreferencesAdapterMongoose
implements IClientNotificationPreferencesAdapter
{
schema: Schema;
preferencesList: Model<IClientNotificationPreferencesDocument>;
constructor() {
this.schema = new Schema(
{
userId: {
type: Schema.Types.ObjectId,
required: true,
unique: true,
ref: "User",
},
preferredChannels: {
type: [String],
required: true,
default: ["whatsapp", "email", "system"],
enum: ["whatsapp", "email", "system"],
},
mutedChannels: {
type: [String],
required: true,
default: [],
enum: ["whatsapp", "email", "system"],
},
},
{
timestamps: true,
}
);
this.preferencesList = models.ClientNotificationPreferences ||
model<IClientNotificationPreferencesDocument>(
"ClientNotificationPreferences",
this.schema
);
}
public async findOne(
filters: FindClientPreferencesParams
): Promise<IClientNotificationPreferences | null> {
const doc = await this.preferencesList.findOne(filters).exec();
return doc ? (doc.toObject() as IClientNotificationPreferences) : null;
}
public async upsert(
userId: string,
data: Partial<
Omit<IClientNotificationPreferences, "id" | "userId" | "createdAt" | "updatedAt">
>
): Promise<IClientNotificationPreferences> {
const doc = await this.preferencesList
.findOneAndUpdate({ userId }, { $set: data }, { upsert: true, new: true })
.exec();
return doc.toObject() as IClientNotificationPreferences;
}
}
@@ -0,0 +1,26 @@
import { NotificationChannel } from "../NotificationJobs/NotificationJobs.Interface";
export interface IClientNotificationPreferences {
id?: string;
userId: string;
preferredChannels: NotificationChannel[];
mutedChannels: NotificationChannel[];
createdAt: Date;
updatedAt: Date;
}
export interface FindClientPreferencesParams {
userId?: string;
}
export interface IClientNotificationPreferencesAdapter {
findOne(
filters: FindClientPreferencesParams
): Promise<IClientNotificationPreferences | null>;
upsert(
userId: string,
data: Partial<
Omit<IClientNotificationPreferences, "id" | "userId" | "createdAt" | "updatedAt">
>
): Promise<IClientNotificationPreferences>;
}
@@ -0,0 +1,51 @@
import { ClientNotificationPreferencesAdapterMongoose } from "./ClientNotificationPreferences.Adapter.Mongoose";
import {
IClientNotificationPreferences,
IClientNotificationPreferencesAdapter,
} from "./ClientNotificationPreferences.Interface";
import { validateSessionUser } from "../../helpers/check";
type FindClientPreferencesParams = {
userId: string;
sessionUser: string;
};
type UpsertClientPreferencesParams = FindClientPreferencesParams & {
data: Partial<
Omit<IClientNotificationPreferences, "id" | "userId" | "createdAt" | "updatedAt">
>;
};
class ClientNotificationPreferencesManager {
preferences: IClientNotificationPreferencesAdapter;
constructor() {
this.preferences = new ClientNotificationPreferencesAdapterMongoose();
}
public async findOne(
params: FindClientPreferencesParams
): Promise<IClientNotificationPreferences | null> {
await this.validateUserOwner(params);
return this.preferences.findOne({ userId: params.userId });
}
public async upsert(
params: UpsertClientPreferencesParams
): Promise<IClientNotificationPreferences> {
await this.validateUserOwner(params);
return this.preferences.upsert(params.userId, params.data);
}
private async validateUserOwner(params: FindClientPreferencesParams): Promise<void> {
await validateSessionUser({ sessionUser: params.sessionUser });
if (String(params.userId) !== String(params.sessionUser)) {
throw new Error("No tienes permisos para modificar preferencias de otro usuario");
}
}
}
const ClientNotificationPreferences = new ClientNotificationPreferencesManager();
export default ClientNotificationPreferences;
@@ -0,0 +1,83 @@
import { Document, Model, Schema, model, models } from "mongoose";
import {
FindCompanyPolicyParams,
ICompanyNotificationPolicy,
ICompanyNotificationPolicyAdapter,
} from "./CompanyNotificationPolicy.Interface";
export interface ICompanyNotificationPolicyDocument
extends Omit<ICompanyNotificationPolicy, "id">,
Document {}
export class CompanyNotificationPolicyAdapterMongoose
implements ICompanyNotificationPolicyAdapter
{
schema: Schema;
policyList: Model<ICompanyNotificationPolicyDocument>;
constructor() {
this.schema = new Schema(
{
companyId: {
type: Schema.Types.ObjectId,
required: true,
unique: true,
ref: "Company",
},
defaultChannels: {
type: [String],
required: true,
default: ["whatsapp", "email", "system"],
enum: ["whatsapp", "email", "system"],
},
mutedChannels: {
type: [String],
required: false,
default: [],
enum: ["whatsapp", "email", "system"],
},
timezone: {
type: String,
required: true,
default: "America/Argentina/Buenos_Aires",
},
quietHours: {
from: { type: String, required: false },
to: { type: String, required: false },
},
reminderRules: [
{
offset: { type: Number, required: true },
enabled: { type: Boolean, required: true, default: true },
},
],
},
{
timestamps: true,
}
);
this.policyList = models.CompanyNotificationPolicy ||
model<ICompanyNotificationPolicyDocument>(
"CompanyNotificationPolicy",
this.schema
);
}
public async findOne(
filters: FindCompanyPolicyParams
): Promise<ICompanyNotificationPolicy | null> {
const doc = await this.policyList.findOne(filters).exec();
return doc ? (doc.toObject() as ICompanyNotificationPolicy) : null;
}
public async upsert(
companyId: string,
data: Partial<Omit<ICompanyNotificationPolicy, "id" | "companyId" | "createdAt" | "updatedAt">>
): Promise<ICompanyNotificationPolicy> {
const doc = await this.policyList
.findOneAndUpdate({ companyId }, { $set: data }, { upsert: true, new: true })
.exec();
return doc.toObject() as ICompanyNotificationPolicy;
}
}
@@ -0,0 +1,35 @@
import { NotificationChannel } from "../NotificationJobs/NotificationJobs.Interface";
export interface QuietHours {
from: string; // "HH:mm" format, e.g. "22:00"
to: string; // "HH:mm" format, e.g. "07:00"
}
export interface ReminderRules {
offset: number; // minutes before appointment
enabled: boolean;
}
export interface ICompanyNotificationPolicy {
id?: string;
companyId: string;
defaultChannels: NotificationChannel[];
mutedChannels?: NotificationChannel[];
timezone: string;
quietHours?: QuietHours;
reminderRules?: ReminderRules[];
createdAt: Date;
updatedAt: Date;
}
export interface FindCompanyPolicyParams {
companyId?: string;
}
export interface ICompanyNotificationPolicyAdapter {
findOne(filters: FindCompanyPolicyParams): Promise<ICompanyNotificationPolicy | null>;
upsert(
companyId: string,
data: Partial<Omit<ICompanyNotificationPolicy, "id" | "companyId" | "createdAt" | "updatedAt">>
): Promise<ICompanyNotificationPolicy>;
}
@@ -0,0 +1,51 @@
import { CompanyNotificationPolicyAdapterMongoose } from "./CompanyNotificationPolicy.Adapter.Mongoose";
import {
ICompanyNotificationPolicy,
ICompanyNotificationPolicyAdapter,
} from "./CompanyNotificationPolicy.Interface";
import { validatePermissionsByCompany, validateSessionUser } from "../../helpers/check";
type FindCompanyPolicyParams = {
companyId: string;
sessionUser: string;
};
type UpsertCompanyPolicyParams = FindCompanyPolicyParams & {
data: Partial<
Omit<ICompanyNotificationPolicy, "id" | "companyId" | "createdAt" | "updatedAt">
>;
};
class CompanyNotificationPolicyManager {
policies: ICompanyNotificationPolicyAdapter;
constructor() {
this.policies = new CompanyNotificationPolicyAdapterMongoose();
}
public async findOne(
params: FindCompanyPolicyParams
): Promise<ICompanyNotificationPolicy | null> {
await validateSessionUser({ sessionUser: params.sessionUser });
await validatePermissionsByCompany({
companyId: params.companyId,
sessionUser: params.sessionUser,
});
return this.policies.findOne({ companyId: params.companyId });
}
public async upsert(params: UpsertCompanyPolicyParams): Promise<ICompanyNotificationPolicy> {
await validateSessionUser({ sessionUser: params.sessionUser });
await validatePermissionsByCompany({
companyId: params.companyId,
sessionUser: params.sessionUser,
});
return this.policies.upsert(params.companyId, params.data);
}
}
const CompanyNotificationPolicy = new CompanyNotificationPolicyManager();
export default CompanyNotificationPolicy;
@@ -0,0 +1,188 @@
import {
CreateJobParams,
} from "../NotificationJobs/NotificationJobs.Interface";
import { NotificationJobsAdapterMongoose } from "../NotificationJobs/NotificationJobs.Adapter.Mongoose";
import { PolicyResolver, ResolvedPolicy } from "./PolicyResolver";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
dayjs.extend(utc);
export interface CreateJobServiceParams {
companyId: string;
clientId: string;
appointmentId: string;
type: CreateJobParams["type"];
scheduledAt?: Date;
appointmentStart?: Date;
payload?: CreateJobParams["payload"];
reminderPayload?: CreateJobParams["payload"];
}
export class NotificationJobService {
private jobAdapter: NotificationJobsAdapterMongoose;
private policyResolver: PolicyResolver;
constructor() {
this.jobAdapter = new NotificationJobsAdapterMongoose();
this.policyResolver = new PolicyResolver();
}
async createJob(params: CreateJobServiceParams): Promise<any[]> {
const resolvedPolicy = await this.policyResolver.resolve(
params.companyId,
params.clientId
);
if (resolvedPolicy.channels.length === 0) {
return [];
}
const now = new Date();
const baseScheduledAt = params.scheduledAt || now;
const adjustedScheduledAt = this.adjustForQuietHours(
baseScheduledAt,
resolvedPolicy
);
const jobs: any[] = [];
for (const channel of resolvedPolicy.channels) {
const job = await this.jobAdapter.create({
companyId: params.companyId,
appointmentId: params.appointmentId,
clientId: params.clientId,
channel,
type: params.type,
scheduledAt: adjustedScheduledAt,
payload: params.payload,
});
jobs.push(job);
}
if (params.appointmentStart) {
const enabledReminderRules = (resolvedPolicy.reminderRules || []).filter(
(rule) => rule.enabled
);
for (const rule of enabledReminderRules) {
const reminderScheduledAt = this.adjustForQuietHours(
dayjs(params.appointmentStart).subtract(rule.offset, "minute").toDate(),
resolvedPolicy
);
if (reminderScheduledAt < now || reminderScheduledAt >= params.appointmentStart) {
continue;
}
for (const channel of resolvedPolicy.channels) {
const job = await this.jobAdapter.create({
companyId: params.companyId,
appointmentId: params.appointmentId,
clientId: params.clientId,
channel,
type: "reminder",
scheduledAt: reminderScheduledAt,
payload: params.reminderPayload,
});
jobs.push(job);
}
}
}
return jobs;
}
async createImmediateJob(params: CreateJobServiceParams): Promise<any[]> {
const resolvedPolicy = await this.policyResolver.resolve(
params.companyId,
params.clientId
);
if (resolvedPolicy.channels.length === 0) {
return [];
}
const scheduledAt = params.scheduledAt || new Date();
const jobs: any[] = [];
for (const channel of resolvedPolicy.channels) {
const job = await this.jobAdapter.create({
companyId: params.companyId,
appointmentId: params.appointmentId,
clientId: params.clientId,
channel,
type: params.type,
scheduledAt,
payload: params.payload,
});
jobs.push(job);
}
return jobs;
}
/**
* Cancel all pending jobs for an appointment.
* Called when an appointment is deleted or rescheduled.
*/
async cancelByAppointment(appointmentId: string): Promise<void> {
return this.jobAdapter.cancelByAppointment(appointmentId);
}
private adjustForQuietHours(scheduledAt: Date, policy: ResolvedPolicy): Date {
if (!policy.quietHours?.from || !policy.quietHours?.to) {
return scheduledAt;
}
const scheduled = dayjs.utc(scheduledAt);
const fromHour = parseInt(policy.quietHours.from.split(":")[0], 10);
const fromMinute = parseInt(policy.quietHours.from.split(":")[1], 10);
const toHour = parseInt(policy.quietHours.to.split(":")[0], 10);
const toMinute = parseInt(policy.quietHours.to.split(":")[1], 10);
const scheduledHour = scheduled.hour();
const scheduledMinute = scheduled.minute();
const isInsideQuietHours = this.isTimeInQuietHours(
scheduledHour,
scheduledMinute,
fromHour,
fromMinute,
toHour,
toMinute
);
if (isInsideQuietHours) {
// Shift to quiet hours end
let adjusted = scheduled.hour(toHour).minute(toMinute).second(0);
// If quiet hours cross midnight, only the pre-midnight segment ends next day.
if (toHour < fromHour && scheduledHour >= fromHour) {
adjusted = adjusted.add(1, "day");
}
return adjusted.toDate();
}
return scheduledAt;
}
private isTimeInQuietHours(
hour: number,
minute: number,
fromHour: number,
fromMinute: number,
toHour: number,
toMinute: number
): boolean {
const currentMinutes = hour * 60 + minute;
const fromMinutes = fromHour * 60 + fromMinute;
const toMinutes = toHour * 60 + toMinute;
if (fromMinutes < toMinutes) {
// Quiet hours within same day (e.g. 01:00-06:00)
return currentMinutes >= fromMinutes && currentMinutes < toMinutes;
} else {
// Quiet hours cross midnight (e.g. 22:00-07:00)
return currentMinutes >= fromMinutes || currentMinutes < toMinutes;
}
}
}
@@ -0,0 +1,158 @@
import { NotificationChannel } from "../NotificationJobs/NotificationJobs.Interface";
import { CompanyNotificationPolicyAdapterMongoose } from "./CompanyNotificationPolicy.Adapter.Mongoose";
import { ClientNotificationPreferencesAdapterMongoose } from "./ClientNotificationPreferences.Adapter.Mongoose";
import { ClientCompanyNotificationOverrideAdapterMongoose } from "./ClientCompanyNotificationOverride.Adapter.Mongoose";
import { IClientNotificationPreferences } from "./ClientNotificationPreferences.Interface";
import { IClientCompanyNotificationOverride } from "./ClientCompanyNotificationOverride.Interface";
import { ICompanyNotificationPolicy } from "./CompanyNotificationPolicy.Interface";
import PlanSubscriptionsList from "../PlanSubscriptions/PlanSubscriptons";
import { PlanFeatures } from "../Plans/Plans.interface";
import ClientsList from "../Clients/Clients";
const DEFAULT_TIMEZONE = "America/Argentina/Buenos_Aires";
const SYSTEM_DEFAULT_CHANNELS: NotificationChannel[] = ["whatsapp", "email", "system"];
const PLAN_CHANNEL_MAP: Record<string, PlanFeatures> = {
whatsapp: PlanFeatures.WAP_NOTIFICATIONS,
email: PlanFeatures.MAIL_NOTIFICATIONS,
};
export interface ResolvedPolicy {
channels: NotificationChannel[];
timezone: string;
quietHours?: { from: string; to: string };
reminderRules?: { offset: number; enabled: boolean }[];
}
export class PolicyResolver {
private companyPolicyAdapter: CompanyNotificationPolicyAdapterMongoose;
private clientPreferencesAdapter: ClientNotificationPreferencesAdapterMongoose;
private clientCompanyOverrideAdapter: ClientCompanyNotificationOverrideAdapterMongoose;
constructor() {
this.companyPolicyAdapter = new CompanyNotificationPolicyAdapterMongoose();
this.clientPreferencesAdapter = new ClientNotificationPreferencesAdapterMongoose();
this.clientCompanyOverrideAdapter = new ClientCompanyNotificationOverrideAdapterMongoose();
}
async resolve(companyId: string, clientId: string): Promise<ResolvedPolicy> {
// Level 2: ClientCompanyNotificationOverride (per-client per-org)
const override = await this.clientCompanyOverrideAdapter.findOne({
clientId,
companyId,
});
const client = await ClientsList.clients.findOne({ _id: clientId });
// Level 3: ClientNotificationPreferences (user global)
const clientPrefs = client?.userId
? await this.clientPreferencesAdapter.findOne({ userId: String(client.userId) })
: null;
// Level 4: CompanyNotificationPolicy (org defaults)
const companyPolicy = await this.companyPolicyAdapter.findOne({ companyId });
// Determine channels from cascade
let channels: NotificationChannel[];
let timezone: string;
let quietHours: ResolvedPolicy["quietHours"];
let reminderRules: ResolvedPolicy["reminderRules"];
if (override) {
channels = [...override.preferredChannels];
} else if (clientPrefs) {
channels = [...clientPrefs.preferredChannels];
} else if (companyPolicy) {
channels = [...companyPolicy.defaultChannels];
} else {
// Level 5: System defaults
channels = [...SYSTEM_DEFAULT_CHANNELS];
}
// Apply muted channels from the winning level only — do NOT merge across levels
const mutedChannels = this.getMutedChannels(override, clientPrefs, companyPolicy);
channels = channels.filter((ch) => !mutedChannels.includes(ch));
// Timezone and quiet hours from company policy
if (companyPolicy) {
timezone = companyPolicy.timezone;
quietHours = companyPolicy.quietHours;
reminderRules = companyPolicy.reminderRules;
} else {
timezone = DEFAULT_TIMEZONE;
}
// Level 1: Plan limits — filter channels by plan features
channels = await this.applyPlanLimits(companyId, channels);
return {
channels,
timezone,
quietHours,
reminderRules,
};
}
/**
* Returns muted channels from the winning cascade level only.
* Per spec, muted channels are NOT merged across levels.
* The level that determines preferredChannels also determines mutedChannels.
*/
private getMutedChannels(
override: IClientCompanyNotificationOverride | null,
clientPrefs: IClientNotificationPreferences | null,
companyPolicy: ICompanyNotificationPolicy | null
): NotificationChannel[] {
if (override) {
// Override wins — use only override's muted channels
return override.mutedChannels || [];
}
if (clientPrefs) {
// Client prefs wins — use only clientPrefs' muted channels
return clientPrefs.mutedChannels || [];
}
if (companyPolicy) {
// Company policy wins — use only companyPolicy' muted channels
return companyPolicy.mutedChannels || [];
}
// System defaults — no muted channels
return [];
}
private async applyPlanLimits(
companyId: string,
channels: NotificationChannel[]
): Promise<NotificationChannel[]> {
// Resolve the company owner's userId for plan feature checks
// Use lazy require to avoid circular dependency (PolicyResolver → CompaniesManager → Appointments → NotificationJobService → PolicyResolver)
const CompaniesManager = require("../Companies/Companies").default;
const company = await CompaniesManager.companies.findOne({ _id: companyId });
if (!company) {
// Company not found — return channels unchanged (fail-open)
return channels;
}
const ownerUserId = String(company.ownerId);
const filtered: NotificationChannel[] = [];
for (const channel of channels) {
const planFeature = PLAN_CHANNEL_MAP[channel];
if (!planFeature) {
// Channel has no plan restriction (e.g., system)
filtered.push(channel);
continue;
}
const canSend = await PlanSubscriptionsList.checkFeature({
userId: ownerUserId,
feature: planFeature,
});
if (canSend) {
filtered.push(channel);
}
}
return filtered;
}
}
@@ -0,0 +1,400 @@
import { NotificationJobStatus } from "../../NotificationJobs/NotificationJobs.Interface";
// Mock all external dependencies
jest.mock("../../NotificationJobs/NotificationJobs.Adapter.Mongoose", () => ({
NotificationJobsAdapterMongoose: jest.fn().mockImplementation(() => ({
create: jest.fn(),
})),
}));
jest.mock("../PolicyResolver", () => ({
PolicyResolver: jest.fn().mockImplementation(() => ({
resolve: jest.fn(),
})),
}));
jest.mock("../../PlanSubscriptions/PlanSubscriptons", () => ({
__esModule: true,
default: {
checkFeature: jest.fn(),
},
}));
import { NotificationJobService } from "../NotificationJobService";
import { ResolvedPolicy } from "../PolicyResolver";
describe("NotificationJobService.createJob", () => {
let service: NotificationJobService;
let mockJobCreate: jest.Mock;
let mockResolverResolve: jest.Mock;
beforeEach(() => {
jest.clearAllMocks();
jest.useRealTimers();
service = new NotificationJobService();
mockJobCreate = (service as any).jobAdapter.create as jest.Mock;
mockResolverResolve = (service as any).policyResolver.resolve as jest.Mock;
});
it("creates a job with resolved channels", async () => {
const resolvedPolicy: ResolvedPolicy = {
channels: ["whatsapp", "email"],
timezone: "America/Argentina/Buenos_Aires",
};
mockResolverResolve.mockResolvedValue(resolvedPolicy);
mockJobCreate.mockResolvedValue({
id: "job-1",
status: NotificationJobStatus.PENDING,
});
const result = await service.createJob({
companyId: "company-1",
clientId: "client-1",
appointmentId: "appt-1",
type: "creation",
});
expect(mockResolverResolve).toHaveBeenCalledWith("company-1", "client-1");
expect(mockJobCreate).toHaveBeenCalledTimes(2); // one per channel
expect(result).toHaveLength(2);
});
it("creates reminder jobs for each enabled rule and resolved channel in addition to immediate jobs", async () => {
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T14:00:00.000Z"));
const resolvedPolicy: ResolvedPolicy = {
channels: ["system", "email"],
timezone: "America/Argentina/Buenos_Aires",
reminderRules: [
{ offset: 2, enabled: true },
{ offset: 5, enabled: true },
],
};
mockResolverResolve.mockResolvedValue(resolvedPolicy);
mockJobCreate.mockResolvedValue({
id: "job-1",
status: NotificationJobStatus.PENDING,
});
const result = await service.createJob({
companyId: "company-1",
clientId: "client-1",
appointmentId: "appt-1",
type: "creation",
appointmentStart: new Date("2026-07-21T14:30:00.000Z"),
});
const createCalls = mockJobCreate.mock.calls.map((call) => call[0]);
const immediateJobs = createCalls.filter((job) => job.type === "creation");
const reminderJobs = createCalls.filter((job) => job.type === "reminder");
expect(mockJobCreate).toHaveBeenCalledTimes(6);
expect(result).toHaveLength(6);
expect(immediateJobs).toHaveLength(2);
expect(reminderJobs).toHaveLength(4);
expect(immediateJobs.map((job) => job.channel).sort()).toEqual(["email", "system"]);
expect(immediateJobs.map((job) => job.scheduledAt.toISOString())).toEqual([
"2026-07-21T14:00:00.000Z",
"2026-07-21T14:00:00.000Z",
]);
expect(reminderJobs.map((job) => job.channel).sort()).toEqual([
"email",
"email",
"system",
"system",
]);
expect(reminderJobs.map((job) => job.scheduledAt.toISOString()).sort()).toEqual([
"2026-07-21T14:25:00.000Z",
"2026-07-21T14:25:00.000Z",
"2026-07-21T14:28:00.000Z",
"2026-07-21T14:28:00.000Z",
]);
});
it("passes reminder snapshot payload to scheduled reminder jobs", async () => {
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T14:00:00.000Z"));
const resolvedPolicy: ResolvedPolicy = {
channels: ["whatsapp", "email"],
timezone: "America/Argentina/Buenos_Aires",
reminderRules: [{ offset: 30, enabled: true }],
};
const payload = { message: "Creation body" };
const reminderPayload = {
email: "client@example.com",
phoneNumber: "5491112345678",
companyOwnerId: "owner-1",
emailMessage: "Reminder email body",
wapMessage: "Reminder WAP body",
};
mockResolverResolve.mockResolvedValue(resolvedPolicy);
mockJobCreate.mockResolvedValue({ id: "job-1" });
await service.createJob({
companyId: "company-1",
clientId: "client-1",
appointmentId: "appt-1",
type: "creation",
appointmentStart: new Date("2026-07-21T15:00:00.000Z"),
payload,
reminderPayload,
});
const createCalls = mockJobCreate.mock.calls.map((call) => call[0]);
expect(createCalls.filter((job) => job.type === "creation")).toEqual([
expect.objectContaining({ channel: "whatsapp", payload }),
expect.objectContaining({ channel: "email", payload }),
]);
expect(createCalls.filter((job) => job.type === "reminder")).toEqual([
expect.objectContaining({ channel: "whatsapp", payload: reminderPayload }),
expect.objectContaining({ channel: "email", payload: reminderPayload }),
]);
});
it("skips reminder jobs scheduled in the past", async () => {
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T14:29:00.000Z"));
const resolvedPolicy: ResolvedPolicy = {
channels: ["system", "email"],
timezone: "America/Argentina/Buenos_Aires",
reminderRules: [
{ offset: 2, enabled: true },
{ offset: 5, enabled: true },
],
};
mockResolverResolve.mockResolvedValue(resolvedPolicy);
mockJobCreate.mockResolvedValue({ id: "job-1" });
await service.createJob({
companyId: "company-1",
clientId: "client-1",
appointmentId: "appt-1",
type: "creation",
appointmentStart: new Date("2026-07-21T14:30:00.000Z"),
});
const createCalls = mockJobCreate.mock.calls.map((call) => call[0]);
expect(createCalls.filter((job) => job.type === "creation")).toHaveLength(2);
expect(createCalls.filter((job) => job.type === "reminder")).toHaveLength(0);
});
it("skips reminder jobs adjusted by quiet hours after appointment start", async () => {
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T20:00:00.000Z"));
const resolvedPolicy: ResolvedPolicy = {
channels: ["system", "email"],
timezone: "America/Argentina/Buenos_Aires",
quietHours: { from: "22:00", to: "07:00" },
reminderRules: [{ offset: 15, enabled: true }],
};
mockResolverResolve.mockResolvedValue(resolvedPolicy);
mockJobCreate.mockResolvedValue({ id: "job-1" });
await service.createJob({
companyId: "company-1",
clientId: "client-1",
appointmentId: "appt-1",
type: "creation",
appointmentStart: new Date("2026-07-21T23:30:00.000Z"),
});
const createCalls = mockJobCreate.mock.calls.map((call) => call[0]);
expect(createCalls.filter((job) => job.type === "creation")).toHaveLength(2);
expect(createCalls.filter((job) => job.type === "reminder")).toHaveLength(0);
});
it("creates no jobs when resolved channels are empty", async () => {
const resolvedPolicy: ResolvedPolicy = {
channels: [],
timezone: "America/Argentina/Buenos_Aires",
};
mockResolverResolve.mockResolvedValue(resolvedPolicy);
const result = await service.createJob({
companyId: "company-1",
clientId: "client-1",
appointmentId: "appt-1",
type: "creation",
});
expect(mockJobCreate).not.toHaveBeenCalled();
expect(result).toHaveLength(0);
});
it("adjusts scheduledAt for quiet hours crossing midnight", async () => {
const resolvedPolicy: ResolvedPolicy = {
channels: ["system"],
timezone: "America/Argentina/Buenos_Aires",
quietHours: { from: "22:00", to: "07:00" },
};
mockResolverResolve.mockResolvedValue(resolvedPolicy);
mockJobCreate.mockResolvedValue({ id: "job-1" });
// scheduledAt at 23:30 UTC — should be adjusted to 07:00 next day UTC
const scheduledAt = new Date("2026-07-21T23:30:00.000Z");
await service.createJob({
companyId: "company-1",
clientId: "client-1",
appointmentId: "appt-1",
type: "creation",
scheduledAt,
});
const createCall = mockJobCreate.mock.calls[0][0];
// 23:30 is inside quiet hours (22:00-07:00), adjusted to 07:00 next day
expect(createCall.scheduledAt.getUTCHours()).toBe(7);
expect(createCall.scheduledAt.getUTCDate()).toBe(22); // next day
});
it("adjusts early morning quiet hours crossing midnight to the same day", async () => {
const resolvedPolicy: ResolvedPolicy = {
channels: ["system"],
timezone: "America/Argentina/Buenos_Aires",
quietHours: { from: "22:00", to: "07:00" },
};
mockResolverResolve.mockResolvedValue(resolvedPolicy);
mockJobCreate.mockResolvedValue({ id: "job-1" });
await service.createJob({
companyId: "company-1",
clientId: "client-1",
appointmentId: "appt-1",
type: "creation",
scheduledAt: new Date("2026-07-21T05:00:00.000Z"),
});
const createCall = mockJobCreate.mock.calls[0][0];
expect(createCall.scheduledAt.toISOString()).toBe("2026-07-21T07:00:00.000Z");
});
it("does not adjust scheduledAt when outside quiet hours", async () => {
const resolvedPolicy: ResolvedPolicy = {
channels: ["system"],
timezone: "America/Argentina/Buenos_Aires",
quietHours: { from: "22:00", to: "07:00" },
};
mockResolverResolve.mockResolvedValue(resolvedPolicy);
mockJobCreate.mockResolvedValue({ id: "job-1" });
// scheduledAt at 14:00 UTC — outside quiet hours (22:00-07:00)
const scheduledAt = new Date("2026-07-21T14:00:00.000Z");
await service.createJob({
companyId: "company-1",
clientId: "client-1",
appointmentId: "appt-1",
type: "creation",
scheduledAt,
});
const createCall = mockJobCreate.mock.calls[0][0];
// 14:00 is outside quiet hours, should remain unchanged
expect(createCall.scheduledAt.getUTCHours()).toBe(14);
expect(createCall.scheduledAt.getUTCDate()).toBe(21);
});
it("passes appointmentId and type to job creation", async () => {
const resolvedPolicy: ResolvedPolicy = {
channels: ["system"],
timezone: "America/Argentina/Buenos_Aires",
};
mockResolverResolve.mockResolvedValue(resolvedPolicy);
mockJobCreate.mockResolvedValue({ id: "job-1" });
const scheduledAt = new Date("2026-07-21T15:00:00.000Z");
await service.createJob({
companyId: "company-1",
clientId: "client-1",
appointmentId: "appt-42",
type: "reminder",
scheduledAt,
});
const createCall = mockJobCreate.mock.calls[0][0];
expect(createCall.appointmentId).toBe("appt-42");
expect(createCall.type).toBe("reminder");
expect(createCall.companyId).toBe("company-1");
expect(createCall.clientId).toBe("client-1");
});
it("creates immediate jobs without reminder offsets", async () => {
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T14:00:00.000Z"));
const resolvedPolicy: ResolvedPolicy = {
channels: ["system", "email"],
timezone: "America/Argentina/Buenos_Aires",
reminderRules: [{ offset: 30, enabled: true }],
};
mockResolverResolve.mockResolvedValue(resolvedPolicy);
mockJobCreate.mockResolvedValue({ id: "job-1" });
const result = await service.createImmediateJob({
companyId: "company-1",
clientId: "client-1",
appointmentId: "appt-1",
type: "reminder",
appointmentStart: new Date("2026-07-21T16:00:00.000Z"),
});
const createCalls = mockJobCreate.mock.calls.map((call) => call[0]);
expect(result).toHaveLength(2);
expect(mockJobCreate).toHaveBeenCalledTimes(2);
expect(createCalls).toEqual([
expect.objectContaining({
channel: "system",
type: "reminder",
scheduledAt: new Date("2026-07-21T14:00:00.000Z"),
}),
expect.objectContaining({
channel: "email",
type: "reminder",
scheduledAt: new Date("2026-07-21T14:00:00.000Z"),
}),
]);
});
it("creates immediate cancellation jobs with snapshot payload for each resolved channel", async () => {
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T14:00:00.000Z"));
const resolvedPolicy: ResolvedPolicy = {
channels: ["system", "email"],
timezone: "America/Argentina/Buenos_Aires",
};
const payload = {
email: "client@example.com",
phoneNumber: "5491112345678",
userId: "user-1",
companyOwnerId: "owner-1",
subject: "Turno cancelado en Barber Shop",
message: "Tu turno fue cancelado.",
};
mockResolverResolve.mockResolvedValue(resolvedPolicy);
mockJobCreate.mockResolvedValue({ id: "job-1" });
const result = await service.createJob({
companyId: "company-1",
clientId: "client-1",
appointmentId: "appt-1",
type: "cancellation",
scheduledAt: new Date(),
payload,
});
const createCalls = mockJobCreate.mock.calls.map((call) => call[0]);
expect(result).toHaveLength(2);
expect(createCalls).toEqual([
expect.objectContaining({
appointmentId: "appt-1",
channel: "system",
type: "cancellation",
scheduledAt: new Date("2026-07-21T14:00:00.000Z"),
payload,
}),
expect.objectContaining({
appointmentId: "appt-1",
channel: "email",
type: "cancellation",
scheduledAt: new Date("2026-07-21T14:00:00.000Z"),
payload,
}),
]);
});
});
@@ -0,0 +1,312 @@
import { NotificationChannel } from "../../NotificationJobs/NotificationJobs.Interface";
// Mock all external dependencies before importing the module under test
jest.mock("../../PlanSubscriptions/PlanSubscriptons", () => ({
__esModule: true,
default: {
checkFeature: jest.fn(),
},
}));
jest.mock("../CompanyNotificationPolicy.Adapter.Mongoose", () => ({
CompanyNotificationPolicyAdapterMongoose: jest.fn().mockImplementation(() => ({
findOne: jest.fn(),
})),
}));
jest.mock("../ClientNotificationPreferences.Adapter.Mongoose", () => ({
ClientNotificationPreferencesAdapterMongoose: jest.fn().mockImplementation(() => ({
findOne: jest.fn(),
})),
}));
jest.mock("../ClientCompanyNotificationOverride.Adapter.Mongoose", () => ({
ClientCompanyNotificationOverrideAdapterMongoose: jest.fn().mockImplementation(() => ({
findOne: jest.fn(),
})),
}));
jest.mock("../../Companies/Companies", () => ({
__esModule: true,
default: {
companies: {
findOne: jest.fn(),
},
},
}));
jest.mock("../../Clients/Clients", () => ({
__esModule: true,
default: {
clients: {
findOne: jest.fn(),
},
},
}));
import { PolicyResolver } from "../PolicyResolver";
import PlanSubscriptionsList from "../../PlanSubscriptions/PlanSubscriptons";
import CompaniesManager from "../../Companies/Companies";
import ClientsList from "../../Clients/Clients";
import { PlanFeatures } from "../../Plans/Plans.interface";
describe("PolicyResolver", () => {
let resolver: PolicyResolver;
let mockCompanyPolicyFindOne: jest.Mock;
let mockClientPreferencesFindOne: jest.Mock;
let mockOverrideFindOne: jest.Mock;
beforeEach(() => {
jest.clearAllMocks();
resolver = new PolicyResolver();
// Access the mocked adapter instances
mockCompanyPolicyFindOne = (
resolver as any
).companyPolicyAdapter.findOne as jest.Mock;
mockClientPreferencesFindOne = (
resolver as any
).clientPreferencesAdapter.findOne as jest.Mock;
mockOverrideFindOne = (
resolver as any
).clientCompanyOverrideAdapter.findOne as jest.Mock;
// Mock CompaniesManager to return a company with an ownerId
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
_id: "company-1",
ownerId: "owner-user-1",
});
(ClientsList.clients.findOne as jest.Mock).mockResolvedValue({
_id: "client-1",
userId: "client-user-1",
});
});
it("returns system defaults when no policies exist", async () => {
mockCompanyPolicyFindOne.mockResolvedValue(null);
mockClientPreferencesFindOne.mockResolvedValue(null);
mockOverrideFindOne.mockResolvedValue(null);
(PlanSubscriptionsList.checkFeature as jest.Mock).mockResolvedValue(true);
const result = await resolver.resolve("company-1", "client-1");
expect(result.channels).toEqual(
expect.arrayContaining(["whatsapp", "email", "system"])
);
expect(result.channels).toHaveLength(3);
});
it("uses company policy defaults when no client override exists", async () => {
mockCompanyPolicyFindOne.mockResolvedValue({
companyId: "company-1",
defaultChannels: ["email" as NotificationChannel],
timezone: "America/Argentina/Buenos_Aires",
});
mockClientPreferencesFindOne.mockResolvedValue(null);
mockOverrideFindOne.mockResolvedValue(null);
(PlanSubscriptionsList.checkFeature as jest.Mock).mockResolvedValue(true);
const result = await resolver.resolve("company-1", "client-1");
expect(result.channels).toEqual(["email"]);
});
it("client override wins over company default", async () => {
mockCompanyPolicyFindOne.mockResolvedValue({
companyId: "company-1",
defaultChannels: ["email" as NotificationChannel],
timezone: "America/Argentina/Buenos_Aires",
});
mockClientPreferencesFindOne.mockResolvedValue(null);
mockOverrideFindOne.mockResolvedValue({
clientId: "client-1",
companyId: "company-1",
preferredChannels: ["whatsapp" as NotificationChannel],
mutedChannels: [],
});
(PlanSubscriptionsList.checkFeature as jest.Mock).mockResolvedValue(true);
const result = await resolver.resolve("company-1", "client-1");
expect(result.channels).toEqual(["whatsapp"]);
});
it("client global preferences used when no override exists", async () => {
mockCompanyPolicyFindOne.mockResolvedValue({
companyId: "company-1",
defaultChannels: ["whatsapp" as NotificationChannel, "email" as NotificationChannel],
timezone: "America/Argentina/Buenos_Aires",
});
mockClientPreferencesFindOne.mockResolvedValue({
userId: "client-user-1",
preferredChannels: ["email" as NotificationChannel],
mutedChannels: [],
});
mockOverrideFindOne.mockResolvedValue(null);
(PlanSubscriptionsList.checkFeature as jest.Mock).mockResolvedValue(true);
const result = await resolver.resolve("company-1", "client-1");
expect(result.channels).toEqual(["email"]);
});
it("excludes muted channels from override", async () => {
mockCompanyPolicyFindOne.mockResolvedValue(null);
mockClientPreferencesFindOne.mockResolvedValue(null);
mockOverrideFindOne.mockResolvedValue({
clientId: "client-1",
companyId: "company-1",
preferredChannels: [
"whatsapp" as NotificationChannel,
"email" as NotificationChannel,
],
mutedChannels: ["whatsapp" as NotificationChannel],
});
(PlanSubscriptionsList.checkFeature as jest.Mock).mockResolvedValue(true);
const result = await resolver.resolve("company-1", "client-1");
expect(result.channels).toEqual(["email"]);
expect(result.channels).not.toContain("whatsapp");
});
it("excludes muted channels from client preferences", async () => {
mockCompanyPolicyFindOne.mockResolvedValue(null);
mockClientPreferencesFindOne.mockResolvedValue({
userId: "client-user-1",
preferredChannels: [
"whatsapp" as NotificationChannel,
"email" as NotificationChannel,
"system" as NotificationChannel,
],
mutedChannels: ["email" as NotificationChannel],
});
mockOverrideFindOne.mockResolvedValue(null);
(PlanSubscriptionsList.checkFeature as jest.Mock).mockResolvedValue(true);
const result = await resolver.resolve("company-1", "client-1");
expect(result.channels).toEqual(
expect.arrayContaining(["whatsapp", "system"])
);
expect(result.channels).not.toContain("email");
});
it("loads global preferences by the appointment client's userId", async () => {
mockCompanyPolicyFindOne.mockResolvedValue(null);
mockClientPreferencesFindOne.mockResolvedValue({
userId: "client-user-1",
preferredChannels: ["system" as NotificationChannel],
mutedChannels: [],
});
mockOverrideFindOne.mockResolvedValue(null);
(PlanSubscriptionsList.checkFeature as jest.Mock).mockResolvedValue(true);
const result = await resolver.resolve("company-1", "client-1");
expect(ClientsList.clients.findOne).toHaveBeenCalledWith({ _id: "client-1" });
expect(mockClientPreferencesFindOne).toHaveBeenCalledWith({ userId: "client-user-1" });
expect(result.channels).toEqual(["system"]);
});
it("plan blocks WhatsApp when feature not available", async () => {
mockCompanyPolicyFindOne.mockResolvedValue({
companyId: "company-1",
defaultChannels: [
"whatsapp" as NotificationChannel,
"email" as NotificationChannel,
],
timezone: "America/Argentina/Buenos_Aires",
});
mockClientPreferencesFindOne.mockResolvedValue(null);
mockOverrideFindOne.mockResolvedValue(null);
(PlanSubscriptionsList.checkFeature as jest.Mock).mockImplementation(
async (params: { feature: string }) => {
if (params.feature === PlanFeatures.WAP_NOTIFICATIONS) return false;
return true;
}
);
const result = await resolver.resolve("company-1", "client-1");
expect(result.channels).not.toContain("whatsapp");
expect(result.channels).toContain("email");
});
it("plan blocks email when feature not available", async () => {
mockCompanyPolicyFindOne.mockResolvedValue({
companyId: "company-1",
defaultChannels: [
"whatsapp" as NotificationChannel,
"email" as NotificationChannel,
],
timezone: "America/Argentina/Buenos_Aires",
});
mockClientPreferencesFindOne.mockResolvedValue(null);
mockOverrideFindOne.mockResolvedValue(null);
(PlanSubscriptionsList.checkFeature as jest.Mock).mockImplementation(
async (params: { feature: string }) => {
if (params.feature === PlanFeatures.MAIL_NOTIFICATIONS) return false;
return true;
}
);
const result = await resolver.resolve("company-1", "client-1");
expect(result.channels).not.toContain("email");
expect(result.channels).toContain("whatsapp");
});
it("returns timezone from company policy", async () => {
mockCompanyPolicyFindOne.mockResolvedValue({
companyId: "company-1",
defaultChannels: ["system" as NotificationChannel],
timezone: "America/New_York",
quietHours: { from: "22:00", to: "07:00" },
});
mockClientPreferencesFindOne.mockResolvedValue(null);
mockOverrideFindOne.mockResolvedValue(null);
(PlanSubscriptionsList.checkFeature as jest.Mock).mockResolvedValue(true);
const result = await resolver.resolve("company-1", "client-1");
expect(result.timezone).toBe("America/New_York");
expect(result.quietHours).toEqual({ from: "22:00", to: "07:00" });
});
it("returns default timezone when no company policy", async () => {
mockCompanyPolicyFindOne.mockResolvedValue(null);
mockClientPreferencesFindOne.mockResolvedValue(null);
mockOverrideFindOne.mockResolvedValue(null);
(PlanSubscriptionsList.checkFeature as jest.Mock).mockResolvedValue(true);
const result = await resolver.resolve("company-1", "client-1");
expect(result.timezone).toBe("America/Argentina/Buenos_Aires");
});
it("returns empty channels when all are muted via override", async () => {
mockCompanyPolicyFindOne.mockResolvedValue(null);
mockClientPreferencesFindOne.mockResolvedValue(null);
mockOverrideFindOne.mockResolvedValue({
clientId: "client-1",
companyId: "company-1",
preferredChannels: [
"whatsapp" as NotificationChannel,
"email" as NotificationChannel,
"system" as NotificationChannel,
],
mutedChannels: [
"whatsapp" as NotificationChannel,
"email" as NotificationChannel,
"system" as NotificationChannel,
],
});
(PlanSubscriptionsList.checkFeature as jest.Mock).mockResolvedValue(true);
const result = await resolver.resolve("company-1", "client-1");
expect(result.channels).toEqual([]);
});
});
@@ -0,0 +1,49 @@
import { Document, Model, Schema, model } from "mongoose";
import {
CreatePlanPaymentParams,
FindPlanPaymentsParams,
IPlanPayment,
IPlanPaymentsAdapter,
} from "./PlanPayments.interface";
export interface IPlanPaymentDocument extends Omit<IPlanPayment, "id">, Document {}
export class PlanPaymentsAdapterMongoose implements IPlanPaymentsAdapter {
schema: Schema;
planPaymentList: Model<IPlanPaymentDocument>;
constructor() {
this.schema = new Schema({
userId: { type: Schema.Types.ObjectId, required: true, ref: "User" },
subscriptionId: { type: Schema.Types.ObjectId, required: true, ref: "PlanSuscription" },
amount: { type: Number, required: true, default: 0 },
paymentDate: { type: Date, required: true, default: Date.now },
paymentMethod: { type: String, required: false },
status: { type: String, required: true },
transactionId: { type: String, required: false },
});
this.schema.index({ transactionId: 1 }, { unique: true, sparse: true });
this.planPaymentList = model<IPlanPaymentDocument>("PlanPayment", this.schema);
}
public async find(filters: FindPlanPaymentsParams): Promise<IPlanPayment[]> {
return this.planPaymentList.find(filters).exec();
}
public async create(data: CreatePlanPaymentParams): Promise<IPlanPayment> {
return this.planPaymentList.create(data);
}
public async createIfMissingByTransactionId(data: CreatePlanPaymentParams): Promise<IPlanPayment> {
if (!data.transactionId) {
return this.create(data);
}
return this.planPaymentList.findOneAndUpdate(
{ transactionId: data.transactionId },
{ $setOnInsert: data },
{ new: true, upsert: true, setDefaultsOnInsert: true }
).exec();
}
}
@@ -1,7 +1,12 @@
export type FindPlanPaymentsParams = { export type FindPlanPaymentsParams = {
_id?: string; _id?: string;
userId?: string;
subscriptionId?: string;
transactionId?: string;
}; };
export type CreatePlanPaymentParams = Omit<IPlanPayment, "id">;
export interface IPlanPayment { export interface IPlanPayment {
id?: string; id?: string;
userId: string; // Referencia al ID del usuario userId: string; // Referencia al ID del usuario
@@ -15,9 +20,13 @@ export interface IPlanPayment {
export interface IPlanPaymentsAdapter { export interface IPlanPaymentsAdapter {
find(filters: FindPlanPaymentsParams): Promise<IPlanPayment[]>; find(filters: FindPlanPaymentsParams): Promise<IPlanPayment[]>;
create(data: CreatePlanPaymentParams): Promise<IPlanPayment>;
createIfMissingByTransactionId(data: CreatePlanPaymentParams): Promise<IPlanPayment>;
} }
export interface IPlanPaymentsManager { export interface IPlanPaymentsManager {
planPayments: IPlanPaymentsAdapter; planPayments: IPlanPaymentsAdapter;
find(data: FindPlanPaymentsParams): Promise<IPlanPayment[]>; find(data: FindPlanPaymentsParams): Promise<IPlanPayment[]>;
create(data: CreatePlanPaymentParams): Promise<IPlanPayment>;
createIfMissingByTransactionId(data: CreatePlanPaymentParams): Promise<IPlanPayment>;
} }
@@ -0,0 +1,31 @@
import { PlanPaymentsAdapterMongoose } from "./PlanPayments.Adapter.Mongoose";
import {
CreatePlanPaymentParams,
FindPlanPaymentsParams,
IPlanPayment,
IPlanPaymentsManager,
} from "./PlanPayments.interface";
class PlanPaymentsManager implements IPlanPaymentsManager {
planPayments: PlanPaymentsAdapterMongoose;
constructor() {
this.planPayments = new PlanPaymentsAdapterMongoose();
}
public async find(data: FindPlanPaymentsParams): Promise<IPlanPayment[]> {
return this.planPayments.find(data);
}
public async create(data: CreatePlanPaymentParams): Promise<IPlanPayment> {
return this.planPayments.create(data);
}
public async createIfMissingByTransactionId(data: CreatePlanPaymentParams): Promise<IPlanPayment> {
return this.planPayments.createIfMissingByTransactionId(data);
}
}
const PlanPaymentsList = new PlanPaymentsManager();
export default PlanPaymentsList;
@@ -1,5 +1,4 @@
import dayjs from "dayjs";
import { import {
FindPlanSuscripcionsParams, FindPlanSuscripcionsParams,
IPlanSuscriptionsAdapter, IPlanSuscriptionsAdapter,
@@ -38,6 +37,26 @@ export class PlanSuscriptionsAdapterMongoose implements IPlanSuscriptionsAdapter
mpDateCreated: { type: Date, required: false }, mpDateCreated: { type: Date, required: false },
mpInitPoint: { type: String, required: false }, mpInitPoint: { type: String, required: false },
mpPreferenceId: { type: String, required: false }, mpPreferenceId: { type: String, required: false },
billingMonths: { type: Number, required: false },
pendingPaymentInitPoint: { type: String, required: false },
pendingPaymentPreferenceId: { type: String, required: false },
pendingPaymentBillingMonths: { type: Number, required: false },
pendingPaymentType: { type: String, required: false, enum: ["extension", "upgrade"] },
pendingPaymentPlanId: { type: Schema.Types.ObjectId, required: false, ref: "Plan" },
pendingPaymentCurrentPlanPrice: { type: Number, required: false },
pendingPaymentRequestedPlanPrice: { type: Number, required: false },
pendingPaymentProratedAmount: { type: Number, required: false },
pendingPaymentRemainingDays: { type: Number, required: false },
pendingPaymentPeriodEndDate: { type: Date, required: false },
lastPaymentStatus: { type: String, required: false, enum: ["rejected", "failed", "cancelled"] },
lastPaymentPreferenceId: { type: String, required: false },
lastPaymentAt: { type: Date, required: false },
lastPaymentType: { type: String, required: false, enum: ["new", "extension", "upgrade"] },
downgradedFromPlanId: { type: Schema.Types.ObjectId, required: false, ref: "Plan" },
downgradedFromPlanName: { type: String, required: false },
downgradedFromPlanCode: { type: String, required: false },
downgradedAt: { type: Date, required: false },
downgradeReason: { type: String, required: false, enum: ["expired"] },
}); });
this.planSuscriptionList = model<IPlanSuscriptionDocument>("PlanSuscription", this.schema); this.planSuscriptionList = model<IPlanSuscriptionDocument>("PlanSuscription", this.schema);
@@ -54,20 +73,32 @@ export class PlanSuscriptionsAdapterMongoose implements IPlanSuscriptionsAdapter
public async findOne( public async findOne(
filters: FindPlanSuscripcionsParams filters: FindPlanSuscripcionsParams
): Promise<IPlanSuscriptionDocument | null> { ): Promise<IPlanSuscriptionDocument | null> {
let subscription = await this.planSuscriptionList const searchCriteria: Record<string, unknown> = {};
.findOne({ userId: filters.sessionUser })
.exec(); if (filters.sessionUser) {
searchCriteria.userId = filters.sessionUser;
}
if (filters.mpPreferenceId) {
searchCriteria.mpPreferenceId = filters.mpPreferenceId;
}
if (filters.mpStatus) {
searchCriteria.mpStatus = filters.mpStatus;
}
let subscription = await this.planSuscriptionList.findOne(searchCriteria).exec();
if (!subscription) { if (!subscription) {
return null; return null;
} }
const dateNow = dayjs(new Date()); const dateNow = new Date();
const dateEnd = dayjs(subscription.endDate); const dateEnd = new Date(subscription.endDate);
try { try {
if (subscription.mpPreferenceId) { if (subscription.mpPreferenceId) {
if (dateNow.isAfter(dateEnd)) { if (dateNow > dateEnd) {
if (subscription.mpStatus == MP_SUBS_STATUS.CANCELLED) { if (subscription.mpStatus == MP_SUBS_STATUS.CANCELLED) {
subscription.mpPreferenceId = ""; subscription.mpPreferenceId = "";
subscription.isActive = false; subscription.isActive = false;
@@ -77,17 +108,7 @@ export class PlanSuscriptionsAdapterMongoose implements IPlanSuscriptionsAdapter
await subscription.save(); await subscription.save();
return subscription; return subscription;
} }
// Without subscriptions, we wait for the webhook to update the status. // Pending Checkout Preferences do not consume paid time; webhook decides the final status.
// If it's expired and not updated by webhook, we just cancel it.
if (subscription.mpStatus === MP_SUBS_STATUS.PENDING) {
subscription.mpPreferenceId = "";
subscription.isActive = false;
subscription.autoRenew = false;
subscription.mpStatus = MP_SUBS_STATUS.CANCELLED;
subscription.mpInitPoint = "";
await subscription.save();
return subscription;
}
} }
} }
} catch (e) { } catch (e) {
@@ -3,6 +3,8 @@ import { IPlanSuscriptionDocument } from "./PlanSubscriptions.Adapter.Mongoose";
export type FindPlanSuscripcionsParams = { export type FindPlanSuscripcionsParams = {
sessionUser: string; sessionUser: string;
mpPreferenceId?: string;
mpStatus?: string;
}; };
export type CreatePlanSuscriptionParams = { export type CreatePlanSuscriptionParams = {
@@ -11,6 +13,7 @@ export type CreatePlanSuscriptionParams = {
mpPlanId: string; mpPlanId: string;
startDate: Date; startDate: Date;
endDate: Date; endDate: Date;
isActive?: boolean;
autoRenew: boolean; autoRenew: boolean;
mpPayerId?: number; mpPayerId?: number;
mpPayerEmail?: string; mpPayerEmail?: string;
@@ -18,6 +21,26 @@ export type CreatePlanSuscriptionParams = {
mpDateCreated?: Date; mpDateCreated?: Date;
mpInitPoint?: string; mpInitPoint?: string;
mpPreferenceId?: string; mpPreferenceId?: string;
billingMonths?: number;
pendingPaymentInitPoint?: string;
pendingPaymentPreferenceId?: string;
pendingPaymentBillingMonths?: number;
pendingPaymentType?: "extension" | "upgrade";
pendingPaymentPlanId?: string;
pendingPaymentCurrentPlanPrice?: number;
pendingPaymentRequestedPlanPrice?: number;
pendingPaymentProratedAmount?: number;
pendingPaymentRemainingDays?: number;
pendingPaymentPeriodEndDate?: Date;
lastPaymentStatus?: LastPlanPaymentStatus;
lastPaymentPreferenceId?: string;
lastPaymentAt?: Date;
lastPaymentType?: LastPlanPaymentType;
downgradedFromPlanId?: string;
downgradedFromPlanName?: string;
downgradedFromPlanCode?: string;
downgradedAt?: Date;
downgradeReason?: DowngradeReason;
}; };
export type CancellPlanSuscriptionParams = { export type CancellPlanSuscriptionParams = {
@@ -47,6 +70,7 @@ export type MPPreApprovalResponse = {
export type ToFreePlanParams = { export type ToFreePlanParams = {
sessionUser: string; sessionUser: string;
downgradeReason?: DowngradeReason;
}; };
export type ToMpParams = { export type ToMpParams = {
@@ -68,6 +92,23 @@ export type GetSubscriptionInitPointParams = {
sessionUser: string; sessionUser: string;
}; };
export type VerifyPendingPlanPaymentParams = {
sessionUser: string;
};
export type VerifyPendingPlanPaymentResponse = {
status: "approved" | "pending" | "not_found" | "rejected" | "failed" | "cancelled";
approved: boolean;
message: string;
subscriptionId?: string;
paymentId?: string;
paymentType?: "new" | "extension" | "upgrade";
};
export type LastPlanPaymentStatus = "rejected" | "failed" | "cancelled";
export type LastPlanPaymentType = "new" | "extension" | "upgrade";
export type DowngradeReason = "expired";
export interface GetSuscriptionInitPointResponse { export interface GetSuscriptionInitPointResponse {
init_point: string; init_point: string;
} }
@@ -88,6 +129,26 @@ export interface IPlanSuscription {
mpDateCreated: Date; // Fecha de creación de la suscripción en MercadoPago. mpDateCreated: Date; // Fecha de creación de la suscripción en MercadoPago.
mpInitPoint: string; // URL de inicio de la suscripción en MercadoPago. mpInitPoint: string; // URL de inicio de la suscripción en MercadoPago.
mpPreferenceId: string; // ID de la preferencia en MercadoPago. mpPreferenceId: string; // ID de la preferencia en MercadoPago.
billingMonths?: number; // Cantidad de meses comprados para pagos únicos.
pendingPaymentInitPoint?: string; // URL de pago pendiente para extender el plan actual.
pendingPaymentPreferenceId?: string; // ID de preferencia pendiente para extender el plan actual.
pendingPaymentBillingMonths?: number; // Meses pendientes de acreditar al aprobarse la extensión.
pendingPaymentType?: "extension" | "upgrade";
pendingPaymentPlanId?: string;
pendingPaymentCurrentPlanPrice?: number;
pendingPaymentRequestedPlanPrice?: number;
pendingPaymentProratedAmount?: number;
pendingPaymentRemainingDays?: number;
pendingPaymentPeriodEndDate?: Date;
lastPaymentStatus?: LastPlanPaymentStatus;
lastPaymentPreferenceId?: string;
lastPaymentAt?: Date;
lastPaymentType?: LastPlanPaymentType;
downgradedFromPlanId?: string;
downgradedFromPlanName?: string;
downgradedFromPlanCode?: string;
downgradedAt?: Date;
downgradeReason?: DowngradeReason;
} }
export interface ISubscriptionInfo { export interface ISubscriptionInfo {
@@ -99,6 +160,18 @@ export interface ISubscriptionInfo {
autoRenew: boolean; autoRenew: boolean;
mpStatus: string; mpStatus: string;
mpDateCreated: Date; mpDateCreated: Date;
pendingPaymentInitPoint?: string;
pendingPaymentPreferenceId?: string;
pendingPaymentType?: "extension" | "upgrade";
lastPaymentStatus?: LastPlanPaymentStatus;
lastPaymentPreferenceId?: string;
lastPaymentAt?: Date;
lastPaymentType?: LastPlanPaymentType;
downgradedFromPlanId?: string;
downgradedFromPlanName?: string;
downgradedFromPlanCode?: string;
downgradedAt?: Date;
downgradeReason?: DowngradeReason;
} }
export interface IPlanSuscriptionsAdapter { export interface IPlanSuscriptionsAdapter {
@@ -0,0 +1,11 @@
const DEFAULT_BILLING_PERIOD_DAYS = 30;
export function calculateProratedUpgradeAmount(
currentPlanPrice: number,
requestedPlanPrice: number,
remainingDays: number,
billingPeriodDays = DEFAULT_BILLING_PERIOD_DAYS
): number {
const dailyDifference = (requestedPlanPrice - currentPlanPrice) / billingPeriodDays;
return Math.round(dailyDifference * remainingDays * 100) / 100;
}
@@ -23,7 +23,6 @@ import {
import axios from "axios"; import axios from "axios";
import PlansList from "../../Models/Plans/Plans"; import PlansList from "../../Models/Plans/Plans";
import dayjs from "dayjs"; import dayjs from "dayjs";
import isSameOrAfter from "dayjs/plugin/isSameOrAfter";
import "dayjs/locale/es"; import "dayjs/locale/es";
import { isNull } from "../../helpers/IsNull"; import { isNull } from "../../helpers/IsNull";
import { PlanFeatures } from "../Plans/Plans.interface"; import { PlanFeatures } from "../Plans/Plans.interface";
@@ -31,9 +30,11 @@ import { NotificationsManager } from "../Notifications/Notifications";
import WapServerList from "../WapServer/WapServer"; import WapServerList from "../WapServer/WapServer";
import CompaniesList from "../Companies/Companies"; import CompaniesList from "../Companies/Companies";
import { NotificationType } from "../../Models/SystemNotifications/SystemNotification.Interface"; import { NotificationType } from "../../Models/SystemNotifications/SystemNotification.Interface";
import { calculateProratedUpgradeAmount } from "./PlanSubscriptions.pricing";
dayjs.locale("es"); dayjs.locale("es");
dayjs.extend(isSameOrAfter);
const DAY_MS = 24 * 60 * 60 * 1000;
class PlanSuscriptionsManager implements IPlanSuscriptionsManager { class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
planSuscriptions: PlanSuscriptionsAdapterMongoose; planSuscriptions: PlanSuscriptionsAdapterMongoose;
@@ -42,6 +43,24 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
this.planSuscriptions = new PlanSuscriptionsAdapterMongoose(); this.planSuscriptions = new PlanSuscriptionsAdapterMongoose();
} }
private shouldDowngradeExpiredSubscription(subscription: IPlanSuscriptionDocument): boolean {
return subscription.mpStatus === MP_SUBS_STATUS.AUTHORIZED || subscription.mpStatus === MP_SUBS_STATUS.CANCELLED;
}
private buildMercadoPagoBackUrl(returnStatus: "success" | "failure" | "pending"): string {
const backUrl = process.env.MP_BACK_URL || "";
if (!backUrl) return backUrl;
try {
const url = new URL(backUrl);
url.searchParams.set("mp_return", returnStatus);
return url.toString();
} catch {
const separator = backUrl.includes("?") ? "&" : "?";
return `${backUrl}${separator}mp_return=${returnStatus}`;
}
}
public async getInitPoint( public async getInitPoint(
data: GetSubscriptionInitPointParams data: GetSubscriptionInitPointParams
): Promise<GetSuscriptionInitPointResponse> { ): Promise<GetSuscriptionInitPointResponse> {
@@ -135,8 +154,9 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
planId: data.planId, planId: data.planId,
mpPlanId: data.planId, mpPlanId: data.planId,
startDate: subscriptionStartDate.toDate(), startDate: subscriptionStartDate.toDate(),
endDate: subscriptionStartDate.clone().add(data.months || 1, "months").toDate(), endDate: subscriptionStartDate.clone().add(10, "years").toDate(),
autoRenew: true, autoRenew: false,
mpStatus: "",
mpInitPoint: "/landing/dashboard", mpInitPoint: "/landing/dashboard",
}); });
} else if (plan.price > 0) { } else if (plan.price > 0) {
@@ -168,8 +188,16 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
if (subscription) { if (subscription) {
const subscriptionStartDate = dayjs(new Date()); const subscriptionStartDate = dayjs(new Date());
subscription.endDate = subscriptionStartDate.clone().add(data.months || 1, "months").toDate(); subscription.startDate = subscriptionStartDate.toDate();
subscription.endDate = subscriptionStartDate.clone().add(10, "years").toDate();
subscription.autoRenew = false;
subscription.mpStatus = "";
subscription.mpInitPoint = dashboardUrl || ""; subscription.mpInitPoint = dashboardUrl || "";
subscription.downgradedFromPlanId = undefined;
subscription.downgradedFromPlanName = undefined;
subscription.downgradedFromPlanCode = undefined;
subscription.downgradedAt = undefined;
subscription.downgradeReason = undefined;
await subscription.save(); await subscription.save();
} }
} else { } else {
@@ -182,6 +210,23 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
months: data.months || 1, months: data.months || 1,
}); });
} else { } else {
const isActivePaidSubscription =
actualPlan.price > 0 &&
(subscription.mpStatus === MP_SUBS_STATUS.AUTHORIZED ||
subscription.mpStatus === MP_SUBS_STATUS.CANCELLED) &&
dayjs(subscription.endDate).isAfter(new Date());
if (isActivePaidSubscription && actualPlan.id !== data.planId) {
if (plan.price <= actualPlan.price) {
throw new Error("No se puede cambiar a un plan igual o inferior desde esta ruta de actualización.");
}
subscription = await this.toMP({
planId: data.planId,
sessionUser: data.sessionUser,
months: data.months || 1,
});
} else {
if (subscription.mpStatus != MP_SUBS_STATUS.CANCELLED && actualPlan.id !== data.planId) { if (subscription.mpStatus != MP_SUBS_STATUS.CANCELLED && actualPlan.id !== data.planId) {
throw new Error( throw new Error(
"No se puede cambiar el plan, ya que el usuario ya tiene una suscripción activa. Para cambiarlo primero debes cancelar tu plan actual." "No se puede cambiar el plan, ya que el usuario ya tiene una suscripción activa. Para cambiarlo primero debes cancelar tu plan actual."
@@ -193,6 +238,7 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
sessionUser: data.sessionUser, sessionUser: data.sessionUser,
months: data.months || 1, months: data.months || 1,
}); });
}
} }
} }
} }
@@ -227,7 +273,7 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
} }
try { try {
const subscriptionStartDate = dayjs(new Date()); const preferenceDate = dayjs(new Date());
let discount = 0; let discount = 0;
if (data.months === 3) discount = plan.discount3Months || 0; if (data.months === 3) discount = plan.discount3Months || 0;
@@ -237,21 +283,74 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
const totalPrice = plan.price * data.months; const totalPrice = plan.price * data.months;
const finalPrice = totalPrice - (totalPrice * (discount / 100)); const finalPrice = totalPrice - (totalPrice * (discount / 100));
const currentSubscription = await this.planSuscriptions.findOne({
sessionUser: data.sessionUser,
});
const currentPlan = currentSubscription
? await PlansList.plans.findOne({ _id: String(currentSubscription.planId) })
: null;
const isUpgradePayment =
currentSubscription &&
currentPlan &&
String(currentSubscription.planId) !== String(data.planId) &&
currentPlan.price > 0 &&
plan.price > currentPlan.price &&
(currentSubscription.mpStatus === MP_SUBS_STATUS.AUTHORIZED ||
currentSubscription.mpStatus === MP_SUBS_STATUS.CANCELLED) &&
dayjs(currentSubscription.endDate).isAfter(preferenceDate);
const remainingDays = isUpgradePayment
? Math.max(0, Math.ceil((new Date(currentSubscription.endDate).getTime() - preferenceDate.toDate().getTime()) / DAY_MS))
: 0;
const proratedAmount = isUpgradePayment
? calculateProratedUpgradeAmount(currentPlan.price, plan.price, remainingDays)
: 0;
if (isUpgradePayment && remainingDays <= 0) {
throw new Error("No se puede actualizar el plan porque la suscripción actual no tiene días restantes.");
}
if (
currentSubscription?.pendingPaymentPreferenceId &&
currentSubscription.pendingPaymentInitPoint
) {
if (String(currentSubscription.pendingPaymentPlanId || currentSubscription.planId) === String(data.planId)) {
return currentSubscription;
}
throw new Error("Ya tenés un pago pendiente para tu plan. Verificalo o esperá a que se actualice antes de intentar otro cambio.");
}
if (
currentSubscription &&
String(currentSubscription.planId) === String(data.planId) &&
(currentSubscription.mpStatus === MP_SUBS_STATUS.AUTHORIZED ||
currentSubscription.mpStatus === MP_SUBS_STATUS.CANCELLED) &&
currentSubscription.pendingPaymentPreferenceId &&
currentSubscription.pendingPaymentInitPoint
) {
return currentSubscription;
}
const response = await axios.post( const response = await axios.post(
"https://api.mercadopago.com/checkout/preferences", "https://api.mercadopago.com/checkout/preferences",
{ {
items: [ items: [
{ {
title: "TurnosXpress.com.ar - Plan " + plan.name + " (" + data.months + " meses)", title: isUpgradePayment
? "TurnosXpress.com.ar - Upgrade al plan " + plan.name
: "TurnosXpress.com.ar - Plan " + plan.name + " (" + data.months + " meses)",
quantity: 1, quantity: 1,
currency_id: "ARS", currency_id: "ARS",
unit_price: finalPrice unit_price: isUpgradePayment ? proratedAmount : finalPrice
} }
], ],
back_urls: { back_urls: {
success: process.env.MP_BACK_URL, success: this.buildMercadoPagoBackUrl("success"),
failure: process.env.MP_BACK_URL, failure: this.buildMercadoPagoBackUrl("failure"),
pending: process.env.MP_BACK_URL pending: this.buildMercadoPagoBackUrl("pending")
}, },
auto_return: "approved", auto_return: "approved",
payer: { payer: {
@@ -270,20 +369,102 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
const subscriptionData = response.data; const subscriptionData = response.data;
this.planSuscriptions.delete({ sessionUser: data.sessionUser }); if (isUpgradePayment && currentSubscription && currentPlan) {
currentSubscription.pendingPaymentInitPoint = subscriptionData.init_point;
currentSubscription.pendingPaymentPreferenceId = subscriptionData.id;
currentSubscription.pendingPaymentBillingMonths = undefined;
currentSubscription.pendingPaymentType = "upgrade";
currentSubscription.pendingPaymentPlanId = data.planId;
currentSubscription.pendingPaymentCurrentPlanPrice = currentPlan.price;
currentSubscription.pendingPaymentRequestedPlanPrice = plan.price;
currentSubscription.pendingPaymentProratedAmount = proratedAmount;
currentSubscription.pendingPaymentRemainingDays = remainingDays;
currentSubscription.pendingPaymentPeriodEndDate = currentSubscription.endDate;
currentSubscription.lastPaymentStatus = undefined;
currentSubscription.lastPaymentPreferenceId = undefined;
currentSubscription.lastPaymentAt = undefined;
currentSubscription.lastPaymentType = undefined;
currentSubscription.downgradedFromPlanId = undefined;
currentSubscription.downgradedFromPlanName = undefined;
currentSubscription.downgradedFromPlanCode = undefined;
currentSubscription.downgradedAt = undefined;
currentSubscription.downgradeReason = undefined;
currentSubscription.mpInitPoint = subscriptionData.init_point;
await currentSubscription.save();
await NotificationsManager.sendSystemNotification({
userId: String(data.sessionUser),
subject: "Se ha creado una actualización de suscripción",
message: `Se ha creado una actualización al plan ${plan.name}.`,
type: NotificationType.BILLING,
code: String(currentSubscription._id)
});
return currentSubscription;
}
if (
currentSubscription &&
String(currentSubscription.planId) === String(data.planId) &&
(currentSubscription.mpStatus === MP_SUBS_STATUS.AUTHORIZED ||
currentSubscription.mpStatus === MP_SUBS_STATUS.CANCELLED)
) {
currentSubscription.pendingPaymentInitPoint = subscriptionData.init_point;
currentSubscription.pendingPaymentPreferenceId = subscriptionData.id;
currentSubscription.pendingPaymentBillingMonths = data.months;
currentSubscription.pendingPaymentType = "extension";
currentSubscription.lastPaymentStatus = undefined;
currentSubscription.lastPaymentPreferenceId = undefined;
currentSubscription.lastPaymentAt = undefined;
currentSubscription.lastPaymentType = undefined;
currentSubscription.downgradedFromPlanId = undefined;
currentSubscription.downgradedFromPlanName = undefined;
currentSubscription.downgradedFromPlanCode = undefined;
currentSubscription.downgradedAt = undefined;
currentSubscription.downgradeReason = undefined;
currentSubscription.mpInitPoint = subscriptionData.init_point;
await currentSubscription.save();
await NotificationsManager.sendSystemNotification({
userId: String(data.sessionUser),
subject: "Se ha creado una nueva extensión de suscripción",
message: `Se ha creado una nueva extensión para el plan ${plan.name}.`,
type: NotificationType.BILLING,
code: String(currentSubscription._id)
});
return currentSubscription;
}
await this.planSuscriptions.delete({ sessionUser: data.sessionUser });
const subscription = await this.planSuscriptions.create({ const subscription = await this.planSuscriptions.create({
userId: data.sessionUser, userId: data.sessionUser,
planId: data.planId, planId: data.planId,
mpPlanId: data.planId, mpPlanId: data.planId,
startDate: subscriptionStartDate.toDate(), startDate: preferenceDate.toDate(),
endDate: subscriptionStartDate.clone().add(data.months, "months").toDate(), endDate: preferenceDate.toDate(),
isActive: false,
autoRenew: false, autoRenew: false,
mpPayerEmail: sessionUser.mpPayerEmail, mpPayerEmail: sessionUser.mpPayerEmail,
mpStatus: MP_SUBS_STATUS.PENDING, mpStatus: MP_SUBS_STATUS.PENDING,
mpDateCreated: new Date(), mpDateCreated: new Date(),
mpInitPoint: subscriptionData.init_point, mpInitPoint: subscriptionData.init_point,
mpPreferenceId: subscriptionData.id, mpPreferenceId: subscriptionData.id,
billingMonths: data.months,
pendingPaymentInitPoint: "",
pendingPaymentPreferenceId: "",
pendingPaymentBillingMonths: undefined,
pendingPaymentType: undefined,
lastPaymentStatus: undefined,
lastPaymentPreferenceId: undefined,
lastPaymentAt: undefined,
lastPaymentType: undefined,
downgradedFromPlanId: undefined,
downgradedFromPlanName: undefined,
downgradedFromPlanCode: undefined,
downgradedAt: undefined,
downgradeReason: undefined,
}); });
await NotificationsManager.sendSystemNotification({ await NotificationsManager.sendSystemNotification({
@@ -330,18 +511,29 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
// We just cancel it locally if not paid, or change to free plan if paid. // We just cancel it locally if not paid, or change to free plan if paid.
} }
const dateNow = dayjs(new Date()).endOf("day"); const dateNow = new Date();
const dateEnd = dayjs(subscription.endDate); const dateEnd = new Date(subscription.endDate);
if (dateNow.isSameOrAfter(dateEnd)) { if (dateNow > dateEnd) {
this.toFreePlan({ await this.toFreePlan({
sessionUser: data.sessionUser, sessionUser: data.sessionUser,
downgradeReason: "expired",
}); });
} else { } else {
subscription.mpStatus = MP_SUBS_STATUS.CANCELLED; subscription.mpStatus = MP_SUBS_STATUS.CANCELLED;
subscription.mpInitPoint = ""; subscription.mpInitPoint = "";
subscription.mpPayerEmail = ""; subscription.mpPayerEmail = "";
subscription.mpPayerId = -1; subscription.mpPayerId = -1;
subscription.pendingPaymentInitPoint = "";
subscription.pendingPaymentPreferenceId = "";
subscription.pendingPaymentBillingMonths = undefined;
subscription.pendingPaymentType = undefined;
subscription.pendingPaymentPlanId = undefined;
subscription.pendingPaymentCurrentPlanPrice = undefined;
subscription.pendingPaymentRequestedPlanPrice = undefined;
subscription.pendingPaymentProratedAmount = undefined;
subscription.pendingPaymentRemainingDays = undefined;
subscription.pendingPaymentPeriodEndDate = undefined;
await subscription.save(); await subscription.save();
} }
} catch (error) { } catch (error) {
@@ -400,12 +592,43 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
//console.log("guardando el plan gratuito..."); //console.log("guardando el plan gratuito...");
//try { //try {
const downgradedFromPlan = data.downgradeReason === "expired"
? await PlansList.plans.findOne({ _id: String(subscription.planId) })
: null;
console.log("cambiando al free plan:", freePlan.id); console.log("cambiando al free plan:", freePlan.id);
subscription.planId = freePlan.id; subscription.planId = freePlan.id;
subscription.mpStatus = ""; subscription.mpStatus = "";
subscription.isActive = true; subscription.isActive = true;
subscription.autoRenew = false;
subscription.mpInitPoint = ""; subscription.mpInitPoint = "";
subscription.mpPreferenceId = ""; subscription.mpPreferenceId = "";
subscription.billingMonths = undefined;
subscription.pendingPaymentInitPoint = "";
subscription.pendingPaymentPreferenceId = "";
subscription.pendingPaymentBillingMonths = undefined;
subscription.pendingPaymentType = undefined;
subscription.pendingPaymentPlanId = undefined;
subscription.pendingPaymentCurrentPlanPrice = undefined;
subscription.pendingPaymentRequestedPlanPrice = undefined;
subscription.pendingPaymentProratedAmount = undefined;
subscription.pendingPaymentRemainingDays = undefined;
subscription.pendingPaymentPeriodEndDate = undefined;
subscription.startDate = new Date();
subscription.endDate = dayjs(new Date()).add(10, "years").toDate();
if (data.downgradeReason === "expired" && downgradedFromPlan) {
subscription.downgradedFromPlanId = String(downgradedFromPlan.id || downgradedFromPlan._id);
subscription.downgradedFromPlanName = downgradedFromPlan.name;
subscription.downgradedFromPlanCode = downgradedFromPlan.code;
subscription.downgradedAt = new Date();
subscription.downgradeReason = "expired";
} else {
subscription.downgradedFromPlanId = undefined;
subscription.downgradedFromPlanName = undefined;
subscription.downgradedFromPlanCode = undefined;
subscription.downgradedAt = undefined;
subscription.downgradeReason = undefined;
}
await subscription.save(); await subscription.save();
// console.log("plan gratuito guardado:", freePlan.id); // console.log("plan gratuito guardado:", freePlan.id);
@@ -430,6 +653,15 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
autoRenew: subscription.autoRenew, autoRenew: subscription.autoRenew,
mpStatus: subscription.mpStatus, mpStatus: subscription.mpStatus,
mpDateCreated: subscription.mpDateCreated, mpDateCreated: subscription.mpDateCreated,
lastPaymentStatus: subscription.lastPaymentStatus,
lastPaymentPreferenceId: subscription.lastPaymentPreferenceId,
lastPaymentAt: subscription.lastPaymentAt,
lastPaymentType: subscription.lastPaymentType,
downgradedFromPlanId: subscription.downgradedFromPlanId,
downgradedFromPlanName: subscription.downgradedFromPlanName,
downgradedFromPlanCode: subscription.downgradedFromPlanCode,
downgradedAt: subscription.downgradedAt,
downgradeReason: subscription.downgradeReason,
}; };
io.to(`user:${String(subscription.userId)}`).emit("suscription_status_updated", susInfo); io.to(`user:${String(subscription.userId)}`).emit("suscription_status_updated", susInfo);
@@ -446,15 +678,14 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
return null; return null;
} }
const dateNow = dayjs(new Date()).endOf("day"); const dateNow = new Date();
const dateEnd = dayjs(subscription.endDate); const dateEnd = new Date(subscription.endDate);
if (dateNow.isSameOrAfter(dateEnd)) { if (dateNow > dateEnd && this.shouldDowngradeExpiredSubscription(subscription)) {
if (subscription.mpStatus === MP_SUBS_STATUS.CANCELLED) { await this.toFreePlan({
this.toFreePlan({ sessionUser: data.sessionUser,
sessionUser: data.sessionUser, downgradeReason: "expired",
}); });
}
} }
return await this.planSuscriptions.findOne(data); return await this.planSuscriptions.findOne(data);
@@ -469,26 +700,30 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
return null; return null;
} }
const dateNow = dayjs(new Date()).endOf("day"); const dateNow = new Date();
const dateEnd = dayjs(subscription.endDate); const dateEnd = new Date(subscription.endDate);
if (dateNow.isSameOrAfter(dateEnd)) { if (dateNow > dateEnd && this.shouldDowngradeExpiredSubscription(subscription)) {
//Esta condicion era la culpable de que todo el tiempo se queria aplicar el free plan. await this.toFreePlan({
//Ya que la fecha de finalizacion queda guardada en la base de datos a pesar de que ya no se use. sessionUser: data.sessionUser,
if ( downgradeReason: "expired",
subscription.mpStatus !== MP_SUBS_STATUS.AUTHORIZED && });
subscription.mpStatus !== "" return this.getSubscriptionByUser(data);
) {
this.toFreePlan({
sessionUser: data.sessionUser,
});
}
} }
const plan = await PlansList.plans.findOne({ let plan = await PlansList.findOne({
_id: subscription.planId, _id: subscription.planId,
}); });
const isPendingPaidSubscription = subscription.mpStatus === MP_SUBS_STATUS.PENDING && plan.price > 0;
if (!isPendingPaidSubscription && (!subscription.isActive || subscription.mpStatus === MP_SUBS_STATUS.PENDING)) {
const freePlan = await PlansList.findOne({ price: 0 });
if (freePlan) {
plan = freePlan;
}
}
if (!plan) { if (!plan) {
throw new Error("No se ha encontrado el plan"); throw new Error("No se ha encontrado el plan");
} }
@@ -502,6 +737,18 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
autoRenew: subscription.autoRenew, autoRenew: subscription.autoRenew,
mpStatus: subscription.mpStatus, mpStatus: subscription.mpStatus,
mpDateCreated: subscription.mpDateCreated, mpDateCreated: subscription.mpDateCreated,
pendingPaymentInitPoint: subscription.pendingPaymentInitPoint,
pendingPaymentPreferenceId: subscription.pendingPaymentPreferenceId,
pendingPaymentType: subscription.pendingPaymentType,
lastPaymentStatus: subscription.lastPaymentStatus,
lastPaymentPreferenceId: subscription.lastPaymentPreferenceId,
lastPaymentAt: subscription.lastPaymentAt,
lastPaymentType: subscription.lastPaymentType,
downgradedFromPlanId: subscription.downgradedFromPlanId,
downgradedFromPlanName: subscription.downgradedFromPlanName,
downgradedFromPlanCode: subscription.downgradedFromPlanCode,
downgradedAt: subscription.downgradedAt,
downgradeReason: subscription.downgradeReason,
}; };
} }
@@ -514,6 +761,10 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
} }
const plan = subscriptionInfo.plan; const plan = subscriptionInfo.plan;
if (!subscriptionInfo.isActive) {
return false;
}
if (!plan) { if (!plan) {
throw new Error("No se encontro el plan de la suscripcion"); throw new Error("No se encontro el plan de la suscripcion");
} }
@@ -553,7 +804,7 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
if (!subscriptionInfo) { if (!subscriptionInfo) {
throw new Error("No se encontro una suscripcion activa"); throw new Error("No se encontro una suscripcion activa");
} }
return subscriptionInfo.isActive; return subscriptionInfo.isActive && subscriptionInfo.mpStatus !== MP_SUBS_STATUS.PENDING;
} }
} }
@@ -1,4 +1,14 @@
import dayjs from "dayjs"; import dayjs from "dayjs";
import { calculateProratedUpgradeAmount } from "../PlanSubscriptions.pricing";
import PlanSubscriptionsList from "../PlanSubscriptons";
import { MP_SUBS_STATUS } from "../PlanSubscriptions.Adapter.Mongoose";
import PlansList from "../../Plans/Plans";
jest.mock("../../../index", () => ({
io: {
to: jest.fn(() => ({ emit: jest.fn() })),
},
}));
describe("PlanSubscriptions Logic", () => { describe("PlanSubscriptions Logic", () => {
it("should calculate correct endDate for 1, 3, 6, 12 months", () => { it("should calculate correct endDate for 1, 3, 6, 12 months", () => {
@@ -40,4 +50,71 @@ describe("PlanSubscriptions Logic", () => {
const final12 = total12 - (total12 * (30 / 100)); // 12000 - 3600 = 8400 const final12 = total12 - (total12 * (30 / 100)); // 12000 - 3600 = 8400
expect(final12).toBe(8400); expect(final12).toBe(8400);
}); });
it("should calculate only the prorated difference for paid plan upgrades", () => {
const currentPlanPrice = 1000;
const requestedPlanPrice = 2500;
const remainingDays = 15;
const proratedAmount = calculateProratedUpgradeAmount(
currentPlanPrice,
requestedPlanPrice,
remainingDays
);
expect(proratedAmount).toBe(750);
});
it("should keep pending paid subscription plan instead of masking it as free", async () => {
const paidPlan = {
id: "paid-plan-id",
name: "Intermedio",
description: "",
features: [],
code: "intermediate",
price: 1000,
annualPrice: 0,
limitOrganizations: 1,
limitEmployees: 1,
limitServices: 1,
limitAppointments: 1,
limitClients: 1,
limitRepeats: 1,
mailNotifications: false,
smsNotifications: false,
wapNotifications: false,
bot: false,
active: true,
dateLimit: false,
payments: false,
};
const subscription = {
id: "subscription-id",
planId: paidPlan.id,
startDate: new Date(),
endDate: new Date(),
isActive: false,
autoRenew: false,
mpStatus: MP_SUBS_STATUS.PENDING,
mpDateCreated: new Date(),
};
const originalPlanSuscriptions = PlanSubscriptionsList.planSuscriptions;
const findOneSpy = jest.spyOn(PlansList, "findOne").mockResolvedValue(paidPlan);
PlanSubscriptionsList.planSuscriptions = {
findOne: jest.fn().mockResolvedValue(subscription),
} as any;
try {
const result = await PlanSubscriptionsList.getSubscriptionByUser({ sessionUser: "user-id" });
expect(result?.plan).toEqual(paidPlan);
expect(result?.mpStatus).toBe(MP_SUBS_STATUS.PENDING);
expect(findOneSpy).toHaveBeenCalledTimes(1);
expect(findOneSpy).toHaveBeenCalledWith({ _id: subscription.planId });
} finally {
PlanSubscriptionsList.planSuscriptions = originalPlanSuscriptions;
findOneSpy.mockRestore();
}
});
}); });
@@ -0,0 +1,153 @@
import { Document, Model, Schema, model } from "mongoose";
import {
CreatePlanUsageCycleParams,
FindPlanUsageCycleParams,
IPlanUsageCycle,
IPlanUsageCycleAdapter,
UpdatePlanUsageCycleParams,
} from "./PlanUsageCycle.Interface";
export interface IPlanUsageCycleDocument extends Omit<IPlanUsageCycle, "id">, Document {}
export class PlanUsageCycleAdapterMongoose implements IPlanUsageCycleAdapter {
schema: Schema;
planUsageCycleList: Model<IPlanUsageCycleDocument>;
constructor() {
this.schema = new Schema({
userId: { type: Schema.Types.ObjectId, required: true, ref: "User" },
subscriptionId: { type: Schema.Types.ObjectId, required: false, ref: "PlanSuscription" },
planId: { type: Schema.Types.ObjectId, required: true, ref: "Plan" },
cycleStart: { type: Date, required: true },
cycleEnd: { type: Date, required: true },
appointmentsCount: { type: Number, required: true, default: 0 },
creationDate: { type: Date, required: true, default: Date.now },
updateDate: { type: Date, required: true, default: Date.now },
});
this.schema.index({ userId: 1, subscriptionId: 1, planId: 1, cycleStart: 1 }, { unique: true });
this.planUsageCycleList = model<IPlanUsageCycleDocument>("PlanUsageCycle", this.schema);
}
public async findOne(filters: FindPlanUsageCycleParams): Promise<IPlanUsageCycleDocument | null> {
return await this.planUsageCycleList.findOne(filters);
}
public async create(data: CreatePlanUsageCycleParams): Promise<IPlanUsageCycleDocument> {
return await this.planUsageCycleList.create({
...data,
appointmentsCount: data.appointmentsCount || 0,
updateDate: new Date(),
});
}
public async getOrCreateCurrent(data: UpdatePlanUsageCycleParams): Promise<IPlanUsageCycleDocument> {
const now = new Date();
return await this.planUsageCycleList.findOneAndUpdate(
{
userId: data.userId,
subscriptionId: data.subscriptionId,
planId: data.planId,
cycleStart: data.cycleStart,
},
{
$setOnInsert: {
userId: data.userId,
subscriptionId: data.subscriptionId,
planId: data.planId,
cycleStart: data.cycleStart,
cycleEnd: data.cycleEnd,
creationDate: now,
},
$set: { updateDate: now },
},
{ new: true, upsert: true }
);
}
public async incrementAppointments(
data: UpdatePlanUsageCycleParams & { quantity: number }
): Promise<IPlanUsageCycleDocument | null> {
const now = new Date();
return await this.planUsageCycleList.findOneAndUpdate(
{
userId: data.userId,
subscriptionId: data.subscriptionId,
planId: data.planId,
cycleStart: data.cycleStart,
},
{
$setOnInsert: {
userId: data.userId,
subscriptionId: data.subscriptionId,
planId: data.planId,
cycleStart: data.cycleStart,
cycleEnd: data.cycleEnd,
creationDate: now,
},
$inc: { appointmentsCount: data.quantity },
$set: { updateDate: now },
},
{ new: true, upsert: true }
);
}
public async incrementAppointmentsIfWithinLimit(
data: UpdatePlanUsageCycleParams & { quantity: number; limit: number }
): Promise<IPlanUsageCycleDocument | null> {
const now = new Date();
return await this.planUsageCycleList.findOneAndUpdate(
{
userId: data.userId,
subscriptionId: data.subscriptionId,
planId: data.planId,
cycleStart: data.cycleStart,
appointmentsCount: { $lte: data.limit - data.quantity },
},
{
$setOnInsert: {
userId: data.userId,
subscriptionId: data.subscriptionId,
planId: data.planId,
cycleStart: data.cycleStart,
cycleEnd: data.cycleEnd,
appointmentsCount: 0,
creationDate: now,
},
$inc: { appointmentsCount: data.quantity },
$set: { updateDate: now },
},
{ new: true }
);
}
public async setAppointmentsCount(
data: UpdatePlanUsageCycleParams & { appointmentsCount: number }
): Promise<IPlanUsageCycleDocument | null> {
const now = new Date();
return await this.planUsageCycleList.findOneAndUpdate(
{
userId: data.userId,
subscriptionId: data.subscriptionId,
planId: data.planId,
cycleStart: data.cycleStart,
},
{
$setOnInsert: {
userId: data.userId,
subscriptionId: data.subscriptionId,
planId: data.planId,
cycleStart: data.cycleStart,
cycleEnd: data.cycleEnd,
creationDate: now,
},
$set: {
appointmentsCount: Math.max(data.appointmentsCount, 0),
updateDate: now,
},
},
{ new: true, upsert: true }
);
}
}
@@ -0,0 +1,99 @@
import { IPlanUsageCycleDocument } from "./PlanUsageCycle.Adapter.Mongoose";
export interface IPlanUsageCycle {
id?: string;
userId: string;
subscriptionId?: string;
planId: string;
cycleStart: Date;
cycleEnd: Date;
appointmentsCount: number;
creationDate: Date;
updateDate: Date;
}
export type FindPlanUsageCycleParams = {
userId?: string;
subscriptionId?: string;
planId?: string;
cycleStart?: Date;
cycleEnd?: Date;
};
export type CreatePlanUsageCycleParams = {
userId: string;
subscriptionId?: string;
planId: string;
cycleStart: Date;
cycleEnd: Date;
appointmentsCount?: number;
};
export type CurrentPlanUsageCycleParams = {
userId: string;
now?: Date;
limit?: number;
};
export type SetPlanUsageCycleAppointmentsParams = CurrentPlanUsageCycleParams & {
appointmentsCount: number;
};
export type SysAdminRecalculatePlanUsageCycleParams = {
userId: string;
};
export type SysAdminRecalculatePlanUsageCycleResult = {
userId: string;
subscriptionId: string;
planId: string;
cycleStart: Date;
cycleEnd: Date;
organizationsCount: number;
employeesCount: number;
servicesCount: number;
clientsCount: number;
repeatsCount: number;
appointmentsCount: number;
};
export type UpdatePlanUsageCycleParams = {
userId: string;
subscriptionId: string;
planId: string;
cycleStart: Date;
cycleEnd: Date;
};
export type CalculateCurrentCycleParams = {
subscriptionStartDate: Date;
now?: Date;
};
export type PlanUsageCycleRange = {
cycleStart: Date;
cycleEnd: Date;
};
export interface IPlanUsageCycleAdapter {
findOne(filters: FindPlanUsageCycleParams): Promise<IPlanUsageCycleDocument | null>;
create(data: CreatePlanUsageCycleParams): Promise<IPlanUsageCycleDocument>;
getOrCreateCurrent(data: UpdatePlanUsageCycleParams): Promise<IPlanUsageCycleDocument>;
incrementAppointments(data: UpdatePlanUsageCycleParams & { quantity: number }): Promise<IPlanUsageCycleDocument | null>;
incrementAppointmentsIfWithinLimit(
data: UpdatePlanUsageCycleParams & { quantity: number; limit: number }
): Promise<IPlanUsageCycleDocument | null>;
setAppointmentsCount(
data: UpdatePlanUsageCycleParams & { appointmentsCount: number }
): Promise<IPlanUsageCycleDocument | null>;
}
export interface IPlanUsageCycleManager {
planUsageCycle: IPlanUsageCycleAdapter;
getCurrentCycle(data: CurrentPlanUsageCycleParams): Promise<IPlanUsageCycleDocument>;
addAppointment(data: CurrentPlanUsageCycleParams & { quantity?: number }): Promise<void>;
reserveAppointment(data: CurrentPlanUsageCycleParams & { quantity?: number }): Promise<boolean>;
releaseAppointment(data: CurrentPlanUsageCycleParams & { quantity?: number }): Promise<void>;
getAppointmentsCount(data: CurrentPlanUsageCycleParams): Promise<number>;
setAppointmentsCount(data: SetPlanUsageCycleAppointmentsParams): Promise<IPlanUsageCycleDocument | null>;
}
@@ -0,0 +1,20 @@
import dayjs from "dayjs";
import { CalculateCurrentCycleParams, PlanUsageCycleRange } from "./PlanUsageCycle.Interface";
export function calculateCurrentPlanUsageCycle(data: CalculateCurrentCycleParams): PlanUsageCycleRange {
const now = dayjs(data.now || new Date());
const anchor = dayjs(data.subscriptionStartDate);
let cycleStart = anchor.clone();
let cycleEnd = cycleStart.clone().add(1, "month");
while (!cycleEnd.isAfter(now)) {
cycleStart = cycleEnd;
cycleEnd = cycleStart.clone().add(1, "month");
}
return {
cycleStart: cycleStart.toDate(),
cycleEnd: cycleEnd.toDate(),
};
}
@@ -0,0 +1,102 @@
import { isNull } from "../../helpers/IsNull";
import PlanSubscriptionsList from "../PlanSubscriptions/PlanSubscriptons";
import {
CurrentPlanUsageCycleParams,
IPlanUsageCycleManager,
SetPlanUsageCycleAppointmentsParams,
UpdatePlanUsageCycleParams,
} from "./PlanUsageCycle.Interface";
import {
IPlanUsageCycleDocument,
PlanUsageCycleAdapterMongoose,
} from "./PlanUsageCycle.Adapter.Mongoose";
import { calculateCurrentPlanUsageCycle } from "./PlanUsageCycle.helpers";
export { calculateCurrentPlanUsageCycle } from "./PlanUsageCycle.helpers";
class PlanUsageCycleManager implements IPlanUsageCycleManager {
planUsageCycle: PlanUsageCycleAdapterMongoose;
constructor() {
this.planUsageCycle = new PlanUsageCycleAdapterMongoose();
}
private async getCurrentCycleData(data: CurrentPlanUsageCycleParams): Promise<UpdatePlanUsageCycleParams> {
const subscription = await PlanSubscriptionsList.findOne({
sessionUser: data.userId,
});
if (!subscription) {
throw new Error("No se ha encontrado una suscripción activa");
}
const { cycleStart, cycleEnd } = calculateCurrentPlanUsageCycle({
subscriptionStartDate: subscription.startDate,
now: data.now,
});
return {
userId: data.userId,
subscriptionId: String(subscription.id),
planId: String(subscription.planId),
cycleStart,
cycleEnd,
};
}
public async getCurrentCycle(data: CurrentPlanUsageCycleParams): Promise<IPlanUsageCycleDocument> {
return await this.planUsageCycle.getOrCreateCurrent(await this.getCurrentCycleData(data));
}
public async addAppointment(data: CurrentPlanUsageCycleParams & { quantity?: number }): Promise<void> {
await this.planUsageCycle.incrementAppointments({
...(await this.getCurrentCycleData(data)),
quantity: isNull<number>(data.quantity, 1),
});
}
public async reserveAppointment(data: CurrentPlanUsageCycleParams & { quantity?: number }): Promise<boolean> {
if (typeof data.limit !== "number") {
return false;
}
const quantity = isNull<number>(data.quantity, 1);
const usageCycleData = await this.getCurrentCycleData(data);
const usageCycle = await this.planUsageCycle.getOrCreateCurrent(usageCycleData);
if (usageCycle.appointmentsCount + quantity > data.limit) {
return false;
}
const updatedCycle = await this.planUsageCycle.incrementAppointmentsIfWithinLimit({
...usageCycleData,
quantity,
limit: data.limit,
});
return !!updatedCycle;
}
public async releaseAppointment(data: CurrentPlanUsageCycleParams & { quantity?: number }): Promise<void> {
await this.addAppointment({
...data,
quantity: -Math.abs(isNull<number>(data.quantity, 1)),
});
}
public async getAppointmentsCount(data: CurrentPlanUsageCycleParams): Promise<number> {
const usageCycle = await this.getCurrentCycle(data);
return usageCycle.appointmentsCount;
}
public async setAppointmentsCount(data: SetPlanUsageCycleAppointmentsParams): Promise<IPlanUsageCycleDocument | null> {
return await this.planUsageCycle.setAppointmentsCount({
...(await this.getCurrentCycleData(data)),
appointmentsCount: data.appointmentsCount,
});
}
}
const PlanUsageCycleList = new PlanUsageCycleManager();
export default PlanUsageCycleList;
@@ -1,4 +1,4 @@
import { FindPlansParams, IPlansAdapter, IPlan } from "./Plans.interface"; import { FindPlansParams, IPlansAdapter, IPlan, SysAdminUpdatePlanParams } from "./Plans.interface";
import { Document, Model, Schema, model } from "mongoose"; import { Document, Model, Schema, model } from "mongoose";
export interface IPlanDocument extends Omit<IPlan, "id">, Document {} export interface IPlanDocument extends Omit<IPlan, "id">, Document {}
@@ -45,4 +45,8 @@ export class PlansAdapterMongoose implements IPlansAdapter {
public async findOne(filters: FindPlansParams): Promise<IPlanDocument | null> { public async findOne(filters: FindPlansParams): Promise<IPlanDocument | null> {
return this.planList.findOne(filters).exec(); return this.planList.findOne(filters).exec();
} }
public async updateOne(planId: string, data: Omit<SysAdminUpdatePlanParams, "planId">): Promise<void> {
await this.planList.updateOne({ _id: planId }, { $set: data }).exec();
}
} }
@@ -12,6 +12,33 @@ export type FindPlansParams = {
price?: number; price?: number;
}; };
export type SysAdminUpdatePlanParams = {
planId: string;
name?: string;
description?: string;
features?: string[];
code?: string;
price?: number;
annualPrice?: number;
limitOrganizations?: number;
limitEmployees?: number;
limitServices?: number;
limitAppointments?: number;
limitClients?: number;
limitRepeats?: number;
mailNotifications?: boolean;
smsNotifications?: boolean;
wapNotifications?: boolean;
payments?: boolean;
bot?: boolean;
active?: boolean;
dateLimit?: boolean;
discount3Months?: number;
discount6Months?: number;
discount12Months?: number;
featured?: boolean;
};
export interface IPlan { export interface IPlan {
id?: string; id?: string;
name: string; name: string;
@@ -44,10 +71,12 @@ export interface IPlan {
export interface IPlansAdapter { export interface IPlansAdapter {
find(filters: FindPlansParams): Promise<IPlan[]>; find(filters: FindPlansParams): Promise<IPlan[]>;
findOne(filters: FindPlansParams): Promise<IPlan | null>; findOne(filters: FindPlansParams): Promise<IPlan | null>;
updateOne(planId: string, data: Omit<SysAdminUpdatePlanParams, "planId">): Promise<void>;
} }
export interface IPlansManager { export interface IPlansManager {
plans: IPlansAdapter; plans: IPlansAdapter;
find(data: FindPlansParams): Promise<IPlan[]>; find(data: FindPlansParams): Promise<IPlan[]>;
findOne(data: FindPlansParams): Promise<IPlan>; findOne(data: FindPlansParams): Promise<IPlan>;
sysAdminUpdatePlan(data: SysAdminUpdatePlanParams): Promise<void>;
} }
+7 -1
View File
@@ -1,6 +1,6 @@
import { isNull } from "../../helpers/IsNull"; import { isNull } from "../../helpers/IsNull";
import { PlansAdapterMongoose } from "./Plans.Adapter.Mongoose"; import { PlansAdapterMongoose } from "./Plans.Adapter.Mongoose";
import { FindPlansParams, IPlan, IPlansManager } from "./Plans.interface"; import { FindPlansParams, IPlan, IPlansManager, SysAdminUpdatePlanParams } from "./Plans.interface";
class PlansManager implements IPlansManager { class PlansManager implements IPlansManager {
plans: PlansAdapterMongoose; plans: PlansAdapterMongoose;
@@ -59,6 +59,12 @@ class PlansManager implements IPlansManager {
return this.getOnePlan(plan); return this.getOnePlan(plan);
} }
public async sysAdminUpdatePlan(data: SysAdminUpdatePlanParams): Promise<void> {
const { planId, ...planData } = data;
await this.findOne({ _id: planId });
await this.plans.updateOne(planId, planData);
}
} }
const PlansList = new PlansManager(); const PlansList = new PlansManager();
@@ -4,6 +4,8 @@ export type ScheduleItem = {
from: string; from: string;
to: string; to: string;
disabled: boolean; disabled: boolean;
serviceScope?: "all" | "specific";
serviceIds?: string[];
}; };
export type FindSchedulesParams = { export type FindSchedulesParams = {
@@ -19,6 +21,7 @@ export type AvailableSchedulesParams = {
weekDay: number; weekDay: number;
from: string; from: string;
to: string; to: string;
serviceId?: string;
}; };
export type PaginateSchedulesParams = FindSchedulesParams & { export type PaginateSchedulesParams = FindSchedulesParams & {
+12
View File
@@ -173,6 +173,14 @@ class SchedulesManager implements ISchedulesManager {
return await this.schedules.find(filters); return await this.schedules.find(filters);
} }
private isScheduleAvailableForService(schedule: ScheduleItem, serviceId?: string): boolean {
if (!serviceId || !schedule.serviceScope || schedule.serviceScope === "all") {
return true;
}
return schedule.serviceScope === "specific" && (schedule.serviceIds || []).includes(serviceId);
}
private async getScheduleByFrom(data: AvailableSchedulesParams): Promise<ScheduleItem | null> { private async getScheduleByFrom(data: AvailableSchedulesParams): Promise<ScheduleItem | null> {
const schedule = await this.schedules.findOne({ const schedule = await this.schedules.findOne({
employeeId: data.employeeId, employeeId: data.employeeId,
@@ -185,6 +193,9 @@ class SchedulesManager implements ISchedulesManager {
if (horario.disabled) { if (horario.disabled) {
continue; continue;
} }
if (!this.isScheduleAvailableForService(horario, data.serviceId)) {
continue;
}
if (horario.from == data.from) { if (horario.from == data.from) {
return horario; return horario;
} }
@@ -236,6 +247,7 @@ class SchedulesManager implements ISchedulesManager {
weekDay: data.weekDay, weekDay: data.weekDay,
from: scheduleFrom.to, from: scheduleFrom.to,
to: data.to, to: data.to,
serviceId: data.serviceId,
}); });
if (!scheduleTo) { if (!scheduleTo) {
@@ -14,6 +14,7 @@ export type AvailableSchedulesOverridesParams = {
dateDay: Date; dateDay: Date;
from: string; from: string;
to: string; to: string;
serviceId?: string;
}; };
export type PaginateSchedulesOverridesParams = FindSchedulesOverridesParams & { export type PaginateSchedulesOverridesParams = FindSchedulesOverridesParams & {
@@ -154,6 +154,14 @@ class SchedulesOverridesManager implements ISchedulesOverridesManager {
return await this.schedulesOverrides.findOne(filters); return await this.schedulesOverrides.findOne(filters);
} }
private isScheduleAvailableForService(schedule: ScheduleItem, serviceId?: string): boolean {
if (!serviceId || !schedule.serviceScope || schedule.serviceScope === "all") {
return true;
}
return schedule.serviceScope === "specific" && (schedule.serviceIds || []).includes(serviceId);
}
private async getScheduleByFrom(data: AvailableSchedulesOverridesParams): Promise<ScheduleItem | null> { private async getScheduleByFrom(data: AvailableSchedulesOverridesParams): Promise<ScheduleItem | null> {
const exactDate = dayjs(data.dateDay).startOf("day").toDate(); const exactDate = dayjs(data.dateDay).startOf("day").toDate();
const schedule = await this.schedulesOverrides.findOne({ const schedule = await this.schedulesOverrides.findOne({
@@ -165,6 +173,7 @@ class SchedulesOverridesManager implements ISchedulesOverridesManager {
if (schedule) { if (schedule) {
for (const horario of schedule.schedules) { for (const horario of schedule.schedules) {
if (horario.disabled) continue; if (horario.disabled) continue;
if (!this.isScheduleAvailableForService(horario, data.serviceId)) continue;
if (horario.from == data.from) return horario; if (horario.from == data.from) return horario;
if (horario.from < data.from && horario.to > data.from) return horario; if (horario.from < data.from && horario.to > data.from) return horario;
} }
@@ -63,6 +63,11 @@ export type CreateSystemNotificationParams = {
code?: string; code?: string;
}; };
export type CreateSystemNotificationBySystemParams = CreateSystemNotificationParams & {
userId: string;
systemToken: string;
};
export type UpdateSystemNotificationParams = { export type UpdateSystemNotificationParams = {
id: string; id: string;
readed: boolean; readed: boolean;
@@ -119,6 +124,7 @@ export interface ISystemNotificationsAdapter {
export interface ISystemNotificationsManager { export interface ISystemNotificationsManager {
notifications: ISystemNotificationsAdapter; notifications: ISystemNotificationsAdapter;
createNotification(data: CreateSystemNotificationParams): Promise<ISystemNotification>; createNotification(data: CreateSystemNotificationParams): Promise<ISystemNotification>;
createNotificationBySystem(data: CreateSystemNotificationBySystemParams): Promise<ISystemNotification>;
updateNotification(data: UpdateSystemNotificationParams): Promise<void>; updateNotification(data: UpdateSystemNotificationParams): Promise<void>;
deleteNotification(data: DeleteSystemNotificationParams): Promise<void>; deleteNotification(data: DeleteSystemNotificationParams): Promise<void>;
deleteConversationNotifications(data: DeleteSystemNotificationsByConversation): Promise<void>; deleteConversationNotifications(data: DeleteSystemNotificationsByConversation): Promise<void>;
@@ -1,6 +1,7 @@
import CompaniesManager from "../Companies/Companies"; import CompaniesManager from "../Companies/Companies";
import { import {
CreateSystemNotificationParams, CreateSystemNotificationParams,
CreateSystemNotificationBySystemParams,
DeleteNotificationsByDateParams, DeleteNotificationsByDateParams,
DeleteNotificationsByUserParams, DeleteNotificationsByUserParams,
DeleteSystemNotificationParams, DeleteSystemNotificationParams,
@@ -108,6 +109,28 @@ class SystemNotificationsManager implements ISystemNotificationsManager {
return newNotification; return newNotification;
} }
public async createNotificationBySystem(
data: CreateSystemNotificationBySystemParams
): Promise<ISystemNotification> {
const expectedToken = process.env.SYSTEM_KEY || process.env.API_KEY;
if (!expectedToken || data.systemToken !== expectedToken) {
throw new Error("Token de sistema inválido");
}
const { systemToken, ...notificationData } = data;
const notification = await this.createNotification(notificationData);
const countUnreadNotifications = await this.finUnreadNotifications({
userId: data.userId,
sessionUser: data.userId,
});
io.to(`user:${data.userId}`).emit("new_system_notification", countUnreadNotifications);
return notification;
}
public async updateNotification(data: UpdateSystemNotificationParams): Promise<void> { public async updateNotification(data: UpdateSystemNotificationParams): Promise<void> {
await validateSessionUser({ await validateSessionUser({
sessionUser: data.sessionUser, sessionUser: data.sessionUser,
@@ -4,8 +4,10 @@ import { TextObjectFilterResult } from "../TextObjectFilter.model";
export enum TemplateTypes { export enum TemplateTypes {
WAP_NEW_APPOINTMENT = "wap-new-appointment", WAP_NEW_APPOINTMENT = "wap-new-appointment",
WAP_REMINDER = "wap-reminder", WAP_REMINDER = "wap-reminder",
WAP_CANCELLATION = "wap-cancellation",
EMAIL_NEW_APPOINTMENT = "email-new-appointment", EMAIL_NEW_APPOINTMENT = "email-new-appointment",
EMAIL_REMINDER = "email-reminder", EMAIL_REMINDER = "email-reminder",
EMAIL_CANCELLATION = "email-cancellation",
} }
export interface ITemplate { export interface ITemplate {
id?: string; id?: string;
+6
View File
@@ -215,12 +215,18 @@ export class TemplatesManager implements ITemplatesManager {
case TemplateTypes.WAP_NEW_APPOINTMENT: case TemplateTypes.WAP_NEW_APPOINTMENT:
defaultTemplate = `${process.env.WAP_ALTA}`; defaultTemplate = `${process.env.WAP_ALTA}`;
break; break;
case TemplateTypes.WAP_CANCELLATION:
defaultTemplate = `${process.env.WAP_CANCELLATION || process.env.WAP_CANCELACION || ""}`;
break;
case TemplateTypes.EMAIL_REMINDER: case TemplateTypes.EMAIL_REMINDER:
defaultTemplate = `${process.env.WAP_REMINDER}`; defaultTemplate = `${process.env.WAP_REMINDER}`;
break; break;
case TemplateTypes.EMAIL_NEW_APPOINTMENT: case TemplateTypes.EMAIL_NEW_APPOINTMENT:
defaultTemplate = `${process.env.WAP_ALTA}`; defaultTemplate = `${process.env.WAP_ALTA}`;
break; break;
case TemplateTypes.EMAIL_CANCELLATION:
defaultTemplate = `${process.env.EMAIL_CANCELLATION || process.env.EMAIL_CANCELACION || process.env.WAP_CANCELLATION || process.env.WAP_CANCELACION || ""}`;
break;
} }
return defaultTemplate; return defaultTemplate;
} }
@@ -205,6 +205,73 @@ export type SysAdminOrganizationsStatusParams = {
userId: string; userId: string;
}; };
export type SysAdminUserSubscriptionDetailsParams = {
userId: string;
};
export type SysAdminExtendUserSubscriptionMode = "add_months" | "set_end_date";
export type SysAdminExtendUserSubscriptionParams = {
userId: string;
mode: SysAdminExtendUserSubscriptionMode;
months?: 1 | 2;
endDate?: string;
reason?: string;
};
export type SysAdminExtendUserSubscriptionResult = {
userId: string;
subscriptionId: string;
previousEndDate: Date;
newEndDate: Date;
paymentId: string;
transactionId?: string;
};
export type SysAdminFinalizeUserSubscriptionParams = {
userId: string;
reason?: string;
};
export type SysAdminFinalizeUserSubscriptionResult = {
userId: string;
subscriptionId: string;
previousEndDate: Date;
newEndDate: Date;
};
export type SysAdminUserSubscriptionPayment = {
id: string;
subscriptionId: string;
amount: number;
paymentDate: Date;
paymentMethod?: string;
status: string;
transactionId?: string;
};
export type SysAdminUserSubscriptionDetailsResult = {
userId: string;
currentSubscription: {
id: string;
plan: {
id: string;
name: string;
code: string;
price: number;
} | null;
startDate: Date;
endDate: Date;
isActive: boolean;
mpStatus?: string;
billingMonths?: number;
pendingPaymentType?: "extension" | "upgrade";
pendingPaymentPreferenceId?: string;
lastPaymentStatus?: string;
} | null;
payments: SysAdminUserSubscriptionPayment[];
};
export type SysAdminUserOrganizationsResult = { export type SysAdminUserOrganizationsResult = {
clientIn: { companyId: string; companyName: string }[]; clientIn: { companyId: string; companyName: string }[];
collaboratorIn: { companyId: string; companyName: string }[]; collaboratorIn: { companyId: string; companyName: string }[];
+10 -31
View File
@@ -197,7 +197,7 @@ class UsersManager implements IUsersManager {
if (sub) { if (sub) {
if (sub.plan.price > 0) { if (sub.plan.price > 0) {
throw new Error("Para darte de baja primero tenes que cancelar tu suscripción."); throw new Error("No podés darte de baja mientras tengas un plan pago vigente. Podés eliminar tus organizaciones para borrar su contenido y seguir usando la cuenta hasta el vencimiento.");
} }
} }
@@ -284,7 +284,7 @@ class UsersManager implements IUsersManager {
userId: data.sessionUser, userId: data.sessionUser,
}); });
//Eliminar los clientes creados con el usuario. //Desvincular los clientes creados con el usuario sin borrar el historial de la organizacion.
const clients = await ClientsList.clients.find({ const clients = await ClientsList.clients.find({
userId: data.sessionUser, userId: data.sessionUser,
}); });
@@ -294,21 +294,7 @@ class UsersManager implements IUsersManager {
continue; continue;
} }
//Tengo que pasarlo como sesionUSer al duenio de la organizacion. para que pueda eliminar. await ClientsList.clients.detachUser({ clientId: String(client.id) });
const orgClient = await CompaniesList.companies.findOne({
_id: String(client.companyId),
});
if (!orgClient) {
continue;
}
//En este caso borra el cliente pero no chequea el saldo de la cuenta.
await ClientsList.deleteClient({
clientId: String(client.id),
checkCashOnAccount: false,
sessionUser: String(orgClient.ownerId),
});
} }
//Eliminar las conversaciones //Eliminar las conversaciones
@@ -630,7 +616,7 @@ class UsersManager implements IUsersManager {
// Generate a JWT token using the payload and a secret word // Generate a JWT token using the payload and a secret word
const token = jwt.sign(payload, `${process.env.SECRET_WORD}`, { const token = jwt.sign(payload, `${process.env.SECRET_WORD}`, {
expiresIn: `${process.env.TOKEN_SESSION_TIME}`, expiresIn: `${process.env.TOKEN_SESSION_TIME}` as any,
}); });
// Create a user object by merging the payload and the token // Create a user object by merging the payload and the token
@@ -684,7 +670,7 @@ class UsersManager implements IUsersManager {
// If the token expires in less than 5 minutes, generate a new one. // If the token expires in less than 5 minutes, generate a new one.
if (decodedPayload.exp < nowInSeconds + fiveMinutesInSeconds) { if (decodedPayload.exp < nowInSeconds + fiveMinutesInSeconds) {
finalToken = jwt.sign(payload, `${process.env.SECRET_WORD}`, { finalToken = jwt.sign(payload, `${process.env.SECRET_WORD}`, {
expiresIn: `${process.env.TOKEN_SESSION_TIME}`, expiresIn: `${process.env.TOKEN_SESSION_TIME}` as any,
}); });
} }
@@ -765,7 +751,7 @@ class UsersManager implements IUsersManager {
// Generate a JWT token using the payload and a secret word // Generate a JWT token using the payload and a secret word
const token = jwt.sign(payload, `${process.env.SECRET_WORD}`, { const token = jwt.sign(payload, `${process.env.SECRET_WORD}`, {
expiresIn: `${process.env.TOKEN_SESSION_TIME}`, expiresIn: `${process.env.TOKEN_SESSION_TIME}` as any,
}); });
// Create a user object by merging the payload and the token // Create a user object by merging the payload and the token
@@ -810,7 +796,7 @@ class UsersManager implements IUsersManager {
// Generate a JWT token using the payload and a secret word // Generate a JWT token using the payload and a secret word
const token = jwt.sign(payload, `${process.env.SECRET_WORD}`, { const token = jwt.sign(payload, `${process.env.SECRET_WORD}`, {
expiresIn: `${process.env.TOKEN_SESSION_TIME}`, expiresIn: `${process.env.TOKEN_SESSION_TIME}` as any,
}); });
// Create a user object by merging the payload and the token // Create a user object by merging the payload and the token
@@ -862,7 +848,7 @@ class UsersManager implements IUsersManager {
// Generate a JWT token using the payload and a secret word // Generate a JWT token using the payload and a secret word
const token = jwt.sign(payload, `${process.env.SECRET_WORD}`, { const token = jwt.sign(payload, `${process.env.SECRET_WORD}`, {
expiresIn: `${process.env.TOKEN_SESSION_TIME}`, expiresIn: `${process.env.TOKEN_SESSION_TIME}` as any,
}); });
// Create a user object by merging the payload and the token // Create a user object by merging the payload and the token
@@ -907,7 +893,7 @@ class UsersManager implements IUsersManager {
// Generate a JWT token using the payload and a secret word // Generate a JWT token using the payload and a secret word
const token = jwt.sign(payload, `${process.env.SECRET_WORD}`, { const token = jwt.sign(payload, `${process.env.SECRET_WORD}`, {
expiresIn: `${process.env.TOKEN_SESSION_TIME}`, expiresIn: `${process.env.TOKEN_SESSION_TIME}` as any,
}); });
// Create a user object by merging the payload and the token // Create a user object by merging the payload and the token
@@ -1279,14 +1265,7 @@ class UsersManager implements IUsersManager {
for (const client of clients) { for (const client of clients) {
if (!client.id) continue; if (!client.id) continue;
const orgClient = await CompaniesList.companies.findOne({ _id: String(client.companyId) }); await ClientsList.clients.detachUser({ clientId: String(client.id) });
if (!orgClient) continue;
await ClientsList.deleteClient({
clientId: String(client.id),
checkCashOnAccount: false,
sessionUser: String(orgClient.ownerId),
});
} }
await MessageConversationList.deleteConversationsByUser({ userId: data.userId }); await MessageConversationList.deleteConversationsByUser({ userId: data.userId });
@@ -5,6 +5,7 @@ import {
AppointmentAdminByClientView, AppointmentAdminByClientView,
AppointmentAdminByDateView, AppointmentAdminByDateView,
CreateAppointmentParams, CreateAppointmentParams,
CreateImmediateAppointmentNotificationJobsParams,
DeleteAppointmentParams, DeleteAppointmentParams,
FindAppointmentsParams, FindAppointmentsParams,
FindAppointmentSchedulesParams, FindAppointmentSchedulesParams,
@@ -28,6 +29,7 @@ import { AppointmentService } from "./Appointments.Service";
import { ApiVoidResult } from "../../Models/Api.VoidResult.type"; import { ApiVoidResult } from "../../Models/Api.VoidResult.type";
import { TextObjectFilterResult } from "../../Models/TextObjectFilter.model"; import { TextObjectFilterResult } from "../../Models/TextObjectFilter.model";
import { rateLimiter } from "../../middleware/ratelimiter"; import { rateLimiter } from "../../middleware/ratelimiter";
import { systemTokenMiddleware } from "../../middleware/systemToken";
@Route("appointments/create") @Route("appointments/create")
@Middlewares(authenticateMiddleware) @Middlewares(authenticateMiddleware)
@@ -294,7 +296,7 @@ export class SendWapNotificationContrnoller extends Controller {
} }
@Route("appointments/send-wap-sys-notification") @Route("appointments/send-wap-sys-notification")
@Middlewares(rateLimiter) @Middlewares(systemTokenMiddleware)
export class SendWapSystemNotificationContrnoller extends Controller { export class SendWapSystemNotificationContrnoller extends Controller {
@Response<ApiValidationError>(500, "Ha ocurrido un error") @Response<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "Done") @SuccessResponse(200, "Done")
@@ -319,7 +321,7 @@ export class SendWapSystemNotificationContrnoller extends Controller {
} }
@Route("appointments/send-email-notification") @Route("appointments/send-email-notification")
@Middlewares(rateLimiter) @Middlewares([rateLimiter, systemTokenMiddleware])
export class SendEmailNotificationContrnoller extends Controller { export class SendEmailNotificationContrnoller extends Controller {
@Response<ApiValidationError>(500, "Ha ocurrido un error") @Response<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "Done") @SuccessResponse(200, "Done")
@@ -342,6 +344,30 @@ export class SendEmailNotificationContrnoller extends Controller {
} }
} }
@Route("appointments/create-immediate-notification-jobs")
@Middlewares(authenticateMiddleware)
export class CreateImmediateAppointmentNotificationJobsController extends Controller {
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "Done")
@Post()
public async createImmediateNotificationJobs(
@Body() requestBody: CreateImmediateAppointmentNotificationJobsParams
): Promise<ApiVoidResult | ApiValidationError> {
try {
await new AppointmentService().createImmediateNotificationJobs(requestBody);
this.setStatus(200);
return {
message: "Done",
success: true,
};
} catch (e) {
const errorOccurred: Error = e as Error; console.error("GET EVENT ERROR:", errorOccurred);
this.setStatus(500);
return new ApiValidationError(500, errorOccurred.message);
}
}
}
@Route("appointments/apply-discount") @Route("appointments/apply-discount")
@Middlewares(authenticateMiddleware) @Middlewares(authenticateMiddleware)
export class ApplyAppointmentDiscountContrnoller extends Controller { export class ApplyAppointmentDiscountContrnoller extends Controller {
@@ -1,6 +1,7 @@
import { TextObjectFilterResult } from "../../Models/TextObjectFilter.model"; import { TextObjectFilterResult } from "../../Models/TextObjectFilter.model";
import AppointmentList from "../../Models/Appointments/Appointments"; import AppointmentList from "../../Models/Appointments/Appointments";
import { import {
CreateImmediateAppointmentNotificationJobsParams,
AppointmentAdminByClientView, AppointmentAdminByClientView,
AppointmentAdminByDateView, AppointmentAdminByDateView,
CreateAppointmentParams, CreateAppointmentParams,
@@ -25,6 +26,7 @@ import {
PaginatedAppointmentEventByClientResult, PaginatedAppointmentEventByClientResult,
} from "../../Models/Appointments/Appointments.Interface"; } from "../../Models/Appointments/Appointments.Interface";
import { connect } from "mongoose"; import { connect } from "mongoose";
import { validatePermissionsByCompany } from "../../helpers/check";
export class AppointmentService { export class AppointmentService {
public async createAppointment(data: CreateAppointmentParams): Promise<IAppointment> { public async createAppointment(data: CreateAppointmentParams): Promise<IAppointment> {
@@ -63,6 +65,15 @@ export class AppointmentService {
): Promise<AppointmentAdminByDateView> { ): Promise<AppointmentAdminByDateView> {
await connect(`${process.env.DATABASE_CONNECTION}`); await connect(`${process.env.DATABASE_CONNECTION}`);
if (!data.companyId || !data.sessionUser) {
throw new Error("No se ha encontrado la organización o el usuario de sesión");
}
await validatePermissionsByCompany({
companyId: data.companyId,
sessionUser: data.sessionUser,
});
const appointments = await AppointmentList.findAppointmentsAdminByDate(data); const appointments = await AppointmentList.findAppointmentsAdminByDate(data);
return appointments; return appointments;
@@ -151,6 +162,11 @@ export class AppointmentService {
await AppointmentList.sendEmailNotification(data); await AppointmentList.sendEmailNotification(data);
} }
public async createImmediateNotificationJobs(data: CreateImmediateAppointmentNotificationJobsParams): Promise<void> {
await connect(`${process.env.DATABASE_CONNECTION}`);
await AppointmentList.createImmediateNotificationJobs(data);
}
public async applyDiscount(data: ApplyAppointmentDiscountParams): Promise<void> { public async applyDiscount(data: ApplyAppointmentDiscountParams): Promise<void> {
await connect(`${process.env.DATABASE_CONNECTION}`); await connect(`${process.env.DATABASE_CONNECTION}`);
await AppointmentList.applyDiscount(data); await AppointmentList.applyDiscount(data);
@@ -10,6 +10,7 @@ import CashFlowList from "../../Models/CashFlow/CashFlow";
import CompaniesList from "../../Models/Companies/Companies"; import CompaniesList from "../../Models/Companies/Companies";
import PlanSubscriptionsList from "../../Models/PlanSubscriptions/PlanSubscriptons"; import PlanSubscriptionsList from "../../Models/PlanSubscriptions/PlanSubscriptons";
import MetricsList from "../../Models/Metrics/Metrics"; import MetricsList from "../../Models/Metrics/Metrics";
import PlanUsageCycleList from "../../Models/PlanUsageCycle/PlanUsageCycle";
export class DashboardService { export class DashboardService {
public async getDashboardSummary(sessionUser: string): Promise<DashboardSummaryResult> { public async getDashboardSummary(sessionUser: string): Promise<DashboardSummaryResult> {
@@ -198,11 +199,18 @@ export class DashboardService {
const isOwner = String(ownerId) === String(params.sessionUser); const isOwner = String(ownerId) === String(params.sessionUser);
// Fetch metrics for owner // Fetch metrics for owner
const metrics = await MetricsList.getMetrics(String(ownerId)); const metricsDocument = await MetricsList.getMetrics(String(ownerId));
const metrics = metricsDocument.toObject ? metricsDocument.toObject() : { ...metricsDocument };
// Fetch subscription for owner // Fetch subscription for owner
const subscription = await PlanSubscriptionsList.getSubscriptionByUser({ sessionUser: String(ownerId) }); const subscription = await PlanSubscriptionsList.getSubscriptionByUser({ sessionUser: String(ownerId) });
if (subscription) {
metrics.appointmentsCount = await PlanUsageCycleList.getAppointmentsCount({
userId: String(ownerId),
});
}
return { return {
metrics, metrics,
subscription, subscription,
@@ -13,6 +13,7 @@ import {
UpdateEmployeeRolesParams, UpdateEmployeeRolesParams,
UpdateEmployeeParams, UpdateEmployeeParams,
IncompleteCollaboratorView, IncompleteCollaboratorView,
RemoveCollaboratorParams,
} from "../../Models/Employees/Employees.Interface"; } from "../../Models/Employees/Employees.Interface";
import { authenticateMiddleware } from "../../middleware/authentication"; import { authenticateMiddleware } from "../../middleware/authentication";
import { ApiVoidResult } from "../../Models/Api.VoidResult.type"; import { ApiVoidResult } from "../../Models/Api.VoidResult.type";
@@ -448,3 +449,27 @@ export class GetIncompleteSetupController extends Controller {
} }
} }
} }
@Route("employees/remove-collaborator")
@Middlewares(authenticateMiddleware)
export class RemoveCollaboratorController extends Controller {
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "El colaborador fue removido con éxito")
@Post()
public async removeCollaborator(
@Body() requestBody: RemoveCollaboratorParams
): Promise<ApiVoidResult | ApiValidationError> {
try {
await new EmployeesService().removeCollaborator(requestBody);
this.setStatus(200);
return {
success: true,
message: "Done",
};
} catch (e) {
const errorOccurred: Error = e as Error;
this.setStatus(500);
return new ApiValidationError(500, errorOccurred.message);
}
}
}
@@ -12,6 +12,7 @@ import {
UpdateEmployeeRolesParams, UpdateEmployeeRolesParams,
UpdateEmployeeParams, UpdateEmployeeParams,
IncompleteCollaboratorView, IncompleteCollaboratorView,
RemoveCollaboratorParams,
} from "../../Models/Employees/Employees.Interface"; } from "../../Models/Employees/Employees.Interface";
import { import {
CreateEmployeeServiceParams, CreateEmployeeServiceParams,
@@ -162,4 +163,9 @@ export class EmployeesService {
await connect(`${process.env.DATABASE_CONNECTION}`); await connect(`${process.env.DATABASE_CONNECTION}`);
await EmployeesList.update(data); await EmployeesList.update(data);
} }
public async removeCollaborator(data: RemoveCollaboratorParams): Promise<void> {
await connect(`${process.env.DATABASE_CONNECTION}`);
await EmployeesList.removeCollaboratorFromCompany(data);
}
} }
@@ -2,6 +2,43 @@ import axios from "axios";
import PlanSubscriptionsList from "../../Models/PlanSubscriptions/PlanSubscriptons"; import PlanSubscriptionsList from "../../Models/PlanSubscriptions/PlanSubscriptons";
import { MP_SUBS_STATUS } from "../../Models/PlanSubscriptions/PlanSubscriptions.Adapter.Mongoose"; import { MP_SUBS_STATUS } from "../../Models/PlanSubscriptions/PlanSubscriptions.Adapter.Mongoose";
import { connect } from "mongoose"; import { connect } from "mongoose";
import dayjs from "dayjs";
import PlanPaymentsList from "../../Models/PlanPayments/PlanPayments";
import { IPlanSuscriptionDocument } from "../../Models/PlanSubscriptions/PlanSubscriptions.Adapter.Mongoose";
import { VerifyPendingPlanPaymentResponse } from "../../Models/PlanSubscriptions/PlanSubscriptions.interface";
type MercadoPagoPaymentData = {
id: string | number;
status: string;
external_reference?: string;
preference_id?: string;
order?: { id?: string };
date_approved?: string;
payer?: { id?: number; email?: string };
transaction_amount?: number;
payment_method_id?: string;
payment_type_id?: string;
date_last_updated?: string;
};
type MercadoPagoMerchantOrder = {
id: string | number;
preference_id?: string;
external_reference?: string;
payments?: MercadoPagoPaymentData[];
};
type MercadoPagoPaymentContext = {
preferenceId?: string;
externalReference?: string;
};
type MercadoPagoPaymentVerification = {
status: "approved" | "rejected" | "failed" | "cancelled" | "pending";
payment: MercadoPagoPaymentData | null;
};
const REJECTED_PAYMENT_STATUSES = ["rejected", "cancelled", "failed"];
export class MercadoPagoWebhookService { export class MercadoPagoWebhookService {
public async handleWebhook(body: any): Promise<void> { public async handleWebhook(body: any): Promise<void> {
@@ -24,31 +61,20 @@ export class MercadoPagoWebhookService {
} }
); );
const paymentData = paymentResponse.data; const paymentData: MercadoPagoPaymentData = paymentResponse.data;
const status = paymentData.status; // approved, pending, rejected, etc. const status = paymentData.status; // approved, pending, rejected, etc.
const userId = paymentData.external_reference; // We passed this when creating preference const paymentContext = await this.resolvePaymentContext(paymentData);
const userId = paymentContext.externalReference || paymentData.external_reference; // We passed this when creating preference
if (userId && status === "approved") { const preferenceId = paymentContext.preferenceId || paymentData.preference_id;
// Update subscription status in DB const subscription = await this.findSubscriptionForPayment(userId, preferenceId);
const subscription = await PlanSubscriptionsList.planSuscriptions.findOne({
sessionUser: userId,
});
if (userId && preferenceId && status === "approved") {
if (subscription) { if (subscription) {
subscription.mpStatus = MP_SUBS_STATUS.AUTHORIZED; await this.applyApprovedPayment(subscription, paymentData, userId, preferenceId);
subscription.isActive = true;
// Assuming endDate is correctly set for the months purchased when preference was created
await subscription.save();
} }
} else if (userId && status === "rejected") { } else if (userId && preferenceId && REJECTED_PAYMENT_STATUSES.includes(status)) {
const subscription = await PlanSubscriptionsList.planSuscriptions.findOne({ if (subscription) {
sessionUser: userId, await this.applyRejectedPayment(subscription, paymentData, userId, preferenceId);
});
if (subscription && subscription.mpStatus !== MP_SUBS_STATUS.AUTHORIZED) {
subscription.mpStatus = MP_SUBS_STATUS.CANCELLED;
subscription.isActive = false;
await subscription.save();
} }
} }
} catch (error) { } catch (error) {
@@ -56,4 +82,320 @@ export class MercadoPagoWebhookService {
} }
} }
} }
public async verifyPendingPlanPayment(sessionUser: string): Promise<VerifyPendingPlanPaymentResponse> {
const pendingSubscription = await PlanSubscriptionsList.planSuscriptions.planSuscriptionList.findOne({
userId: sessionUser,
mpPreferenceId: { $exists: true, $ne: "" },
mpStatus: MP_SUBS_STATUS.PENDING,
}).exec();
const pendingExistingSubscription = await PlanSubscriptionsList.planSuscriptions.planSuscriptionList.findOne({
userId: sessionUser,
pendingPaymentPreferenceId: { $exists: true, $ne: "" },
mpStatus: MP_SUBS_STATUS.AUTHORIZED,
}).exec();
const subscription = pendingSubscription || pendingExistingSubscription;
if (!subscription) {
return {
status: "not_found",
approved: false,
message: "No se encontró un pago pendiente para verificar.",
};
}
const preferenceId = pendingSubscription
? subscription.mpPreferenceId
: subscription.pendingPaymentPreferenceId;
if (!preferenceId) {
return {
status: "not_found",
approved: false,
message: "No se encontró una preferencia de pago pendiente para verificar.",
subscriptionId: String(subscription._id),
};
}
const paymentVerification = await this.findPaymentForPreference(sessionUser, preferenceId);
if (!paymentVerification.payment || paymentVerification.status === "pending") {
return {
status: "pending",
approved: false,
message: "El pago todavía no figura confirmado.",
subscriptionId: String(subscription._id),
paymentType: this.getPaymentType(subscription, preferenceId),
};
}
const paymentType = this.getPaymentType(subscription, preferenceId);
const payment = paymentVerification.payment;
if (paymentVerification.status !== "approved") {
await this.applyRejectedPayment(subscription, payment, sessionUser, preferenceId);
return {
status: paymentVerification.status,
approved: false,
message: "El pago fue rechazado. Tu plan actual no cambió y podés intentarlo nuevamente.",
subscriptionId: String(subscription._id),
paymentId: String(payment.id),
paymentType,
};
}
await this.applyApprovedPayment(subscription, payment, sessionUser, preferenceId);
return {
status: "approved",
approved: true,
message: "El pago fue aprobado y la suscripción fue actualizada.",
subscriptionId: String(subscription._id),
paymentId: String(payment.id),
paymentType,
};
}
private async findPaymentForPreference(sessionUser: string, preferenceId: string): Promise<MercadoPagoPaymentVerification> {
const merchantOrders = await this.searchMerchantOrdersByPreferenceId(preferenceId);
const merchantOrder = merchantOrders.find((order) => {
return !order.external_reference || order.external_reference === sessionUser;
});
const merchantOrderPayment = this.pickMostRelevantPayment(merchantOrder?.payments || []);
if (merchantOrderPayment) {
const payment = await this.getPaymentById(merchantOrderPayment.id);
return {
status: this.normalizePaymentStatus(payment.status),
payment,
};
}
const response = await axios.get("https://api.mercadopago.com/v1/payments/search", {
headers: this.getMercadoPagoHeaders(),
params: {
external_reference: sessionUser,
sort: "date_created",
criteria: "desc",
limit: 20,
},
});
const payments: MercadoPagoPaymentData[] = response.data?.results || [];
const payment = this.pickMostRelevantPayment(payments.filter((payment) => payment.preference_id === preferenceId));
if (!payment) {
return { status: "pending", payment: null };
}
const fullPayment = await this.getPaymentById(payment.id);
return {
status: this.normalizePaymentStatus(fullPayment.status),
payment: fullPayment,
};
}
private pickMostRelevantPayment(payments: MercadoPagoPaymentData[]): MercadoPagoPaymentData | null {
return payments.find((payment) => payment.status === "approved")
|| payments.find((payment) => REJECTED_PAYMENT_STATUSES.includes(payment.status))
|| payments[0]
|| null;
}
private normalizePaymentStatus(status: string): MercadoPagoPaymentVerification["status"] {
if (status === "approved") return "approved";
if (status === "cancelled") return "cancelled";
if (status === "failed") return "failed";
if (status === "rejected") return "rejected";
return "pending";
}
private async resolvePaymentContext(paymentData: MercadoPagoPaymentData): Promise<MercadoPagoPaymentContext> {
if (paymentData.preference_id) {
return {
preferenceId: paymentData.preference_id,
externalReference: paymentData.external_reference,
};
}
if (!paymentData.order?.id) {
return {
externalReference: paymentData.external_reference,
};
}
const merchantOrder = await this.getMerchantOrderById(paymentData.order.id);
return {
preferenceId: merchantOrder?.preference_id,
externalReference: merchantOrder?.external_reference || paymentData.external_reference,
};
}
private async searchMerchantOrdersByPreferenceId(preferenceId: string): Promise<MercadoPagoMerchantOrder[]> {
const response = await axios.get("https://api.mercadopago.com/merchant_orders/search", {
headers: this.getMercadoPagoHeaders(),
params: { preference_id: preferenceId },
});
return response.data?.elements || [];
}
private async getMerchantOrderById(merchantOrderId: string): Promise<MercadoPagoMerchantOrder | null> {
const response = await axios.get(`https://api.mercadopago.com/merchant_orders/${merchantOrderId}`, {
headers: this.getMercadoPagoHeaders(),
});
return response.data || null;
}
private async getPaymentById(paymentId: string | number): Promise<MercadoPagoPaymentData> {
const response = await axios.get(`https://api.mercadopago.com/v1/payments/${paymentId}`, {
headers: this.getMercadoPagoHeaders(),
});
return response.data;
}
private getMercadoPagoHeaders(): Record<string, string> {
return {
Authorization: `Bearer ${process.env.MP_ACCESS_TOKEN}`,
"Content-Type": "application/json",
};
}
private async findSubscriptionForPayment(userId?: string, preferenceId?: string): Promise<IPlanSuscriptionDocument | null> {
if (!userId || !preferenceId) return null;
let subscription = await PlanSubscriptionsList.planSuscriptions.findOne({
sessionUser: userId,
mpPreferenceId: preferenceId,
mpStatus: MP_SUBS_STATUS.PENDING,
});
if (!subscription) {
subscription = await PlanSubscriptionsList.planSuscriptions.planSuscriptionList.findOne({
userId,
pendingPaymentPreferenceId: preferenceId,
mpStatus: MP_SUBS_STATUS.AUTHORIZED,
}).exec();
}
return subscription;
}
private async applyApprovedPayment(
subscription: IPlanSuscriptionDocument,
paymentData: MercadoPagoPaymentData,
userId: string,
preferenceId: string
): Promise<void> {
const approvedAt = paymentData.date_approved ? new Date(paymentData.date_approved) : new Date();
const billingMonths = subscription.pendingPaymentBillingMonths || subscription.billingMonths || 1;
const isExtensionPayment = subscription.pendingPaymentPreferenceId === preferenceId;
const isUpgradePayment = isExtensionPayment && subscription.pendingPaymentType === "upgrade";
const periodStart = isExtensionPayment && dayjs(subscription.endDate).isAfter(approvedAt)
? subscription.endDate
: approvedAt;
if (isUpgradePayment && subscription.pendingPaymentPlanId) {
subscription.planId = subscription.pendingPaymentPlanId;
}
subscription.mpStatus = MP_SUBS_STATUS.AUTHORIZED;
subscription.isActive = true;
subscription.autoRenew = false;
subscription.startDate = isExtensionPayment ? subscription.startDate : approvedAt;
subscription.endDate = isUpgradePayment
? subscription.endDate
: dayjs(periodStart).add(billingMonths, "months").toDate();
subscription.mpPayerId = paymentData.payer?.id || subscription.mpPayerId;
subscription.mpPayerEmail = paymentData.payer?.email || subscription.mpPayerEmail;
subscription.pendingPaymentInitPoint = "";
subscription.pendingPaymentPreferenceId = "";
subscription.pendingPaymentBillingMonths = undefined;
subscription.pendingPaymentType = undefined;
subscription.pendingPaymentPlanId = undefined;
subscription.pendingPaymentCurrentPlanPrice = undefined;
subscription.pendingPaymentRequestedPlanPrice = undefined;
subscription.pendingPaymentProratedAmount = undefined;
subscription.pendingPaymentRemainingDays = undefined;
subscription.pendingPaymentPeriodEndDate = undefined;
subscription.lastPaymentStatus = undefined;
subscription.lastPaymentPreferenceId = undefined;
subscription.lastPaymentAt = undefined;
subscription.lastPaymentType = undefined;
subscription.downgradedFromPlanId = undefined;
subscription.downgradedFromPlanName = undefined;
subscription.downgradedFromPlanCode = undefined;
subscription.downgradedAt = undefined;
subscription.downgradeReason = undefined;
await subscription.save();
await PlanPaymentsList.createIfMissingByTransactionId({
userId,
subscriptionId: String(subscription._id),
amount: paymentData.transaction_amount || 0,
paymentDate: approvedAt,
paymentMethod: paymentData.payment_method_id || paymentData.payment_type_id || "mercadopago",
status: "completed",
transactionId: String(paymentData.id),
});
}
private async applyRejectedPayment(
subscription: IPlanSuscriptionDocument,
paymentData: MercadoPagoPaymentData,
userId: string,
preferenceId: string
): Promise<void> {
const isExtensionPayment = subscription.pendingPaymentPreferenceId === preferenceId;
const paymentStatus = this.normalizePaymentStatus(paymentData.status);
const paymentType = this.getPaymentType(subscription, preferenceId);
if (isExtensionPayment) {
subscription.pendingPaymentInitPoint = "";
subscription.pendingPaymentPreferenceId = "";
subscription.pendingPaymentBillingMonths = undefined;
subscription.pendingPaymentType = undefined;
subscription.pendingPaymentPlanId = undefined;
subscription.pendingPaymentCurrentPlanPrice = undefined;
subscription.pendingPaymentRequestedPlanPrice = undefined;
subscription.pendingPaymentProratedAmount = undefined;
subscription.pendingPaymentRemainingDays = undefined;
subscription.pendingPaymentPeriodEndDate = undefined;
} else {
subscription.mpStatus = MP_SUBS_STATUS.CANCELLED;
subscription.isActive = false;
}
if (paymentStatus !== "pending" && paymentStatus !== "approved") {
subscription.lastPaymentStatus = paymentStatus;
subscription.lastPaymentPreferenceId = preferenceId;
subscription.lastPaymentAt = paymentData.date_last_updated ? new Date(paymentData.date_last_updated) : new Date();
subscription.lastPaymentType = paymentType;
}
await subscription.save();
await PlanPaymentsList.createIfMissingByTransactionId({
userId,
subscriptionId: String(subscription._id),
amount: paymentData.transaction_amount || 0,
paymentDate: new Date(),
paymentMethod: paymentData.payment_method_id || paymentData.payment_type_id || "mercadopago",
status: "failed",
transactionId: String(paymentData.id),
});
}
private getPaymentType(subscription: IPlanSuscriptionDocument, preferenceId: string): "new" | "extension" | "upgrade" {
if (subscription.pendingPaymentPreferenceId === preferenceId) {
return subscription.pendingPaymentType === "upgrade" ? "upgrade" : "extension";
}
return "new";
}
} }
@@ -0,0 +1,74 @@
import { MercadoPagoWebhookService } from "../MercadoPagoWebhook.Service";
import PlanPaymentsList from "../../../Models/PlanPayments/PlanPayments";
jest.mock("../../../Models/PlanPayments/PlanPayments", () => ({
__esModule: true,
default: {
createIfMissingByTransactionId: jest.fn(),
},
}));
jest.mock("../../../Models/PlanSubscriptions/PlanSubscriptons", () => ({
__esModule: true,
default: {},
}));
describe("MercadoPagoWebhookService", () => {
beforeEach(() => {
jest.clearAllMocks();
});
it("should clear pending upgrade metadata without changing the current plan or dates when payment is rejected", async () => {
const startDate = new Date("2026-07-01T00:00:00.000Z");
const endDate = new Date("2026-08-01T00:00:00.000Z");
const subscription = {
_id: "subscription-id",
planId: "current-plan-id",
startDate,
endDate,
pendingPaymentInitPoint: "https://checkout.example",
pendingPaymentPreferenceId: "preference-id",
pendingPaymentBillingMonths: undefined,
pendingPaymentType: "upgrade",
pendingPaymentPlanId: "requested-plan-id",
pendingPaymentCurrentPlanPrice: 1000,
pendingPaymentRequestedPlanPrice: 2000,
pendingPaymentProratedAmount: 500,
pendingPaymentRemainingDays: 15,
pendingPaymentPeriodEndDate: endDate,
save: jest.fn().mockResolvedValue(undefined),
};
const service = new MercadoPagoWebhookService() as any;
await service.applyRejectedPayment(
subscription,
{
id: "payment-id",
status: "rejected",
transaction_amount: 500,
payment_method_id: "visa",
},
"user-id",
"preference-id"
);
expect(subscription.planId).toBe("current-plan-id");
expect(subscription.startDate).toBe(startDate);
expect(subscription.endDate).toBe(endDate);
expect(subscription.pendingPaymentInitPoint).toBe("");
expect(subscription.pendingPaymentPreferenceId).toBe("");
expect(subscription.pendingPaymentType).toBeUndefined();
expect(subscription.pendingPaymentPlanId).toBeUndefined();
expect(subscription.pendingPaymentProratedAmount).toBeUndefined();
expect(subscription.save).toHaveBeenCalledTimes(1);
expect(PlanPaymentsList.createIfMissingByTransactionId).toHaveBeenCalledWith({
userId: "user-id",
subscriptionId: "subscription-id",
amount: 500,
paymentDate: expect.any(Date),
paymentMethod: "visa",
status: "failed",
transactionId: "payment-id",
});
});
});
@@ -0,0 +1,150 @@
import { ApiValidationError } from "../../Models/Server.Error.model";
import { Body, Controller, Middlewares, Post, Response, Route, SuccessResponse } from "tsoa";
import { authenticateMiddleware } from "../../middleware/authentication";
import {
NotificationPoliciesService,
FindCompanyPolicyParams,
UpsertCompanyPolicyParams,
FindClientPreferencesParams,
UpsertClientPreferencesParams,
FindClientCompanyOverrideParams,
UpsertClientCompanyOverrideParams,
} from "./NotificationPolicies.Service";
import { ICompanyNotificationPolicy } from "../../Models/NotificationPolicies/CompanyNotificationPolicy.Interface";
import { IClientNotificationPreferences } from "../../Models/NotificationPolicies/ClientNotificationPreferences.Interface";
import { IClientCompanyNotificationOverride } from "../../Models/NotificationPolicies/ClientCompanyNotificationOverride.Interface";
@Route("notifications/policy/find")
@Middlewares(authenticateMiddleware)
export class FindCompanyNotificationPolicyController extends Controller {
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "Política encontrada")
@Post()
public async findCompanyPolicy(
@Body() requestBody: FindCompanyPolicyParams
): Promise<ICompanyNotificationPolicy | ApiValidationError> {
try {
const policy = await new NotificationPoliciesService().getCompanyPolicy(requestBody);
this.setStatus(200);
if (!policy) {
return new ApiValidationError(404, "No se encontró una política de notificación para esta organización");
}
return policy;
} catch (e) {
const errorOccurred: Error = e as Error;
this.setStatus(500);
return new ApiValidationError(500, errorOccurred.message);
}
}
}
@Route("notifications/policy/save")
@Middlewares(authenticateMiddleware)
export class UpsertCompanyNotificationPolicyController extends Controller {
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "Política guardada con éxito")
@Post()
public async upsertCompanyPolicy(
@Body() requestBody: UpsertCompanyPolicyParams
): Promise<ICompanyNotificationPolicy | ApiValidationError> {
try {
const policy = await new NotificationPoliciesService().upsertCompanyPolicy(requestBody);
this.setStatus(200);
return policy;
} catch (e) {
const errorOccurred: Error = e as Error;
this.setStatus(500);
return new ApiValidationError(500, errorOccurred.message);
}
}
}
@Route("notifications/preferences/find")
@Middlewares(authenticateMiddleware)
export class FindClientNotificationPreferencesController extends Controller {
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "Preferencias encontradas")
@Post()
public async findClientPreferences(
@Body() requestBody: FindClientPreferencesParams
): Promise<IClientNotificationPreferences | ApiValidationError> {
try {
const preferences = await new NotificationPoliciesService().getClientPreferences(requestBody);
this.setStatus(200);
if (!preferences) {
return new ApiValidationError(404, "No se encontraron preferencias de notificación para este cliente");
}
return preferences;
} catch (e) {
const errorOccurred: Error = e as Error;
this.setStatus(500);
return new ApiValidationError(500, errorOccurred.message);
}
}
}
@Route("notifications/preferences/save")
@Middlewares(authenticateMiddleware)
export class UpsertClientNotificationPreferencesController extends Controller {
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "Preferencias guardadas con éxito")
@Post()
public async upsertClientPreferences(
@Body() requestBody: UpsertClientPreferencesParams
): Promise<IClientNotificationPreferences | ApiValidationError> {
try {
const preferences = await new NotificationPoliciesService().upsertClientPreferences(requestBody);
this.setStatus(200);
return preferences;
} catch (e) {
const errorOccurred: Error = e as Error;
this.setStatus(500);
return new ApiValidationError(500, errorOccurred.message);
}
}
}
@Route("notifications/override/find")
@Middlewares(authenticateMiddleware)
export class FindClientCompanyNotificationOverrideController extends Controller {
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "Override encontrado")
@Post()
public async findClientCompanyOverride(
@Body() requestBody: FindClientCompanyOverrideParams
): Promise<IClientCompanyNotificationOverride | ApiValidationError> {
try {
const override = await new NotificationPoliciesService().getClientCompanyOverride(requestBody);
this.setStatus(200);
if (!override) {
return new ApiValidationError(404, "No se encontró una preferencia específica para este cliente en esta organización");
}
return override;
} catch (e) {
const errorOccurred: Error = e as Error;
this.setStatus(500);
return new ApiValidationError(500, errorOccurred.message);
}
}
}
@Route("notifications/override/save")
@Middlewares(authenticateMiddleware)
export class UpsertClientCompanyNotificationOverrideController extends Controller {
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "Override guardado con éxito")
@Post()
public async upsertClientCompanyOverride(
@Body() requestBody: UpsertClientCompanyOverrideParams
): Promise<IClientCompanyNotificationOverride | ApiValidationError> {
try {
const override = await new NotificationPoliciesService().upsertClientCompanyOverride(requestBody);
this.setStatus(200);
return override;
} catch (e) {
const errorOccurred: Error = e as Error;
this.setStatus(500);
return new ApiValidationError(500, errorOccurred.message);
}
}
}
@@ -0,0 +1,132 @@
import { connect } from "mongoose";
import CompanyNotificationPolicy from "../../Models/NotificationPolicies/CompanyNotificationPolicy";
import ClientNotificationPreferences from "../../Models/NotificationPolicies/ClientNotificationPreferences";
import ClientCompanyNotificationOverride from "../../Models/NotificationPolicies/ClientCompanyNotificationOverride";
import {
ICompanyNotificationPolicy,
} from "../../Models/NotificationPolicies/CompanyNotificationPolicy.Interface";
import {
IClientNotificationPreferences,
} from "../../Models/NotificationPolicies/ClientNotificationPreferences.Interface";
import {
IClientCompanyNotificationOverride,
} from "../../Models/NotificationPolicies/ClientCompanyNotificationOverride.Interface";
import { NotificationChannel } from "../../Models/NotificationJobs/NotificationJobs.Interface";
export interface SessionUserParams {
sessionUser: string;
}
export interface FindCompanyPolicyParams extends SessionUserParams {
companyId: string;
}
export interface UpsertCompanyPolicyParams extends SessionUserParams {
companyId: string;
defaultChannels?: NotificationChannel[];
timezone?: string;
quietHours?: { from: string; to: string };
reminderRules?: { offset: number; enabled: boolean }[];
}
export interface FindClientPreferencesParams extends SessionUserParams {
clientId: string;
}
export interface UpsertClientPreferencesParams extends SessionUserParams {
clientId: string;
preferredChannels?: NotificationChannel[];
mutedChannels?: NotificationChannel[];
}
export interface FindClientCompanyOverrideParams extends SessionUserParams {
clientId: string;
companyId: string;
}
export interface UpsertClientCompanyOverrideParams extends SessionUserParams {
clientId: string;
companyId: string;
preferredChannels?: NotificationChannel[];
mutedChannels?: NotificationChannel[];
}
export class NotificationPoliciesService {
public async getCompanyPolicy(
params: FindCompanyPolicyParams
): Promise<ICompanyNotificationPolicy | null> {
await connect(`${process.env.DATABASE_CONNECTION}`);
return CompanyNotificationPolicy.findOne(params);
}
public async upsertCompanyPolicy(
params: UpsertCompanyPolicyParams
): Promise<ICompanyNotificationPolicy> {
await connect(`${process.env.DATABASE_CONNECTION}`);
const data: Partial<
Omit<ICompanyNotificationPolicy, "id" | "companyId" | "createdAt" | "updatedAt">
> = {};
if (params.defaultChannels !== undefined) data.defaultChannels = params.defaultChannels;
if (params.timezone !== undefined) data.timezone = params.timezone;
if (params.quietHours !== undefined) data.quietHours = params.quietHours;
if (params.reminderRules !== undefined) data.reminderRules = params.reminderRules;
return CompanyNotificationPolicy.upsert({
companyId: params.companyId,
sessionUser: params.sessionUser,
data,
});
}
public async getClientPreferences(
params: FindClientPreferencesParams
): Promise<IClientNotificationPreferences | null> {
await connect(`${process.env.DATABASE_CONNECTION}`);
return ClientNotificationPreferences.findOne({
userId: params.clientId,
sessionUser: params.sessionUser,
});
}
public async upsertClientPreferences(
params: UpsertClientPreferencesParams
): Promise<IClientNotificationPreferences> {
await connect(`${process.env.DATABASE_CONNECTION}`);
const data: Partial<
Omit<IClientNotificationPreferences, "id" | "userId" | "createdAt" | "updatedAt">
> = {};
if (params.preferredChannels !== undefined) data.preferredChannels = params.preferredChannels;
if (params.mutedChannels !== undefined) data.mutedChannels = params.mutedChannels;
return ClientNotificationPreferences.upsert({
userId: params.clientId,
sessionUser: params.sessionUser,
data,
});
}
public async getClientCompanyOverride(
params: FindClientCompanyOverrideParams
): Promise<IClientCompanyNotificationOverride | null> {
await connect(`${process.env.DATABASE_CONNECTION}`);
return ClientCompanyNotificationOverride.findOne(params);
}
public async upsertClientCompanyOverride(
params: UpsertClientCompanyOverrideParams
): Promise<IClientCompanyNotificationOverride> {
await connect(`${process.env.DATABASE_CONNECTION}`);
const data: Partial<
Omit<
IClientCompanyNotificationOverride,
"id" | "clientId" | "companyId" | "createdAt" | "updatedAt"
>
> = {};
if (params.preferredChannels !== undefined) data.preferredChannels = params.preferredChannels;
if (params.mutedChannels !== undefined) data.mutedChannels = params.mutedChannels;
return ClientCompanyNotificationOverride.upsert({
clientId: params.clientId,
companyId: params.companyId,
sessionUser: params.sessionUser,
data,
});
}
}
@@ -0,0 +1,61 @@
import { Body, Controller, Middlewares, Post, Route, SuccessResponse } from "tsoa";
import { rateLimiter } from "../../middleware/ratelimiter";
import { systemTokenMiddleware } from "../../middleware/systemToken";
import { NotificationsManager } from "../../Models/Notifications/Notifications";
import { ApiVoidResult } from "../../Models/Api.VoidResult.type";
interface SendEmailByContentParams {
systemToken: string;
email: string;
subject: string;
message: string;
}
interface SendWapByContentParams {
systemToken: string;
phoneNumber: string;
message: string;
companyId: string;
sessionUser: string;
}
@Route("notifications/send-email")
@Middlewares([rateLimiter, systemTokenMiddleware])
export class SendEmailByContentController extends Controller {
@SuccessResponse(200, "Done")
@Post()
public async sendEmailByContent(@Body() requestBody: SendEmailByContentParams): Promise<ApiVoidResult> {
await NotificationsManager.sendEmail({
email: requestBody.email,
subject: requestBody.subject,
message: requestBody.message,
});
this.setStatus(200);
return {
message: "Done",
success: true,
};
}
}
@Route("notifications/send-wap")
@Middlewares([rateLimiter, systemTokenMiddleware])
export class SendWapByContentController extends Controller {
@SuccessResponse(200, "Done")
@Post()
public async sendWapByContent(@Body() requestBody: SendWapByContentParams): Promise<ApiVoidResult> {
await NotificationsManager.sendWap({
phoneNumber: requestBody.phoneNumber,
message: requestBody.message,
companyId: requestBody.companyId,
sessionUser: requestBody.sessionUser,
});
this.setStatus(200);
return {
message: "Done",
success: true,
};
}
}
@@ -7,9 +7,12 @@ import {
GetSubscriptionInitPointParams, GetSubscriptionInitPointParams,
GetSuscriptionInitPointResponse, GetSuscriptionInitPointResponse,
MPPreApprovalResponse, MPPreApprovalResponse,
VerifyPendingPlanPaymentParams,
VerifyPendingPlanPaymentResponse,
} from "../../Models/PlanSubscriptions/PlanSubscriptions.interface"; } from "../../Models/PlanSubscriptions/PlanSubscriptions.interface";
import { authenticateMiddleware } from "../../middleware/authentication"; import { authenticateMiddleware } from "../../middleware/authentication";
import { ApiVoidResult } from "../../Models/Api.VoidResult.type"; import { ApiVoidResult } from "../../Models/Api.VoidResult.type";
import { rateLimiter } from "../../middleware/ratelimiter";
@Route("suscriptions/get-init-point") @Route("suscriptions/get-init-point")
@Middlewares(authenticateMiddleware) @Middlewares(authenticateMiddleware)
@@ -75,3 +78,24 @@ export class PlanSubscriptionsCancellController extends Controller {
} }
} }
} }
@Route("suscriptions/verify-pending-payment")
@Middlewares([rateLimiter, authenticateMiddleware])
export class PlanSubscriptionsVerifyPendingPaymentController extends Controller {
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "Done")
@Post()
public async verifyPendingPayment(
@Body() requestBody: VerifyPendingPlanPaymentParams
): Promise<VerifyPendingPlanPaymentResponse | ApiValidationError> {
try {
const result = await new PlanSubscriptionsService().verifyPendingPayment(requestBody);
this.setStatus(200);
return result;
} catch (e) {
const errorOccurred: Error = e as Error;
this.setStatus(500);
return new ApiValidationError(500, errorOccurred.message);
}
}
}
@@ -5,8 +5,12 @@ import {
GetSubscriptionInitPointParams, GetSubscriptionInitPointParams,
GetSuscriptionInitPointResponse, GetSuscriptionInitPointResponse,
MPPreApprovalResponse, MPPreApprovalResponse,
VerifyPendingPlanPaymentParams,
VerifyPendingPlanPaymentResponse,
} from "../../Models/PlanSubscriptions/PlanSubscriptions.interface"; } from "../../Models/PlanSubscriptions/PlanSubscriptions.interface";
import { connect } from "mongoose"; import { connect } from "mongoose";
import { validateSessionUser } from "../../helpers/check";
import { MercadoPagoWebhookService } from "../MercadoPago/MercadoPagoWebhook.Service";
export class PlanSubscriptionsService { export class PlanSubscriptionsService {
public async getInitPoint( public async getInitPoint(
@@ -27,4 +31,10 @@ export class PlanSubscriptionsService {
await connect(`${process.env.DATABASE_CONNECTION}`); await connect(`${process.env.DATABASE_CONNECTION}`);
await PlanSubscriptionsList.cancell(data); await PlanSubscriptionsList.cancell(data);
} }
public async verifyPendingPayment(data: VerifyPendingPlanPaymentParams): Promise<VerifyPendingPlanPaymentResponse> {
await connect(`${process.env.DATABASE_CONNECTION}`);
await validateSessionUser({ sessionUser: data.sessionUser });
return new MercadoPagoWebhookService().verifyPendingPlanPayment(data.sessionUser);
}
} }

Some files were not shown because too many files have changed in this diff Show More