@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.
@@ -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">&#10060;</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] Placeholder for sendAppointmentCancelledEmail for ${data.recipientRole}: ${data.recipientProfile.id}`,
1362
+ `[AppointmentMailingService] Preparing to send appointment cancellation email to ${data.recipientRole}: ${data.recipientProfile.id}`,
706
1363
  );
707
- return Promise.resolve();
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] Placeholder for sendAppointmentRescheduledProposalEmail to patient: ${data.patientProfile.id}`,
1489
+ `[AppointmentMailingService] Preparing to send reschedule proposal email to patient: ${data.patientProfile.id}`,
715
1490
  );
716
- return Promise.resolve();
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> {