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 MAX_JITTER_MS = 3000;
|
||||||
const THROTTLE_RETRY_DELAY_MS = 8000;
|
const THROTTLE_RETRY_DELAY_MS = 8000;
|
||||||
const DEFAULT_CLEANUP_INTERVAL_MS = 60 * 60 * 1000;
|
const DEFAULT_CLEANUP_INTERVAL_MS = 60 * 60 * 1000;
|
||||||
|
const CLEANUP_RETENTION_DAYS = 7;
|
||||||
const SUPPORTED_APPOINTMENT_NOTIFICATION_TYPES = new Set(["creation", "reminder", "cancellation"]);
|
const SUPPORTED_APPOINTMENT_NOTIFICATION_TYPES = new Set(["creation", "reminder", "cancellation"]);
|
||||||
|
|
||||||
function resolveCleanupIntervalMs(): number {
|
function resolveCleanupIntervalMs(): number {
|
||||||
@@ -41,6 +42,12 @@ export function buildStartOfDay(date: Date): Date {
|
|||||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
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 {
|
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");
|
const value = job.id || job._id || job.get?.("_id");
|
||||||
if (value) return String(value);
|
if (value) return String(value);
|
||||||
@@ -91,7 +98,7 @@ export class JobProcessor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async cleanupOldJobs(now = new Date()): Promise<void> {
|
private async cleanupOldJobs(now = new Date()): Promise<void> {
|
||||||
const cutoff = buildStartOfDay(now);
|
const cutoff = buildCleanupCutoff(now);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const deletedCount = await this.jobModel.deleteBeforeScheduledAt(cutoff);
|
const deletedCount = await this.jobModel.deleteBeforeScheduledAt(cutoff);
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ export function buildNotificationText(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveNotificationContent(job: INotificationJobDocument): Promise<ResolvedNotificationContent> {
|
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 { email, phoneNumber, userId, companyOwnerId } = job.payload;
|
||||||
const subject = job.channel === "email"
|
const subject = job.channel === "email"
|
||||||
? job.payload.emailSubject
|
? job.payload.emailSubject
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ export type NotificationChannel = "whatsapp" | "email" | "system";
|
|||||||
export type NotificationJobStatus = "pending" | "processing" | "sent" | "failed" | "cancelled";
|
export type NotificationJobStatus = "pending" | "processing" | "sent" | "failed" | "cancelled";
|
||||||
export type NotificationJobType = "creation" | "reminder" | "update" | "cancellation";
|
export type NotificationJobType = "creation" | "reminder" | "update" | "cancellation";
|
||||||
|
|
||||||
|
export const DELETABLE_NOTIFICATION_JOB_STATUSES: NotificationJobStatus[] = ["sent", "failed", "cancelled"];
|
||||||
|
|
||||||
export interface NotificationJobPayload {
|
export interface NotificationJobPayload {
|
||||||
email?: string;
|
email?: string;
|
||||||
phoneNumber?: string;
|
phoneNumber?: string;
|
||||||
@@ -106,6 +108,7 @@ export class NotificationJobModel {
|
|||||||
async deleteBeforeScheduledAt(cutoff: Date): Promise<number> {
|
async deleteBeforeScheduledAt(cutoff: Date): Promise<number> {
|
||||||
const result = await this.notificationJobList.deleteMany({
|
const result = await this.notificationJobList.deleteMany({
|
||||||
scheduledAt: { $lt: cutoff },
|
scheduledAt: { $lt: cutoff },
|
||||||
|
status: { $in: DELETABLE_NOTIFICATION_JOB_STATUSES },
|
||||||
}).exec();
|
}).exec();
|
||||||
|
|
||||||
return result.deletedCount || 0;
|
return result.deletedCount || 0;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { dispatchEmail, dispatchWhatsApp } from "../ChannelDispatchers.js";
|
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";
|
import { resolveNotificationContent } from "../NotificationContentResolver.js";
|
||||||
|
|
||||||
jest.mock("../ChannelDispatchers.js", () => ({
|
jest.mock("../ChannelDispatchers.js", () => ({
|
||||||
@@ -103,7 +104,7 @@ describe("JobProcessor", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("old job cleanup", () => {
|
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 deleteBeforeScheduledAt = jest.fn().mockResolvedValue(3);
|
||||||
const now = new Date(2026, 6, 21, 15, 30, 0, 0);
|
const now = new Date(2026, 6, 21, 15, 30, 0, 0);
|
||||||
|
|
||||||
@@ -112,7 +113,30 @@ describe("JobProcessor", () => {
|
|||||||
now
|
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 () => {
|
it("uses WhatsApp cancellation payload content for WhatsApp jobs", async () => {
|
||||||
const result = await resolveNotificationContent({
|
const result = await resolveNotificationContent({
|
||||||
appointmentId: "deleted-appointment-1",
|
appointmentId: "deleted-appointment-1",
|
||||||
|
|||||||
@@ -54,6 +54,15 @@ export type CreateImmediateAppointmentNotificationJobsParams = {
|
|||||||
type: APPOINTMENT_NOTIFICATION_TYPE;
|
type: APPOINTMENT_NOTIFICATION_TYPE;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AppointmentNotificationPreviewParams = CreateImmediateAppointmentNotificationJobsParams;
|
||||||
|
|
||||||
|
export type AppointmentNotificationPreviewResult = {
|
||||||
|
subject: string;
|
||||||
|
message: string;
|
||||||
|
emailMessage: string;
|
||||||
|
wapMessage: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type MoveAppointmentParams = {
|
export type MoveAppointmentParams = {
|
||||||
id: string;
|
id: string;
|
||||||
companyId: string;
|
companyId: string;
|
||||||
@@ -328,6 +337,7 @@ export interface IAppointmentsManager {
|
|||||||
sendWapNotification(data: SendAppointmentNotificationParams): Promise<void>;
|
sendWapNotification(data: SendAppointmentNotificationParams): Promise<void>;
|
||||||
sendEmailNotification(data: SendAppointmentNotificationParams): Promise<void>;
|
sendEmailNotification(data: SendAppointmentNotificationParams): Promise<void>;
|
||||||
createImmediateNotificationJobs(data: CreateImmediateAppointmentNotificationJobsParams): Promise<void>;
|
createImmediateNotificationJobs(data: CreateImmediateAppointmentNotificationJobsParams): Promise<void>;
|
||||||
|
getAppointmentNotificationPreview(data: AppointmentNotificationPreviewParams): Promise<AppointmentNotificationPreviewResult>;
|
||||||
sendWapSystemNotification(data: SendWapSystemNotificationParams): Promise<void>;
|
sendWapSystemNotification(data: SendWapSystemNotificationParams): Promise<void>;
|
||||||
deleteAppointmentsByCompany(data: DeleteAppointmentsByCompanyParams): Promise<void>;
|
deleteAppointmentsByCompany(data: DeleteAppointmentsByCompanyParams): Promise<void>;
|
||||||
changeEmployeeToOwner(data: ChangeEmployeeToOwnerParams): Promise<void>;
|
changeEmployeeToOwner(data: ChangeEmployeeToOwnerParams): Promise<void>;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
AppointmentEvent,
|
AppointmentEvent,
|
||||||
AppointmentEventByClient,
|
AppointmentEventByClient,
|
||||||
AppointmentNotificationIntent,
|
AppointmentNotificationIntent,
|
||||||
|
AppointmentNotificationPreviewResult,
|
||||||
ChangeEmployeeToOwnerParams,
|
ChangeEmployeeToOwnerParams,
|
||||||
CountAppointmentsByMonthParams,
|
CountAppointmentsByMonthParams,
|
||||||
CreateAppointmentParams,
|
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> {
|
public async sendWapNotification(data: SendAppointmentNotificationParams): Promise<void> {
|
||||||
if (!data.sessionUser) {
|
if (!data.sessionUser) {
|
||||||
throw new Error("No se ha encontrado el usuario o no tiene permisos para realizar esta acción");
|
throw new Error("No se ha encontrado el usuario o no tiene permisos para realizar esta acción");
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
import { Document, Model, Schema, model } from "mongoose";
|
import { Document, Model, Schema, model } from "mongoose";
|
||||||
import {
|
import {
|
||||||
CreateJobParams,
|
CreateJobParams,
|
||||||
|
DeleteOldJobsParams,
|
||||||
|
DeleteOldJobsResult,
|
||||||
INotificationJob,
|
INotificationJob,
|
||||||
INotificationJobAdapter,
|
INotificationJobAdapter,
|
||||||
NotificationJobStatus,
|
NotificationJobStatus,
|
||||||
} from "./NotificationJobs.Interface";
|
} 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
|
export interface INotificationJobDocument
|
||||||
extends Omit<INotificationJob, "id">,
|
extends Omit<INotificationJob, "id">,
|
||||||
Document {}
|
Document {}
|
||||||
@@ -13,6 +23,7 @@ export interface INotificationJobDocument
|
|||||||
export class NotificationJobsAdapterMongoose implements INotificationJobAdapter {
|
export class NotificationJobsAdapterMongoose implements INotificationJobAdapter {
|
||||||
schema: Schema;
|
schema: Schema;
|
||||||
notificationJobList: Model<INotificationJobDocument>;
|
notificationJobList: Model<INotificationJobDocument>;
|
||||||
|
private lastCleanupAt?: number;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.schema = new Schema(
|
this.schema = new Schema(
|
||||||
@@ -73,6 +84,7 @@ export class NotificationJobsAdapterMongoose implements INotificationJobAdapter
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async create(data: CreateJobParams): Promise<INotificationJob> {
|
public async create(data: CreateJobParams): Promise<INotificationJob> {
|
||||||
|
await this.cleanupOldJobsIfDueSafely();
|
||||||
const doc = await this.notificationJobList.create({
|
const doc = await this.notificationJobList.create({
|
||||||
...data,
|
...data,
|
||||||
status: NotificationJobStatus.PENDING,
|
status: NotificationJobStatus.PENDING,
|
||||||
@@ -111,6 +123,7 @@ export class NotificationJobsAdapterMongoose implements INotificationJobAdapter
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async cancelByAppointment(appointmentId: string): Promise<void> {
|
public async cancelByAppointment(appointmentId: string): Promise<void> {
|
||||||
|
await this.cleanupOldJobsIfDueSafely();
|
||||||
await this.notificationJobList
|
await this.notificationJobList
|
||||||
.updateMany(
|
.updateMany(
|
||||||
{
|
{
|
||||||
@@ -123,9 +136,52 @@ export class NotificationJobsAdapterMongoose implements INotificationJobAdapter
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async findByAppointment(appointmentId: string): Promise<INotificationJob[]> {
|
public async findByAppointment(appointmentId: string): Promise<INotificationJob[]> {
|
||||||
|
await this.cleanupOldJobsIfDueSafely();
|
||||||
const docs = await this.notificationJobList
|
const docs = await this.notificationJobList
|
||||||
.find({ appointmentId })
|
.find({ appointmentId })
|
||||||
.exec();
|
.exec();
|
||||||
return docs.map((d) => d.toObject() as INotificationJob);
|
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;
|
payload?: NotificationJobPayload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DeleteOldJobsParams {
|
||||||
|
scheduledBefore: Date;
|
||||||
|
statuses: NotificationJobStatus[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeleteOldJobsResult {
|
||||||
|
deletedCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface INotificationJobAdapter {
|
export interface INotificationJobAdapter {
|
||||||
create(data: CreateJobParams): Promise<INotificationJob>;
|
create(data: CreateJobParams): Promise<INotificationJob>;
|
||||||
findPendingDue(now: Date): Promise<INotificationJob[]>;
|
findPendingDue(now: Date): Promise<INotificationJob[]>;
|
||||||
@@ -68,4 +77,5 @@ export interface INotificationJobAdapter {
|
|||||||
): Promise<void>;
|
): Promise<void>;
|
||||||
cancelByAppointment(appointmentId: string): Promise<void>;
|
cancelByAppointment(appointmentId: string): Promise<void>;
|
||||||
findByAppointment(appointmentId: string): Promise<INotificationJob[]>;
|
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,
|
AppointmentAdminByDateView,
|
||||||
CreateAppointmentParams,
|
CreateAppointmentParams,
|
||||||
CreateImmediateAppointmentNotificationJobsParams,
|
CreateImmediateAppointmentNotificationJobsParams,
|
||||||
|
AppointmentNotificationPreviewParams,
|
||||||
|
AppointmentNotificationPreviewResult,
|
||||||
DeleteAppointmentParams,
|
DeleteAppointmentParams,
|
||||||
FindAppointmentsParams,
|
FindAppointmentsParams,
|
||||||
FindAppointmentSchedulesParams,
|
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")
|
@Route("appointments/apply-discount")
|
||||||
@Middlewares(authenticateMiddleware)
|
@Middlewares(authenticateMiddleware)
|
||||||
export class ApplyAppointmentDiscountContrnoller extends Controller {
|
export class ApplyAppointmentDiscountContrnoller extends Controller {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { TextObjectFilterResult } from "../../Models/TextObjectFilter.model";
|
|||||||
import AppointmentList from "../../Models/Appointments/Appointments";
|
import AppointmentList from "../../Models/Appointments/Appointments";
|
||||||
import {
|
import {
|
||||||
CreateImmediateAppointmentNotificationJobsParams,
|
CreateImmediateAppointmentNotificationJobsParams,
|
||||||
|
AppointmentNotificationPreviewParams,
|
||||||
|
AppointmentNotificationPreviewResult,
|
||||||
AppointmentAdminByClientView,
|
AppointmentAdminByClientView,
|
||||||
AppointmentAdminByDateView,
|
AppointmentAdminByDateView,
|
||||||
CreateAppointmentParams,
|
CreateAppointmentParams,
|
||||||
@@ -167,6 +169,11 @@ export class AppointmentService {
|
|||||||
await AppointmentList.createImmediateNotificationJobs(data);
|
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> {
|
public async applyDiscount(data: ApplyAppointmentDiscountParams): Promise<void> {
|
||||||
await connect(`${process.env.DATABASE_CONNECTION}`);
|
await connect(`${process.env.DATABASE_CONNECTION}`);
|
||||||
await AppointmentList.applyDiscount(data);
|
await AppointmentList.applyDiscount(data);
|
||||||
|
|||||||
@@ -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;
|
type: APPOINTMENT_NOTIFICATION_TYPE;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AppointmentNotificationPreviewResult = {
|
||||||
|
subject: string;
|
||||||
|
message: string;
|
||||||
|
emailMessage: string;
|
||||||
|
wapMessage: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type DeleteAppointmentParams = {
|
export type DeleteAppointmentParams = {
|
||||||
id: string;
|
id: string;
|
||||||
validation?: boolean;
|
validation?: boolean;
|
||||||
|
|||||||
+18
@@ -1,6 +1,7 @@
|
|||||||
import ApiRequest from "@services/Api.Service";
|
import ApiRequest from "@services/Api.Service";
|
||||||
import {
|
import {
|
||||||
ApplyAppointmentDiscountParams,
|
ApplyAppointmentDiscountParams,
|
||||||
|
AppointmentNotificationPreviewResult,
|
||||||
AppointmentAdminByClientView,
|
AppointmentAdminByClientView,
|
||||||
AppointmentEventByClient,
|
AppointmentEventByClient,
|
||||||
DeleteAppointmentDiscountParams,
|
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({
|
const schemaApplyDiscount = Yup.object().shape({
|
||||||
appointmentId: Yup.string().required("Debe seleccionar un turno."),
|
appointmentId: Yup.string().required("Debe seleccionar un turno."),
|
||||||
companyId: Yup.string().required("Organización no seleccionada."),
|
companyId: Yup.string().required("Organización no seleccionada."),
|
||||||
|
|||||||
+158
@@ -13,12 +13,14 @@ import {
|
|||||||
deleteAppointment,
|
deleteAppointment,
|
||||||
deleteAppointmentDiscount,
|
deleteAppointmentDiscount,
|
||||||
findAppointmentsAdminByClient,
|
findAppointmentsAdminByClient,
|
||||||
|
getAppointmentNotificationPreview,
|
||||||
updateAppointment,
|
updateAppointment,
|
||||||
} from "../Appointments.Service";
|
} from "../Appointments.Service";
|
||||||
import {
|
import {
|
||||||
ApplyAppointmentDiscountParams,
|
ApplyAppointmentDiscountParams,
|
||||||
APPOINTMENT_NOTIFICATION_TYPE,
|
APPOINTMENT_NOTIFICATION_TYPE,
|
||||||
AppointmentEventByClient,
|
AppointmentEventByClient,
|
||||||
|
AppointmentNotificationPreviewResult,
|
||||||
DeleteAppointmentDiscountParams,
|
DeleteAppointmentDiscountParams,
|
||||||
UpdateAppointmentParams,
|
UpdateAppointmentParams,
|
||||||
} from "@core/Models/Appointments.model";
|
} 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 { useMenuStore } from "@core/Store/Menu.Store";
|
||||||
import NotificationsActiveOutlinedIcon from "@mui/icons-material/NotificationsActiveOutlined";
|
import NotificationsActiveOutlinedIcon from "@mui/icons-material/NotificationsActiveOutlined";
|
||||||
import DeleteIcon from "@mui/icons-material/Delete";
|
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 Switch from "@mui/material/Switch";
|
||||||
import { ThemeProvider } from "@emotion/react";
|
import { ThemeProvider } from "@emotion/react";
|
||||||
@@ -91,6 +97,8 @@ export default function ClientAppointment() {
|
|||||||
const [comments, setComments] = useState<string>("");
|
const [comments, setComments] = useState<string>("");
|
||||||
|
|
||||||
const [dialogDiscountVisible, setDialogDiscountVisible] = useState(false);
|
const [dialogDiscountVisible, setDialogDiscountVisible] = useState(false);
|
||||||
|
const [dialogShareVisible, setDialogShareVisible] = useState(false);
|
||||||
|
const [sharePreview, setSharePreview] = useState<AppointmentNotificationPreviewResult | undefined>(undefined);
|
||||||
|
|
||||||
const [discountsFilterContent] = useState<TextObjectModalContent>({
|
const [discountsFilterContent] = useState<TextObjectModalContent>({
|
||||||
header: (
|
header: (
|
||||||
@@ -361,6 +369,14 @@ export default function ClientAppointment() {
|
|||||||
icon: <NotificationsActiveOutlinedIcon />,
|
icon: <NotificationsActiveOutlinedIcon />,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
menu.add({
|
||||||
|
text: "Compartir",
|
||||||
|
onClick: () => {
|
||||||
|
openShareDialog();
|
||||||
|
},
|
||||||
|
icon: <ShareIcon />,
|
||||||
|
});
|
||||||
|
|
||||||
if (showQuitDiscount) {
|
if (showQuitDiscount) {
|
||||||
menu.divider();
|
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 = () => {
|
const applyDiscountHandler = () => {
|
||||||
if (!SessionUser.userId) {
|
if (!SessionUser.userId) {
|
||||||
return;
|
return;
|
||||||
@@ -728,6 +804,88 @@ export default function ClientAppointment() {
|
|||||||
</>
|
</>
|
||||||
</DialogWindow>
|
</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
|
<DialogWindow
|
||||||
visible={dialogChangeServiceVisible}
|
visible={dialogChangeServiceVisible}
|
||||||
setVisible={setDialogChangeServiceVisible}
|
setVisible={setDialogChangeServiceVisible}
|
||||||
|
|||||||
@@ -159,6 +159,15 @@ export default function UserMenu(): React.ReactElement {
|
|||||||
goTo(`/admin/org/profile/${orgId}/appointments/${targetDate}`);
|
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 = () => {
|
const getUserName = () => {
|
||||||
return SessionInfo.loged ? SessionInfo.userFullName : "Anonimo";
|
return SessionInfo.loged ? SessionInfo.userFullName : "Anonimo";
|
||||||
};
|
};
|
||||||
@@ -263,6 +272,15 @@ export default function UserMenu(): React.ReactElement {
|
|||||||
iconAlign="left"
|
iconAlign="left"
|
||||||
onClick={() => goTo("/messenger")}
|
onClick={() => goTo("/messenger")}
|
||||||
/>
|
/>
|
||||||
|
{userBelongsToOrganization() && (
|
||||||
|
<ButtonOption
|
||||||
|
text="Download"
|
||||||
|
icon="/download-icon.svg"
|
||||||
|
textAlign="left"
|
||||||
|
iconAlign="left"
|
||||||
|
onClick={handleDownloadClick}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{!SessionInfo.loged && (
|
{!SessionInfo.loged && (
|
||||||
|
|||||||
Reference in New Issue
Block a user