diff --git a/notification-sender/src/Models/Jobs/JobProcessor.ts b/notification-sender/src/Models/Jobs/JobProcessor.ts index c9a703d..6a74268 100644 --- a/notification-sender/src/Models/Jobs/JobProcessor.ts +++ b/notification-sender/src/Models/Jobs/JobProcessor.ts @@ -9,6 +9,7 @@ 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 CLEANUP_RETENTION_DAYS = 7; const SUPPORTED_APPOINTMENT_NOTIFICATION_TYPES = new Set(["creation", "reminder", "cancellation"]); function resolveCleanupIntervalMs(): number { @@ -41,6 +42,12 @@ export function buildStartOfDay(date: Date): Date { return new Date(date.getFullYear(), date.getMonth(), date.getDate()); } +export function buildCleanupCutoff(now: Date): Date { + const cutoff = buildStartOfDay(now); + cutoff.setDate(cutoff.getDate() - CLEANUP_RETENTION_DAYS); + return cutoff; +} + 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); @@ -91,7 +98,7 @@ export class JobProcessor { } private async cleanupOldJobs(now = new Date()): Promise { - const cutoff = buildStartOfDay(now); + const cutoff = buildCleanupCutoff(now); try { const deletedCount = await this.jobModel.deleteBeforeScheduledAt(cutoff); diff --git a/notification-sender/src/Models/Jobs/NotificationContentResolver.ts b/notification-sender/src/Models/Jobs/NotificationContentResolver.ts index 52f8333..0923b48 100644 --- a/notification-sender/src/Models/Jobs/NotificationContentResolver.ts +++ b/notification-sender/src/Models/Jobs/NotificationContentResolver.ts @@ -147,7 +147,7 @@ export function buildNotificationText(params: { } export async function resolveNotificationContent(job: INotificationJobDocument): Promise { - if ((job.type === "creation" || job.type === "reminder" || job.type === "cancellation") && job.payload) { + if ((job.type === "creation" || job.type === "reminder" || job.type === "update" || job.type === "cancellation") && job.payload) { const { email, phoneNumber, userId, companyOwnerId } = job.payload; const subject = job.channel === "email" ? job.payload.emailSubject diff --git a/notification-sender/src/Models/Jobs/NotificationJob.Adapter.Mongoose.ts b/notification-sender/src/Models/Jobs/NotificationJob.Adapter.Mongoose.ts index 7f37b1e..ad6e8d2 100644 --- a/notification-sender/src/Models/Jobs/NotificationJob.Adapter.Mongoose.ts +++ b/notification-sender/src/Models/Jobs/NotificationJob.Adapter.Mongoose.ts @@ -4,6 +4,8 @@ export type NotificationChannel = "whatsapp" | "email" | "system"; export type NotificationJobStatus = "pending" | "processing" | "sent" | "failed" | "cancelled"; export type NotificationJobType = "creation" | "reminder" | "update" | "cancellation"; +export const DELETABLE_NOTIFICATION_JOB_STATUSES: NotificationJobStatus[] = ["sent", "failed", "cancelled"]; + export interface NotificationJobPayload { email?: string; phoneNumber?: string; @@ -106,6 +108,7 @@ export class NotificationJobModel { async deleteBeforeScheduledAt(cutoff: Date): Promise { const result = await this.notificationJobList.deleteMany({ scheduledAt: { $lt: cutoff }, + status: { $in: DELETABLE_NOTIFICATION_JOB_STATUSES }, }).exec(); return result.deletedCount || 0; diff --git a/notification-sender/src/Models/Jobs/__tests__/JobProcessor.test.ts b/notification-sender/src/Models/Jobs/__tests__/JobProcessor.test.ts index 5dde17c..a44e756 100644 --- a/notification-sender/src/Models/Jobs/__tests__/JobProcessor.test.ts +++ b/notification-sender/src/Models/Jobs/__tests__/JobProcessor.test.ts @@ -1,5 +1,6 @@ import { dispatchEmail, dispatchWhatsApp } from "../ChannelDispatchers.js"; -import { buildPendingJobQuery, buildStartOfDay, calculateNextRetryAt, JobProcessor } from "../JobProcessor.js"; +import { buildCleanupCutoff, buildPendingJobQuery, buildStartOfDay, calculateNextRetryAt, JobProcessor } from "../JobProcessor.js"; +import { DELETABLE_NOTIFICATION_JOB_STATUSES, NotificationJobModel } from "../NotificationJob.Adapter.Mongoose.js"; import { resolveNotificationContent } from "../NotificationContentResolver.js"; jest.mock("../ChannelDispatchers.js", () => ({ @@ -103,7 +104,7 @@ describe("JobProcessor", () => { }); describe("old job cleanup", () => { - it("deletes jobs scheduled before the start of the current day", async () => { + it("deletes jobs scheduled before the 7-day retention cutoff", async () => { const deleteBeforeScheduledAt = jest.fn().mockResolvedValue(3); const now = new Date(2026, 6, 21, 15, 30, 0, 0); @@ -112,7 +113,30 @@ describe("JobProcessor", () => { now ); - expect(deleteBeforeScheduledAt).toHaveBeenCalledWith(new Date(2026, 6, 21, 0, 0, 0, 0)); + expect(deleteBeforeScheduledAt).toHaveBeenCalledWith(new Date(2026, 6, 14, 0, 0, 0, 0)); + }); + + it("builds the 7-day retention cutoff from the start of the local day", () => { + expect(buildCleanupCutoff(new Date(2026, 6, 21, 15, 30, 0, 0))).toEqual( + new Date(2026, 6, 14, 0, 0, 0, 0) + ); + }); + + it("deletes only terminal statuses and never pending or processing jobs", async () => { + const exec = jest.fn().mockResolvedValue({ deletedCount: 3 }); + const deleteMany = jest.fn(() => ({ exec })); + const model = Object.create(NotificationJobModel.prototype) as NotificationJobModel; + (model as any).notificationJobList = { deleteMany }; + const cutoff = new Date("2026-07-14T00:00:00.000Z"); + + await model.deleteBeforeScheduledAt(cutoff); + + expect(deleteMany).toHaveBeenCalledWith({ + scheduledAt: { $lt: cutoff }, + status: { $in: ["sent", "failed", "cancelled"] }, + }); + expect(DELETABLE_NOTIFICATION_JOB_STATUSES).not.toContain("pending"); + expect(DELETABLE_NOTIFICATION_JOB_STATUSES).not.toContain("processing"); }); }); diff --git a/notification-sender/src/Models/Jobs/__tests__/NotificationContentResolver.test.ts b/notification-sender/src/Models/Jobs/__tests__/NotificationContentResolver.test.ts index 9c82769..554b63a 100644 --- a/notification-sender/src/Models/Jobs/__tests__/NotificationContentResolver.test.ts +++ b/notification-sender/src/Models/Jobs/__tests__/NotificationContentResolver.test.ts @@ -158,6 +158,111 @@ describe("NotificationContentResolver", () => { }); }); + it("uses update payload content for email jobs 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: "email", + type: "update", + payload: { + email: "client@example.com", + phoneNumber: "5491112345678", + userId: "user-1", + companyOwnerId: "owner-1", + subject: "Generic update subject", + message: "Generic update body.", + emailSubject: "Email update subject", + emailMessage: "Email update body.", + wapMessage: "WAP update body.", + systemSubject: "System update subject", + systemMessage: "System update body.", + }, + } as any); + + expect(appointmentLookup).not.toHaveBeenCalled(); + expect(result).toEqual({ + email: "client@example.com", + phoneNumber: "5491112345678", + userId: "user-1", + companyOwnerId: "owner-1", + subject: "Email update subject", + message: "Email update body.", + }); + }); + + it("uses update payload content for WhatsApp jobs 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: "update", + payload: { + email: "client@example.com", + phoneNumber: "5491112345678", + userId: "user-1", + companyOwnerId: "owner-1", + subject: "Generic update subject", + message: "Generic update body.", + emailSubject: "Email update subject", + emailMessage: "Email update body.", + wapMessage: "WAP update body.", + systemSubject: "System update subject", + systemMessage: "System update body.", + }, + } as any); + + expect(appointmentLookup).not.toHaveBeenCalled(); + expect(result).toEqual({ + email: "client@example.com", + phoneNumber: "5491112345678", + userId: "user-1", + companyOwnerId: "owner-1", + subject: "Generic update subject", + message: "WAP update body.", + }); + }); + + it("uses update payload content for system jobs 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: "system", + type: "update", + payload: { + email: "client@example.com", + phoneNumber: "5491112345678", + userId: "user-1", + companyOwnerId: "owner-1", + subject: "Generic update subject", + message: "Generic update body.", + emailSubject: "Email update subject", + emailMessage: "Email update body.", + wapMessage: "WAP update body.", + systemSubject: "System update subject", + systemMessage: "System update body.", + }, + } as any); + + expect(appointmentLookup).not.toHaveBeenCalled(); + expect(result).toEqual({ + email: "client@example.com", + phoneNumber: "5491112345678", + userId: "user-1", + companyOwnerId: "owner-1", + subject: "System update subject", + message: "System update body.", + }); + }); + it("uses WhatsApp cancellation payload content for WhatsApp jobs", async () => { const result = await resolveNotificationContent({ appointmentId: "deleted-appointment-1", diff --git a/server/src/Models/Appointments/Appointments.Interface.ts b/server/src/Models/Appointments/Appointments.Interface.ts index 5326d60..2a4ef36 100644 --- a/server/src/Models/Appointments/Appointments.Interface.ts +++ b/server/src/Models/Appointments/Appointments.Interface.ts @@ -54,6 +54,15 @@ export type CreateImmediateAppointmentNotificationJobsParams = { type: APPOINTMENT_NOTIFICATION_TYPE; }; +export type AppointmentNotificationPreviewParams = CreateImmediateAppointmentNotificationJobsParams; + +export type AppointmentNotificationPreviewResult = { + subject: string; + message: string; + emailMessage: string; + wapMessage: string; +}; + export type MoveAppointmentParams = { id: string; companyId: string; @@ -328,6 +337,7 @@ export interface IAppointmentsManager { sendWapNotification(data: SendAppointmentNotificationParams): Promise; sendEmailNotification(data: SendAppointmentNotificationParams): Promise; createImmediateNotificationJobs(data: CreateImmediateAppointmentNotificationJobsParams): Promise; + getAppointmentNotificationPreview(data: AppointmentNotificationPreviewParams): Promise; sendWapSystemNotification(data: SendWapSystemNotificationParams): Promise; deleteAppointmentsByCompany(data: DeleteAppointmentsByCompanyParams): Promise; changeEmployeeToOwner(data: ChangeEmployeeToOwnerParams): Promise; diff --git a/server/src/Models/Appointments/Appointments.ts b/server/src/Models/Appointments/Appointments.ts index 799587f..515f6ac 100644 --- a/server/src/Models/Appointments/Appointments.ts +++ b/server/src/Models/Appointments/Appointments.ts @@ -7,6 +7,7 @@ import { AppointmentEvent, AppointmentEventByClient, AppointmentNotificationIntent, + AppointmentNotificationPreviewResult, ChangeEmployeeToOwnerParams, CountAppointmentsByMonthParams, CreateAppointmentParams, @@ -2903,6 +2904,52 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise { }); } + public async getAppointmentNotificationPreview(data: CreateImmediateAppointmentNotificationJobsParams): Promise { + await validateSessionUser({ sessionUser: data.sessionUser }); + + 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 checkAppointment = await this.Appointments.findOne({ + _id: data.appointmentId, + }); + + if (!checkAppointment) { + throw new Error("El turno no existe"); + } + + await validatePermissionsByCompany({ + companyId: String(checkAppointment.companyId), + sessionUser: data.sessionUser, + }); + + 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 isCreation = data.type === APPOINTMENT_NOTIFICATION_TYPE.CREATION; + const subject = isCreation + ? `Turno reservado en ${emailContent.companyCheck.name}` + : `Recordatorio de turno en ${emailContent.companyCheck.name}`; + + return { + subject, + message: emailContent.message, + emailMessage: emailContent.message, + wapMessage: wapContent.message, + }; + } + public async sendWapNotification(data: SendAppointmentNotificationParams): Promise { if (!data.sessionUser) { throw new Error("No se ha encontrado el usuario o no tiene permisos para realizar esta acción"); diff --git a/server/src/Models/NotificationJobs/NotificationJobs.Adapter.Mongoose.ts b/server/src/Models/NotificationJobs/NotificationJobs.Adapter.Mongoose.ts index 8587b2f..7607152 100644 --- a/server/src/Models/NotificationJobs/NotificationJobs.Adapter.Mongoose.ts +++ b/server/src/Models/NotificationJobs/NotificationJobs.Adapter.Mongoose.ts @@ -1,11 +1,21 @@ import { Document, Model, Schema, model } from "mongoose"; import { CreateJobParams, + DeleteOldJobsParams, + DeleteOldJobsResult, INotificationJob, INotificationJobAdapter, NotificationJobStatus, } from "./NotificationJobs.Interface"; +export const NOTIFICATION_JOB_CLEANUP_RETENTION_DAYS = 7; +export const NOTIFICATION_JOB_CLEANUP_INTERVAL_MS = 60 * 60 * 1000; +export const NOTIFICATION_JOB_CLEANUP_STATUSES = [ + NotificationJobStatus.SENT, + NotificationJobStatus.FAILED, + NotificationJobStatus.CANCELLED, +]; + export interface INotificationJobDocument extends Omit, Document {} @@ -13,6 +23,7 @@ export interface INotificationJobDocument export class NotificationJobsAdapterMongoose implements INotificationJobAdapter { schema: Schema; notificationJobList: Model; + private lastCleanupAt?: number; constructor() { this.schema = new Schema( @@ -73,6 +84,7 @@ export class NotificationJobsAdapterMongoose implements INotificationJobAdapter } public async create(data: CreateJobParams): Promise { + await this.cleanupOldJobsIfDueSafely(); const doc = await this.notificationJobList.create({ ...data, status: NotificationJobStatus.PENDING, @@ -111,6 +123,7 @@ export class NotificationJobsAdapterMongoose implements INotificationJobAdapter } public async cancelByAppointment(appointmentId: string): Promise { + await this.cleanupOldJobsIfDueSafely(); await this.notificationJobList .updateMany( { @@ -123,9 +136,52 @@ export class NotificationJobsAdapterMongoose implements INotificationJobAdapter } public async findByAppointment(appointmentId: string): Promise { + await this.cleanupOldJobsIfDueSafely(); const docs = await this.notificationJobList .find({ appointmentId }) .exec(); return docs.map((d) => d.toObject() as INotificationJob); } + + public async deleteOldJobs( + data: DeleteOldJobsParams + ): Promise { + const result = await this.notificationJobList + .deleteMany({ + scheduledAt: { $lt: data.scheduledBefore }, + status: { $in: data.statuses }, + }) + .exec(); + + return { deletedCount: result.deletedCount || 0 }; + } + + public async cleanupOldJobsIfDue(now = new Date()): Promise { + if ( + this.lastCleanupAt !== undefined && + now.getTime() - this.lastCleanupAt < NOTIFICATION_JOB_CLEANUP_INTERVAL_MS + ) { + return; + } + + const scheduledBefore = new Date(now.getTime()); + scheduledBefore.setDate( + scheduledBefore.getDate() - NOTIFICATION_JOB_CLEANUP_RETENTION_DAYS + ); + + await this.deleteOldJobs({ + scheduledBefore, + statuses: NOTIFICATION_JOB_CLEANUP_STATUSES, + }); + this.lastCleanupAt = now.getTime(); + } + + private async cleanupOldJobsIfDueSafely(): Promise { + try { + await this.cleanupOldJobsIfDue(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn(`Notification jobs cleanup failed: ${message}`); + } + } } diff --git a/server/src/Models/NotificationJobs/NotificationJobs.Interface.ts b/server/src/Models/NotificationJobs/NotificationJobs.Interface.ts index cdb0712..87cff4d 100644 --- a/server/src/Models/NotificationJobs/NotificationJobs.Interface.ts +++ b/server/src/Models/NotificationJobs/NotificationJobs.Interface.ts @@ -58,6 +58,15 @@ export interface CreateJobParams { payload?: NotificationJobPayload; } +export interface DeleteOldJobsParams { + scheduledBefore: Date; + statuses: NotificationJobStatus[]; +} + +export interface DeleteOldJobsResult { + deletedCount: number; +} + export interface INotificationJobAdapter { create(data: CreateJobParams): Promise; findPendingDue(now: Date): Promise; @@ -68,4 +77,5 @@ export interface INotificationJobAdapter { ): Promise; cancelByAppointment(appointmentId: string): Promise; findByAppointment(appointmentId: string): Promise; + deleteOldJobs(data: DeleteOldJobsParams): Promise; } diff --git a/server/src/Models/NotificationJobs/__tests__/NotificationJobs.Adapter.Mongoose.test.ts b/server/src/Models/NotificationJobs/__tests__/NotificationJobs.Adapter.Mongoose.test.ts new file mode 100644 index 0000000..c663cf3 --- /dev/null +++ b/server/src/Models/NotificationJobs/__tests__/NotificationJobs.Adapter.Mongoose.test.ts @@ -0,0 +1,89 @@ +import { + NOTIFICATION_JOB_CLEANUP_STATUSES, + NotificationJobsAdapterMongoose, +} from "../NotificationJobs.Adapter.Mongoose"; +import { NotificationJobStatus } from "../NotificationJobs.Interface"; + +describe("NotificationJobsAdapterMongoose cleanup", () => { + const now = new Date("2026-07-21T15:30:00.000Z"); + + function buildAdapter() { + return Object.create(NotificationJobsAdapterMongoose.prototype) as NotificationJobsAdapterMongoose; + } + + it("runs throttled retention cleanup before create on the production adapter path", async () => { + const adapter = buildAdapter(); + const deleteOldJobs = jest.fn().mockResolvedValue({ deletedCount: 2 }); + const create = jest.fn().mockResolvedValue({ toObject: () => ({ id: "job-1" }) }); + (adapter as any).deleteOldJobs = deleteOldJobs; + (adapter as any).notificationJobList = { create }; + + jest.useFakeTimers().setSystemTime(now); + + await adapter.create({ + companyId: "company-1", + appointmentId: "appointment-1", + clientId: "client-1", + channel: "email", + type: "creation", + scheduledAt: now, + }); + + expect(deleteOldJobs).toHaveBeenCalledWith({ + scheduledBefore: new Date("2026-07-14T15:30:00.000Z"), + statuses: NOTIFICATION_JOB_CLEANUP_STATUSES, + }); + expect(NOTIFICATION_JOB_CLEANUP_STATUSES).toEqual([ + NotificationJobStatus.SENT, + NotificationJobStatus.FAILED, + NotificationJobStatus.CANCELLED, + ]); + expect(create).toHaveBeenCalledWith(expect.objectContaining({ + status: NotificationJobStatus.PENDING, + attempts: 0, + maxAttempts: 3, + })); + }); + + it("continues creating jobs when production cleanup fails", async () => { + const adapter = buildAdapter(); + const deleteOldJobs = jest.fn().mockRejectedValue(new Error("database down")); + const create = jest.fn().mockResolvedValue({ toObject: () => ({ id: "job-1" }) }); + const warn = jest.spyOn(console, "warn").mockImplementation(() => undefined); + (adapter as any).deleteOldJobs = deleteOldJobs; + (adapter as any).notificationJobList = { create }; + + await expect(adapter.create({ + companyId: "company-1", + appointmentId: "appointment-1", + clientId: "client-1", + channel: "email", + type: "creation", + scheduledAt: now, + })).resolves.toEqual({ id: "job-1" }); + + expect(warn).toHaveBeenCalledWith("Notification jobs cleanup failed: database down"); + expect(create).toHaveBeenCalledWith(expect.objectContaining({ + status: NotificationJobStatus.PENDING, + })); + + warn.mockRestore(); + }); + + it("throttles only after cleanup succeeds", async () => { + const adapter = buildAdapter(); + const deleteOldJobs = jest + .fn() + .mockRejectedValueOnce(new Error("database down")) + .mockResolvedValueOnce({ deletedCount: 0 }) + .mockResolvedValueOnce({ deletedCount: 0 }); + (adapter as any).deleteOldJobs = deleteOldJobs; + + await expect(adapter.cleanupOldJobsIfDue(now)).rejects.toThrow("database down"); + await adapter.cleanupOldJobsIfDue(new Date(now.getTime() + 1000)); + await adapter.cleanupOldJobsIfDue(new Date(now.getTime() + 2000)); + await adapter.cleanupOldJobsIfDue(new Date(now.getTime() + 60 * 60 * 1000 + 1000)); + + expect(deleteOldJobs).toHaveBeenCalledTimes(3); + }); +}); diff --git a/server/src/Models/NotificationJobs/__tests__/NotificationJobs.test.ts b/server/src/Models/NotificationJobs/__tests__/NotificationJobs.test.ts new file mode 100644 index 0000000..e08ac04 --- /dev/null +++ b/server/src/Models/NotificationJobs/__tests__/NotificationJobs.test.ts @@ -0,0 +1,87 @@ +import { NotificationJobStatus } from "../NotificationJobs.Interface"; + +const mockCreate = jest.fn(); +const mockCancelByAppointment = jest.fn(); +const mockFindByAppointment = jest.fn(); +const mockDeleteOldJobs = jest.fn(); + +jest.mock("../NotificationJobs.Adapter.Mongoose", () => ({ + NotificationJobsAdapterMongoose: jest.fn().mockImplementation(() => ({ + create: mockCreate, + cancelByAppointment: mockCancelByAppointment, + findByAppointment: mockFindByAppointment, + deleteOldJobs: mockDeleteOldJobs, + })), +})); + +describe("NotificationJobManager cleanup", () => { + const loadNotificationJobsList = () => { + jest.isolateModules(() => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + require("../NotificationJobs"); + }); + + // eslint-disable-next-line @typescript-eslint/no-var-requires + return require("../NotificationJobs").default; + }; + + beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + jest.useFakeTimers().setSystemTime(new Date("2026-07-29T12:00:00.000Z")); + mockCreate.mockResolvedValue({ id: "job-1" }); + mockCancelByAppointment.mockResolvedValue(undefined); + mockFindByAppointment.mockResolvedValue([]); + mockDeleteOldJobs.mockResolvedValue({ deletedCount: 0 }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("deletes only completed old jobs before creating a job", async () => { + const NotificationJobsList = loadNotificationJobsList(); + + await NotificationJobsList.createJob({ + companyId: "company-1", + appointmentId: "appointment-1", + clientId: "client-1", + channel: "email", + type: "creation", + scheduledAt: new Date("2026-07-29T12:30:00.000Z"), + }); + + expect(mockDeleteOldJobs).toHaveBeenCalledWith({ + scheduledBefore: new Date("2026-07-22T12:00:00.000Z"), + statuses: [ + NotificationJobStatus.SENT, + NotificationJobStatus.FAILED, + NotificationJobStatus.CANCELLED, + ], + }); + expect(mockCreate).toHaveBeenCalledTimes(1); + }); + + it("throttles cleanup across manager operations", async () => { + const NotificationJobsList = loadNotificationJobsList(); + + await NotificationJobsList.createJob({ + companyId: "company-1", + appointmentId: "appointment-1", + clientId: "client-1", + channel: "email", + type: "creation", + scheduledAt: new Date("2026-07-29T12:30:00.000Z"), + }); + await NotificationJobsList.cancelByAppointment("appointment-1"); + await NotificationJobsList.findByAppointment("appointment-1"); + + expect(mockDeleteOldJobs).toHaveBeenCalledTimes(1); + + jest.setSystemTime(new Date("2026-07-29T13:00:00.000Z")); + + await NotificationJobsList.findByAppointment("appointment-1"); + + expect(mockDeleteOldJobs).toHaveBeenCalledTimes(2); + }); +}); diff --git a/server/src/api/Appointments/Appointments.Controller.ts b/server/src/api/Appointments/Appointments.Controller.ts index 1e22c59..264a2ac 100644 --- a/server/src/api/Appointments/Appointments.Controller.ts +++ b/server/src/api/Appointments/Appointments.Controller.ts @@ -6,6 +6,8 @@ import { AppointmentAdminByDateView, CreateAppointmentParams, CreateImmediateAppointmentNotificationJobsParams, + AppointmentNotificationPreviewParams, + AppointmentNotificationPreviewResult, DeleteAppointmentParams, FindAppointmentsParams, FindAppointmentSchedulesParams, @@ -368,6 +370,27 @@ export class CreateImmediateAppointmentNotificationJobsController extends Contro } } +@Route("appointments/notification-preview") +@Middlewares(authenticateMiddleware) +export class AppointmentNotificationPreviewController extends Controller { + @Response(500, "Ha ocurrido un error") + @SuccessResponse(200, "Done") + @Post() + public async getAppointmentNotificationPreview( + @Body() requestBody: AppointmentNotificationPreviewParams + ): Promise { + try { + const preview = await new AppointmentService().getAppointmentNotificationPreview(requestBody); + this.setStatus(200); + return preview; + } catch (e) { + const errorOccurred: Error = e as Error; console.error("GET EVENT ERROR:", errorOccurred); + this.setStatus(500); + return new ApiValidationError(500, errorOccurred.message); + } + } +} + @Route("appointments/apply-discount") @Middlewares(authenticateMiddleware) export class ApplyAppointmentDiscountContrnoller extends Controller { diff --git a/server/src/api/Appointments/Appointments.Service.ts b/server/src/api/Appointments/Appointments.Service.ts index fbf91a7..181f46e 100644 --- a/server/src/api/Appointments/Appointments.Service.ts +++ b/server/src/api/Appointments/Appointments.Service.ts @@ -2,6 +2,8 @@ import { TextObjectFilterResult } from "../../Models/TextObjectFilter.model"; import AppointmentList from "../../Models/Appointments/Appointments"; import { CreateImmediateAppointmentNotificationJobsParams, + AppointmentNotificationPreviewParams, + AppointmentNotificationPreviewResult, AppointmentAdminByClientView, AppointmentAdminByDateView, CreateAppointmentParams, @@ -167,6 +169,11 @@ export class AppointmentService { await AppointmentList.createImmediateNotificationJobs(data); } + public async getAppointmentNotificationPreview(data: AppointmentNotificationPreviewParams): Promise { + await connect(`${process.env.DATABASE_CONNECTION}`); + return AppointmentList.getAppointmentNotificationPreview(data); + } + public async applyDiscount(data: ApplyAppointmentDiscountParams): Promise { await connect(`${process.env.DATABASE_CONNECTION}`); await AppointmentList.applyDiscount(data); diff --git a/txclient/public/download-icon.svg b/txclient/public/download-icon.svg new file mode 100644 index 0000000..0a2f8fe --- /dev/null +++ b/txclient/public/download-icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/txclient/public/download/tx-latest.apk b/txclient/public/download/tx-latest.apk new file mode 100644 index 0000000..232e892 Binary files /dev/null and b/txclient/public/download/tx-latest.apk differ diff --git a/txclient/src/Models/Appointments.model.ts b/txclient/src/Models/Appointments.model.ts index fc86951..64d71f8 100644 --- a/txclient/src/Models/Appointments.model.ts +++ b/txclient/src/Models/Appointments.model.ts @@ -39,6 +39,13 @@ export type SendAppointmentNotificationParams = { type: APPOINTMENT_NOTIFICATION_TYPE; }; +export type AppointmentNotificationPreviewResult = { + subject: string; + message: string; + emailMessage: string; + wapMessage: string; +}; + export type DeleteAppointmentParams = { id: string; validation?: boolean; diff --git a/txclient/src/app/admin/(client-profile)/org/[oid]/client/[clientId]/appointments/Appointments.Service.ts b/txclient/src/app/admin/(client-profile)/org/[oid]/client/[clientId]/appointments/Appointments.Service.ts index f705e82..0d5bff6 100644 --- a/txclient/src/app/admin/(client-profile)/org/[oid]/client/[clientId]/appointments/Appointments.Service.ts +++ b/txclient/src/app/admin/(client-profile)/org/[oid]/client/[clientId]/appointments/Appointments.Service.ts @@ -1,6 +1,7 @@ import ApiRequest from "@services/Api.Service"; import { ApplyAppointmentDiscountParams, + AppointmentNotificationPreviewResult, AppointmentAdminByClientView, AppointmentEventByClient, DeleteAppointmentDiscountParams, @@ -108,6 +109,23 @@ export const createImmediateNotificationJobs = async ( }); }; +export const getAppointmentNotificationPreview = async ( + data: SendAppointmentNotificationParams +): Promise => { + return new Promise((resolve, reject) => { + schemaSendWapNotification + .validate(data, { abortEarly: true }) + .then(() => { + resolve( + ApiRequest.post("appointments/notification-preview", data) + ); + }) + .catch((error) => { + reject(new ApiError(500, error.message)); + }); + }); +}; + const schemaApplyDiscount = Yup.object().shape({ appointmentId: Yup.string().required("Debe seleccionar un turno."), companyId: Yup.string().required("Organización no seleccionada."), diff --git a/txclient/src/app/admin/(client-profile)/org/[oid]/client/[clientId]/appointments/[appointmentId]/page.tsx b/txclient/src/app/admin/(client-profile)/org/[oid]/client/[clientId]/appointments/[appointmentId]/page.tsx index d6ef330..6edd3ab 100644 --- a/txclient/src/app/admin/(client-profile)/org/[oid]/client/[clientId]/appointments/[appointmentId]/page.tsx +++ b/txclient/src/app/admin/(client-profile)/org/[oid]/client/[clientId]/appointments/[appointmentId]/page.tsx @@ -13,12 +13,14 @@ import { deleteAppointment, deleteAppointmentDiscount, findAppointmentsAdminByClient, + getAppointmentNotificationPreview, updateAppointment, } from "../Appointments.Service"; import { ApplyAppointmentDiscountParams, APPOINTMENT_NOTIFICATION_TYPE, AppointmentEventByClient, + AppointmentNotificationPreviewResult, DeleteAppointmentDiscountParams, UpdateAppointmentParams, } from "@core/Models/Appointments.model"; @@ -27,6 +29,10 @@ import Textbox from "@core/app/components/Textbox/Textbox"; import { useMenuStore } from "@core/Store/Menu.Store"; import NotificationsActiveOutlinedIcon from "@mui/icons-material/NotificationsActiveOutlined"; import DeleteIcon from "@mui/icons-material/Delete"; +import ContentCopyIcon from "@mui/icons-material/ContentCopy"; +import EmailIcon from "@mui/icons-material/Email"; +import ShareIcon from "@mui/icons-material/Share"; +import WhatsAppIcon from "@mui/icons-material/WhatsApp"; import Switch from "@mui/material/Switch"; import { ThemeProvider } from "@emotion/react"; @@ -91,6 +97,8 @@ export default function ClientAppointment() { const [comments, setComments] = useState(""); const [dialogDiscountVisible, setDialogDiscountVisible] = useState(false); + const [dialogShareVisible, setDialogShareVisible] = useState(false); + const [sharePreview, setSharePreview] = useState(undefined); const [discountsFilterContent] = useState({ header: ( @@ -361,6 +369,14 @@ export default function ClientAppointment() { icon: , }); + menu.add({ + text: "Compartir", + onClick: () => { + openShareDialog(); + }, + icon: , + }); + if (showQuitDiscount) { menu.divider(); @@ -408,6 +424,66 @@ export default function ClientAppointment() { }); }; + const openShareDialog = () => { + if (!appointmentId) { + return; + } + + setDialogShareVisible(true); + eventHandler.setEventType(EVENT_TYPES.LOADING); + getAppointmentNotificationPreview({ + appointmentId: appointmentId.toString(), + type: APPOINTMENT_NOTIFICATION_TYPE.REMINDER, + sessionUser: SessionUser.userId, + }) + .then((preview) => { + setSharePreview(preview); + }) + .catch((error) => { + alert.showError(error.format()); + setDialogShareVisible(false); + }) + .finally(() => { + eventHandler.setEventType(EVENT_TYPES.SLEEP); + }); + }; + + const copyShareText = async () => { + if (!sharePreview) { + return; + } + + await navigator.clipboard.writeText(sharePreview.wapMessage); + alert.showSuccess("Texto copiado al portapapeles"); + }; + + const shareByWhatsApp = () => { + if (!sharePreview) { + return; + } + + window.open(`https://wa.me/?text=${encodeURIComponent(sharePreview.wapMessage)}`, "_blank"); + }; + + const shareByEmail = () => { + if (!sharePreview) { + return; + } + + window.location.href = `mailto:?subject=${encodeURIComponent(sharePreview.subject)}&body=${encodeURIComponent(sharePreview.emailMessage)}`; + }; + + const nativeShare = async () => { + if (!sharePreview || !navigator.share) { + return; + } + + await navigator.share({ + title: sharePreview.subject, + text: sharePreview.wapMessage, + }); + }; + const applyDiscountHandler = () => { if (!SessionUser.userId) { return; @@ -728,6 +804,88 @@ export default function ClientAppointment() { + + <> + +

Compartir recordatorio

+

+ Este texto usa el mismo contenido del recordatorio de notificaciones. +

+