@blackcode_sa/metaestetics-api 1.14.57 → 1.14.59
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/admin/index.d.mts +41 -1
- package/dist/admin/index.d.ts +41 -1
- package/dist/admin/index.js +1104 -227
- package/dist/admin/index.mjs +1104 -227
- package/dist/index.d.mts +39 -3
- package/dist/index.d.ts +39 -3
- package/dist/index.js +95 -27
- package/dist/index.mjs +96 -27
- package/package.json +1 -1
- package/src/admin/aggregation/appointment/appointment.aggregation.service.ts +12 -0
- package/src/admin/mailing/appointment/appointment.mailing.service.ts +876 -4
- package/src/admin/notifications/notifications.admin.ts +57 -0
- package/src/services/procedure/procedure.service.ts +137 -40
- package/src/types/appointment/index.ts +6 -0
- package/src/types/notifications/index.ts +14 -0
|
@@ -411,6 +411,634 @@ const clinicAppointmentRequestedTemplate = `
|
|
|
411
411
|
</html>
|
|
412
412
|
`;
|
|
413
413
|
|
|
414
|
+
const appointmentCancelledTemplate = `
|
|
415
|
+
<!DOCTYPE html>
|
|
416
|
+
<html lang="en">
|
|
417
|
+
<head>
|
|
418
|
+
<meta charset="UTF-8">
|
|
419
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
420
|
+
<title>Appointment Cancelled</title>
|
|
421
|
+
<style>
|
|
422
|
+
body {
|
|
423
|
+
margin: 0;
|
|
424
|
+
padding: 0;
|
|
425
|
+
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
|
426
|
+
background: linear-gradient(135deg, #d4736c 0%, #b85450 100%);
|
|
427
|
+
min-height: 100vh;
|
|
428
|
+
}
|
|
429
|
+
.email-container {
|
|
430
|
+
max-width: 600px;
|
|
431
|
+
margin: 0 auto;
|
|
432
|
+
background: #ffffff;
|
|
433
|
+
border-radius: 20px;
|
|
434
|
+
overflow: hidden;
|
|
435
|
+
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
|
436
|
+
margin-top: 40px;
|
|
437
|
+
margin-bottom: 40px;
|
|
438
|
+
}
|
|
439
|
+
.header {
|
|
440
|
+
background: linear-gradient(135deg, #d4736c 0%, #b85450 100%);
|
|
441
|
+
padding: 40px 30px;
|
|
442
|
+
text-align: center;
|
|
443
|
+
color: white;
|
|
444
|
+
}
|
|
445
|
+
.header h1 {
|
|
446
|
+
margin: 0;
|
|
447
|
+
font-size: 28px;
|
|
448
|
+
font-weight: 300;
|
|
449
|
+
letter-spacing: 1px;
|
|
450
|
+
}
|
|
451
|
+
.header .subtitle {
|
|
452
|
+
margin: 10px 0 0 0;
|
|
453
|
+
font-size: 16px;
|
|
454
|
+
opacity: 0.9;
|
|
455
|
+
font-weight: 300;
|
|
456
|
+
}
|
|
457
|
+
.content {
|
|
458
|
+
padding: 40px 30px;
|
|
459
|
+
}
|
|
460
|
+
.greeting {
|
|
461
|
+
font-size: 18px;
|
|
462
|
+
color: #333;
|
|
463
|
+
margin-bottom: 25px;
|
|
464
|
+
font-weight: 400;
|
|
465
|
+
}
|
|
466
|
+
.cancellation-notice {
|
|
467
|
+
background: linear-gradient(135deg, #ffebee 0%, #ffcdd2 100%);
|
|
468
|
+
border-radius: 15px;
|
|
469
|
+
padding: 25px;
|
|
470
|
+
margin: 25px 0;
|
|
471
|
+
border-left: 5px solid #d4736c;
|
|
472
|
+
}
|
|
473
|
+
.cancellation-notice p {
|
|
474
|
+
margin: 0;
|
|
475
|
+
color: #c62828;
|
|
476
|
+
font-size: 15px;
|
|
477
|
+
font-weight: 500;
|
|
478
|
+
line-height: 1.6;
|
|
479
|
+
}
|
|
480
|
+
.cancelled-by-info {
|
|
481
|
+
background: #fafafa;
|
|
482
|
+
border-radius: 10px;
|
|
483
|
+
padding: 15px 20px;
|
|
484
|
+
margin-top: 15px;
|
|
485
|
+
}
|
|
486
|
+
.cancelled-by-info .label {
|
|
487
|
+
font-size: 12px;
|
|
488
|
+
color: #757575;
|
|
489
|
+
text-transform: uppercase;
|
|
490
|
+
letter-spacing: 0.5px;
|
|
491
|
+
margin-bottom: 5px;
|
|
492
|
+
}
|
|
493
|
+
.cancelled-by-info .value {
|
|
494
|
+
font-size: 14px;
|
|
495
|
+
color: #424242;
|
|
496
|
+
font-weight: 500;
|
|
497
|
+
}
|
|
498
|
+
.reason-box {
|
|
499
|
+
background: linear-gradient(135deg, #fff8e1 0%, #ffecb3 100%);
|
|
500
|
+
border-radius: 15px;
|
|
501
|
+
padding: 20px;
|
|
502
|
+
margin: 20px 0;
|
|
503
|
+
border-left: 5px solid #ffa000;
|
|
504
|
+
}
|
|
505
|
+
.reason-box .label {
|
|
506
|
+
font-size: 14px;
|
|
507
|
+
font-weight: 600;
|
|
508
|
+
color: #e65100;
|
|
509
|
+
margin-bottom: 8px;
|
|
510
|
+
}
|
|
511
|
+
.reason-box .reason-text {
|
|
512
|
+
font-size: 15px;
|
|
513
|
+
color: #424242;
|
|
514
|
+
line-height: 1.6;
|
|
515
|
+
font-style: italic;
|
|
516
|
+
}
|
|
517
|
+
.appointment-card {
|
|
518
|
+
background: linear-gradient(135deg, #f5f5f5 0%, #eeeeee 100%);
|
|
519
|
+
border-radius: 15px;
|
|
520
|
+
padding: 30px;
|
|
521
|
+
margin: 25px 0;
|
|
522
|
+
border-left: 5px solid #9e9e9e;
|
|
523
|
+
opacity: 0.9;
|
|
524
|
+
}
|
|
525
|
+
.appointment-title {
|
|
526
|
+
font-size: 20px;
|
|
527
|
+
color: #757575;
|
|
528
|
+
margin-bottom: 20px;
|
|
529
|
+
font-weight: 600;
|
|
530
|
+
}
|
|
531
|
+
.appointment-details {
|
|
532
|
+
display: grid;
|
|
533
|
+
gap: 15px;
|
|
534
|
+
}
|
|
535
|
+
.detail-row {
|
|
536
|
+
display: flex;
|
|
537
|
+
align-items: center;
|
|
538
|
+
padding: 8px 0;
|
|
539
|
+
}
|
|
540
|
+
.detail-label {
|
|
541
|
+
font-weight: 600;
|
|
542
|
+
color: #757575;
|
|
543
|
+
min-width: 120px;
|
|
544
|
+
font-size: 14px;
|
|
545
|
+
}
|
|
546
|
+
.detail-value {
|
|
547
|
+
color: #616161;
|
|
548
|
+
font-size: 16px;
|
|
549
|
+
font-weight: 500;
|
|
550
|
+
text-decoration: line-through;
|
|
551
|
+
}
|
|
552
|
+
.procedure-name {
|
|
553
|
+
color: #757575;
|
|
554
|
+
font-weight: 600;
|
|
555
|
+
}
|
|
556
|
+
.clinic-name {
|
|
557
|
+
color: #9e9e9e;
|
|
558
|
+
font-weight: 600;
|
|
559
|
+
}
|
|
560
|
+
.rebook-section {
|
|
561
|
+
background: linear-gradient(135deg, #e8f5e9 0%, #c8e6c9 100%);
|
|
562
|
+
border-radius: 15px;
|
|
563
|
+
padding: 25px;
|
|
564
|
+
margin: 30px 0;
|
|
565
|
+
text-align: center;
|
|
566
|
+
border-left: 5px solid #4caf50;
|
|
567
|
+
}
|
|
568
|
+
.rebook-section h3 {
|
|
569
|
+
margin: 0 0 15px 0;
|
|
570
|
+
color: #2e7d32;
|
|
571
|
+
font-weight: 600;
|
|
572
|
+
font-size: 18px;
|
|
573
|
+
}
|
|
574
|
+
.rebook-section p {
|
|
575
|
+
margin: 0;
|
|
576
|
+
color: #555;
|
|
577
|
+
font-size: 15px;
|
|
578
|
+
line-height: 1.6;
|
|
579
|
+
}
|
|
580
|
+
.support-section {
|
|
581
|
+
background: #f8f9fa;
|
|
582
|
+
border-radius: 15px;
|
|
583
|
+
padding: 20px;
|
|
584
|
+
margin: 25px 0;
|
|
585
|
+
text-align: center;
|
|
586
|
+
}
|
|
587
|
+
.support-section h4 {
|
|
588
|
+
margin: 0 0 10px 0;
|
|
589
|
+
color: #555;
|
|
590
|
+
font-weight: 600;
|
|
591
|
+
font-size: 16px;
|
|
592
|
+
}
|
|
593
|
+
.support-section p {
|
|
594
|
+
margin: 0;
|
|
595
|
+
color: #757575;
|
|
596
|
+
font-size: 14px;
|
|
597
|
+
line-height: 1.6;
|
|
598
|
+
}
|
|
599
|
+
.footer {
|
|
600
|
+
background: #f8f9fa;
|
|
601
|
+
padding: 25px 30px;
|
|
602
|
+
text-align: center;
|
|
603
|
+
color: #666;
|
|
604
|
+
font-size: 14px;
|
|
605
|
+
border-top: 1px solid #eee;
|
|
606
|
+
}
|
|
607
|
+
.logo {
|
|
608
|
+
font-size: 24px;
|
|
609
|
+
font-weight: 700;
|
|
610
|
+
color: white;
|
|
611
|
+
margin-bottom: 5px;
|
|
612
|
+
}
|
|
613
|
+
.divider {
|
|
614
|
+
height: 2px;
|
|
615
|
+
background: linear-gradient(90deg, #d4736c, #b85450);
|
|
616
|
+
margin: 25px 0;
|
|
617
|
+
border-radius: 1px;
|
|
618
|
+
}
|
|
619
|
+
.icon {
|
|
620
|
+
text-align: center;
|
|
621
|
+
margin: 20px 0;
|
|
622
|
+
font-size: 48px;
|
|
623
|
+
}
|
|
624
|
+
</style>
|
|
625
|
+
</head>
|
|
626
|
+
<body>
|
|
627
|
+
<div class="email-container">
|
|
628
|
+
<div class="header">
|
|
629
|
+
<div class="logo">MetaEstetics</div>
|
|
630
|
+
<h1>Appointment Cancelled</h1>
|
|
631
|
+
<div class="subtitle">We're Sorry to See This Change</div>
|
|
632
|
+
</div>
|
|
633
|
+
|
|
634
|
+
<div class="content">
|
|
635
|
+
<div class="icon">❌</div>
|
|
636
|
+
|
|
637
|
+
<div class="greeting">
|
|
638
|
+
Dear <strong>{{recipientName}}</strong>,
|
|
639
|
+
</div>
|
|
640
|
+
|
|
641
|
+
<div class="cancellation-notice">
|
|
642
|
+
<p><strong>Your appointment has been cancelled.</strong> We wanted to let you know that the following appointment is no longer scheduled.</p>
|
|
643
|
+
<div class="cancelled-by-info">
|
|
644
|
+
<div class="label">Cancelled By</div>
|
|
645
|
+
<div class="value">{{cancelledByDisplay}}</div>
|
|
646
|
+
</div>
|
|
647
|
+
</div>
|
|
648
|
+
|
|
649
|
+
{{#if cancellationReason}}
|
|
650
|
+
<div class="reason-box">
|
|
651
|
+
<div class="label">Reason for Cancellation</div>
|
|
652
|
+
<div class="reason-text">"{{cancellationReason}}"</div>
|
|
653
|
+
</div>
|
|
654
|
+
{{/if}}
|
|
655
|
+
|
|
656
|
+
<div class="appointment-card">
|
|
657
|
+
<div class="appointment-title">Cancelled Appointment Details</div>
|
|
658
|
+
<div class="appointment-details">
|
|
659
|
+
<div class="detail-row">
|
|
660
|
+
<div class="detail-label">Procedure:</div>
|
|
661
|
+
<div class="detail-value procedure-name">{{procedureName}}</div>
|
|
662
|
+
</div>
|
|
663
|
+
<div class="detail-row">
|
|
664
|
+
<div class="detail-label">Date:</div>
|
|
665
|
+
<div class="detail-value">{{appointmentDate}}</div>
|
|
666
|
+
</div>
|
|
667
|
+
<div class="detail-row">
|
|
668
|
+
<div class="detail-label">Time:</div>
|
|
669
|
+
<div class="detail-value">{{appointmentTime}}</div>
|
|
670
|
+
</div>
|
|
671
|
+
<div class="detail-row">
|
|
672
|
+
<div class="detail-label">Practitioner:</div>
|
|
673
|
+
<div class="detail-value">{{practitionerName}}</div>
|
|
674
|
+
</div>
|
|
675
|
+
<div class="detail-row">
|
|
676
|
+
<div class="detail-label">Location:</div>
|
|
677
|
+
<div class="detail-value clinic-name">{{clinicName}}</div>
|
|
678
|
+
</div>
|
|
679
|
+
</div>
|
|
680
|
+
</div>
|
|
681
|
+
|
|
682
|
+
<div class="divider"></div>
|
|
683
|
+
|
|
684
|
+
<div class="rebook-section">
|
|
685
|
+
<h3>Would You Like to Reschedule?</h3>
|
|
686
|
+
<p>
|
|
687
|
+
We'd love to see you! If you'd like to book a new appointment,
|
|
688
|
+
simply open the MetaEstetics app and browse available times that work for you.
|
|
689
|
+
</p>
|
|
690
|
+
</div>
|
|
691
|
+
|
|
692
|
+
<div class="support-section">
|
|
693
|
+
<h4>Need Assistance?</h4>
|
|
694
|
+
<p>
|
|
695
|
+
If you have any questions about this cancellation or need help rebooking,
|
|
696
|
+
please contact {{clinicName}} directly through the app or reach out to our support team.
|
|
697
|
+
</p>
|
|
698
|
+
</div>
|
|
699
|
+
</div>
|
|
700
|
+
|
|
701
|
+
<div class="footer">
|
|
702
|
+
<p style="margin: 0 0 10px 0;">
|
|
703
|
+
<strong>MetaEstetics</strong> - Premium Aesthetic Services
|
|
704
|
+
</p>
|
|
705
|
+
<p style="margin: 0; font-size: 12px; color: #999;">
|
|
706
|
+
This is an automated message. Please do not reply to this email.
|
|
707
|
+
</p>
|
|
708
|
+
</div>
|
|
709
|
+
</div>
|
|
710
|
+
</body>
|
|
711
|
+
</html>
|
|
712
|
+
`;
|
|
713
|
+
|
|
714
|
+
const appointmentRescheduledProposalTemplate = `
|
|
715
|
+
<!DOCTYPE html>
|
|
716
|
+
<html lang="en">
|
|
717
|
+
<head>
|
|
718
|
+
<meta charset="UTF-8">
|
|
719
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
720
|
+
<title>Appointment Reschedule Proposal</title>
|
|
721
|
+
<style>
|
|
722
|
+
body {
|
|
723
|
+
margin: 0;
|
|
724
|
+
padding: 0;
|
|
725
|
+
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
|
726
|
+
background: linear-gradient(135deg, #a48a76 0%, #67574A 100%);
|
|
727
|
+
min-height: 100vh;
|
|
728
|
+
}
|
|
729
|
+
.email-container {
|
|
730
|
+
max-width: 600px;
|
|
731
|
+
margin: 0 auto;
|
|
732
|
+
background: #ffffff;
|
|
733
|
+
border-radius: 20px;
|
|
734
|
+
overflow: hidden;
|
|
735
|
+
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
|
736
|
+
margin-top: 40px;
|
|
737
|
+
margin-bottom: 40px;
|
|
738
|
+
}
|
|
739
|
+
.header {
|
|
740
|
+
background: linear-gradient(135deg, #ff9800 0%, #f57c00 100%);
|
|
741
|
+
padding: 40px 30px;
|
|
742
|
+
text-align: center;
|
|
743
|
+
color: white;
|
|
744
|
+
}
|
|
745
|
+
.header h1 {
|
|
746
|
+
margin: 0;
|
|
747
|
+
font-size: 28px;
|
|
748
|
+
font-weight: 300;
|
|
749
|
+
letter-spacing: 1px;
|
|
750
|
+
}
|
|
751
|
+
.header .subtitle {
|
|
752
|
+
margin: 10px 0 0 0;
|
|
753
|
+
font-size: 16px;
|
|
754
|
+
opacity: 0.9;
|
|
755
|
+
font-weight: 300;
|
|
756
|
+
}
|
|
757
|
+
.content {
|
|
758
|
+
padding: 40px 30px;
|
|
759
|
+
}
|
|
760
|
+
.greeting {
|
|
761
|
+
font-size: 18px;
|
|
762
|
+
color: #333;
|
|
763
|
+
margin-bottom: 25px;
|
|
764
|
+
font-weight: 400;
|
|
765
|
+
}
|
|
766
|
+
.info-box {
|
|
767
|
+
background: linear-gradient(135deg, #fff3e0 0%, #ffe0b2 100%);
|
|
768
|
+
border-radius: 15px;
|
|
769
|
+
padding: 25px;
|
|
770
|
+
margin: 25px 0;
|
|
771
|
+
border-left: 5px solid #ff9800;
|
|
772
|
+
}
|
|
773
|
+
.info-box p {
|
|
774
|
+
margin: 0;
|
|
775
|
+
color: #e65100;
|
|
776
|
+
font-size: 15px;
|
|
777
|
+
font-weight: 500;
|
|
778
|
+
line-height: 1.6;
|
|
779
|
+
}
|
|
780
|
+
.time-comparison {
|
|
781
|
+
display: grid;
|
|
782
|
+
gap: 20px;
|
|
783
|
+
margin: 25px 0;
|
|
784
|
+
}
|
|
785
|
+
.time-card {
|
|
786
|
+
background: linear-gradient(135deg, #f8f6f5 0%, #f5f3f2 100%);
|
|
787
|
+
border-radius: 15px;
|
|
788
|
+
padding: 25px;
|
|
789
|
+
border-left: 5px solid #a48a76;
|
|
790
|
+
}
|
|
791
|
+
.time-card.old-time {
|
|
792
|
+
border-left-color: #9e9e9e;
|
|
793
|
+
opacity: 0.8;
|
|
794
|
+
}
|
|
795
|
+
.time-card.new-time {
|
|
796
|
+
border-left-color: #ff9800;
|
|
797
|
+
background: linear-gradient(135deg, #fff8e1 0%, #ffe0b2 100%);
|
|
798
|
+
}
|
|
799
|
+
.time-label {
|
|
800
|
+
font-size: 14px;
|
|
801
|
+
font-weight: 600;
|
|
802
|
+
color: #666;
|
|
803
|
+
text-transform: uppercase;
|
|
804
|
+
letter-spacing: 0.5px;
|
|
805
|
+
margin-bottom: 10px;
|
|
806
|
+
}
|
|
807
|
+
.time-label.old {
|
|
808
|
+
color: #757575;
|
|
809
|
+
}
|
|
810
|
+
.time-label.new {
|
|
811
|
+
color: #f57c00;
|
|
812
|
+
}
|
|
813
|
+
.appointment-card {
|
|
814
|
+
background: linear-gradient(135deg, #f8f6f5 0%, #f5f3f2 100%);
|
|
815
|
+
border-radius: 15px;
|
|
816
|
+
padding: 30px;
|
|
817
|
+
margin: 25px 0;
|
|
818
|
+
border-left: 5px solid #a48a76;
|
|
819
|
+
}
|
|
820
|
+
.appointment-title {
|
|
821
|
+
font-size: 20px;
|
|
822
|
+
color: #a48a76;
|
|
823
|
+
margin-bottom: 20px;
|
|
824
|
+
font-weight: 600;
|
|
825
|
+
}
|
|
826
|
+
.appointment-details {
|
|
827
|
+
display: grid;
|
|
828
|
+
gap: 15px;
|
|
829
|
+
}
|
|
830
|
+
.detail-row {
|
|
831
|
+
display: flex;
|
|
832
|
+
align-items: center;
|
|
833
|
+
padding: 8px 0;
|
|
834
|
+
}
|
|
835
|
+
.detail-label {
|
|
836
|
+
font-weight: 600;
|
|
837
|
+
color: #555;
|
|
838
|
+
min-width: 120px;
|
|
839
|
+
font-size: 14px;
|
|
840
|
+
}
|
|
841
|
+
.detail-value {
|
|
842
|
+
color: #333;
|
|
843
|
+
font-size: 16px;
|
|
844
|
+
font-weight: 500;
|
|
845
|
+
}
|
|
846
|
+
.procedure-name {
|
|
847
|
+
color: #67574A;
|
|
848
|
+
font-weight: 600;
|
|
849
|
+
}
|
|
850
|
+
.clinic-name {
|
|
851
|
+
color: #a48a76;
|
|
852
|
+
font-weight: 600;
|
|
853
|
+
}
|
|
854
|
+
.action-section {
|
|
855
|
+
background: linear-gradient(135deg, #e8f5e9 0%, #c8e6c9 100%);
|
|
856
|
+
border-radius: 15px;
|
|
857
|
+
padding: 30px;
|
|
858
|
+
margin: 30px 0;
|
|
859
|
+
text-align: center;
|
|
860
|
+
border-left: 5px solid #4caf50;
|
|
861
|
+
}
|
|
862
|
+
.action-section h3 {
|
|
863
|
+
margin: 0 0 15px 0;
|
|
864
|
+
color: #2e7d32;
|
|
865
|
+
font-weight: 600;
|
|
866
|
+
font-size: 18px;
|
|
867
|
+
}
|
|
868
|
+
.action-section p {
|
|
869
|
+
margin: 0 0 20px 0;
|
|
870
|
+
color: #555;
|
|
871
|
+
font-size: 15px;
|
|
872
|
+
line-height: 1.6;
|
|
873
|
+
}
|
|
874
|
+
.action-required-box {
|
|
875
|
+
background: linear-gradient(135deg, #fff3e0 0%, #ffecb3 100%);
|
|
876
|
+
border: 2px solid #ff9800;
|
|
877
|
+
border-radius: 15px;
|
|
878
|
+
padding: 25px;
|
|
879
|
+
margin: 25px 0;
|
|
880
|
+
text-align: center;
|
|
881
|
+
}
|
|
882
|
+
.action-required-box h3 {
|
|
883
|
+
margin: 0 0 15px 0;
|
|
884
|
+
color: #e65100;
|
|
885
|
+
font-weight: 700;
|
|
886
|
+
font-size: 18px;
|
|
887
|
+
}
|
|
888
|
+
.action-required-box p {
|
|
889
|
+
margin: 0 0 12px 0;
|
|
890
|
+
color: #bf360c;
|
|
891
|
+
font-size: 15px;
|
|
892
|
+
line-height: 1.6;
|
|
893
|
+
}
|
|
894
|
+
.action-required-box p:last-child {
|
|
895
|
+
margin-bottom: 0;
|
|
896
|
+
}
|
|
897
|
+
.pending-notice {
|
|
898
|
+
background: #fff8e1;
|
|
899
|
+
border-radius: 8px;
|
|
900
|
+
padding: 12px 15px;
|
|
901
|
+
margin-top: 15px;
|
|
902
|
+
display: inline-block;
|
|
903
|
+
}
|
|
904
|
+
.pending-notice p {
|
|
905
|
+
margin: 0;
|
|
906
|
+
color: #f57c00;
|
|
907
|
+
font-size: 14px;
|
|
908
|
+
font-weight: 600;
|
|
909
|
+
}
|
|
910
|
+
.footer {
|
|
911
|
+
background: #f8f9fa;
|
|
912
|
+
padding: 25px 30px;
|
|
913
|
+
text-align: center;
|
|
914
|
+
color: #666;
|
|
915
|
+
font-size: 14px;
|
|
916
|
+
border-top: 1px solid #eee;
|
|
917
|
+
}
|
|
918
|
+
.logo {
|
|
919
|
+
font-size: 24px;
|
|
920
|
+
font-weight: 700;
|
|
921
|
+
color: white;
|
|
922
|
+
margin-bottom: 5px;
|
|
923
|
+
}
|
|
924
|
+
.divider {
|
|
925
|
+
height: 2px;
|
|
926
|
+
background: linear-gradient(90deg, #a48a76, #67574A);
|
|
927
|
+
margin: 25px 0;
|
|
928
|
+
border-radius: 1px;
|
|
929
|
+
}
|
|
930
|
+
.icon {
|
|
931
|
+
text-align: center;
|
|
932
|
+
margin: 20px 0;
|
|
933
|
+
font-size: 48px;
|
|
934
|
+
}
|
|
935
|
+
.arrow {
|
|
936
|
+
text-align: center;
|
|
937
|
+
font-size: 32px;
|
|
938
|
+
color: #ff9800;
|
|
939
|
+
margin: 10px 0;
|
|
940
|
+
}
|
|
941
|
+
</style>
|
|
942
|
+
</head>
|
|
943
|
+
<body>
|
|
944
|
+
<div class="email-container">
|
|
945
|
+
<div class="header">
|
|
946
|
+
<div class="logo">MetaEstetics</div>
|
|
947
|
+
<h1>Appointment Reschedule Proposal</h1>
|
|
948
|
+
<div class="subtitle">Action Required</div>
|
|
949
|
+
</div>
|
|
950
|
+
|
|
951
|
+
<div class="content">
|
|
952
|
+
<div class="icon">📅</div>
|
|
953
|
+
|
|
954
|
+
<div class="greeting">
|
|
955
|
+
Dear <strong>{{patientName}}</strong>,
|
|
956
|
+
</div>
|
|
957
|
+
|
|
958
|
+
<p style="color: #555; font-size: 16px; line-height: 1.6; margin-bottom: 25px;">
|
|
959
|
+
We hope this message finds you well. We need to propose a new time for your upcoming appointment. Please review the details below and confirm if the new time works for you.
|
|
960
|
+
</p>
|
|
961
|
+
|
|
962
|
+
<div class="info-box">
|
|
963
|
+
<p><strong>⚠️ Important:</strong> Please respond to this reschedule proposal as soon as possible. Your appointment will remain pending until you confirm or reject the new time.</p>
|
|
964
|
+
</div>
|
|
965
|
+
|
|
966
|
+
<div class="appointment-card">
|
|
967
|
+
<div class="appointment-title">📋 Appointment Details</div>
|
|
968
|
+
<div class="appointment-details">
|
|
969
|
+
<div class="detail-row">
|
|
970
|
+
<div class="detail-label">Procedure:</div>
|
|
971
|
+
<div class="detail-value procedure-name">{{procedureName}}</div>
|
|
972
|
+
</div>
|
|
973
|
+
<div class="detail-row">
|
|
974
|
+
<div class="detail-label">Practitioner:</div>
|
|
975
|
+
<div class="detail-value">{{practitionerName}}</div>
|
|
976
|
+
</div>
|
|
977
|
+
<div class="detail-row">
|
|
978
|
+
<div class="detail-label">Location:</div>
|
|
979
|
+
<div class="detail-value clinic-name">{{clinicName}}</div>
|
|
980
|
+
</div>
|
|
981
|
+
</div>
|
|
982
|
+
</div>
|
|
983
|
+
|
|
984
|
+
<div class="time-comparison">
|
|
985
|
+
<div class="time-card old-time">
|
|
986
|
+
<div class="time-label old">Previous Time</div>
|
|
987
|
+
<div style="font-size: 18px; font-weight: 600; color: #424242; margin-bottom: 8px;">{{previousDate}}</div>
|
|
988
|
+
<div style="font-size: 16px; color: #616161;">{{previousTime}}</div>
|
|
989
|
+
</div>
|
|
990
|
+
|
|
991
|
+
<div class="arrow">↓</div>
|
|
992
|
+
|
|
993
|
+
<div class="time-card new-time">
|
|
994
|
+
<div class="time-label new">Proposed New Time</div>
|
|
995
|
+
<div style="font-size: 18px; font-weight: 600; color: #e65100; margin-bottom: 8px;">{{newDate}}</div>
|
|
996
|
+
<div style="font-size: 16px; color: #f57c00; font-weight: 500;">{{newTime}}</div>
|
|
997
|
+
</div>
|
|
998
|
+
</div>
|
|
999
|
+
|
|
1000
|
+
<div class="divider"></div>
|
|
1001
|
+
|
|
1002
|
+
<div class="action-required-box">
|
|
1003
|
+
<h3>Your Response is Required</h3>
|
|
1004
|
+
<p>
|
|
1005
|
+
<strong>Missed our notification?</strong> Please open the MetaEstetics app to confirm or reject this reschedule request.
|
|
1006
|
+
</p>
|
|
1007
|
+
<p>
|
|
1008
|
+
Please respond as soon as possible so we can finalize your appointment.
|
|
1009
|
+
</p>
|
|
1010
|
+
<div class="pending-notice">
|
|
1011
|
+
<p>Your appointment will remain pending until you respond.</p>
|
|
1012
|
+
</div>
|
|
1013
|
+
</div>
|
|
1014
|
+
|
|
1015
|
+
<div class="action-section">
|
|
1016
|
+
<h3>How to Respond</h3>
|
|
1017
|
+
<p>
|
|
1018
|
+
Open the MetaEstetics app and navigate to your appointments.
|
|
1019
|
+
If the new time works for you, simply tap "Accept Reschedule".
|
|
1020
|
+
If not, you can reject it and we'll work with you to find an alternative time.
|
|
1021
|
+
</p>
|
|
1022
|
+
</div>
|
|
1023
|
+
|
|
1024
|
+
<p style="color: #555; font-size: 14px; line-height: 1.6; margin-top: 25px;">
|
|
1025
|
+
<strong>Need Help?</strong> If you have any questions or concerns about this reschedule, please contact us directly through the app or reach out to {{clinicName}}.
|
|
1026
|
+
</p>
|
|
1027
|
+
</div>
|
|
1028
|
+
|
|
1029
|
+
<div class="footer">
|
|
1030
|
+
<p style="margin: 0 0 10px 0;">
|
|
1031
|
+
<strong>MetaEstetics</strong> - Premium Aesthetic Services
|
|
1032
|
+
</p>
|
|
1033
|
+
<p style="margin: 0; font-size: 12px; color: #999;">
|
|
1034
|
+
This is an automated message. Please do not reply to this email.
|
|
1035
|
+
</p>
|
|
1036
|
+
</div>
|
|
1037
|
+
</div>
|
|
1038
|
+
</body>
|
|
1039
|
+
</html>
|
|
1040
|
+
`;
|
|
1041
|
+
|
|
414
1042
|
// --- Interface Definitions for Email Data ---
|
|
415
1043
|
|
|
416
1044
|
export interface AppointmentEmailDataBase {
|
|
@@ -700,20 +1328,264 @@ export class AppointmentMailingService extends BaseMailingService {
|
|
|
700
1328
|
}
|
|
701
1329
|
}
|
|
702
1330
|
|
|
1331
|
+
/**
|
|
1332
|
+
* Gets a user-friendly display text for who cancelled the appointment
|
|
1333
|
+
* @param cancelledBy - The entity that cancelled the appointment
|
|
1334
|
+
* @param clinicName - The clinic name for context
|
|
1335
|
+
* @returns User-friendly cancellation source text
|
|
1336
|
+
*/
|
|
1337
|
+
private getCancelledByDisplayText(
|
|
1338
|
+
cancelledBy: 'patient' | 'clinic' | 'practitioner' | 'system',
|
|
1339
|
+
clinicName: string,
|
|
1340
|
+
): string {
|
|
1341
|
+
switch (cancelledBy) {
|
|
1342
|
+
case 'patient':
|
|
1343
|
+
return 'Patient Request';
|
|
1344
|
+
case 'clinic':
|
|
1345
|
+
return `${clinicName} (Clinic)`;
|
|
1346
|
+
case 'practitioner':
|
|
1347
|
+
return 'Your Practitioner';
|
|
1348
|
+
case 'system':
|
|
1349
|
+
return 'System (Automatic)';
|
|
1350
|
+
default:
|
|
1351
|
+
return 'Unknown';
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
/**
|
|
1356
|
+
* Sends an appointment cancellation email to the recipient
|
|
1357
|
+
* @param data - Appointment cancellation email data
|
|
1358
|
+
* @returns Promise with the sending result
|
|
1359
|
+
*/
|
|
703
1360
|
async sendAppointmentCancelledEmail(data: AppointmentCancellationEmailData): Promise<any> {
|
|
704
1361
|
Logger.info(
|
|
705
|
-
`[AppointmentMailingService]
|
|
1362
|
+
`[AppointmentMailingService] Preparing to send appointment cancellation email to ${data.recipientRole}: ${data.recipientProfile.id}`,
|
|
706
1363
|
);
|
|
707
|
-
|
|
1364
|
+
|
|
1365
|
+
const recipientEmail = data.recipientProfile.email;
|
|
1366
|
+
|
|
1367
|
+
if (!recipientEmail) {
|
|
1368
|
+
Logger.error('[AppointmentMailingService] Recipient email not found for cancellation.', {
|
|
1369
|
+
recipientId: data.recipientProfile.id,
|
|
1370
|
+
role: data.recipientRole,
|
|
1371
|
+
});
|
|
1372
|
+
throw new Error('Recipient email address is missing.');
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
// Get clinic timezone from appointment data, default to UTC if not available
|
|
1376
|
+
const clinicTimezone = data.appointment.clinic_tz || 'UTC';
|
|
1377
|
+
|
|
1378
|
+
Logger.debug('[AppointmentMailingService] Formatting appointment time for cancellation', {
|
|
1379
|
+
clinicTimezone,
|
|
1380
|
+
utcTime: data.appointment.appointmentStartTime.toDate().toISOString(),
|
|
1381
|
+
});
|
|
1382
|
+
|
|
1383
|
+
// Format time with timezone label for clarity
|
|
1384
|
+
const formattedTime = this.formatTimestampInClinicTimezone(
|
|
1385
|
+
data.appointment.appointmentStartTime,
|
|
1386
|
+
clinicTimezone,
|
|
1387
|
+
'time',
|
|
1388
|
+
);
|
|
1389
|
+
const timezoneName = this.getTimezoneDisplayName(clinicTimezone);
|
|
1390
|
+
|
|
1391
|
+
// Determine the cancelled by source from the appointment
|
|
1392
|
+
const cancelledBy = data.appointment.canceledBy || 'system';
|
|
1393
|
+
const cancelledByDisplay = this.getCancelledByDisplayText(
|
|
1394
|
+
cancelledBy,
|
|
1395
|
+
data.appointment.clinicInfo.name,
|
|
1396
|
+
);
|
|
1397
|
+
|
|
1398
|
+
// Get recipient name based on role
|
|
1399
|
+
const recipientName =
|
|
1400
|
+
data.recipientRole === 'patient'
|
|
1401
|
+
? data.appointment.patientInfo.fullName
|
|
1402
|
+
: data.appointment.practitionerInfo.name;
|
|
1403
|
+
|
|
1404
|
+
// Build template variables
|
|
1405
|
+
const templateVariables: Record<string, string> = {
|
|
1406
|
+
recipientName,
|
|
1407
|
+
procedureName: data.appointment.procedureInfo.name,
|
|
1408
|
+
appointmentDate: this.formatTimestampInClinicTimezone(
|
|
1409
|
+
data.appointment.appointmentStartTime,
|
|
1410
|
+
clinicTimezone,
|
|
1411
|
+
'date',
|
|
1412
|
+
),
|
|
1413
|
+
appointmentTime: `${formattedTime} (${timezoneName})`,
|
|
1414
|
+
practitionerName: data.appointment.practitionerInfo.name,
|
|
1415
|
+
clinicName: data.appointment.clinicInfo.name,
|
|
1416
|
+
cancelledByDisplay,
|
|
1417
|
+
};
|
|
1418
|
+
|
|
1419
|
+
// Handle cancellation reason - render with or without the reason block
|
|
1420
|
+
const cancellationReason = data.cancellationReason || data.appointment.cancellationReason;
|
|
1421
|
+
let html = appointmentCancelledTemplate;
|
|
1422
|
+
|
|
1423
|
+
if (cancellationReason) {
|
|
1424
|
+
// Replace the conditional block with the reason content
|
|
1425
|
+
templateVariables.cancellationReason = cancellationReason;
|
|
1426
|
+
html = html.replace(
|
|
1427
|
+
/\{\{#if cancellationReason\}\}([\s\S]*?)\{\{\/if\}\}/g,
|
|
1428
|
+
'$1',
|
|
1429
|
+
);
|
|
1430
|
+
} else {
|
|
1431
|
+
// Remove the conditional block entirely
|
|
1432
|
+
html = html.replace(/\{\{#if cancellationReason\}\}[\s\S]*?\{\{\/if\}\}/g, '');
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
html = this.renderTemplate(html, templateVariables);
|
|
1436
|
+
|
|
1437
|
+
const subject =
|
|
1438
|
+
data.options?.customSubject ||
|
|
1439
|
+
`Appointment Cancelled: ${data.appointment.procedureInfo.name}`;
|
|
1440
|
+
const fromAddress =
|
|
1441
|
+
data.options?.fromAddress ||
|
|
1442
|
+
`MetaEstetics <no-reply@${data.options?.mailgunDomain || this.DEFAULT_MAILGUN_DOMAIN}>`;
|
|
1443
|
+
const domainToSendFrom = data.options?.mailgunDomain || this.DEFAULT_MAILGUN_DOMAIN;
|
|
1444
|
+
|
|
1445
|
+
const mailgunSendData = {
|
|
1446
|
+
to: recipientEmail,
|
|
1447
|
+
from: fromAddress,
|
|
1448
|
+
subject,
|
|
1449
|
+
html,
|
|
1450
|
+
};
|
|
1451
|
+
|
|
1452
|
+
try {
|
|
1453
|
+
const result = await this.sendEmail(domainToSendFrom, mailgunSendData);
|
|
1454
|
+
await this.logEmailAttempt(
|
|
1455
|
+
{ to: recipientEmail, subject, templateName: 'appointment_cancelled' },
|
|
1456
|
+
true,
|
|
1457
|
+
);
|
|
1458
|
+
Logger.info(
|
|
1459
|
+
`[AppointmentMailingService] Successfully sent cancellation email to ${recipientEmail}`,
|
|
1460
|
+
);
|
|
1461
|
+
return result;
|
|
1462
|
+
} catch (error) {
|
|
1463
|
+
await this.logEmailAttempt(
|
|
1464
|
+
{
|
|
1465
|
+
to: recipientEmail,
|
|
1466
|
+
subject,
|
|
1467
|
+
templateName: 'appointment_cancelled',
|
|
1468
|
+
},
|
|
1469
|
+
false,
|
|
1470
|
+
error,
|
|
1471
|
+
);
|
|
1472
|
+
Logger.error(
|
|
1473
|
+
`[AppointmentMailingService] Error sending cancellation email to ${recipientEmail}:`,
|
|
1474
|
+
error,
|
|
1475
|
+
);
|
|
1476
|
+
throw error;
|
|
1477
|
+
}
|
|
708
1478
|
}
|
|
709
1479
|
|
|
1480
|
+
/**
|
|
1481
|
+
* Sends a reschedule proposal email to the patient
|
|
1482
|
+
* @param data - Appointment reschedule proposal email data
|
|
1483
|
+
* @returns Promise with the sending result
|
|
1484
|
+
*/
|
|
710
1485
|
async sendAppointmentRescheduledProposalEmail(
|
|
711
1486
|
data: AppointmentRescheduledProposalEmailData,
|
|
712
1487
|
): Promise<any> {
|
|
713
1488
|
Logger.info(
|
|
714
|
-
`[AppointmentMailingService]
|
|
1489
|
+
`[AppointmentMailingService] Preparing to send reschedule proposal email to patient: ${data.patientProfile.id}`,
|
|
715
1490
|
);
|
|
716
|
-
|
|
1491
|
+
|
|
1492
|
+
const recipientEmail = data.patientProfile.email;
|
|
1493
|
+
|
|
1494
|
+
if (!recipientEmail) {
|
|
1495
|
+
Logger.error('[AppointmentMailingService] Patient email not found for reschedule proposal.', {
|
|
1496
|
+
patientId: data.patientProfile.id,
|
|
1497
|
+
});
|
|
1498
|
+
throw new Error('Patient email address is missing.');
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
// Get clinic timezone from appointment data, default to UTC if not available
|
|
1502
|
+
const clinicTimezone = data.appointment.clinic_tz || 'UTC';
|
|
1503
|
+
|
|
1504
|
+
Logger.debug('[AppointmentMailingService] Formatting appointment times for reschedule', {
|
|
1505
|
+
clinicTimezone,
|
|
1506
|
+
previousTime: data.previousStartTime.toDate().toISOString(),
|
|
1507
|
+
newTime: data.appointment.appointmentStartTime.toDate().toISOString(),
|
|
1508
|
+
});
|
|
1509
|
+
|
|
1510
|
+
// Format previous time
|
|
1511
|
+
const previousFormattedTime = this.formatTimestampInClinicTimezone(
|
|
1512
|
+
data.previousStartTime,
|
|
1513
|
+
clinicTimezone,
|
|
1514
|
+
'time',
|
|
1515
|
+
);
|
|
1516
|
+
const previousFormattedDate = this.formatTimestampInClinicTimezone(
|
|
1517
|
+
data.previousStartTime,
|
|
1518
|
+
clinicTimezone,
|
|
1519
|
+
'date',
|
|
1520
|
+
);
|
|
1521
|
+
const previousTimezoneName = this.getTimezoneDisplayName(clinicTimezone);
|
|
1522
|
+
|
|
1523
|
+
// Format new proposed time
|
|
1524
|
+
const newFormattedTime = this.formatTimestampInClinicTimezone(
|
|
1525
|
+
data.appointment.appointmentStartTime,
|
|
1526
|
+
clinicTimezone,
|
|
1527
|
+
'time',
|
|
1528
|
+
);
|
|
1529
|
+
const newFormattedDate = this.formatTimestampInClinicTimezone(
|
|
1530
|
+
data.appointment.appointmentStartTime,
|
|
1531
|
+
clinicTimezone,
|
|
1532
|
+
'date',
|
|
1533
|
+
);
|
|
1534
|
+
const newTimezoneName = this.getTimezoneDisplayName(clinicTimezone);
|
|
1535
|
+
|
|
1536
|
+
const templateVariables = {
|
|
1537
|
+
patientName: data.appointment.patientInfo.fullName,
|
|
1538
|
+
procedureName: data.appointment.procedureInfo.name,
|
|
1539
|
+
practitionerName: data.appointment.practitionerInfo.name,
|
|
1540
|
+
clinicName: data.appointment.clinicInfo.name,
|
|
1541
|
+
previousDate: previousFormattedDate,
|
|
1542
|
+
previousTime: `${previousFormattedTime} (${previousTimezoneName})`,
|
|
1543
|
+
newDate: newFormattedDate,
|
|
1544
|
+
newTime: `${newFormattedTime} (${newTimezoneName})`,
|
|
1545
|
+
};
|
|
1546
|
+
|
|
1547
|
+
const html = this.renderTemplate(appointmentRescheduledProposalTemplate, templateVariables);
|
|
1548
|
+
const subject =
|
|
1549
|
+
data.options?.customSubject ||
|
|
1550
|
+
`Action Required: Reschedule Proposal for Your ${data.appointment.procedureInfo.name} Appointment`;
|
|
1551
|
+
const fromAddress =
|
|
1552
|
+
data.options?.fromAddress ||
|
|
1553
|
+
`MetaEstetics <no-reply@${data.options?.mailgunDomain || this.DEFAULT_MAILGUN_DOMAIN}>`;
|
|
1554
|
+
const domainToSendFrom = data.options?.mailgunDomain || this.DEFAULT_MAILGUN_DOMAIN;
|
|
1555
|
+
|
|
1556
|
+
const mailgunSendData = {
|
|
1557
|
+
to: recipientEmail,
|
|
1558
|
+
from: fromAddress,
|
|
1559
|
+
subject,
|
|
1560
|
+
html,
|
|
1561
|
+
};
|
|
1562
|
+
|
|
1563
|
+
try {
|
|
1564
|
+
const result = await this.sendEmail(domainToSendFrom, mailgunSendData);
|
|
1565
|
+
await this.logEmailAttempt(
|
|
1566
|
+
{ to: recipientEmail, subject, templateName: 'appointment_rescheduled_proposal' },
|
|
1567
|
+
true,
|
|
1568
|
+
);
|
|
1569
|
+
Logger.info(
|
|
1570
|
+
`[AppointmentMailingService] Successfully sent reschedule proposal email to ${recipientEmail}`,
|
|
1571
|
+
);
|
|
1572
|
+
return result;
|
|
1573
|
+
} catch (error) {
|
|
1574
|
+
await this.logEmailAttempt(
|
|
1575
|
+
{
|
|
1576
|
+
to: recipientEmail,
|
|
1577
|
+
subject,
|
|
1578
|
+
templateName: 'appointment_rescheduled_proposal',
|
|
1579
|
+
},
|
|
1580
|
+
false,
|
|
1581
|
+
error,
|
|
1582
|
+
);
|
|
1583
|
+
Logger.error(
|
|
1584
|
+
`[AppointmentMailingService] Error sending reschedule proposal email to ${recipientEmail}:`,
|
|
1585
|
+
error,
|
|
1586
|
+
);
|
|
1587
|
+
throw error;
|
|
1588
|
+
}
|
|
717
1589
|
}
|
|
718
1590
|
|
|
719
1591
|
async sendReviewRequestEmail(data: ReviewRequestEmailData): Promise<any> {
|