feat: add appointment notification sharing functionality via WhatsApp, email, and system clipboard
This commit is contained in:
@@ -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<void> {
|
||||
const cutoff = buildStartOfDay(now);
|
||||
const cutoff = buildCleanupCutoff(now);
|
||||
|
||||
try {
|
||||
const deletedCount = await this.jobModel.deleteBeforeScheduledAt(cutoff);
|
||||
|
||||
@@ -147,7 +147,7 @@ export function buildNotificationText(params: {
|
||||
}
|
||||
|
||||
export async function resolveNotificationContent(job: INotificationJobDocument): Promise<ResolvedNotificationContent> {
|
||||
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
|
||||
|
||||
@@ -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<number> {
|
||||
const result = await this.notificationJobList.deleteMany({
|
||||
scheduledAt: { $lt: cutoff },
|
||||
status: { $in: DELETABLE_NOTIFICATION_JOB_STATUSES },
|
||||
}).exec();
|
||||
|
||||
return result.deletedCount || 0;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<void>;
|
||||
sendEmailNotification(data: SendAppointmentNotificationParams): Promise<void>;
|
||||
createImmediateNotificationJobs(data: CreateImmediateAppointmentNotificationJobsParams): Promise<void>;
|
||||
getAppointmentNotificationPreview(data: AppointmentNotificationPreviewParams): Promise<AppointmentNotificationPreviewResult>;
|
||||
sendWapSystemNotification(data: SendWapSystemNotificationParams): Promise<void>;
|
||||
deleteAppointmentsByCompany(data: DeleteAppointmentsByCompanyParams): Promise<void>;
|
||||
changeEmployeeToOwner(data: ChangeEmployeeToOwnerParams): Promise<void>;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
AppointmentEvent,
|
||||
AppointmentEventByClient,
|
||||
AppointmentNotificationIntent,
|
||||
AppointmentNotificationPreviewResult,
|
||||
ChangeEmployeeToOwnerParams,
|
||||
CountAppointmentsByMonthParams,
|
||||
CreateAppointmentParams,
|
||||
@@ -2903,6 +2904,52 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
public async getAppointmentNotificationPreview(data: CreateImmediateAppointmentNotificationJobsParams): Promise<AppointmentNotificationPreviewResult> {
|
||||
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<void> {
|
||||
if (!data.sessionUser) {
|
||||
throw new Error("No se ha encontrado el usuario o no tiene permisos para realizar esta acción");
|
||||
|
||||
@@ -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<INotificationJob, "id">,
|
||||
Document {}
|
||||
@@ -13,6 +23,7 @@ export interface INotificationJobDocument
|
||||
export class NotificationJobsAdapterMongoose implements INotificationJobAdapter {
|
||||
schema: Schema;
|
||||
notificationJobList: Model<INotificationJobDocument>;
|
||||
private lastCleanupAt?: number;
|
||||
|
||||
constructor() {
|
||||
this.schema = new Schema(
|
||||
@@ -73,6 +84,7 @@ export class NotificationJobsAdapterMongoose implements INotificationJobAdapter
|
||||
}
|
||||
|
||||
public async create(data: CreateJobParams): Promise<INotificationJob> {
|
||||
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<void> {
|
||||
await this.cleanupOldJobsIfDueSafely();
|
||||
await this.notificationJobList
|
||||
.updateMany(
|
||||
{
|
||||
@@ -123,9 +136,52 @@ export class NotificationJobsAdapterMongoose implements INotificationJobAdapter
|
||||
}
|
||||
|
||||
public async findByAppointment(appointmentId: string): Promise<INotificationJob[]> {
|
||||
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<DeleteOldJobsResult> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
try {
|
||||
await this.cleanupOldJobsIfDue();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn(`Notification jobs cleanup failed: ${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<INotificationJob>;
|
||||
findPendingDue(now: Date): Promise<INotificationJob[]>;
|
||||
@@ -68,4 +77,5 @@ export interface INotificationJobAdapter {
|
||||
): Promise<void>;
|
||||
cancelByAppointment(appointmentId: string): Promise<void>;
|
||||
findByAppointment(appointmentId: string): Promise<INotificationJob[]>;
|
||||
deleteOldJobs(data: DeleteOldJobsParams): Promise<DeleteOldJobsResult>;
|
||||
}
|
||||
|
||||
+89
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<ApiValidationError>(500, "Ha ocurrido un error")
|
||||
@SuccessResponse(200, "Done")
|
||||
@Post()
|
||||
public async getAppointmentNotificationPreview(
|
||||
@Body() requestBody: AppointmentNotificationPreviewParams
|
||||
): Promise<AppointmentNotificationPreviewResult | ApiValidationError> {
|
||||
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 {
|
||||
|
||||
@@ -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<AppointmentNotificationPreviewResult> {
|
||||
await connect(`${process.env.DATABASE_CONNECTION}`);
|
||||
return AppointmentList.getAppointmentNotificationPreview(data);
|
||||
}
|
||||
|
||||
public async applyDiscount(data: ApplyAppointmentDiscountParams): Promise<void> {
|
||||
await connect(`${process.env.DATABASE_CONNECTION}`);
|
||||
await AppointmentList.applyDiscount(data);
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 3V15" stroke="#3f3a4d" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M7 10L12 15L17 10" stroke="#3f3a4d" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M5 19H19" stroke="#3f3a4d" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 376 B |
Binary file not shown.
@@ -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;
|
||||
|
||||
+18
@@ -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<AppointmentNotificationPreviewResult> => {
|
||||
return new Promise<AppointmentNotificationPreviewResult>((resolve, reject) => {
|
||||
schemaSendWapNotification
|
||||
.validate(data, { abortEarly: true })
|
||||
.then(() => {
|
||||
resolve(
|
||||
ApiRequest.post<AppointmentNotificationPreviewResult>("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."),
|
||||
|
||||
+158
@@ -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<string>("");
|
||||
|
||||
const [dialogDiscountVisible, setDialogDiscountVisible] = useState(false);
|
||||
const [dialogShareVisible, setDialogShareVisible] = useState(false);
|
||||
const [sharePreview, setSharePreview] = useState<AppointmentNotificationPreviewResult | undefined>(undefined);
|
||||
|
||||
const [discountsFilterContent] = useState<TextObjectModalContent>({
|
||||
header: (
|
||||
@@ -361,6 +369,14 @@ export default function ClientAppointment() {
|
||||
icon: <NotificationsActiveOutlinedIcon />,
|
||||
});
|
||||
|
||||
menu.add({
|
||||
text: "Compartir",
|
||||
onClick: () => {
|
||||
openShareDialog();
|
||||
},
|
||||
icon: <ShareIcon />,
|
||||
});
|
||||
|
||||
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() {
|
||||
</>
|
||||
</DialogWindow>
|
||||
|
||||
<DialogWindow
|
||||
visible={dialogShareVisible}
|
||||
setVisible={setDialogShareVisible}
|
||||
>
|
||||
<>
|
||||
<DialogContent
|
||||
style={{
|
||||
maxWidth: "520px",
|
||||
minWidth: "300px",
|
||||
margin: "auto",
|
||||
}}
|
||||
>
|
||||
<h3 style={{ marginBottom: "10px" }}>Compartir recordatorio</h3>
|
||||
<p style={{ fontSize: "14px", fontWeight: 500, marginBottom: "10px" }}>
|
||||
Este texto usa el mismo contenido del recordatorio de notificaciones.
|
||||
</p>
|
||||
<textarea
|
||||
readOnly
|
||||
value={sharePreview?.wapMessage || ""}
|
||||
style={{
|
||||
width: "100%",
|
||||
minHeight: "180px",
|
||||
border: "1px solid var(--gray)",
|
||||
borderRadius: "8px",
|
||||
color: "var(--black)",
|
||||
fontSize: "14px",
|
||||
padding: "10px",
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions style={{ flexWrap: "wrap", gap: "8px" }}>
|
||||
<ButtonMaterial
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<WhatsAppIcon />}
|
||||
disabled={!sharePreview}
|
||||
onClick={shareByWhatsApp}
|
||||
>
|
||||
WhatsApp
|
||||
</ButtonMaterial>
|
||||
<ButtonMaterial
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<EmailIcon />}
|
||||
disabled={!sharePreview}
|
||||
onClick={shareByEmail}
|
||||
>
|
||||
Email
|
||||
</ButtonMaterial>
|
||||
<ButtonMaterial
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<ContentCopyIcon />}
|
||||
disabled={!sharePreview}
|
||||
onClick={copyShareText}
|
||||
>
|
||||
Copiar
|
||||
</ButtonMaterial>
|
||||
{typeof navigator !== "undefined" && !!navigator.share && (
|
||||
<ButtonMaterial
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<ShareIcon />}
|
||||
disabled={!sharePreview}
|
||||
onClick={nativeShare}
|
||||
>
|
||||
Compartir
|
||||
</ButtonMaterial>
|
||||
)}
|
||||
<ButtonMaterial
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
onClick={() => {
|
||||
setDialogShareVisible(false);
|
||||
}}
|
||||
>
|
||||
Cerrar
|
||||
</ButtonMaterial>
|
||||
</DialogActions>
|
||||
</>
|
||||
</DialogWindow>
|
||||
|
||||
<DialogWindow
|
||||
visible={dialogChangeServiceVisible}
|
||||
setVisible={setDialogChangeServiceVisible}
|
||||
|
||||
@@ -159,6 +159,15 @@ export default function UserMenu(): React.ReactElement {
|
||||
goTo(`/admin/org/profile/${orgId}/appointments/${targetDate}`);
|
||||
};
|
||||
|
||||
const handleDownloadClick = () => {
|
||||
toggleMenu();
|
||||
window.location.href = "/download/tx-latest.apk";
|
||||
};
|
||||
|
||||
const userBelongsToOrganization = () => {
|
||||
return Object.keys(SessionInfo.organizationSubscriptions || {}).length > 0;
|
||||
};
|
||||
|
||||
const getUserName = () => {
|
||||
return SessionInfo.loged ? SessionInfo.userFullName : "Anonimo";
|
||||
};
|
||||
@@ -263,6 +272,15 @@ export default function UserMenu(): React.ReactElement {
|
||||
iconAlign="left"
|
||||
onClick={() => goTo("/messenger")}
|
||||
/>
|
||||
{userBelongsToOrganization() && (
|
||||
<ButtonOption
|
||||
text="Download"
|
||||
icon="/download-icon.svg"
|
||||
textAlign="left"
|
||||
iconAlign="left"
|
||||
onClick={handleDownloadClick}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!SessionInfo.loged && (
|
||||
|
||||
Reference in New Issue
Block a user