35 Commits

Author SHA1 Message Date
horacio c77d479344 feat: implement StepEngine architecture for assistant onboarding and configuration flows 2026-07-28 20:01:59 -03:00
horacio 0a285776c7 refactor: simplify StepEngine action logic and add public link display after onboarding completion 2026-07-28 19:14:34 -03:00
horacio f39ba44f59 refactor: centralize week calculation logic and standardize schedule boundary date normalization across client and server 2026-07-28 18:12:10 -03:00
horacio ece3879c8d feat: implement backend and frontend support for managing reservation period extensions and summaries 2026-07-28 17:10:20 -03:00
horacio 68d98e8b40 refactor: modularize notification job creation and enforce plan-based channel availability in policy resolver 2026-07-27 22:51:33 -03:00
horacio dc020d94d2 feat: add public link and QR code generation flow to assistant 2026-07-27 21:50:59 -03:00
horacio 20b0363767 docs: add content guide and QR code for marketing trifold, update design asset 2026-07-27 20:32:01 -03:00
horacio 6c75929e21 feat: implement WhatsApp bot setup flow in Assistant dashboard 2026-07-24 14:10:26 -03:00
horacio e6662aac18 feat: add opencode skill for tx-admin-endpoint collection and API generation 2026-07-22 19:44:47 -03:00
horacio 4029566063 feat: add notification configuration flow to StepEngine and integrate ClientPicker component 2026-07-22 17:27:15 -03:00
horacio 3988e328eb feat: add public visibility configuration flow for organizations and services 2026-07-22 16:57:16 -03:00
horacio dd3e07311d feat: add public employee profile view and implement ratings review page components 2026-07-22 16:27:46 -03:00
horacio 279e1b54cc feat: add showPublicScores and showPublicOpinions flags to services with plan-based access control 2026-07-22 14:05:39 -03:00
horacio da3c3abb52 feat: implement rating summary service methods and integrate rating display components into the organization header and pending ratings page 2026-07-22 12:13:50 -03:00
horacio 383a5bc6b4 feat: implement full-stack ratings system for appointments with user submission and pending ratings dashboard integration 2026-07-21 23:49:16 -03:00
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
301 changed files with 34438 additions and 4706 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
*.sln
*.sw?
# AI tooling
.atl/
@@ -0,0 +1,86 @@
---
name: tx-admin-endpoint
description: "Trigger: crear colección, nueva colección, crear endpoint, nuevo endpoint, Models, api, turnosxpress admin. Reglas para crear colecciones y endpoints del backend admin de turnosxpress."
license: Apache-2.0
metadata:
author: gentleman-programming
version: "1.0"
---
## Activation Contract
Load this skill when creating a new collection or a new endpoint in the `server` backend of the turnosxpress admin (the API consumed by admins/employees, not the public client app). Applies to both the data layer (`server/src/Models`) and the HTTP layer (`server/src/api`). Example to imitate: `Clients`, `Posts`, `SchedulesDisabled`.
## Hard Rules
- Use the ACTUAL paths: collections live in `server/src/Models/<Name>/`, endpoints in `server/src/api/<Name>/`. The folder name is the collection name (e.g. `Clients`, `Posts`).
- A collection is ALWAYS exactly 3 files with separated responsibilities (never collapse them):
| File | Owns | Forbidden here |
|------|------|-----------------|
| `<Name>.Interface.ts` | All TS types: `*Params` (Create/Update/Delete/Find), `IX`, `IXDocument` re-export, `IXAdapter`, `IXManager`, view/result types. | No Mongoose, no business logic. |
| `<Name>.Adapter.Mongoose.ts` | Mongoose ONLY: `Schema`, `IXDocument extends IX & Document`, `model()`, and pure DB ops (`create`, `delete`, `find`, `findOne`, `paginate`). Implements `IXAdapter`. | No business rules, NO security checks. |
| `<Name>.ts` | Business rules AND security. A `<Name>Manager implements IXManager` class that instantiates the adapter in the constructor; exported as a singleton (`const XList = new ...Manager(); export default XList;`). | No Schema, no `model()`. |
- SECURITY STANDARD (mandatory, even though `Clients.ts` inlines it — that is legacy): use the shared helpers from `server/src/helpers/check.ts`:
- `validateSessionUser({ sessionUser })` — confirm the session user exists. Call FIRST in any method that receives `sessionUser`.
- `validatePermissionsByCompany({ companyId, sessionUser })` — confirm the company exists AND the session user has ADMIN role. Call whenever a `companyId` is involved.
- Modern reference: `server/src/Models/Posts/Posts.ts`, `server/src/Models/SchedulesDisabled/SchedulesDisabled.ts`.
- Role checks beyond ADMIN (EMPLOYEE, etc.) go through `EmployeesList.checkPermission` / `checkRoleById`.
- An endpoint is ALWAYS 2 files:
- `<Name>.Controller.ts` — tsoa decorators. One controller class per `@Route("resource/action")`. Each handler `@Post()`, takes `@Body()`, sets status, wraps in try/catch and returns `new ApiValidationError(500, message)` on error. Import `ApiValidationError` from `../../Models/Server.Error.model`.
- `<Name>.Service.ts` — thin: each method `await connect(process.env.DATABASE_CONNECTION)` then delegates to the manager singleton. No business logic here.
- `authenticationMiddleware` (from `server/src/middleware/authentication.ts`) MUST be applied with `@Middlewares(authenticateMiddleware)` on EVERY controller, UNLESS the endpoint is intentionally public. Never omit it without explicit confirmation (see Ask Before Building).
- 99% of endpoints use `@Post()`. Default to POST; use GET/other only with a concrete, justified reason.
- After adding/changing controllers, regenerate the tsoa routes/spec: `npm run routes` (binds `tsoa spec-and-routes`); compile with `npm run build`.
## Decision Gates
| Situation | Decision |
|-----------|----------|
| Does the method take `sessionUser`? | Call `validateSessionUser` first. |
| Does the method act on a `companyId`? | Then `validatePermissionsByCompany`. |
| Endpoint exposed in the admin backend? | Default: `@Middlewares(authenticateMiddleware)`. |
| Endpoint hits WhatsApp notification / public recovery / ratings or another abuse-prone path? | Consider `rateLimiter` (or `ratingRateLimiter`) from `server/src/middleware/ratelimiter.ts`. When both apply: `@Middlewares([rateLimiter, authenticateMiddleware])`. |
| Internal/system-only endpoint? | Use `systemTokenMiddleware` instead of/with the above. |
| Doubt about middleware, role, or method? | STOP and ask. See Ask Before Building. |
## Ask Before Building (HARD — security first)
STOP and ask the user BEFORE writing security or middleware code whenever there is ANY doubt. Never guess on these:
1. Which validations/roles/plan features the new method requires (ADMIN vs EMPLOYEE vs extra feature gates).
2. Whether the endpoint should be public (no `authenticateMiddleware`) or authenticated. A missing auth middleware is a security hole — when unsure, default to KEEPING `authenticateMiddleware` and confirm.
3. Whether `rateLimiter` should be applied to a given endpoint. Do NOT add it by default; ask when the endpoint could be abuse-prone.
4. Whether a non-POST method is genuinely needed.
Ask at most a couple of focused questions, then wait. Do not proceed by assumption on security.
## Execution Steps
Creating a collection:
1. Create `server/src/Models/<Name>/<Name>.Interface.ts` — define all params, `IX`, `IXAdapter`, `IXManager`, view/result types.
2. Create `server/src/Models/<Name>/<Name>.Adapter.Mongoose.ts` — schema, `IXDocument`, `model()`, pure DB ops implementing `IXAdapter`.
3. Create `server/src/Models/<Name>/<Name>.ts``<Name>Manager implements IXManager`; instantiate the adapter in the constructor; put business rules; call `validateSessionUser` / `validatePermissionsByCompany` at the top of each secured method; export a singleton default.
4. Re-check Ask Before Building for any unconfirmed security requirement.
Creating an endpoint:
1. Create `server/src/api/<Name>/<Name>.Service.ts` — one method per action, `connect(process.env.DATABASE_CONNECTION)` then delegate to the manager singleton.
2. Create `server/src/api/<Name>/<Name>.Controller.ts` — one controller class per `@Route`; `@Middlewares(authenticateMiddleware)` unless confirmed public; `@Post()` + `@Body()`; try/catch returning `ApiValidationError`.
3. Apply Decision Gates (rateLimiter? system token? method?).
4. Regenerate routes: `npm run routes` then `npm run build`.
## Output Contract
Return: files created, the singletons/middlewares wired, any security decision that was confirmed with the user, and whether `npm run routes` / `npm run build` were run.
## References
- `server/src/Models/Clients/Clients.ts`, `Clients.Interface.ts`, `Clients.Adapter.Mongoose.ts` — canonical 3-file collection example (note: Clients inlines security; do NOT copy that part).
- `server/src/Models/Posts/Posts.ts` — modern security pattern using the shared helpers.
- `server/src/helpers/check.ts``validateSessionUser`, `validatePermissionsByCompany`, `validatePermissionsByClient`.
- `server/src/api/Clients/Clients.Controller.ts`, `Clients.Service.ts` — endpoint pattern (Controller + Service).
- `server/src/middleware/authentication.ts`, `server/src/middleware/ratelimiter.ts` — middleware sources.
- `server/tsoa.json`, `server/package.json` (`npm run routes`) — route generation.
+1
View File
@@ -1,2 +1,3 @@
# 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_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": {
"dev": "NODE_NO_WARNINGS=1 DOTENV_DISABLE_TELEMETRY=1 node --loader ts-node/esm src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
"start": "node dist/index.js",
"test": "jest --config jest.config.cjs"
},
"keywords": [],
"author": "",
@@ -14,8 +15,11 @@
"description": "",
"devDependencies": {
"@types/axios": "^0.9.36",
"@types/jest": "^30.0.0",
"@types/mongoose": "^5.11.96",
"@types/node": "^24.3.3",
"jest": "^30.4.2",
"ts-jest": "^29.4.11",
"ts-node": "^10.9.2",
"typescript": "^5.9.2"
},
@@ -0,0 +1,185 @@
import { logDone, logError, logIntent } from "../../config/logger.js";
export interface DispatchResult {
success: boolean;
providerResponse?: string;
error?: string;
}
function formatResponseFailure(endpoint: string, data: unknown): string {
const serialized = JSON.stringify(data);
return `${endpoint} returned success=false${serialized && serialized !== "{}" ? ` data=${serialized}` : " without error details"}`;
}
function getApiBaseUrl(): string {
const raw = process.env.API_URL || "";
if (!raw.trim()) {
return "";
}
try {
return new URL(raw).origin;
} catch {
return raw.replace(/\/$/, "");
}
}
function buildApiUrl(path: string): string {
return `${getApiBaseUrl()}${path}`;
}
function formatDispatchError(error: unknown): string {
if (typeof error === "object" && error !== null && "isAxiosError" in error) {
const axiosError = error as {
message?: string;
response?: { status?: number; data?: unknown };
};
const details = [
axiosError.response?.status ? `status=${axiosError.response.status}` : undefined,
axiosError.response?.data !== undefined ? `data=${JSON.stringify(axiosError.response.data)}` : undefined,
axiosError.message ? `message=${axiosError.message}` : undefined,
].filter(Boolean);
return details.join(" ") || "Axios request failed without details";
}
return error instanceof Error ? error.message : String(error);
}
export async function dispatchWhatsApp(params: {
companyId: string;
appointmentId: string;
type: string;
systemToken: string;
phoneNumber: string;
message: string;
companyOwnerId: string;
}): Promise<DispatchResult> {
try {
if (!params.phoneNumber.trim() || !params.message.trim() || !params.companyOwnerId.trim()) {
return { success: false, error: "WhatsApp dispatch requires phoneNumber, message, and companyOwnerId" };
}
// Send worker-rendered content directly so cancellation jobs survive appointment deletion.
const axios = (await import("axios")).default;
const response = await axios.post(
buildApiUrl("/notifications/send-wap"),
{
systemToken: params.systemToken,
phoneNumber: params.phoneNumber,
message: params.message,
companyId: params.companyId,
sessionUser: params.companyOwnerId,
},
{
headers: { "Content-Type": "application/json" },
}
);
logDone(`WhatsApp dispatched for company ${params.companyId}`);
return { success: true, providerResponse: JSON.stringify(response.data) };
} catch (error) {
const msg = formatDispatchError(error);
logError(`WhatsApp dispatch failed for company ${params.companyId}:`, msg);
return { success: false, error: msg };
}
}
export async function dispatchEmail(params: {
appointmentId: string;
systemToken: string;
type: string;
email: string;
subject: string;
message: string;
}): Promise<DispatchResult> {
try {
if (!params.email.trim() || !params.subject.trim() || !params.message.trim()) {
return { success: false, error: "Email dispatch requires email, subject, and message" };
}
// Send worker-rendered content directly so cancellation jobs survive appointment deletion.
const axios = (await import("axios")).default;
const response = await axios.post(
buildApiUrl("/notifications/send-email"),
{
systemToken: params.systemToken,
email: params.email,
subject: params.subject,
message: params.message,
},
{
headers: { "Content-Type": "application/json" },
}
);
const responseData = response.data as { success?: boolean };
if (responseData.success === false) {
return {
success: false,
error: formatResponseFailure("notifications/send-email", response.data),
};
}
logDone(`Email dispatched to ${params.email}`);
return { success: true, providerResponse: JSON.stringify(response.data) };
} catch (error) {
const msg = formatDispatchError(error);
logError(`Email dispatch failed for ${params.email}:`, msg);
return { success: false, error: msg };
}
}
export async function dispatchSystem(params: {
systemToken: string;
userId: string;
subject: string;
message: string;
companyId?: string;
serviceId?: number;
conversationId?: string;
type?: string;
code?: string;
}): Promise<DispatchResult> {
try {
if (!params.userId.trim() || !params.subject.trim() || !params.message.trim()) {
return { success: false, error: "System dispatch requires userId, subject, and message" };
}
// Delegate to the server API for system notification
const axios = (await import("axios")).default;
const response = await axios.post(
buildApiUrl("/systemnotifications/create"),
{
userId: params.userId,
subject: params.subject,
message: params.message,
companyId: params.companyId,
serviceId: params.serviceId,
conversationId: params.conversationId,
type: params.type,
code: params.code,
systemToken: params.systemToken,
},
{
headers: { "Content-Type": "application/json" },
}
);
const responseData = response.data as { success?: boolean };
if (responseData.success === false) {
return {
success: false,
error: formatResponseFailure("systemnotifications/create", response.data),
};
}
logDone(`System notification dispatched to user ${params.userId}`);
return { success: true, providerResponse: JSON.stringify(response.data) };
} catch (error) {
const msg = formatDispatchError(error);
logError(`System notification dispatch failed for user ${params.userId}:`, msg);
return { success: false, error: msg };
}
}
@@ -0,0 +1,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) => {
logger.info(`${bold}${message}${reset}\n\r`, error);
const details = error instanceof Error ? error.stack || error.message : String(error || "Unknown error");
logger.error(`${bold}${message}${reset}\n${details}`);
};
export const logWarn = (message: string) => {
+4 -4
View File
@@ -1,7 +1,7 @@
import dotenv from "dotenv";
import logger, { logDone, logError, logIntent } from "./config/logger.js";
import mongoose from "mongoose";
import { Notifications } from "./Models/Notifications/Notifications.js";
import { JobProcessor } from "./Models/Jobs/JobProcessor.js";
logIntent("Iniciando Notification Sender.");
@@ -28,10 +28,10 @@ mongoose
.then(() => {
const start = async () => {
logDone("Conexión a MongoDB establecida correctamente.");
logger.info("🚀 Notification Sender running");
logger.info("🚀 Notification Sender running (job-based polling)");
const worker = new Notifications();
await worker.start();
const processor = new JobProcessor();
await processor.startPolling();
};
start();
@@ -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
+274
View File
@@ -0,0 +1,274 @@
# TurnosXpress — contenido actualizado para tríptico
## 1. Mensaje principal
**TurnosXpress es una plataforma para gestionar turnos, clientes, servicios, pagos y comunicación desde un solo lugar.**
Pensada para profesionales, equipos y negocios que trabajan con reservas, permite ordenar la agenda diaria, publicar servicios online y facilitar que los clientes soliciten turnos desde cualquier dispositivo.
### Frases sugeridas
- **Tu agenda online, simple y organizada.**
- **Reservá, administrá y comunicá mejor tus turnos.**
- **Menos mensajes sueltos. Más control sobre tu agenda.**
- **Turnos online para negocios que quieren trabajar mejor.**
## 2. Qué es TurnosXpress
TurnosXpress ayuda a negocios y profesionales a administrar sus reservas sin depender todo el tiempo del teléfono, los mensajes manuales o una agenda en papel.
Con la plataforma, el negocio puede:
- Crear su perfil público.
- Publicar servicios.
- Configurar días y horarios disponibles.
- Administrar turnos por fecha, servicio, cliente y profesional.
- Organizar clientes, pagos, caja y cuenta corriente.
- Configurar notificaciones y comunicación por WhatsApp según el plan disponible.
- Trabajar con colaboradores o profesionales dentro de una misma organización.
- Mostrar información pública, opiniones, puntajes y profesionales cuando corresponda.
El objetivo es que cada negocio tenga una agenda digital clara, una presencia online más profesional y herramientas de administración que acompañen el crecimiento.
## 3. Público al que está dirigido
TurnosXpress está pensado para cualquier actividad que trabaje con citas, reservas o atención por horarios.
Ejemplos de rubros posibles:
- Centros de estética y belleza.
- Peluquerías y barberías.
- Consultorios y profesionales independientes.
- Clases particulares, talleres y capacitaciones.
- Estudios, centros de bienestar y servicios personalizados.
- Equipos con varios colaboradores que necesitan coordinar agendas.
La plataforma sirve tanto para profesionales que trabajan solos como para organizaciones que necesitan distribuir servicios, horarios y turnos entre varias personas.
## 4. Propuesta de valor
### Reservas online para tus clientes
Tus clientes pueden encontrar tus servicios y solicitar turnos desde el celular, la computadora o cualquier dispositivo conectado.
### Agenda digital siempre ordenada
Centralizá los turnos del negocio en una agenda clara, consultable por fecha, cliente, servicio y profesional.
### Menos coordinación manual
Reducí llamadas, mensajes repetidos y cruces de horarios. El sistema muestra disponibilidad según la configuración del negocio.
### Administración del negocio
Además de los turnos, TurnosXpress permite gestionar clientes, pagos, caja, descuentos y cuenta corriente.
### Comunicación por WhatsApp
La plataforma cuenta con herramientas para conectar WhatsApp y configurar avisos o notificaciones según las funciones disponibles en cada plan.
### Presencia pública configurable
Cada negocio puede publicar su perfil, servicios e información visible para clientes. También puede configurar la visibilidad de opiniones, puntajes y profesionales cuando el plan y la configuración lo permiten.
### Planes para distintas etapas
TurnosXpress ofrece un plan gratuito/base para comenzar y planes pagos para negocios que necesitan más capacidad, más herramientas o funciones avanzadas.
## 5. Funciones principales
### Agenda y turnos
- Agenda digital por día.
- Creación, edición, cancelación y movimiento de turnos.
- Turnos asociados a clientes, servicios y profesionales.
- Consulta de turnos próximos e históricos.
- Soporte para turnos recurrentes según el plan disponible.
### Servicios
- Publicación de servicios con nombre, descripción, duración y precio.
- Servicios públicos o privados.
- Imagen, color y datos de ubicación.
- Configuración de cupos o límites cuando corresponde.
### Horarios y disponibilidad
- Configuración de días y rangos horarios.
- Horarios por organización o por colaborador/profesional.
- Disponibilidad por servicio.
- Control para que los clientes reserven dentro de los horarios habilitados.
### Clientes
- Registro y administración de clientes.
- Historial de turnos por cliente.
- Datos de contacto y preferencias.
- Cuenta corriente y movimientos asociados.
### Pagos, caja y cuenta corriente
- Registro de pagos vinculados a turnos.
- Estados de pago: pendiente, aprobado o rechazado.
- Métodos como efectivo, transferencia, crédito, débito, cuenta corriente u otros.
- Caja con movimientos, filtros y balance.
- Cuenta corriente para controlar saldos y movimientos de clientes.
### Colaboradores y profesionales
- Administración de colaboradores dentro de una organización.
- Roles y permisos.
- Horarios y servicios asignados por profesional.
- Visibilidad pública de profesionales cuando corresponde.
### Notificaciones y WhatsApp
- Configuración de notificaciones del negocio.
- Preferencias generales y por cliente.
- WhatsApp como canal principal para automatizar avisos según el plan y la configuración.
- Asistente guiado para conectar y administrar WhatsApp.
> Nota comercial: conviene comunicar WhatsApp como herramienta de conexión y avisos de la plataforma, no como integración oficial de WhatsApp Business API.
### Opiniones y reputación
- Opiniones verificadas vinculadas a organización, servicio o profesional.
- Configuración para mostrar u ocultar opiniones y puntajes.
- Herramientas para fortalecer la presencia pública del negocio.
### Planes y MercadoPago
- Plan gratuito/base para comenzar.
- Planes pagos con mayor capacidad y funciones avanzadas.
- Gestión de contratación o actualización de plan.
- Pagos de planes procesados con MercadoPago.
> Nota comercial: MercadoPago está confirmado para pagos de planes/suscripciones. No prometer cobro online de turnos a clientes salvo que se verifique esa funcionalidad para la campaña específica.
## 6. Cómo empezar
### Paso 1 — Registrate
Creá tu cuenta en TurnosXpress y accedé al panel para comenzar a configurar tu negocio.
URL sugerida:
`https://turnosxpress.com.ar/landing/signup`
### Paso 2 — Elegí tu plan
Podés comenzar con el plan gratuito/base o elegir un plan pago según el tamaño y las necesidades de tu negocio.
URL sugerida:
`https://turnosxpress.com.ar/landing/pricing`
### Paso 3 — Configurá tu negocio
Creá el perfil de tu organización, cargá tus servicios, definí horarios y configurá colaboradores si trabajás con un equipo.
### Paso 4 — Compartí tu enlace
Publicá tu perfil o compartí el enlace de reserva para que tus clientes puedan solicitar turnos online.
### Paso 5 — Administrá todo desde el panel
Consultá la agenda, gestioná clientes, registrá pagos, revisá caja, configurá notificaciones y mantené el negocio organizado.
## 7. Flujo de uso resumido
1. El profesional o negocio se registra.
2. Selecciona un plan.
3. Crea el perfil de la organización.
4. Carga servicios, horarios y colaboradores.
5. Comparte su enlace o publica su perfil.
6. El cliente elige servicio, día y horario disponible.
7. El turno queda registrado en la agenda.
8. El negocio administra la atención, los pagos y la comunicación desde el panel.
## 8. Contenido sugerido para cara exterior del tríptico
### Portada
**TurnosXpress**
**Tu agenda online, simple y organizada.**
Gestioná turnos, clientes, servicios, pagos y comunicación desde una plataforma pensada para negocios que trabajan con reservas.
**Probá TurnosXpress y empezá a ordenar tu agenda hoy.**
### Panel de beneficios
**Reservas online**
Tus clientes pueden solicitar turnos desde cualquier dispositivo.
**Agenda digital**
Visualizá y administrá tus turnos de forma clara.
**Clientes y pagos**
Centralizá información, movimientos, caja y cuenta corriente.
**WhatsApp y notificaciones**
Configurá avisos y comunicación según tu plan.
**Perfil público**
Mostrá tus servicios, profesionales, opiniones y puntajes cuando corresponda.
### Llamado a la acción
**Empezá gratis y configurá tu negocio en pocos pasos.**
Sitio web: `https://turnosxpress.com.ar`
Email: `info@turnosxpress.com.ar`
Teléfono: `11-2390-2059`
## 9. Contenido sugerido para cara interior del tríptico
### Título interior
**Todo lo que necesitás para administrar tus turnos en un solo lugar**
### Bloque 1 — Configurá tu negocio
Creá tu organización, cargá los servicios que ofrecés, definí duración, precio, ubicación y horarios disponibles.
### Bloque 2 — Publicá y recibí reservas
Compartí tu perfil o enlace de reserva para que tus clientes puedan elegir un servicio y solicitar un turno online.
### Bloque 3 — Administrá tu día a día
Consultá la agenda, gestioná clientes, registrá pagos, revisá caja y mantené el historial del negocio ordenado.
### Bloque 4 — Sumá comunicación y visibilidad
Configurá WhatsApp, notificaciones, opiniones, puntajes y profesionales visibles según el plan y la configuración de tu organización.
### Bloque 5 — Crecé con planes flexibles
Comenzá con el plan gratuito/base y pasá a un plan superior cuando necesites más capacidad o funciones avanzadas.
## 10. Llamados a la acción recomendados
- **Probá TurnosXpress gratis.**
- **Creá tu agenda online hoy.**
- **Compartí tus servicios y empezá a recibir reservas.**
- **Ordená turnos, clientes y pagos desde un solo panel.**
- **Digitalizá tu negocio sin complicarte.**
## 11. Mensajes que conviene evitar o matizar
- Evitar prometer SMS como función disponible actualmente.
- Evitar decir “automatización multicanal completa” sin aclarar alcance por plan y configuración.
- Evitar presentar WhatsApp como integración oficial de WhatsApp Business API.
- Evitar prometer cobro online de turnos con MercadoPago si la campaña no verifica esa función específica.
- Evitar enfocar el folleto en herramientas internas de sysadmin; no son beneficios directos para el negocio final.
## 12. Resumen comercial
TurnosXpress ya no es solamente una agenda online. Es una plataforma de gestión para negocios con turnos: permite publicar servicios, organizar disponibilidad, recibir reservas, administrar clientes, registrar pagos, controlar caja, trabajar con colaboradores y mejorar la comunicación con herramientas como WhatsApp y notificaciones configurables.
El mensaje actualizado debe transmitir una idea central:
**TurnosXpress ayuda a que el negocio trabaje más ordenado, atienda mejor y dependa menos de la coordinación manual.**
Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.
+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_BACK_URL = https://turnosxpress.com.ar/landing/dashboard
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
@@ -54,6 +54,7 @@ DONWEB_MAIL_API_KEY = eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpYXQiOjE3NDYxMTc3N
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_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
@@ -67,4 +68,4 @@ SYSTEM_KEY = 753a2sd1du64-c412-3c3a-9a4hdr9-ac419213957e
FILE_SERVER_URL = http://localhost:4000/
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_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_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
@@ -65,4 +66,4 @@ SYSTEM_KEY = 753a2sd1du64-c412-3c3a-9a4hdr9-ac419213957e
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_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_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
@@ -65,4 +66,4 @@ SYSTEM_KEY = 753a2sd1du64-c412-3c3a-9a4hdr9-ac419213957e
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_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
@@ -65,4 +66,4 @@ SYSTEM_KEY = ad7c956a-76bf-45hdr60-8a50-2ca7b2180997
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": "",
"license": "ISC",
"devDependencies": {
"@types/bcryptjs": "^2.4.2",
"@types/cors": "^2.8.13",
"@types/express": "4.17.17",
"@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.19",
"@types/express": "^4.17.25",
"@types/jest": "^30.0.0",
"@types/jsonwebtoken": "^9.0.2",
"@types/sanitize-html": "^2.16.0",
"@types/socket.io": "^3.0.1",
"@types/swagger-ui-express": "^4.1.3",
"concurrently": "^8.2.0",
"csv-parse": "^5.4.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/nodemailer": "^6.4.24",
"@types/sanitize-html": "^2.16.1",
"@types/socket.io": "^3.0.2",
"@types/swagger-ui-express": "^4.1.8",
"concurrently": "^8.2.2",
"csv-parse": "^5.6.0",
"jest": "^30.4.2",
"ts-jest": "^29.4.11",
"ts-node-dev": "^2.0.0",
"typescript": "5.0.4"
"typescript": "^5.9.3"
},
"dependencies": {
"@types/nodemailer": "^6.4.16",
"axios": "^1.7.7",
"axios": "^1.18.1",
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dayjs": "^1.11.13",
"dotenv": "^16.0.3",
"express": "^4.21.1",
"google-auth-library": "^10.1.0",
"joi": "^17.9.2",
"jsonwebtoken": "^9.0.1",
"mongoose": "^8.8.3",
"multer": "^1.4.5-lts.1",
"nodemailer": "^6.9.15",
"sanitize-html": "^2.17.0",
"cors": "^2.8.6",
"dayjs": "^1.11.21",
"dotenv": "^16.6.1",
"express": "^4.22.2",
"google-auth-library": "^10.9.0",
"joi": "^17.13.4",
"jsonwebtoken": "^9.0.3",
"mongoose": "8.8.3",
"multer": "^1.4.5-lts.2",
"nodemailer": "^6.10.1",
"sanitize-html": "^2.17.6",
"sharp": "^0.33.5",
"socket.io": "^4.8.1",
"swagger-ui-express": "^5.0.0",
"socket.io": "^4.8.3",
"swagger-ui-express": "^5.0.1",
"tsoa": "^5.1.1"
}
}
@@ -12,6 +12,7 @@ export type FindAppointmentsParams = {
clientId?: string;
start?: Date;
dateDay?: Date;
sessionUser?: string;
};
export type FindAppointmentsByUserParams = {
@@ -35,9 +36,19 @@ export type FindAppointmentsByCollaboratorPaginatedParams = FindAppointmentsByCo
export enum APPOINTMENT_NOTIFICATION_TYPE {
"CREATION" = "creation",
"REMINDER" = "reminder",
"UPDATE" = "update",
"CANCELLATION" = "cancellation",
}
export type SendAppointmentNotificationParams = {
appointmentId: string;
sessionUser?: string;
systemToken?: string;
type: APPOINTMENT_NOTIFICATION_TYPE;
channel?: "whatsapp" | "email";
};
export type CreateImmediateAppointmentNotificationJobsParams = {
appointmentId: string;
sessionUser: string;
type: APPOINTMENT_NOTIFICATION_TYPE;
@@ -62,6 +73,7 @@ export type ChangeServiceParams = {
export type SendWapSystemNotificationParams = {
appointmentId: string;
systemToken: string;
type: APPOINTMENT_NOTIFICATION_TYPE;
};
export type HeatMapConfig = {
@@ -120,6 +132,7 @@ export type UpdateAppointmentParams = {
export type DeleteAppointmentParams = {
id: string;
validation?: boolean;
notification?: boolean;
sessionUser: string;
};
@@ -244,6 +257,7 @@ export interface AppointmentEventByClient {
serviceName: string;
serviceDescription: string;
collaboratorId: string;
collaboratorUserId: string;
collaboratorName: string;
collaboratorAvatar: string;
appointmentDate: string;
@@ -313,6 +327,7 @@ export interface IAppointmentsManager {
getAvailableDates(data: GetAvailableDatesParams): Promise<GetAvailableDatesResult>;
sendWapNotification(data: SendAppointmentNotificationParams): Promise<void>;
sendEmailNotification(data: SendAppointmentNotificationParams): Promise<void>;
createImmediateNotificationJobs(data: CreateImmediateAppointmentNotificationJobsParams): Promise<void>;
sendWapSystemNotification(data: SendWapSystemNotificationParams): Promise<void>;
deleteAppointmentsByCompany(data: DeleteAppointmentsByCompanyParams): Promise<void>;
changeEmployeeToOwner(data: ChangeEmployeeToOwnerParams): Promise<void>;
+526 -116
View File
@@ -10,6 +10,7 @@ import {
ChangeEmployeeToOwnerParams,
CountAppointmentsByMonthParams,
CreateAppointmentParams,
CreateImmediateAppointmentNotificationJobsParams,
DeleteAppointmentDiscountParams,
DeleteAppointmentParams,
DeleteAppointmentsByCompanyParams,
@@ -70,12 +71,13 @@ import { DiscountType } from "../Discounts/Discounts.Interface";
import Discounts from "../Discounts/Discounts";
import { validatePermissionsByCompany, validateSessionUser } from "../../helpers/check";
import Templates from "../Templates/Templates";
import { CollaboratorSchedulesView } from "../Schedules/Schedules.Interface";
import { CollaboratorSchedulesView, ScheduleItem } from "../Schedules/Schedules.Interface";
import ClientAccount from "../ClientAccounts/ClientAccount";
import SchedulesDisabledList from "../SchedulesDisabled/SchedulesDisabled";
import DiscountsEmail from "../DiscountsEmail/DiscountsEmail";
import SchedulesOverridesList from "../SchedulesOverrides/SchedulesOverrides";
import { NotificationType } from "../../Models/SystemNotifications/SystemNotification.Interface";
import { NotificationJobService } from "../NotificationPolicies/NotificationJobService";
dayjs.locale("es");
dayjs.extend(isSameOrAfter);
@@ -84,9 +86,19 @@ dayjs.extend(weekday);
class AppointmentManager implements IAppointmentsManager {
Appointments: AppointmentsAdapterMongoose;
private jobService: NotificationJobService;
constructor() {
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> {
@@ -305,12 +317,6 @@ class AppointmentManager implements IAppointmentsManager {
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({
_id: data.serviceId,
companyId: data.companyId,
@@ -443,6 +449,18 @@ class AppointmentManager implements IAppointmentsManager {
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 {
const start = dayjs(data.start).clone();
const end = dayjs(data.start)
@@ -467,6 +485,7 @@ class AppointmentManager implements IAppointmentsManager {
comments: data.comments,
sessionUser: data.sessionUser,
});
appointmentCreated = true;
if (data.repeatId) {
const repeat = await RepeatsList.repeats.findOne({
@@ -489,8 +508,9 @@ class AppointmentManager implements IAppointmentsManager {
}
}
await MetricsList.addAppointment({
await MetricsList.metrics.addAppointment({
userId: companyCheck.ownerId,
companyId: data.companyId,
quantity: 1,
});
@@ -509,38 +529,51 @@ class AppointmentManager implements IAppointmentsManager {
type: NotificationType.APPOINTMENT,
code: String((newAppointment as any)._id)
});
}
//Intento enviar notificacion al cliente por Email
try {
if (newAppointment.id) {
await this.sendEmailNotification({
appointmentId: String(newAppointment.id),
type: APPOINTMENT_NOTIFICATION_TYPE.CREATION,
sessionUser: String(companyCheck.ownerId),
});
}
} catch (err) {
console.log(
`Error al enviar la notificacion por Email para ${companyCheck.name}(${companyCheck._id}):`,
err
);
}
//Create jobs for email and whatsapp notifications
if (notification && newAppointment.id) {
const emailContent = await this.tryToSendNotification({
appointmentId: String(newAppointment.id),
sessionUser: String(companyCheck.ownerId),
type: APPOINTMENT_NOTIFICATION_TYPE.CREATION,
channel: "email",
});
const wapContent = await this.tryToSendNotification({
appointmentId: String(newAppointment.id),
sessionUser: String(companyCheck.ownerId),
type: APPOINTMENT_NOTIFICATION_TYPE.CREATION,
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
try {
if (newAppointment.id) {
await this.sendWapNotification({
appointmentId: String(newAppointment.id),
type: APPOINTMENT_NOTIFICATION_TYPE.CREATION,
sessionUser: String(companyCheck.ownerId),
});
}
} catch (err) {
console.log(
`Error al enviar la notificacion por Whatsapp para ${companyCheck.name}(${companyCheck._id}):`,
err
);
}
await this.createCreationNotificationJobs({
appointmentId: String(newAppointment.id),
companyId: String(companyCheck._id),
clientId: String(checkClient._id),
clientUserId: checkClient.userId ? String(checkClient.userId) : undefined,
clientEmail: checkClient.email,
clientPhoneNumber,
companyOwnerId: String(companyCheck.ownerId),
companyName: companyCheck.name,
appointmentStart: new Date(data.start),
emailMessage: emailContent.message,
wapMessage: wapContent.message,
reminderEmailMessage: reminderEmailContent.message,
reminderWapMessage: reminderWapContent.message,
});
}
//Envio la notificacion al profesional.
@@ -559,6 +592,13 @@ class AppointmentManager implements IAppointmentsManager {
return newAppointment;
} catch (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.");
}
}
@@ -920,6 +960,7 @@ class AppointmentManager implements IAppointmentsManager {
dateDay: dayjs(data.start).startOf("day").toDate(),
from: checkFrom,
to: checkTo,
serviceId: data.serviceId,
});
} else {
//chequear que el colaborador este disponible.
@@ -929,6 +970,7 @@ class AppointmentManager implements IAppointmentsManager {
weekDay: weekDay,
from: checkFrom,
to: checkTo,
serviceId: data.serviceId,
});
}
@@ -1037,6 +1079,9 @@ class AppointmentManager implements IAppointmentsManager {
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 clientUserName = userClient
? joinStrings([userClient.firstName, userClient.lastName], clientName)
@@ -1236,9 +1281,17 @@ class AppointmentManager implements IAppointmentsManager {
_id: String(employee.userId),
});
if (!userEmployee) {
throw new Error("No se ha encontrado el usuario del colaborador");
}
// Historical read snapshot fallback: use Employee snapshot if User is deleted
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({
_id: String(appointment.serviceId),
@@ -1256,14 +1309,6 @@ class AppointmentManager implements IAppointmentsManager {
? getAvatar(client.userId, userClient.avatar, 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);
let ctaBalance = 0;
@@ -1285,8 +1330,9 @@ class AppointmentManager implements IAppointmentsManager {
serviceName: service.name,
serviceDescription: service.description,
collaboratorId: employee.id,
collaboratorName: employeeFullName,
collaboratorAvatar: employeeAvatar,
collaboratorUserId: String(employee.userId),
collaboratorName: collaboratorFullName,
collaboratorAvatar: collaboratorAvatar,
appointmentDate: dayjs(appointment.start).toISOString(),
startTime: appointment.startHour,
endTime: appointment.endHour,
@@ -1394,6 +1440,7 @@ class AppointmentManager implements IAppointmentsManager {
serviceName: service.name,
serviceDescription: service.description,
collaboratorId: employee.id,
collaboratorUserId: String(employee.userId),
collaboratorName: employeeFullName,
collaboratorAvatar: employeeAvatar,
appointmentDate: dayjs(appointment.start).toISOString(),
@@ -1503,6 +1550,7 @@ class AppointmentManager implements IAppointmentsManager {
serviceName: service.name,
serviceDescription: service.description,
collaboratorId: employee.id,
collaboratorUserId: String(employee.userId),
collaboratorName: employeeFullName,
collaboratorAvatar: employeeAvatar,
appointmentDate: dayjs(appointment.start).toISOString(),
@@ -1621,6 +1669,7 @@ class AppointmentManager implements IAppointmentsManager {
serviceName: service.name,
serviceDescription: service.description,
collaboratorId: String(employee.id || (employee as any)._id),
collaboratorUserId: String(employee.userId),
collaboratorName: employeeFullName,
collaboratorAvatar: employeeAvatar,
appointmentDate: dayjs(appointment.start).toISOString(),
@@ -1738,6 +1787,7 @@ class AppointmentManager implements IAppointmentsManager {
serviceName: service.name,
serviceDescription: service.description,
collaboratorId: employee.id,
collaboratorUserId: String(employee.userId),
collaboratorName: employeeFullName,
collaboratorAvatar: employeeAvatar,
appointmentDate: dayjs(appointment.start).toISOString(),
@@ -1865,6 +1915,7 @@ public async findAppointmentsByCollaboratorPaginated(
serviceName: service.name,
serviceDescription: service.description,
collaboratorId: String(employee.id || (employee as any)._id),
collaboratorUserId: String(employee.userId),
collaboratorName: employeeFullName,
collaboratorAvatar: employeeAvatar,
appointmentDate: dayjs(appointment.start).toISOString(),
@@ -2036,36 +2087,50 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
code: String(checkAppointment._id)
});
//Intento enviar notificacion al cliente por Email
try {
if (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
);
}
//Cancel existing pending jobs before creating new ones for the rescheduled appointment
if (checkAppointment.id) {
await this.jobService.cancelByAppointment(String(checkAppointment.id));
//Intento enviar notificacion al cliente por Whatsapp
try {
if (checkAppointment.id) {
await this.sendWapNotification({
appointmentId: String(checkAppointment.id),
type: APPOINTMENT_NOTIFICATION_TYPE.REMINDER,
sessionUser: String(companyCheck.ownerId),
});
}
} catch (err) {
console.log(
`Error al enviar la notificacion por Whatsapp para ${companyCheck.name}(${companyCheck._id}):`,
err
);
const emailContent = await this.tryToSendNotification({
appointmentId: String(checkAppointment.id),
sessionUser: String(companyCheck.ownerId),
type: APPOINTMENT_NOTIFICATION_TYPE.UPDATE,
channel: "email",
});
const wapContent = await this.tryToSendNotification({
appointmentId: String(checkAppointment.id),
sessionUser: String(companyCheck.ownerId),
type: APPOINTMENT_NOTIFICATION_TYPE.UPDATE,
channel: "whatsapp",
});
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,167 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
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}`;
const payload = {
email: data.clientEmail,
phoneNumber: data.clientPhoneNumber,
userId: data.clientUserId,
companyOwnerId: data.companyOwnerId,
subject: systemSubject,
message: data.emailMessage,
emailSubject: "TurnosXpress :: Alta de turno",
emailMessage: data.emailMessage,
wapMessage: data.wapMessage,
systemSubject,
systemMessage: data.emailMessage,
};
await this.jobService.createMandatoryCreationJob({
companyId: data.companyId,
clientId: data.clientId,
appointmentId: data.appointmentId,
type: "creation",
payload,
});
await this.jobService.createReminderJobs({
companyId: data.companyId,
clientId: data.clientId,
appointmentId: data.appointmentId,
type: "reminder",
appointmentStart: data.appointmentStart,
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> {
const { validation = false } = data;
const { validation = false, notification = false } = data;
const sessionUser = await UsersManager.users.findOne({
_id: data.sessionUser,
});
@@ -2200,23 +2424,41 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
try {
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 });
//Actualizar el heatmap.
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
await NotificationsManager.sendSystemNotification({
userId: String(employeeCheck.userId),
@@ -2271,6 +2513,10 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
continue;
}
if (!this.isScheduleAvailableForService(schedule, data.serviceId)) {
continue;
}
const scheduleDataFrom = schedule.from.split(":");
const scheduleDataTo = schedule.to.split(":");
const hourFrom = parseInt(scheduleDataFrom[0]);
@@ -2346,8 +2592,16 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
throw new Error("La compañia no existe");
}
if (!(await EmployeesList.checkPermission(companyCheck, data.sessionUser, EmployeeRoles.ADMIN))) {
throw new Error(NoPermissionMessage());
const hasSystemToken = this.hasValidSystemToken(data.systemToken);
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({
@@ -2427,9 +2681,13 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
wapMessage = wapMessage.replaceAll("<---ORGANIZACION--->", companyCheck.name);
wapMessage = wapMessage.replaceAll("<---BR--->", "\r\n");
} 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({
id: String(companyCheck.templateWapAltaId),
id: String(templateId),
companyId: String(companyCheck._id),
});
@@ -2439,7 +2697,9 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
}
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(
@@ -2467,6 +2727,61 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
wapMessage = wapMessage.replaceAll("<---SERVICIO--->", checkService.name);
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("<---BR--->", "\r\n");
}
@@ -2479,15 +2794,24 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
}
public async sendEmailNotification(data: SendAppointmentNotificationParams): Promise<void> {
const sessionUser = await UsersManager.users.findOne({
_id: data.sessionUser,
});
if (!this.hasValidSystemToken(data.systemToken)) {
if (!data.sessionUser) {
throw new Error("No se ha encontrado el usuario o no tiene permisos para realizar esta acción");
}
if (!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({
_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({
userId: String(companyCheck.ownerId),
@@ -2510,6 +2834,10 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
subjectEmail = "TurnosXpress :: Recordatorio";
} else if (data.type == APPOINTMENT_NOTIFICATION_TYPE.CREATION) {
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...
@@ -2520,7 +2848,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> {
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({
_id: data.sessionUser,
});
@@ -2529,7 +2916,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");
}
const { message, checkClient, companyCheck } = await this.tryToSendNotification(data);
const { message, checkClient, companyCheck } = await this.tryToSendNotification({
...data,
channel: "whatsapp",
});
const canSend = await PlanSubscriptionsList.checkFeature({
userId: String(companyCheck.ownerId),
@@ -2555,13 +2945,7 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
}
public async sendWapSystemNotification(data: SendWapSystemNotificationParams): Promise<void> {
if (!data.systemToken || data.systemToken == "") {
throw new Error("No tiene permisos para realizar esta acción");
}
const systemToken = `${process.env.SYSTEM_KEY}`;
if (systemToken != data.systemToken) {
if (!this.hasValidSystemToken(data.systemToken)) {
throw new Error("No tiene permisos para realizar esta acción");
}
@@ -2584,7 +2968,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
const { message, checkClient, companyCheck } = await this.tryToSendNotification({
appointmentId: data.appointmentId,
sessionUser: String(company.ownerId),
type: APPOINTMENT_NOTIFICATION_TYPE.REMINDER,
systemToken: data.systemToken,
type: data.type,
});
const canSend = await PlanSubscriptionsList.checkFeature({
@@ -2743,7 +3128,17 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
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());
} else if (!hasOverride) {
// Check if the current date is within any enabled schedule range
@@ -2766,7 +3161,17 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
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());
}
}
@@ -2780,6 +3185,11 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
inRangeDates: [],
} 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();
@@ -0,0 +1,760 @@
/**
* 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([]),
createMandatoryCreationJob: jest.fn().mockResolvedValue([]),
createReminderJobs: 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([]),
createMandatoryCreationJob: jest.fn().mockResolvedValue([]),
createReminderJobs: 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.createMandatoryCreationJob).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.",
}),
})
);
expect((AppointmentsList as any).jobService.createReminderJobs).toHaveBeenCalledWith(
expect.objectContaining({
type: "reminder",
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,
PaginateClientsParams,
PaginateClientsResults,
DetachClientUserParams,
} from "./Clients.Interface";
import { Document, FilterQuery, Model, Schema, model } from "mongoose";
@@ -96,4 +97,8 @@ export class ClientsAdapterMongoose implements IClientsAdapter {
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;
};
export type DetachClientUserParams = {
clientId: string;
};
export type FindClientByIdParams = {
id: string;
companyId: string;
@@ -77,7 +81,7 @@ export type DeleteClientParams = {
export interface IClient {
id?: string;
companyId: string;
userId: string;
userId?: string;
fromClientId: string;
status: boolean;
firstName: string;
@@ -116,6 +120,7 @@ export interface IClientsAdapter {
find(filters: FindClientsParams): Promise<IClient[]>;
findOne(filters: FindClientsParams): Promise<IClientDocument | null>;
paginate(filters: PaginateClientsParams): Promise<PaginateClientsResults>;
detachUser(data: DetachClientUserParams): Promise<void>;
}
export interface IClientsManager {
+3 -1
View File
@@ -195,7 +195,9 @@ class ClientManager implements IClientsManager {
if (appointment) {
appointment.clientId = data.toClientId;
appointment.userId = clientTo.userId;
if (clientTo.userId) {
appointment.userId = clientTo.userId;
}
await appointment.save();
}
}
@@ -58,13 +58,18 @@ export class CompaniesAdapterMongoose implements ICompaniesAdapter {
templateWapNotifId: { 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" },
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 },
onboardingCompleted: { type: Boolean, required: false, default: false },
fixedPostIds: { type: Array, required: false },
banned: { type: Boolean, required: false, default: false },
showPublicScores: { type: Boolean, required: false, default: true },
showPublicOpinions: { type: Boolean, required: false, default: true },
showPublicProfessionals: { type: Boolean, required: false, default: true },
});
this.companyList = model<ICompanyDocument>("Company", this.schema);
@@ -205,6 +210,10 @@ export class CompaniesAdapterMongoose implements ICompaniesAdapter {
updateCompany.templateWapAltaId = data.templateWapAltaId;
}
if (data.templateWapCancellationId) {
updateCompany.templateWapCancellationId = data.templateWapCancellationId;
}
if (data.templateEmailNotifId) {
updateCompany.templateEmailNotifId = data.templateEmailNotifId;
}
@@ -213,6 +222,10 @@ export class CompaniesAdapterMongoose implements ICompaniesAdapter {
updateCompany.templateEmailAltaId = data.templateEmailAltaId;
}
if (data.templateEmailCancellationId) {
updateCompany.templateEmailCancellationId = data.templateEmailCancellationId;
}
if (data.onboardingStep !== undefined) {
updateCompany.onboardingStep = data.onboardingStep;
}
@@ -225,6 +238,18 @@ export class CompaniesAdapterMongoose implements ICompaniesAdapter {
updateCompany.banned = data.banned;
}
if (data.showPublicScores !== undefined) {
updateCompany.showPublicScores = data.showPublicScores;
}
if (data.showPublicOpinions !== undefined) {
updateCompany.showPublicOpinions = data.showPublicOpinions;
}
if (data.showPublicProfessionals !== undefined) {
updateCompany.showPublicProfessionals = data.showPublicProfessionals;
}
updateCompany.save();
//actualizo los campos correspondientes a la localizacion de los servicios de la organizacion.
@@ -47,11 +47,16 @@ export type UpdateCompanyParams = {
sessionUser: string;
templateWapNotifId?: string;
templateWapAltaId?: string;
templateWapCancellationId?: string;
templateEmailNotifId?: string;
templateEmailAltaId?: string;
templateEmailCancellationId?: string;
onboardingStep?: number;
onboardingCompleted?: boolean;
banned?: boolean;
showPublicScores?: boolean;
showPublicOpinions?: boolean;
showPublicProfessionals?: boolean;
};
export type SetCompanyFileParams = {
@@ -144,12 +149,17 @@ export interface ICompany {
automaticNotifications?: boolean;
templateWapNotifId?: string;
templateWapAltaId?: string;
templateWapCancellationId?: string;
templateEmailNotifId?: string;
templateEmailAltaId?: string;
templateEmailCancellationId?: string;
onboardingStep?: number;
onboardingCompleted?: boolean;
fixedPostIds?: Array<string>;
banned?: boolean;
showPublicScores?: boolean;
showPublicOpinions?: boolean;
showPublicProfessionals?: boolean;
}
export interface MyOranizationsView {
@@ -192,11 +202,16 @@ export interface MyOranizationsView {
appointmentAlert?: string;
templateWapNotifId?: string;
templateWapAltaId?: string;
templateWapCancellationId?: string;
templateEmailNotifId?: string;
templateEmailAltaId?: string;
templateEmailCancellationId?: string;
onboardingStep?: number;
onboardingCompleted?: boolean;
banned?: boolean;
showPublicScores?: boolean;
showPublicOpinions?: boolean;
showPublicProfessionals?: boolean;
}
export interface ClientOrganizationView {
+37 -3
View File
@@ -67,6 +67,17 @@ class CompaniesManager implements ICompaniesManager {
this.companies = new CompaniesAdapterMongoose();
}
private async validatePublicRatingFlagsPlan(ownerId: string): Promise<void> {
const { default: PlanSubscriptionsList } = await import("../PlanSubscriptions/PlanSubscriptons");
const subscription = await PlanSubscriptionsList.getSubscriptionByUser({
sessionUser: ownerId,
});
if (!subscription || subscription.plan.price === 0) {
throw new Error("Esta configuración está disponible solo para planes pagos.");
}
}
public async toggleFixedPost(data: FixCompanyPostParams): Promise<void> {
await validateSessionUser({
sessionUser: data.sessionUser,
@@ -489,6 +500,14 @@ class CompaniesManager implements ICompaniesManager {
throw new Error(NoPermissionMessage());
}
if (
data.showPublicScores !== undefined ||
data.showPublicOpinions !== undefined ||
data.showPublicProfessionals !== undefined
) {
await this.validatePublicRatingFlagsPlan(String(companyCheck.ownerId));
}
let updateData = {
...data,
};
@@ -536,7 +555,8 @@ class CompaniesManager implements ICompaniesManager {
const employ = await EmployeesList.employees.findOne({
companyId: data.id,
userId: String(data.sessionUser),
});
removed: { $ne: true },
} as any);
if (!employ) {
throw new Error("El usuario no participa de la organización");
@@ -596,6 +616,9 @@ class CompaniesManager implements ICompaniesManager {
templateEmailAltaId: isNull<string>(company.templateEmailAltaId, ""),
onboardingStep: company.onboardingStep,
onboardingCompleted: company.onboardingCompleted,
showPublicScores: company.showPublicScores ?? true,
showPublicOpinions: company.showPublicOpinions ?? true,
showPublicProfessionals: company.showPublicProfessionals ?? true,
};
}
@@ -659,7 +682,11 @@ class CompaniesManager implements ICompaniesManager {
continue;
}
const isAdmin = await EmployeesList.checkPermission(companyDoc, employ.id, EmployeeRoles.ADMIN);
const isAdmin = await EmployeesList.checkPermission(
companyDoc,
data.sessionUser,
EmployeeRoles.ADMIN
);
returnData.push({
id: isNull<string>(company.id, ""),
@@ -715,13 +742,17 @@ class CompaniesManager implements ICompaniesManager {
templateEmailAltaId: isNull<string>(company.templateEmailAltaId, ""),
onboardingStep: company.onboardingStep,
onboardingCompleted: company.onboardingCompleted,
showPublicScores: company.showPublicScores ?? true,
showPublicOpinions: company.showPublicOpinions ?? true,
showPublicProfessionals: company.showPublicProfessionals ?? true,
});
}
//Organizaciones en las que participa como colaborador.
const orgCollaborator = await EmployeesList.employees.find({
userId: data.sessionUser,
});
removed: { $ne: true },
} as any);
for (const item of orgCollaborator) {
if (item.companyId) {
@@ -784,6 +815,9 @@ class CompaniesManager implements ICompaniesManager {
templateEmailAltaId: isNull<string>(company.templateEmailAltaId, ""),
onboardingStep: company.onboardingStep,
onboardingCompleted: company.onboardingCompleted,
showPublicScores: company.showPublicScores ?? true,
showPublicOpinions: company.showPublicOpinions ?? true,
showPublicProfessionals: company.showPublicProfessionals ?? true,
});
}
}
+273 -4
View File
@@ -8,12 +8,16 @@ import {
PaginateEmployeesParams,
PaginateEmployeesResults,
CompanyEmployeesView,
PublicCompanyEmployeeView,
FindPublicEmployeeParams,
PublicEmployeeView,
FindEmployeesParams,
FindEmployeesByIdParams,
UpdateEmployeeRolesParams,
UpdateEmployeeParams,
DeleteEmployeesByCompany,
DeleteEmployeeParams,
RemoveCollaboratorParams,
} from "./Employees.Interface";
import UsersManager from "../Users/Users";
import { ICompanyDocument } from "../Companies/Companies.Adapter.Mongoose";
@@ -26,7 +30,9 @@ import { TextObjectFilterResult } from "../../Models/TextObjectFilter.model";
import MetricsList from "../../Models/Metrics/Metrics";
import { NotificationsManager } from "../Notifications/Notifications";
import EmployeesServicesList from "../EmployeesServices/EmployeesServices";
import HeatMapList from "../HeatMap/HeatMap";
import SchedulesList from "../Schedules/Schedules";
import AppointmentList from "../Appointments/Appointments";
import { IncompleteCollaboratorView } from "./Employees.Interface";
import { NotificationType } from "../../Models/SystemNotifications/SystemNotification.Interface";
@@ -51,7 +57,11 @@ class EmployeeManager implements IEmployeesManager {
role: EmployeeRoles
): Promise<boolean> {
//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) {
return false;
@@ -112,8 +122,59 @@ class EmployeeManager implements IEmployeesManager {
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) {
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);
@@ -346,7 +407,7 @@ class EmployeeManager implements IEmployeesManager {
}
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(
employees.map(async (employee) => {
@@ -384,6 +445,71 @@ class EmployeeManager implements IEmployeesManager {
return view;
}
public async findPublicByCompanyId(
data: FindEmployeesParams
): Promise<PublicCompanyEmployeeView[]> {
const employees = await this.employees.find({
companyId: data.companyId,
guestOk: true,
hostOk: true,
removed: { $ne: true },
} as any);
const view = await Promise.all(
employees.map(async (employee) => {
const userData = await UsersManager.users.findOne({ _id: employee.userId });
if (!userData) {
return null;
}
const strFirstName = isNull<string>(userData.firstName, "");
const strLastName = isNull<string>(userData.lastName, "");
const strFullName = joinStrings([strFirstName, strLastName], " ");
return {
employeeId: isNull<string>(employee.id, ""),
userId: employee.userId,
firstName: strFirstName,
lastName: strLastName,
avatarUrl: getAvatar(userData.id, userData.avatar, strFullName),
};
})
);
return view.filter((employee): employee is PublicCompanyEmployeeView => employee !== null);
}
public async findPublicByEmployeeId(data: FindPublicEmployeeParams): Promise<PublicEmployeeView> {
const employee = await this.employees.findOne({
_id: data.employeeId,
guestOk: true,
hostOk: true,
removed: { $ne: true },
} as any);
if (!employee) {
throw new Error("El colaborador no existe");
}
const userData = await UsersManager.users.findOne({ _id: employee.userId });
if (!userData) {
throw new Error("El colaborador no existe");
}
const strFirstName = isNull<string>(userData.firstName, "");
const strLastName = isNull<string>(userData.lastName, "");
const strFullName = joinStrings([strFirstName, strLastName], " ");
return {
employeeId: isNull<string>(employee.id, ""),
firstName: strFirstName,
lastName: strLastName,
avatarUrl: getAvatar(userData.id, userData.avatar, strFullName),
};
}
public async findById(data: FindEmployeesByIdParams): Promise<CompanyEmployeesView> {
const employee = await this.employees.findOne({ _id: data.id });
@@ -422,7 +548,7 @@ class EmployeeManager implements IEmployeesManager {
}
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[] = [];
for (const colaborador of employees) {
@@ -522,8 +648,151 @@ class EmployeeManager implements IEmployeesManager {
}
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();
export default EmployeesList;
@@ -23,6 +23,18 @@ export class EmployeesAdapterMongoose implements IEmployeesAdapter {
limit: { type: Number, required: true, default: -1 },
hostOk: { 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 },
});
@@ -68,6 +68,13 @@ export type DeleteEmployeeParams = {
employeeId: string;
};
export type RemoveCollaboratorParams = {
employeeId: string;
companyId: string;
replacementEmployeeId?: string;
sessionUser: string;
};
export interface IEmployee {
id?: string;
companyId: string;
@@ -77,6 +84,15 @@ export interface IEmployee {
limit?: number;
guestOk?: boolean;
hostOk?: boolean;
removed?: boolean;
removedAt?: Date;
removedBy?: string;
profileSnapshot?: {
firstName?: string;
lastName?: string;
email?: string;
avatar?: string;
};
}
export interface CompanyEmployeesView {
@@ -93,6 +109,33 @@ export interface CompanyEmployeesView {
hostOk: boolean;
fullOk: boolean;
calendarColor: string;
removed?: boolean;
removedAt?: Date;
profileSnapshot?: {
firstName?: string;
lastName?: string;
email?: string;
avatar?: string;
};
}
export interface PublicCompanyEmployeeView {
employeeId: string;
userId: string;
firstName: string;
lastName: string;
avatarUrl: string;
}
export interface FindPublicEmployeeParams {
employeeId: string;
}
export interface PublicEmployeeView {
employeeId: string;
firstName: string;
lastName: string;
avatarUrl: string;
}
export interface IncompleteCollaboratorView {
@@ -119,10 +162,13 @@ export interface IEmployeesManager {
updateEmployeeRoles(data: UpdateEmployeeRolesParams): Promise<void>;
update(data: UpdateEmployeeParams): Promise<void>;
findByCompanyId(data: FindEmployeesParams): Promise<CompanyEmployeesView[]>;
findPublicByCompanyId(data: FindEmployeesParams): Promise<PublicCompanyEmployeeView[]>;
findPublicByEmployeeId(data: FindPublicEmployeeParams): Promise<PublicEmployeeView>;
findById(data: FindEmployeesByIdParams): Promise<CompanyEmployeesView>;
textObjectFilter(data: FindEmployeesParams): Promise<TextObjectFilterResult[]>;
deleteEmployeesByCompany(data: DeleteEmployeesByCompany): Promise<void>;
deleteEmployee(data: DeleteEmployeeParams): Promise<void>;
rejectGuest(data: ValidateEmployeeParams): Promise<void>;
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) {
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({
@@ -160,7 +164,11 @@ class EmployeesServicesManager implements IEmployeesServicesManager {
});
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);
@@ -253,7 +261,11 @@ class EmployeesServicesManager implements IEmployeesServicesManager {
});
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({
@@ -330,7 +342,11 @@ class EmployeesServicesManager implements IEmployeesServicesManager {
_id: data.employeeId,
});
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({
@@ -371,7 +387,11 @@ class EmployeesServicesManager implements IEmployeesServicesManager {
});
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({
@@ -416,6 +436,15 @@ class EmployeesServicesManager implements IEmployeesServicesManager {
});
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 });
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 = {
userId?: string;
companyId?: string;
quantity?: number;
limit?: number;
};
export type DeleteMetricsByCompany = {
@@ -45,6 +47,8 @@ export interface IMetricsManager {
addEmployee(data: MetricsParams): Promise<void>;
addService(data: MetricsParams): Promise<void>;
addAppointment(data: MetricsParams): Promise<void>;
reserveAppointment(data: MetricsParams): Promise<boolean>;
releaseAppointment(data: MetricsParams): Promise<void>;
addClient(data: MetricsParams): Promise<void>;
canAddOrganization(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 RepeatsList from "../Repeats/Repeats";
import { isNull } from "../../helpers/IsNull";
import PlanUsageCycleList from "../PlanUsageCycle/PlanUsageCycle";
class MetricsManager implements IMetricsManager {
metrics: MetricsAdapterMongoose;
@@ -39,7 +40,8 @@ class MetricsManager implements IMetricsManager {
for (const org of organizations) {
const employees = await EmployeesList.employees.find({
companyId: String(org.id),
});
removed: { $ne: true },
} as any);
employeesLength += employees.length;
const services = await ServiceList.services.find({
@@ -231,17 +233,64 @@ class MetricsManager implements IMetricsManager {
if (plan.limitAppointments < 0) {
return true;
}
const metrics = await this.getMetrics(userId);
if (metrics.appointmentsCount >= plan.limitAppointments) {
const appointmentsCount = await PlanUsageCycleList.getAppointmentsCount({ userId });
if (appointmentsCount >= plan.limitAppointments) {
return false;
}
return true;
}
public async addAppointment(data: MetricsParams): Promise<void> {
if (data.userId) {
await PlanUsageCycleList.addAppointment({
userId: data.userId,
quantity: data.quantity,
});
}
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> {
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,280 @@
import {
CreateJobParams,
NotificationChannel,
} 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;
}
async createMandatoryCreationJob(params: CreateJobServiceParams): Promise<any[]> {
const payloadChannels = this.getPayloadChannels(params.payload);
const channels = await this.policyResolver.resolveAvailableChannels(
params.companyId,
payloadChannels
);
if (channels.length === 0) {
return [];
}
const scheduledAt = params.scheduledAt || new Date();
const jobs: any[] = [];
for (const channel of channels) {
const job = await this.jobAdapter.create({
companyId: params.companyId,
appointmentId: params.appointmentId,
clientId: params.clientId,
channel,
type: "creation",
scheduledAt,
payload: params.payload,
});
jobs.push(job);
}
return jobs;
}
async createReminderJobs(params: CreateJobServiceParams): Promise<any[]> {
const resolvedPolicy = await this.policyResolver.resolve(
params.companyId,
params.clientId
);
if (!params.appointmentStart || resolvedPolicy.channels.length === 0) {
return [];
}
const now = new Date();
const jobs: any[] = [];
const enabledReminderRules = (resolvedPolicy.reminderRules || []).filter(
(rule) => rule.enabled
);
for (const rule of enabledReminderRules) {
const reminderScheduledAt = this.adjustForQuietHours(
dayjs(params.appointmentStart).subtract(rule.offset, "minute").toDate(),
resolvedPolicy
);
if (reminderScheduledAt < now || reminderScheduledAt >= params.appointmentStart) {
continue;
}
for (const channel of resolvedPolicy.channels) {
const job = await this.jobAdapter.create({
companyId: params.companyId,
appointmentId: params.appointmentId,
clientId: params.clientId,
channel,
type: "reminder",
scheduledAt: reminderScheduledAt,
payload: params.reminderPayload,
});
jobs.push(job);
}
}
return jobs;
}
/**
* Cancel all pending jobs for an appointment.
* 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 getPayloadChannels(payload?: CreateJobParams["payload"]): NotificationChannel[] {
const channels: NotificationChannel[] = [];
if (payload?.email) {
channels.push("email");
}
if (payload?.phoneNumber) {
channels.push("whatsapp");
}
if (payload?.userId) {
channels.push("system");
}
return channels;
}
private isTimeInQuietHours(
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,173 @@
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,
};
}
async resolveAvailableChannels(
companyId: string,
channels: NotificationChannel[]
): Promise<NotificationChannel[]> {
const companyPolicy = await this.companyPolicyAdapter.findOne({ companyId });
const enabledChannels = companyPolicy
? companyPolicy.defaultChannels
: SYSTEM_DEFAULT_CHANNELS;
const companyEnabledChannels = channels.filter((channel) =>
enabledChannels.includes(channel)
);
return this.applyPlanLimits(companyId, companyEnabledChannels);
}
/**
* Returns muted channels from the winning cascade level only.
* 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,511 @@
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(),
resolveAvailableChannels: 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;
let mockResolveAvailableChannels: 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;
mockResolveAvailableChannels = (service as any).policyResolver.resolveAvailableChannels 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("creates mandatory creation jobs from available payload destinations without resolving reminder policy", async () => {
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T14:00:00.000Z"));
mockJobCreate.mockResolvedValue({ id: "job-1" });
mockResolveAvailableChannels.mockResolvedValue(["email", "whatsapp"]);
const payload = {
email: "client@example.com",
phoneNumber: "5491112345678",
companyOwnerId: "owner-1",
emailMessage: "Alta email",
wapMessage: "Alta WAP",
};
const result = await service.createMandatoryCreationJob({
companyId: "company-1",
clientId: "client-1",
appointmentId: "appt-1",
type: "creation",
payload,
});
const createCalls = mockJobCreate.mock.calls.map((call) => call[0]);
expect(mockResolverResolve).not.toHaveBeenCalled();
expect(mockResolveAvailableChannels).toHaveBeenCalledWith("company-1", [
"email",
"whatsapp",
]);
expect(result).toHaveLength(2);
expect(createCalls).toEqual([
expect.objectContaining({
channel: "email",
type: "creation",
scheduledAt: new Date("2026-07-21T14:00:00.000Z"),
payload,
}),
expect.objectContaining({
channel: "whatsapp",
type: "creation",
scheduledAt: new Date("2026-07-21T14:00:00.000Z"),
payload,
}),
]);
});
it("does not create mandatory creation jobs for channels missing destination data", async () => {
mockResolveAvailableChannels.mockResolvedValue([]);
await service.createMandatoryCreationJob({
companyId: "company-1",
clientId: "client-1",
appointmentId: "appt-1",
type: "creation",
payload: {
companyOwnerId: "owner-1",
emailMessage: "Alta email",
wapMessage: "Alta WAP",
},
});
expect(mockResolverResolve).not.toHaveBeenCalled();
expect(mockResolveAvailableChannels).toHaveBeenCalledWith("company-1", []);
expect(mockJobCreate).not.toHaveBeenCalled();
});
it("does not create mandatory creation jobs for unavailable channels", async () => {
mockResolveAvailableChannels.mockResolvedValue(["email"]);
const payload = {
email: "client@example.com",
phoneNumber: "5491112345678",
companyOwnerId: "owner-1",
};
const result = await service.createMandatoryCreationJob({
companyId: "company-1",
clientId: "client-1",
appointmentId: "appt-1",
type: "creation",
payload,
});
const createCalls = mockJobCreate.mock.calls.map((call) => call[0]);
expect(result).toHaveLength(1);
expect(createCalls).toEqual([
expect.objectContaining({ channel: "email", type: "creation", payload }),
]);
});
it("keeps reminder jobs policy-aware when resolved channels are empty", async () => {
const resolvedPolicy: ResolvedPolicy = {
channels: [],
timezone: "America/Argentina/Buenos_Aires",
reminderRules: [{ offset: 30, enabled: true }],
};
mockResolverResolve.mockResolvedValue(resolvedPolicy);
const result = await service.createReminderJobs({
companyId: "company-1",
clientId: "client-1",
appointmentId: "appt-1",
type: "reminder",
appointmentStart: new Date("2026-07-21T15:00:00.000Z"),
reminderPayload: { email: "client@example.com" },
});
expect(mockResolverResolve).toHaveBeenCalledWith("company-1", "client-1");
expect(mockJobCreate).not.toHaveBeenCalled();
expect(result).toHaveLength(0);
});
it("adjusts scheduledAt for quiet hours crossing midnight", async () => {
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,351 @@
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([]);
});
it("resolves available channels from company defaults and plan limits without client preferences", async () => {
mockCompanyPolicyFindOne.mockResolvedValue({
companyId: "company-1",
defaultChannels: ["whatsapp" as NotificationChannel, "email" as NotificationChannel],
mutedChannels: ["email" as NotificationChannel],
timezone: "America/Argentina/Buenos_Aires",
});
(PlanSubscriptionsList.checkFeature as jest.Mock).mockResolvedValue(true);
const result = await resolver.resolveAvailableChannels("company-1", [
"whatsapp",
"email",
"system",
]);
expect(result).toEqual(["whatsapp", "email"]);
expect(mockClientPreferencesFindOne).not.toHaveBeenCalled();
expect(mockOverrideFindOne).not.toHaveBeenCalled();
});
it("removes available channels disallowed by the owner plan", async () => {
mockCompanyPolicyFindOne.mockResolvedValue({
companyId: "company-1",
defaultChannels: ["whatsapp" as NotificationChannel, "email" as NotificationChannel],
timezone: "America/Argentina/Buenos_Aires",
});
(PlanSubscriptionsList.checkFeature as jest.Mock).mockImplementation(
async (params: { feature: string }) =>
params.feature !== PlanFeatures.WAP_NOTIFICATIONS
);
const result = await resolver.resolveAvailableChannels("company-1", [
"whatsapp",
"email",
]);
expect(result).toEqual(["email"]);
});
});
@@ -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 = {
_id?: string;
userId?: string;
subscriptionId?: string;
transactionId?: string;
};
export type CreatePlanPaymentParams = Omit<IPlanPayment, "id">;
export interface IPlanPayment {
id?: string;
userId: string; // Referencia al ID del usuario
@@ -15,9 +20,13 @@ export interface IPlanPayment {
export interface IPlanPaymentsAdapter {
find(filters: FindPlanPaymentsParams): Promise<IPlanPayment[]>;
create(data: CreatePlanPaymentParams): Promise<IPlanPayment>;
createIfMissingByTransactionId(data: CreatePlanPaymentParams): Promise<IPlanPayment>;
}
export interface IPlanPaymentsManager {
planPayments: IPlanPaymentsAdapter;
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 {
FindPlanSuscripcionsParams,
IPlanSuscriptionsAdapter,
@@ -38,6 +37,26 @@ export class PlanSuscriptionsAdapterMongoose implements IPlanSuscriptionsAdapter
mpDateCreated: { type: Date, required: false },
mpInitPoint: { 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);
@@ -54,20 +73,32 @@ export class PlanSuscriptionsAdapterMongoose implements IPlanSuscriptionsAdapter
public async findOne(
filters: FindPlanSuscripcionsParams
): Promise<IPlanSuscriptionDocument | null> {
let subscription = await this.planSuscriptionList
.findOne({ userId: filters.sessionUser })
.exec();
const searchCriteria: Record<string, unknown> = {};
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) {
return null;
}
const dateNow = dayjs(new Date());
const dateEnd = dayjs(subscription.endDate);
const dateNow = new Date();
const dateEnd = new Date(subscription.endDate);
try {
if (subscription.mpPreferenceId) {
if (dateNow.isAfter(dateEnd)) {
if (dateNow > dateEnd) {
if (subscription.mpStatus == MP_SUBS_STATUS.CANCELLED) {
subscription.mpPreferenceId = "";
subscription.isActive = false;
@@ -77,17 +108,7 @@ export class PlanSuscriptionsAdapterMongoose implements IPlanSuscriptionsAdapter
await subscription.save();
return subscription;
}
// Without subscriptions, we wait for the webhook to update the 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;
}
// Pending Checkout Preferences do not consume paid time; webhook decides the final status.
}
}
} catch (e) {
@@ -3,6 +3,8 @@ import { IPlanSuscriptionDocument } from "./PlanSubscriptions.Adapter.Mongoose";
export type FindPlanSuscripcionsParams = {
sessionUser: string;
mpPreferenceId?: string;
mpStatus?: string;
};
export type CreatePlanSuscriptionParams = {
@@ -11,6 +13,7 @@ export type CreatePlanSuscriptionParams = {
mpPlanId: string;
startDate: Date;
endDate: Date;
isActive?: boolean;
autoRenew: boolean;
mpPayerId?: number;
mpPayerEmail?: string;
@@ -18,6 +21,26 @@ export type CreatePlanSuscriptionParams = {
mpDateCreated?: Date;
mpInitPoint?: 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 = {
@@ -47,6 +70,7 @@ export type MPPreApprovalResponse = {
export type ToFreePlanParams = {
sessionUser: string;
downgradeReason?: DowngradeReason;
};
export type ToMpParams = {
@@ -68,6 +92,23 @@ export type GetSubscriptionInitPointParams = {
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 {
init_point: string;
}
@@ -88,6 +129,26 @@ export interface IPlanSuscription {
mpDateCreated: Date; // Fecha de creación 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.
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 {
@@ -99,6 +160,18 @@ export interface ISubscriptionInfo {
autoRenew: boolean;
mpStatus: string;
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 {
@@ -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 PlansList from "../../Models/Plans/Plans";
import dayjs from "dayjs";
import isSameOrAfter from "dayjs/plugin/isSameOrAfter";
import "dayjs/locale/es";
import { isNull } from "../../helpers/IsNull";
import { PlanFeatures } from "../Plans/Plans.interface";
@@ -31,9 +30,11 @@ import { NotificationsManager } from "../Notifications/Notifications";
import WapServerList from "../WapServer/WapServer";
import CompaniesList from "../Companies/Companies";
import { NotificationType } from "../../Models/SystemNotifications/SystemNotification.Interface";
import { calculateProratedUpgradeAmount } from "./PlanSubscriptions.pricing";
dayjs.locale("es");
dayjs.extend(isSameOrAfter);
const DAY_MS = 24 * 60 * 60 * 1000;
class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
planSuscriptions: PlanSuscriptionsAdapterMongoose;
@@ -42,6 +43,24 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
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(
data: GetSubscriptionInitPointParams
): Promise<GetSuscriptionInitPointResponse> {
@@ -135,8 +154,9 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
planId: data.planId,
mpPlanId: data.planId,
startDate: subscriptionStartDate.toDate(),
endDate: subscriptionStartDate.clone().add(data.months || 1, "months").toDate(),
autoRenew: true,
endDate: subscriptionStartDate.clone().add(10, "years").toDate(),
autoRenew: false,
mpStatus: "",
mpInitPoint: "/landing/dashboard",
});
} else if (plan.price > 0) {
@@ -168,8 +188,16 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
if (subscription) {
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.downgradedFromPlanId = undefined;
subscription.downgradedFromPlanName = undefined;
subscription.downgradedFromPlanCode = undefined;
subscription.downgradedAt = undefined;
subscription.downgradeReason = undefined;
await subscription.save();
}
} else {
@@ -182,6 +210,23 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
months: data.months || 1,
});
} 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) {
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."
@@ -193,6 +238,7 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
sessionUser: data.sessionUser,
months: data.months || 1,
});
}
}
}
}
@@ -227,7 +273,7 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
}
try {
const subscriptionStartDate = dayjs(new Date());
const preferenceDate = dayjs(new Date());
let discount = 0;
if (data.months === 3) discount = plan.discount3Months || 0;
@@ -237,21 +283,74 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
const totalPrice = plan.price * data.months;
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(
"https://api.mercadopago.com/checkout/preferences",
{
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,
currency_id: "ARS",
unit_price: finalPrice
unit_price: isUpgradePayment ? proratedAmount : finalPrice
}
],
back_urls: {
success: process.env.MP_BACK_URL,
failure: process.env.MP_BACK_URL,
pending: process.env.MP_BACK_URL
success: this.buildMercadoPagoBackUrl("success"),
failure: this.buildMercadoPagoBackUrl("failure"),
pending: this.buildMercadoPagoBackUrl("pending")
},
auto_return: "approved",
payer: {
@@ -270,20 +369,102 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
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({
userId: data.sessionUser,
planId: data.planId,
mpPlanId: data.planId,
startDate: subscriptionStartDate.toDate(),
endDate: subscriptionStartDate.clone().add(data.months, "months").toDate(),
startDate: preferenceDate.toDate(),
endDate: preferenceDate.toDate(),
isActive: false,
autoRenew: false,
mpPayerEmail: sessionUser.mpPayerEmail,
mpStatus: MP_SUBS_STATUS.PENDING,
mpDateCreated: new Date(),
mpInitPoint: subscriptionData.init_point,
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({
@@ -330,18 +511,29 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
// We just cancel it locally if not paid, or change to free plan if paid.
}
const dateNow = dayjs(new Date()).endOf("day");
const dateEnd = dayjs(subscription.endDate);
const dateNow = new Date();
const dateEnd = new Date(subscription.endDate);
if (dateNow.isSameOrAfter(dateEnd)) {
this.toFreePlan({
if (dateNow > dateEnd) {
await this.toFreePlan({
sessionUser: data.sessionUser,
downgradeReason: "expired",
});
} else {
subscription.mpStatus = MP_SUBS_STATUS.CANCELLED;
subscription.mpInitPoint = "";
subscription.mpPayerEmail = "";
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();
}
} catch (error) {
@@ -400,12 +592,43 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
//console.log("guardando el plan gratuito...");
//try {
const downgradedFromPlan = data.downgradeReason === "expired"
? await PlansList.plans.findOne({ _id: String(subscription.planId) })
: null;
console.log("cambiando al free plan:", freePlan.id);
subscription.planId = freePlan.id;
subscription.mpStatus = "";
subscription.isActive = true;
subscription.autoRenew = false;
subscription.mpInitPoint = "";
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();
// console.log("plan gratuito guardado:", freePlan.id);
@@ -430,6 +653,15 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
autoRenew: subscription.autoRenew,
mpStatus: subscription.mpStatus,
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);
@@ -446,15 +678,14 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
return null;
}
const dateNow = dayjs(new Date()).endOf("day");
const dateEnd = dayjs(subscription.endDate);
const dateNow = new Date();
const dateEnd = new Date(subscription.endDate);
if (dateNow.isSameOrAfter(dateEnd)) {
if (subscription.mpStatus === MP_SUBS_STATUS.CANCELLED) {
this.toFreePlan({
sessionUser: data.sessionUser,
});
}
if (dateNow > dateEnd && this.shouldDowngradeExpiredSubscription(subscription)) {
await this.toFreePlan({
sessionUser: data.sessionUser,
downgradeReason: "expired",
});
}
return await this.planSuscriptions.findOne(data);
@@ -469,26 +700,30 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
return null;
}
const dateNow = dayjs(new Date()).endOf("day");
const dateEnd = dayjs(subscription.endDate);
const dateNow = new Date();
const dateEnd = new Date(subscription.endDate);
if (dateNow.isSameOrAfter(dateEnd)) {
//Esta condicion era la culpable de que todo el tiempo se queria aplicar el free plan.
//Ya que la fecha de finalizacion queda guardada en la base de datos a pesar de que ya no se use.
if (
subscription.mpStatus !== MP_SUBS_STATUS.AUTHORIZED &&
subscription.mpStatus !== ""
) {
this.toFreePlan({
sessionUser: data.sessionUser,
});
}
if (dateNow > dateEnd && this.shouldDowngradeExpiredSubscription(subscription)) {
await this.toFreePlan({
sessionUser: data.sessionUser,
downgradeReason: "expired",
});
return this.getSubscriptionByUser(data);
}
const plan = await PlansList.plans.findOne({
let plan = await PlansList.findOne({
_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) {
throw new Error("No se ha encontrado el plan");
}
@@ -502,6 +737,18 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
autoRenew: subscription.autoRenew,
mpStatus: subscription.mpStatus,
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;
if (!subscriptionInfo.isActive) {
return false;
}
if (!plan) {
throw new Error("No se encontro el plan de la suscripcion");
}
@@ -553,7 +804,7 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
if (!subscriptionInfo) {
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 { 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", () => {
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
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";
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> {
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;
};
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 {
id?: string;
name: string;
@@ -44,10 +71,12 @@ export interface IPlan {
export interface IPlansAdapter {
find(filters: FindPlansParams): Promise<IPlan[]>;
findOne(filters: FindPlansParams): Promise<IPlan | null>;
updateOne(planId: string, data: Omit<SysAdminUpdatePlanParams, "planId">): Promise<void>;
}
export interface IPlansManager {
plans: IPlansAdapter;
find(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 { PlansAdapterMongoose } from "./Plans.Adapter.Mongoose";
import { FindPlansParams, IPlan, IPlansManager } from "./Plans.interface";
import { FindPlansParams, IPlan, IPlansManager, SysAdminUpdatePlanParams } from "./Plans.interface";
class PlansManager implements IPlansManager {
plans: PlansAdapterMongoose;
@@ -59,6 +59,12 @@ class PlansManager implements IPlansManager {
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();
@@ -0,0 +1,191 @@
import { Document, Model, Schema, Types, model, models } from "mongoose";
import getAvatar from "../../helpers/getAvatar";
import { joinStrings } from "../../helpers/String";
import {
FindRatingsParams,
IRating,
IRatingsAdapter,
RATING_COMMENT_MAX_LENGTH,
RatingTargetSummaryParams,
RatingTargetSummaryResult,
RatingTargetReviewsParams,
RatingTargetReviewsResult,
RatingTargetType,
} from "./Ratings.Interface";
export interface IRatingDocument extends Omit<IRating, "id">, Document {
_id: Types.ObjectId;
}
type RatingReviewDocument = IRatingDocument & {
userId?: {
_id: Types.ObjectId;
firstName?: string;
lastName?: string;
avatar?: string;
};
};
const GENERIC_REVIEW_AUTHOR = "Cliente verificado";
export class RatingsAdapterMongoose implements IRatingsAdapter {
schema: Schema;
ratingsList: Model<IRatingDocument>;
constructor() {
this.schema = new Schema(
{
appointmentId: { type: Schema.Types.ObjectId, required: true, ref: "Appointment" },
userId: { type: Schema.Types.ObjectId, required: true, ref: "User" },
companyId: { type: Schema.Types.ObjectId, required: true, ref: "Companie" },
serviceId: { type: Schema.Types.ObjectId, required: true, ref: "Service" },
employeeId: { type: Schema.Types.ObjectId, required: true, ref: "Employee" },
targetType: { type: String, required: true, enum: Object.values(RatingTargetType) },
targetId: { type: Schema.Types.ObjectId, required: true },
score: { type: Number, required: true, min: 1, max: 5 },
comment: { type: String, required: false, default: "", maxlength: RATING_COMMENT_MAX_LENGTH },
},
{ timestamps: true }
);
this.schema.index({ userId: 1, targetType: 1, targetId: 1 }, { unique: true });
this.ratingsList = (models.Rating as Model<IRatingDocument>) || model<IRatingDocument>("Rating", this.schema);
}
public async create(data: Omit<IRating, "id" | "createdAt" | "updatedAt">): Promise<IRating> {
const rating = await this.ratingsList.create(data);
return this.mapRating(rating);
}
public async find(filters: FindRatingsParams): Promise<IRating[]> {
const ratings = await this.ratingsList.find(filters).exec();
return ratings.map((rating) => this.mapRating(rating));
}
public async findOne(filters: FindRatingsParams): Promise<IRating | null> {
const rating = await this.ratingsList.findOne(filters).exec();
return rating ? this.mapRating(rating) : null;
}
public async count(filters: FindRatingsParams): Promise<number> {
return this.ratingsList.countDocuments(filters).exec();
}
public async summaryByTarget(filters: RatingTargetSummaryParams): Promise<RatingTargetSummaryResult> {
const [summary] = await this.ratingsList
.aggregate<{ averageScore: number; totalCount: number }>([
{
$match: {
targetType: filters.targetType,
targetId: new Types.ObjectId(filters.targetId),
},
},
{
$group: {
_id: null,
averageScore: { $avg: "$score" },
totalCount: { $sum: 1 },
},
},
])
.exec();
return {
averageScore: summary?.averageScore || 0,
totalCount: summary?.totalCount || 0,
};
}
public async summaryByTargets(filters: { targetType: RatingTargetType; targetIds: string[] }): Promise<RatingTargetSummaryResult> {
const targetIds = filters.targetIds.filter((targetId) => Types.ObjectId.isValid(targetId));
if (targetIds.length === 0) {
return { averageScore: 0, totalCount: 0 };
}
const [summary] = await this.ratingsList
.aggregate<{ averageScore: number; totalCount: number }>([
{
$match: {
targetType: filters.targetType,
targetId: { $in: targetIds.map((targetId) => new Types.ObjectId(targetId)) },
},
},
{
$group: {
_id: null,
averageScore: { $avg: "$score" },
totalCount: { $sum: 1 },
},
},
])
.exec();
return {
averageScore: summary?.averageScore || 0,
totalCount: summary?.totalCount || 0,
};
}
public async reviewsByTarget(filters: RatingTargetReviewsParams): Promise<RatingTargetReviewsResult> {
const page = filters.page || 1;
const pageSize = filters.pageSize || filters.limit || 20;
const targetReviewsFilter = {
targetType: filters.targetType,
targetId: new Types.ObjectId(filters.targetId),
comment: { $regex: /\S/ },
};
const total = await this.ratingsList.countDocuments(targetReviewsFilter).exec();
const ratings = await this.ratingsList
.find(targetReviewsFilter)
.sort({ score: -1, createdAt: -1 })
.skip((page - 1) * pageSize)
.limit(pageSize)
.select("score comment createdAt userId")
.populate("userId", "firstName lastName avatar")
.exec();
const reviews = (ratings as unknown as RatingReviewDocument[]).map((rating) => {
const user = rating.userId;
const displayName = user ? joinStrings([user.firstName, user.lastName], " ") : "";
const authorName = displayName || GENERIC_REVIEW_AUTHOR;
return {
id: rating._id.toString(),
score: rating.score,
comment: rating.comment || "",
createdAt: rating.createdAt,
author: {
displayName: authorName,
avatarUrl: user ? getAvatar(user._id.toString(), user.avatar, "") : "",
},
};
});
return {
reviews,
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
};
}
private mapRating(rating: IRatingDocument): IRating {
return {
id: rating._id.toString(),
appointmentId: rating.appointmentId.toString(),
userId: rating.userId.toString(),
companyId: rating.companyId.toString(),
serviceId: rating.serviceId.toString(),
employeeId: rating.employeeId.toString(),
targetType: rating.targetType,
targetId: rating.targetId.toString(),
score: rating.score,
comment: rating.comment,
createdAt: rating.createdAt,
updatedAt: rating.updatedAt,
};
}
}
@@ -0,0 +1,125 @@
export enum RatingTargetType {
COMPANY = "company",
SERVICE = "service",
EMPLOYEE = "employee",
}
export const RATING_COMMENT_MAX_LENGTH = 1000;
export type FindRatingsParams = {
_id?: string;
appointmentId?: string;
userId?: string;
companyId?: string;
serviceId?: string;
employeeId?: string;
targetType?: RatingTargetType;
targetId?: string | { $in: string[] };
};
export type CreateRatingParams = {
appointmentId: string;
targetType: RatingTargetType;
targetId: string;
score: number;
comment?: string;
sessionUser: string;
};
export type PendingRatingsByUserParams = {
sessionUser: string;
};
export type CountRatingsByUserParams = {
sessionUser: string;
};
export type CountRatingsByUserResult = {
count: number;
};
export type ProfessionalRatingSummaryByUserParams = {
sessionUser: string;
};
export type RatingTargetSummaryParams = {
targetType: RatingTargetType;
targetId: string;
};
export type RatingTargetSummaryResult = {
averageScore: number;
totalCount: number;
};
export type RatingTargetReviewsParams = RatingTargetSummaryParams & {
page?: number;
pageSize?: number;
limit?: number;
};
export type RatingTargetReviewItem = {
id: string;
score: number;
comment: string;
createdAt: Date;
author: {
displayName: string;
avatarUrl: string;
};
};
export type RatingTargetReviewsResult = {
reviews: RatingTargetReviewItem[];
total: number;
page: number;
pageSize: number;
totalPages: number;
};
export interface IRating {
id?: string;
appointmentId: string;
userId: string;
companyId: string;
serviceId: string;
employeeId: string;
targetType: RatingTargetType;
targetId: string;
score: number;
comment?: string;
createdAt: Date;
updatedAt: Date;
}
export interface PendingRatingItem {
appointmentId: string;
appointmentDate: string;
targetType: RatingTargetType;
targetId: string;
targetName: string;
companyId: string;
serviceId: string;
employeeId: string;
serviceName: string;
}
export interface IRatingsAdapter {
create(data: Omit<IRating, "id" | "createdAt" | "updatedAt">): Promise<IRating>;
find(filters: FindRatingsParams): Promise<IRating[]>;
findOne(filters: FindRatingsParams): Promise<IRating | null>;
count(filters: FindRatingsParams): Promise<number>;
summaryByTarget(filters: RatingTargetSummaryParams): Promise<RatingTargetSummaryResult>;
summaryByTargets(filters: { targetType: RatingTargetType; targetIds: string[] }): Promise<RatingTargetSummaryResult>;
reviewsByTarget(filters: RatingTargetReviewsParams): Promise<RatingTargetReviewsResult>;
}
export interface IRatingsManager {
ratings: IRatingsAdapter;
createRating(data: CreateRatingParams): Promise<IRating>;
pendingByUser(data: PendingRatingsByUserParams): Promise<PendingRatingItem[]>;
countByUser(data: CountRatingsByUserParams): Promise<CountRatingsByUserResult>;
professionalSummaryByUser(data: ProfessionalRatingSummaryByUserParams): Promise<RatingTargetSummaryResult>;
summaryByTarget(data: RatingTargetSummaryParams): Promise<RatingTargetSummaryResult>;
reviewsByTarget(data: RatingTargetReviewsParams): Promise<RatingTargetReviewsResult>;
}
+296
View File
@@ -0,0 +1,296 @@
import dayjs from "dayjs";
import { Types } from "mongoose";
import { isNull } from "../../helpers/IsNull";
import { joinStrings } from "../../helpers/String";
import AppointmentList from "../Appointments/Appointments";
import CompaniesManager from "../Companies/Companies";
import EmployeesList from "../Employees/Employee";
import ServicesManager from "../Services/Service";
import UsersManager from "../Users/Users";
import { RatingsAdapterMongoose } from "./Ratings.Adapter.Mongoose";
import {
CountRatingsByUserParams,
CountRatingsByUserResult,
CreateRatingParams,
IRating,
IRatingsManager,
PendingRatingItem,
PendingRatingsByUserParams,
ProfessionalRatingSummaryByUserParams,
RATING_COMMENT_MAX_LENGTH,
RatingTargetSummaryParams,
RatingTargetSummaryResult,
RatingTargetReviewsParams,
RatingTargetReviewsResult,
RatingTargetType,
} from "./Ratings.Interface";
const PENDING_RATINGS_APPOINTMENT_LIMIT = 50;
const TARGET_REVIEWS_LIMIT = 20;
class RatingsManager implements IRatingsManager {
ratings: RatingsAdapterMongoose;
constructor() {
this.ratings = new RatingsAdapterMongoose();
}
public async createRating(data: CreateRatingParams): Promise<IRating> {
await this.validateSessionUser(data.sessionUser);
this.validateScore(data.score);
this.validateTargetType(data.targetType);
const comment = isNull<string>(data.comment, "").trim();
this.validateComment(comment);
const appointment = await AppointmentList.Appointments.AppointmentList.findOne({
_id: data.appointmentId,
userId: data.sessionUser,
});
if (!appointment) {
throw new Error("No se encontró un turno válido para calificar.");
}
if (!appointment.present || !dayjs(appointment.dateDay).isBefore(dayjs().startOf("day"))) {
throw new Error("Solo puedes calificar turnos pasados a los que asististe.");
}
const expectedTargetId = this.getTargetId({
targetType: data.targetType,
companyId: String(appointment.companyId),
serviceId: String(appointment.serviceId),
employeeId: String(appointment.employeeId),
});
if (String(data.targetId) !== expectedTargetId) {
throw new Error("La calificación no corresponde al turno seleccionado.");
}
const existingRating = await this.ratings.findOne({
userId: data.sessionUser,
targetType: data.targetType,
targetId: data.targetId,
});
if (existingRating) {
throw new Error("Ya calificaste este item.");
}
try {
return await this.ratings.create({
appointmentId: data.appointmentId,
userId: data.sessionUser,
companyId: String(appointment.companyId),
serviceId: String(appointment.serviceId),
employeeId: String(appointment.employeeId),
targetType: data.targetType,
targetId: data.targetId,
score: data.score,
comment,
});
} catch (error) {
if (this.isDuplicateRatingError(error)) {
throw new Error("Ya calificaste este item.");
}
throw error;
}
}
public async pendingByUser(data: PendingRatingsByUserParams): Promise<PendingRatingItem[]> {
await this.validateSessionUser(data.sessionUser);
const appointments = await AppointmentList.Appointments.AppointmentList.find({
userId: data.sessionUser,
present: true,
dateDay: { $lt: dayjs().startOf("day").toDate() },
})
.sort({ start: -1 })
.limit(PENDING_RATINGS_APPOINTMENT_LIMIT);
const pendingItems: PendingRatingItem[] = [];
const seenTargets = new Set<string>();
const candidateTargetIds = Array.from(
new Set(
appointments.flatMap((appointment) => [
String(appointment.companyId),
String(appointment.serviceId),
String(appointment.employeeId),
])
)
);
const candidateTargetObjectIds = this.toObjectIds(candidateTargetIds);
const existingRatings = await this.ratings.find({
userId: data.sessionUser,
targetId: { $in: candidateTargetIds },
});
const ratedTargets = new Set(
existingRatings.map((rating) => `${rating.targetType}:${String(rating.targetId)}`)
);
const companies = await CompaniesManager.companies.find({ _id: { $in: candidateTargetObjectIds } } as any);
const services = await ServicesManager.services.find({ _id: { $in: candidateTargetObjectIds } } as any);
const employees = await EmployeesList.employees.find({ _id: { $in: candidateTargetObjectIds } } as any);
const employeeUserIds = Array.from(new Set(employees.map((employee) => String(employee.userId))));
const employeeUsers = await UsersManager.users.find({ _id: { $in: this.toObjectIds(employeeUserIds) } } as any);
const companiesById = new Map(companies.map((company) => [String(company.id), company]));
const servicesById = new Map(services.map((service) => [String(service.id), service]));
const employeesById = new Map(employees.map((employee) => [String(employee.id), employee]));
const employeeUsersById = new Map(employeeUsers.map((user) => [String(user.id), user]));
for (const appointment of appointments) {
const company = companiesById.get(String(appointment.companyId));
const service = servicesById.get(String(appointment.serviceId));
const employee = employeesById.get(String(appointment.employeeId));
if (!company || !service || !employee) {
continue;
}
const userEmployee = employeeUsersById.get(String(employee.userId));
const employeeName = userEmployee
? joinStrings([isNull<string>(userEmployee.firstName, ""), isNull<string>(userEmployee.lastName, "")], " ")
: "Profesional";
const candidates = [
{ targetType: RatingTargetType.COMPANY, targetId: String(appointment.companyId), targetName: company.name },
{ targetType: RatingTargetType.SERVICE, targetId: String(appointment.serviceId), targetName: service.name },
{ targetType: RatingTargetType.EMPLOYEE, targetId: String(appointment.employeeId), targetName: employeeName },
];
for (const candidate of candidates) {
const seenKey = `${candidate.targetType}:${candidate.targetId}`;
if (seenTargets.has(seenKey)) {
continue;
}
if (ratedTargets.has(seenKey)) {
seenTargets.add(seenKey);
continue;
}
pendingItems.push({
appointmentId: String(appointment.id),
appointmentDate: dayjs(appointment.start).toISOString(),
targetType: candidate.targetType,
targetId: candidate.targetId,
targetName: candidate.targetName,
companyId: String(appointment.companyId),
serviceId: String(appointment.serviceId),
employeeId: String(appointment.employeeId),
serviceName: service.name,
});
seenTargets.add(seenKey);
}
}
return pendingItems;
}
public async countByUser(data: CountRatingsByUserParams): Promise<CountRatingsByUserResult> {
await this.validateSessionUser(data.sessionUser);
return {
count: await this.ratings.count({ userId: data.sessionUser }),
};
}
public async professionalSummaryByUser(data: ProfessionalRatingSummaryByUserParams): Promise<RatingTargetSummaryResult> {
await this.validateSessionUser(data.sessionUser);
const employees = await EmployeesList.employees.find({
userId: data.sessionUser,
removed: { $ne: true },
} as any);
const employeeIds = employees.map((employee) => String(employee.id));
return this.ratings.summaryByTargets({
targetType: RatingTargetType.EMPLOYEE,
targetIds: employeeIds,
});
}
public async summaryByTarget(data: RatingTargetSummaryParams): Promise<RatingTargetSummaryResult> {
this.validateTargetType(data.targetType);
if (!Types.ObjectId.isValid(data.targetId)) {
throw new Error("No se ha proporcionado un objetivo válido.");
}
return this.ratings.summaryByTarget(data);
}
public async reviewsByTarget(data: RatingTargetReviewsParams): Promise<RatingTargetReviewsResult> {
this.validateTargetType(data.targetType);
if (!Types.ObjectId.isValid(data.targetId)) {
throw new Error("No se ha proporcionado un objetivo válido.");
}
const pageSize = Math.min(Math.max(data.pageSize || data.limit || TARGET_REVIEWS_LIMIT, 1), TARGET_REVIEWS_LIMIT);
const page = Math.max(data.page || 1, 1);
return this.ratings.reviewsByTarget({
...data,
page,
pageSize,
limit: pageSize,
});
}
private async validateSessionUser(sessionUser: string): Promise<void> {
const user = await UsersManager.users.findOne({ _id: sessionUser });
if (!user) {
throw new Error("No se ha encontrado el usuario o no tiene permisos para realizar esta acción");
}
}
private toObjectIds(ids: string[]): Types.ObjectId[] {
return ids.filter((id) => Types.ObjectId.isValid(id)).map((id) => new Types.ObjectId(id));
}
private validateScore(score: number): void {
if (!Number.isInteger(score) || score < 1 || score > 5) {
throw new Error("La calificación debe estar entre 1 y 5.");
}
}
private validateTargetType(targetType: RatingTargetType): void {
if (!Object.values(RatingTargetType).includes(targetType)) {
throw new Error("El tipo de calificación no es válido.");
}
}
private validateComment(comment: string): void {
if (comment.length > RATING_COMMENT_MAX_LENGTH) {
throw new Error(`El comentario no puede superar los ${RATING_COMMENT_MAX_LENGTH} caracteres.`);
}
}
private isDuplicateRatingError(error: unknown): boolean {
return Boolean(error && typeof error === "object" && "code" in error && error.code === 11000);
}
private getTargetId(data: {
targetType: RatingTargetType;
companyId: string;
serviceId: string;
employeeId: string;
}): string {
if (data.targetType === RatingTargetType.COMPANY) {
return data.companyId;
}
if (data.targetType === RatingTargetType.SERVICE) {
return data.serviceId;
}
return data.employeeId;
}
}
const RatingsList = new RatingsManager();
export default RatingsList;
@@ -4,6 +4,8 @@ export type ScheduleItem = {
from: string;
to: string;
disabled: boolean;
serviceScope?: "all" | "specific";
serviceIds?: string[];
};
export type FindSchedulesParams = {
@@ -19,6 +21,7 @@ export type AvailableSchedulesParams = {
weekDay: number;
from: string;
to: string;
serviceId?: string;
};
export type PaginateSchedulesParams = FindSchedulesParams & {
+12
View File
@@ -173,6 +173,14 @@ class SchedulesManager implements ISchedulesManager {
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> {
const schedule = await this.schedules.findOne({
employeeId: data.employeeId,
@@ -185,6 +193,9 @@ class SchedulesManager implements ISchedulesManager {
if (horario.disabled) {
continue;
}
if (!this.isScheduleAvailableForService(horario, data.serviceId)) {
continue;
}
if (horario.from == data.from) {
return horario;
}
@@ -236,6 +247,7 @@ class SchedulesManager implements ISchedulesManager {
weekDay: data.weekDay,
from: scheduleFrom.to,
to: data.to,
serviceId: data.serviceId,
});
if (!scheduleTo) {
@@ -49,6 +49,49 @@ export type EnableScheduleParams = {
sessionUser: string;
};
export type ReservationPeriodScope = "employee" | "all";
export type ReservationPeriodDurationUnit = "week" | "month";
export type ReservationPeriodDuration = {
unit: ReservationPeriodDurationUnit;
value: number;
};
export type ReservationPeriodsSummaryParams = {
companyId: string;
sessionUser: string;
};
export type ExtendReservationPeriodsParams = {
companyId: string;
scope: ReservationPeriodScope;
employeeId?: string;
duration?: ReservationPeriodDuration;
weeks?: number;
sessionUser: string;
};
export type ReservationPeriodEmployeeSummary = {
employeeId: string;
employeeFullName: string;
employeeEmail: string;
employeeAvatar: string;
employeeUserId: string;
isOpen: boolean;
currentOpenUntil: Date | null;
daysRemaining: number | null;
};
export type ReservationPeriodsSummary = {
companyId: string;
status: "missing" | "expired" | "near-ending" | "open";
worstDaysRemaining: number | null;
currentOpenUntil: Date | null;
nearEndingThresholdDays: number;
employees: ReservationPeriodEmployeeSummary[];
};
export type DeleteSchedulesEnabledByCompanyParams = {
companyId: string;
};
@@ -94,4 +137,6 @@ export interface ISchedulesEnabledManager {
exists(data: CheckSchedulesEnabledParams): Promise<boolean>;
deleteSchedulesEnabledByCompany(data: DeleteSchedulesEnabledByCompanyParams): Promise<void>;
deleteSchedulesEnabledByEmployee(data: DeleteSchedulesEnabledByEmployeeParams): Promise<void>;
summarizeReservationPeriods(data: ReservationPeriodsSummaryParams): Promise<ReservationPeriodsSummary>;
extendReservationPeriods(data: ExtendReservationPeriodsParams): Promise<ReservationPeriodsSummary>;
}
@@ -9,6 +9,10 @@ import {
FindSchedulesEnabledParams,
ISchedulesEnabled,
ISchedulesEnabledManager,
ReservationPeriodEmployeeSummary,
ReservationPeriodsSummary,
ReservationPeriodsSummaryParams,
ExtendReservationPeriodsParams,
SchedulesEnabledView,
} from "./SchedulesEnabled.Interface";
import { EmployeeRoles } from "../Employees/Employees.Interface";
@@ -21,10 +25,26 @@ import { joinStrings } from "../../helpers/String";
import getAvatar from "../../helpers/getAvatar";
import { NotificationsManager } from "../Notifications/Notifications";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone";
import "dayjs/locale/es";
import { NotificationType } from "../../Models/SystemNotifications/SystemNotification.Interface";
import { validatePermissionsByCompany, validateSessionUser } from "../../helpers/check";
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.locale("es");
const SCHEDULE_BOUNDARY_TIMEZONE = "America/Argentina/Buenos_Aires";
const NEAR_ENDING_THRESHOLD_DAYS = 14;
const ALLOWED_RESERVATION_PERIOD_DURATIONS = [
{ unit: "week", value: 1 },
{ unit: "week", value: 2 },
{ unit: "week", value: 3 },
{ unit: "month", value: 1 },
{ unit: "month", value: 2 },
] as const;
class SchedulesEnabledManager implements ISchedulesEnabledManager {
schedulesEnabled: SchedulesEnabledAdapterMongoose;
@@ -73,8 +93,8 @@ class SchedulesEnabledManager implements ISchedulesEnabledManager {
const schedulesEnabledCheck = await this.schedulesEnabled.findOne({
employeeId: data.employeeId,
companyId: data.companyId,
startDate: dayjs(data.startDate).startOf("day").toDate(),
endDate: dayjs(data.endDate).endOf("day").toDate(),
startDate: this.normalizeScheduleStartDate(data.startDate),
endDate: this.normalizeScheduleEndDate(data.endDate),
});
if (schedulesEnabledCheck) {
@@ -84,8 +104,8 @@ class SchedulesEnabledManager implements ISchedulesEnabledManager {
const newEmplyeesEnabled = await this.schedulesEnabled.create({
employeeId: data.employeeId,
companyId: data.companyId,
startDate: dayjs(data.startDate).startOf("day").toDate(),
endDate: dayjs(data.endDate).endOf("day").toDate(),
startDate: this.normalizeScheduleStartDate(data.startDate),
endDate: this.normalizeScheduleEndDate(data.endDate),
sessionUser: data.sessionUser,
});
@@ -125,7 +145,8 @@ class SchedulesEnabledManager implements ISchedulesEnabledManager {
const employeeCheck = await EmployeesList.employees.findOne({
companyId: data.companyId,
_id: employeeId,
});
removed: { $ne: true },
} as any);
if (!employeeCheck) {
continue;
@@ -134,8 +155,8 @@ class SchedulesEnabledManager implements ISchedulesEnabledManager {
const schedulesEnabledCheck = await this.schedulesEnabled.findOne({
employeeId: employeeId,
companyId: data.companyId,
startDate: dayjs(data.startDate).startOf("day").toDate(),
endDate: dayjs(data.endDate).endOf("day").toDate(),
startDate: this.normalizeScheduleStartDate(data.startDate),
endDate: this.normalizeScheduleEndDate(data.endDate),
});
if (schedulesEnabledCheck) {
@@ -145,8 +166,8 @@ class SchedulesEnabledManager implements ISchedulesEnabledManager {
await this.schedulesEnabled.create({
employeeId: employeeId,
companyId: data.companyId,
startDate: dayjs(data.startDate).startOf("day").toDate(),
endDate: dayjs(data.endDate).endOf("day").toDate(),
startDate: this.normalizeScheduleStartDate(data.startDate),
endDate: this.normalizeScheduleEndDate(data.endDate),
sessionUser: data.sessionUser,
});
@@ -185,7 +206,8 @@ class SchedulesEnabledManager implements ISchedulesEnabledManager {
const employeeCheck = await EmployeesList.employees.findOne({
companyId: data.companyId,
_id: employeeId,
});
removed: { $ne: true },
} as any);
if (!employeeCheck) {
continue;
@@ -194,8 +216,8 @@ class SchedulesEnabledManager implements ISchedulesEnabledManager {
const schedulesEnabledCheck = await this.schedulesEnabled.findOne({
employeeId: employeeId,
companyId: data.companyId,
startDate: dayjs(data.startDate).startOf("day").toDate(),
endDate: dayjs(data.endDate).endOf("day").toDate(),
startDate: this.normalizeScheduleStartDate(data.startDate),
endDate: this.normalizeScheduleEndDate(data.endDate),
});
if (schedulesEnabledCheck) {
@@ -229,11 +251,16 @@ class SchedulesEnabledManager implements ISchedulesEnabledManager {
}
public async find(data: FindSchedulesEnabledParams): Promise<SchedulesEnabledView[]> {
let employeeFilter: any = { companyId: data.companyId };
const rangeStart = this.normalizeScheduleStartDate(data.startDate);
const rangeEnd = this.normalizeScheduleEndDate(data.endDate);
let employeeFilter: any = {
companyId: data.companyId,
removed: { $ne: true },
};
if (data.employeeId) {
employeeFilter = {
...employeeFilter,
employeeId: data.employeeId,
_id: data.employeeId,
};
}
const employees = await EmployeesList.employees.find(employeeFilter);
@@ -244,8 +271,8 @@ class SchedulesEnabledManager implements ISchedulesEnabledManager {
const schedulesEnabled = await this.schedulesEnabled.schedulesEnabledList.findOne({
employeeId: employee.id,
companyId: data.companyId,
startDate: dayjs(data.startDate).startOf("day").toDate(),
endDate: dayjs(data.endDate).endOf("day").toDate(),
startDate: { $lte: rangeStart },
endDate: { $gte: rangeEnd },
});
const employUser = await UsersManager.users.findOne({
@@ -297,6 +324,189 @@ class SchedulesEnabledManager implements ISchedulesEnabledManager {
await this.schedulesEnabled.delete(String(data.id));
}
public async summarizeReservationPeriods(
data: ReservationPeriodsSummaryParams
): Promise<ReservationPeriodsSummary> {
await validateSessionUser({ sessionUser: data.sessionUser });
await validatePermissionsByCompany({ companyId: data.companyId, sessionUser: data.sessionUser });
return this.buildReservationPeriodsSummary(data.companyId);
}
public async extendReservationPeriods(
data: ExtendReservationPeriodsParams
): Promise<ReservationPeriodsSummary> {
await validateSessionUser({ sessionUser: data.sessionUser });
await validatePermissionsByCompany({ companyId: data.companyId, sessionUser: data.sessionUser });
const duration = data.duration || (data.weeks ? { unit: "week" as const, value: Number(data.weeks) } : null);
if (!duration || !ALLOWED_RESERVATION_PERIOD_DURATIONS.some(option => option.unit === duration.unit && option.value === Number(duration.value))) {
throw new Error("El período seleccionado no es válido");
}
const employees = await EmployeesList.employees.find({
companyId: data.companyId,
removed: { $ne: true },
} as any);
let targetEmployees = employees;
if (data.scope === "employee") {
if (!data.employeeId) {
throw new Error("Debe seleccionar un colaborador");
}
targetEmployees = employees.filter(employee => String(employee.id) === String(data.employeeId));
if (targetEmployees.length === 0) {
throw new Error("El colaborador no existe o no pertenece a la organización");
}
} else if (data.scope !== "all") {
throw new Error("El alcance seleccionado no es válido");
}
if (targetEmployees.length === 0) {
throw new Error("No hay colaboradores disponibles para extender el período de reservas");
}
const today = dayjs().startOf("day");
const summary = await this.buildReservationPeriodsSummary(data.companyId);
const operations = targetEmployees.flatMap(employee => {
const employeeSummary = summary.employees.find(item => String(item.employeeId) === String(employee.id));
const startDate = employeeSummary?.currentOpenUntil && !dayjs(employeeSummary.currentOpenUntil).endOf("day").isBefore(today)
? this.getNextWeekStartAfter(dayjs(employeeSummary.currentOpenUntil))
: this.getWeekStart(today);
const weekRanges = this.buildReservationPeriodWeekRanges(startDate, duration);
return weekRanges.map(range => ({
updateOne: {
filter: {
employeeId: employee.id,
companyId: data.companyId,
startDate: this.normalizeScheduleStartDate(range.startDate),
endDate: this.normalizeScheduleEndDate(range.endDate),
},
update: {
$setOnInsert: {
employeeId: employee.id,
companyId: data.companyId,
startDate: this.normalizeScheduleStartDate(range.startDate),
endDate: this.normalizeScheduleEndDate(range.endDate),
},
},
upsert: true,
},
}));
});
await this.schedulesEnabled.schedulesEnabledList.bulkWrite(operations, { ordered: true });
return this.buildReservationPeriodsSummary(data.companyId);
}
private getWeekStart(date: dayjs.Dayjs): dayjs.Dayjs {
return date.startOf("day").subtract((date.day() + 6) % 7, "day");
}
private getNextWeekStartAfter(date: dayjs.Dayjs): dayjs.Dayjs {
const nextDay = date.add(1, "day").startOf("day");
const weekStart = this.getWeekStart(nextDay);
return weekStart.isBefore(nextDay) ? weekStart.add(1, "week") : weekStart;
}
private buildReservationPeriodWeekRanges(
startWeek: dayjs.Dayjs,
duration: { unit: "week" | "month"; value: number }
): { startDate: dayjs.Dayjs; endDate: dayjs.Dayjs }[] {
const ranges: { startDate: dayjs.Dayjs; endDate: dayjs.Dayjs }[] = [];
const targetEnd = duration.unit === "week"
? startWeek.add(Number(duration.value), "week").subtract(1, "day").endOf("day")
: startWeek.add(Number(duration.value), "month").subtract(1, "day").endOf("day");
let currentWeekStart = startWeek.startOf("day");
while (!currentWeekStart.isAfter(targetEnd)) {
ranges.push({
startDate: currentWeekStart,
endDate: currentWeekStart.add(6, "day").endOf("day"),
});
currentWeekStart = currentWeekStart.add(1, "week");
}
return ranges;
}
private normalizeScheduleStartDate(date: string | Date | dayjs.Dayjs): Date {
return dayjs.tz(this.getScheduleDateOnly(date), SCHEDULE_BOUNDARY_TIMEZONE).startOf("day").toDate();
}
private normalizeScheduleEndDate(date: string | Date | dayjs.Dayjs): Date {
return dayjs.tz(this.getScheduleDateOnly(date), SCHEDULE_BOUNDARY_TIMEZONE).endOf("day").toDate();
}
private getScheduleDateOnly(date: string | Date | dayjs.Dayjs): string {
if (typeof date === "string") {
return date.slice(0, 10);
}
if (dayjs.isDayjs(date)) {
return date.format("YYYY-MM-DD");
}
return date.toISOString().slice(0, 10);
}
private async buildReservationPeriodsSummary(companyId: string): Promise<ReservationPeriodsSummary> {
const today = dayjs().startOf("day");
const employees = await EmployeesList.findByCompanyId({ companyId });
const employeeSummaries: ReservationPeriodEmployeeSummary[] = [];
for (const employee of employees) {
const latestPeriod = await this.schedulesEnabled.schedulesEnabledList
.findOne({ companyId, employeeId: employee.id })
.sort({ endDate: -1 })
.exec();
const currentOpenUntil = latestPeriod ? latestPeriod.endDate : null;
const daysRemaining = currentOpenUntil
? dayjs(currentOpenUntil).endOf("day").diff(today, "day")
: null;
employeeSummaries.push({
employeeId: employee.id,
employeeFullName: employee.fullName,
employeeEmail: employee.email,
employeeAvatar: employee.avatar,
employeeUserId: employee.userId,
isOpen: daysRemaining !== null && daysRemaining >= 0,
currentOpenUntil,
daysRemaining,
});
}
const currentOpenUntilDates = employeeSummaries
.map(employee => employee.currentOpenUntil)
.filter((date): date is Date => Boolean(date));
const remainingValues = employeeSummaries
.map(employee => employee.daysRemaining)
.filter((days): days is number => days !== null);
const hasMissing = employeeSummaries.some(employee => employee.currentOpenUntil === null);
const hasExpired = employeeSummaries.some(employee => employee.daysRemaining !== null && employee.daysRemaining < 0);
const hasNearEnding = employeeSummaries.some(employee => employee.daysRemaining !== null && employee.daysRemaining <= NEAR_ENDING_THRESHOLD_DAYS);
return {
companyId,
status: hasMissing ? "missing" : hasExpired ? "expired" : hasNearEnding ? "near-ending" : "open",
worstDaysRemaining: remainingValues.length > 0 ? Math.min(...remainingValues) : null,
currentOpenUntil: currentOpenUntilDates.length > 0
? currentOpenUntilDates.reduce((max, date) => dayjs(date).isAfter(max) ? date : max, currentOpenUntilDates[0])
: null,
nearEndingThresholdDays: NEAR_ENDING_THRESHOLD_DAYS,
employees: employeeSummaries,
};
}
}
const SchedulesEnabledList = new SchedulesEnabledManager();
@@ -14,6 +14,7 @@ export type AvailableSchedulesOverridesParams = {
dateDay: Date;
from: string;
to: string;
serviceId?: string;
};
export type PaginateSchedulesOverridesParams = FindSchedulesOverridesParams & {
@@ -154,6 +154,14 @@ class SchedulesOverridesManager implements ISchedulesOverridesManager {
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> {
const exactDate = dayjs(data.dateDay).startOf("day").toDate();
const schedule = await this.schedulesOverrides.findOne({
@@ -165,6 +173,7 @@ class SchedulesOverridesManager implements ISchedulesOverridesManager {
if (schedule) {
for (const horario of schedule.schedules) {
if (horario.disabled) continue;
if (!this.isScheduleAvailableForService(horario, data.serviceId)) continue;
if (horario.from == data.from) return horario;
if (horario.from < data.from && horario.to > data.from) return horario;
}
@@ -42,6 +42,8 @@ export class ServicesAdapterMongoose implements IServicesAdapter {
longitude: { type: Number, required: false, default: 0 },
published: { type: String, required: false },
banned: { type: Boolean, required: false, default: false },
showPublicScores: { type: Boolean, required: false, default: true },
showPublicOpinions: { type: Boolean, required: false, default: true },
});
this.serviceList = model<IServiceDocument>("Service", this.schema);
@@ -93,6 +95,8 @@ export class ServicesAdapterMongoose implements IServicesAdapter {
if (data.latitude) updateService.latitude = data.latitude;
if (data.longitude) updateService.longitude = data.longitude;
if (data.banned !== undefined) updateService.banned = data.banned;
if (data.showPublicScores !== undefined) updateService.showPublicScores = data.showPublicScores;
if (data.showPublicOpinions !== undefined) updateService.showPublicOpinions = data.showPublicOpinions;
await updateService.save();
}
@@ -57,6 +57,8 @@ export type CreateServiceParams = {
latitude?: number;
longitude?: number;
sessionUser: string;
showPublicScores?: boolean;
showPublicOpinions?: boolean;
};
export type UpdateServiceParams = {
@@ -81,6 +83,8 @@ export type UpdateServiceParams = {
longitude?: number;
sessionUser: string;
banned?: boolean;
showPublicScores?: boolean;
showPublicOpinions?: boolean;
};
export type PaginatePublicServicesParams = FindServicesParams & {
@@ -155,6 +159,8 @@ export interface IService {
published?: SERVICE_PUBLISHED_STATUS;
discountId?: string;
banned?: boolean;
showPublicScores?: boolean;
showPublicOpinions?: boolean;
}
export interface CompanyServiceView {
@@ -176,6 +182,8 @@ export interface CompanyServiceView {
fontShadowColor: string;
published: SERVICE_PUBLISHED_STATUS;
banned?: boolean;
showPublicScores: boolean;
showPublicOpinions: boolean;
}
export interface PublicServiceView {
@@ -199,6 +207,8 @@ export interface PublicServiceView {
fontColor: string;
fontShadowColor: string;
banned?: boolean;
showPublicScores: boolean;
showPublicOpinions: boolean;
}
export interface FindServicesByCompanyParams {
+25
View File
@@ -46,6 +46,17 @@ class ServiceManager implements IServicesManager {
this.services = new ServicesAdapterMongoose();
}
private async validatePublicRatingFlagsPlan(ownerId: string): Promise<void> {
const { default: PlanSubscriptionsList } = await import("../PlanSubscriptions/PlanSubscriptons");
const subscription = await PlanSubscriptionsList.getSubscriptionByUser({
sessionUser: ownerId,
});
if (!subscription || subscription.plan.price === 0) {
throw new Error("Esta configuración está disponible solo para planes pagos.");
}
}
public async setPublishedStatus(data: SetPublishedStatusParams): Promise<void> {
const sessionUser = await UsersManager.users.findOne({
_id: data.sessionUser,
@@ -145,6 +156,10 @@ class ServiceManager implements IServicesManager {
throw new Error("Ha alcanzado el limite de servicios permitidos de acuerdo a su plan.");
}
if (data.showPublicScores !== undefined || data.showPublicOpinions !== undefined) {
await this.validatePublicRatingFlagsPlan(String(companyCheck.ownerId));
}
const newService = await this.services.create({
...data,
...{
@@ -190,6 +205,10 @@ class ServiceManager implements IServicesManager {
throw new Error(NoPermissionMessage());
}
if (data.showPublicScores !== undefined || data.showPublicOpinions !== undefined) {
await this.validatePublicRatingFlagsPlan(String(companyCheck.ownerId));
}
await this.services.update(data);
}
@@ -307,6 +326,8 @@ class ServiceManager implements IServicesManager {
service.published,
SERVICE_PUBLISHED_STATUS.PRIVATE
),
showPublicScores: service.showPublicScores ?? true,
showPublicOpinions: service.showPublicOpinions ?? true,
});
}
@@ -363,6 +384,8 @@ class ServiceManager implements IServicesManager {
fontColor: isNull<string>(service.fontColor, defFontColor),
fontShadowColor: isNull<string>(service.fontShadowColor, defFontShadowColor),
published: isNull<SERVICE_PUBLISHED_STATUS>(service.published, SERVICE_PUBLISHED_STATUS.PRIVATE),
showPublicScores: service.showPublicScores ?? true,
showPublicOpinions: service.showPublicOpinions ?? true,
};
}
@@ -486,6 +509,8 @@ class ServiceManager implements IServicesManager {
color: isNull<string>(service.color, defColor),
fontColor: isNull<string>(service.fontColor, defFontColor),
fontShadowColor: isNull<string>(service.fontShadowColor, defFontShadowColor),
showPublicScores: service.showPublicScores ?? true,
showPublicOpinions: service.showPublicOpinions ?? true,
});
}
@@ -63,6 +63,11 @@ export type CreateSystemNotificationParams = {
code?: string;
};
export type CreateSystemNotificationBySystemParams = CreateSystemNotificationParams & {
userId: string;
systemToken: string;
};
export type UpdateSystemNotificationParams = {
id: string;
readed: boolean;
@@ -119,6 +124,7 @@ export interface ISystemNotificationsAdapter {
export interface ISystemNotificationsManager {
notifications: ISystemNotificationsAdapter;
createNotification(data: CreateSystemNotificationParams): Promise<ISystemNotification>;
createNotificationBySystem(data: CreateSystemNotificationBySystemParams): Promise<ISystemNotification>;
updateNotification(data: UpdateSystemNotificationParams): Promise<void>;
deleteNotification(data: DeleteSystemNotificationParams): Promise<void>;
deleteConversationNotifications(data: DeleteSystemNotificationsByConversation): Promise<void>;
@@ -1,6 +1,7 @@
import CompaniesManager from "../Companies/Companies";
import {
CreateSystemNotificationParams,
CreateSystemNotificationBySystemParams,
DeleteNotificationsByDateParams,
DeleteNotificationsByUserParams,
DeleteSystemNotificationParams,
@@ -108,6 +109,28 @@ class SystemNotificationsManager implements ISystemNotificationsManager {
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> {
await validateSessionUser({
sessionUser: data.sessionUser,
@@ -4,8 +4,10 @@ import { TextObjectFilterResult } from "../TextObjectFilter.model";
export enum TemplateTypes {
WAP_NEW_APPOINTMENT = "wap-new-appointment",
WAP_REMINDER = "wap-reminder",
WAP_CANCELLATION = "wap-cancellation",
EMAIL_NEW_APPOINTMENT = "email-new-appointment",
EMAIL_REMINDER = "email-reminder",
EMAIL_CANCELLATION = "email-cancellation",
}
export interface ITemplate {
id?: string;
+6
View File
@@ -215,12 +215,18 @@ export class TemplatesManager implements ITemplatesManager {
case TemplateTypes.WAP_NEW_APPOINTMENT:
defaultTemplate = `${process.env.WAP_ALTA}`;
break;
case TemplateTypes.WAP_CANCELLATION:
defaultTemplate = `${process.env.WAP_CANCELLATION || process.env.WAP_CANCELACION || ""}`;
break;
case TemplateTypes.EMAIL_REMINDER:
defaultTemplate = `${process.env.WAP_REMINDER}`;
break;
case TemplateTypes.EMAIL_NEW_APPOINTMENT:
defaultTemplate = `${process.env.WAP_ALTA}`;
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;
}
@@ -205,6 +205,73 @@ export type SysAdminOrganizationsStatusParams = {
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 = {
clientIn: { companyId: string; companyName: string }[];
collaboratorIn: { companyId: string; companyName: string }[];

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