feat: add appointment notification sharing functionality via WhatsApp, email, and system clipboard

This commit is contained in:
2026-07-29 14:38:13 -03:00
parent 3d8911c093
commit 2a33f636a0
19 changed files with 679 additions and 5 deletions
@@ -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>;
}
@@ -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);
+5
View File
@@ -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;
@@ -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."),
@@ -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 && (