@blackcode_sa/metaestetics-api 1.14.58 → 1.14.60
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 +42 -1
- package/dist/admin/index.d.ts +42 -1
- package/dist/admin/index.js +572 -19
- package/dist/admin/index.mjs +572 -19
- package/dist/index.d.mts +19 -2
- package/dist/index.d.ts +19 -2
- package/dist/index.js +1 -0
- package/dist/index.mjs +1 -0
- package/package.json +1 -1
- package/src/admin/aggregation/appointment/appointment.aggregation.service.ts +28 -6
- package/src/admin/mailing/appointment/appointment.mailing.service.ts +498 -7
- package/src/admin/notifications/notifications.admin.ts +104 -6
- package/src/types/appointment/index.ts +6 -0
- package/src/types/notifications/index.ts +14 -0
|
@@ -411,6 +411,306 @@ 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
|
+
|
|
414
714
|
const appointmentRescheduledProposalTemplate = `
|
|
415
715
|
<!DOCTYPE html>
|
|
416
716
|
<html lang="en">
|
|
@@ -571,6 +871,42 @@ const appointmentRescheduledProposalTemplate = `
|
|
|
571
871
|
font-size: 15px;
|
|
572
872
|
line-height: 1.6;
|
|
573
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
|
+
}
|
|
574
910
|
.footer {
|
|
575
911
|
background: #f8f9fa;
|
|
576
912
|
padding: 25px 30px;
|
|
@@ -662,16 +998,29 @@ const appointmentRescheduledProposalTemplate = `
|
|
|
662
998
|
</div>
|
|
663
999
|
|
|
664
1000
|
<div class="divider"></div>
|
|
665
|
-
|
|
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
|
+
|
|
666
1015
|
<div class="action-section">
|
|
667
|
-
<h3>
|
|
1016
|
+
<h3>How to Respond</h3>
|
|
668
1017
|
<p>
|
|
669
|
-
|
|
670
|
-
If the new time works for you, simply tap "Accept Reschedule".
|
|
1018
|
+
Open the MetaEstetics app and navigate to your appointments.
|
|
1019
|
+
If the new time works for you, simply tap "Accept Reschedule".
|
|
671
1020
|
If not, you can reject it and we'll work with you to find an alternative time.
|
|
672
1021
|
</p>
|
|
673
1022
|
</div>
|
|
674
|
-
|
|
1023
|
+
|
|
675
1024
|
<p style="color: #555; font-size: 14px; line-height: 1.6; margin-top: 25px;">
|
|
676
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}}.
|
|
677
1026
|
</p>
|
|
@@ -979,11 +1328,153 @@ export class AppointmentMailingService extends BaseMailingService {
|
|
|
979
1328
|
}
|
|
980
1329
|
}
|
|
981
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
|
+
*/
|
|
982
1360
|
async sendAppointmentCancelledEmail(data: AppointmentCancellationEmailData): Promise<any> {
|
|
983
1361
|
Logger.info(
|
|
984
|
-
`[AppointmentMailingService]
|
|
1362
|
+
`[AppointmentMailingService] Preparing to send appointment cancellation email to ${data.recipientRole}: ${data.recipientProfile.id}`,
|
|
985
1363
|
);
|
|
986
|
-
|
|
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
|
+
}
|
|
987
1478
|
}
|
|
988
1479
|
|
|
989
1480
|
/**
|
|
@@ -43,6 +43,47 @@ export class NotificationsAdmin {
|
|
|
43
43
|
return docRef.id;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Creates a notification and immediately attempts to send it.
|
|
48
|
+
* If immediate send fails, the notification remains PENDING for cron pickup.
|
|
49
|
+
* Returns the notification ID regardless of send success.
|
|
50
|
+
*/
|
|
51
|
+
async createAndSendNotificationImmediately(
|
|
52
|
+
notification: Omit<Notification, "id">
|
|
53
|
+
): Promise<string> {
|
|
54
|
+
// Create the notification first
|
|
55
|
+
const notificationId = await this.createNotification(notification);
|
|
56
|
+
|
|
57
|
+
// Immediately try to send it
|
|
58
|
+
try {
|
|
59
|
+
const fullNotification: Notification = {
|
|
60
|
+
...notification,
|
|
61
|
+
id: notificationId,
|
|
62
|
+
status: NotificationStatus.PENDING,
|
|
63
|
+
} as Notification;
|
|
64
|
+
|
|
65
|
+
const sent = await this.sendPushNotification(fullNotification);
|
|
66
|
+
|
|
67
|
+
if (sent) {
|
|
68
|
+
Logger.info(
|
|
69
|
+
`[NotificationsAdmin] Notification ${notificationId} sent immediately`
|
|
70
|
+
);
|
|
71
|
+
} else {
|
|
72
|
+
Logger.info(
|
|
73
|
+
`[NotificationsAdmin] Notification ${notificationId} immediate send failed, will be retried by cron`
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
} catch (error) {
|
|
77
|
+
// Don't throw - notification is created, cron will pick it up
|
|
78
|
+
Logger.error(
|
|
79
|
+
`[NotificationsAdmin] Error sending notification ${notificationId} immediately, will be retried by cron:`,
|
|
80
|
+
error
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return notificationId;
|
|
85
|
+
}
|
|
86
|
+
|
|
46
87
|
/**
|
|
47
88
|
* Priprema Expo poruku za slanje
|
|
48
89
|
*/
|
|
@@ -406,7 +447,7 @@ export class NotificationsAdmin {
|
|
|
406
447
|
};
|
|
407
448
|
|
|
408
449
|
try {
|
|
409
|
-
const notificationId = await this.
|
|
450
|
+
const notificationId = await this.createAndSendNotificationImmediately(
|
|
410
451
|
notificationData as Notification
|
|
411
452
|
);
|
|
412
453
|
console.log(
|
|
@@ -479,7 +520,7 @@ export class NotificationsAdmin {
|
|
|
479
520
|
};
|
|
480
521
|
|
|
481
522
|
try {
|
|
482
|
-
const notificationId = await this.
|
|
523
|
+
const notificationId = await this.createAndSendNotificationImmediately(
|
|
483
524
|
notificationData as Notification
|
|
484
525
|
);
|
|
485
526
|
console.log(
|
|
@@ -527,7 +568,7 @@ export class NotificationsAdmin {
|
|
|
527
568
|
};
|
|
528
569
|
|
|
529
570
|
try {
|
|
530
|
-
const notificationId = await this.
|
|
571
|
+
const notificationId = await this.createAndSendNotificationImmediately(
|
|
531
572
|
notificationData as Notification
|
|
532
573
|
);
|
|
533
574
|
console.log(
|
|
@@ -583,7 +624,7 @@ export class NotificationsAdmin {
|
|
|
583
624
|
};
|
|
584
625
|
|
|
585
626
|
try {
|
|
586
|
-
const notificationId = await this.
|
|
627
|
+
const notificationId = await this.createAndSendNotificationImmediately(
|
|
587
628
|
notificationData as Notification
|
|
588
629
|
);
|
|
589
630
|
console.log(
|
|
@@ -631,7 +672,7 @@ export class NotificationsAdmin {
|
|
|
631
672
|
};
|
|
632
673
|
|
|
633
674
|
try {
|
|
634
|
-
const notificationId = await this.
|
|
675
|
+
const notificationId = await this.createAndSendNotificationImmediately(
|
|
635
676
|
notificationData as Notification
|
|
636
677
|
);
|
|
637
678
|
console.log(
|
|
@@ -692,7 +733,7 @@ export class NotificationsAdmin {
|
|
|
692
733
|
};
|
|
693
734
|
|
|
694
735
|
try {
|
|
695
|
-
const notificationId = await this.
|
|
736
|
+
const notificationId = await this.createAndSendNotificationImmediately(
|
|
696
737
|
notificationData as Notification
|
|
697
738
|
);
|
|
698
739
|
console.log(
|
|
@@ -707,4 +748,61 @@ export class NotificationsAdmin {
|
|
|
707
748
|
return null;
|
|
708
749
|
}
|
|
709
750
|
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* Sends a reminder push notification for a pending reschedule request.
|
|
754
|
+
* Used when a clinic has proposed a reschedule and the patient hasn't responded.
|
|
755
|
+
* @param appointment The appointment with pending reschedule.
|
|
756
|
+
* @param patientUserId The ID of the patient.
|
|
757
|
+
* @param patientExpoTokens Array of Expo push tokens for the patient.
|
|
758
|
+
* @param reminderCount Optional count of reminders already sent (for tracking).
|
|
759
|
+
*/
|
|
760
|
+
async sendRescheduleReminderPush(
|
|
761
|
+
appointment: Appointment,
|
|
762
|
+
patientUserId: string,
|
|
763
|
+
patientExpoTokens: string[],
|
|
764
|
+
reminderCount?: number
|
|
765
|
+
): Promise<string | null> {
|
|
766
|
+
if (!patientExpoTokens || patientExpoTokens.length === 0) {
|
|
767
|
+
console.log(
|
|
768
|
+
`[NotificationsAdmin] No expo tokens for patient ${patientUserId} for appointment ${appointment.id} reschedule reminder. Skipping push.`
|
|
769
|
+
);
|
|
770
|
+
return null;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
const title = "Reminder: Reschedule Request Pending";
|
|
774
|
+
const body = `You have a pending reschedule request for your ${appointment.procedureInfo.name} appointment. Please respond in the app.`;
|
|
775
|
+
|
|
776
|
+
const notificationTimestampForDb = admin.firestore.Timestamp.now();
|
|
777
|
+
|
|
778
|
+
const notificationData: Omit<
|
|
779
|
+
Notification,
|
|
780
|
+
"id" | "createdAt" | "updatedAt" | "status" | "isRead"
|
|
781
|
+
> = {
|
|
782
|
+
userId: patientUserId,
|
|
783
|
+
userRole: UserRole.PATIENT,
|
|
784
|
+
notificationType: NotificationType.APPOINTMENT_RESCHEDULED_REMINDER,
|
|
785
|
+
notificationTime: notificationTimestampForDb as any,
|
|
786
|
+
notificationTokens: patientExpoTokens,
|
|
787
|
+
title,
|
|
788
|
+
body,
|
|
789
|
+
appointmentId: appointment.id,
|
|
790
|
+
};
|
|
791
|
+
|
|
792
|
+
try {
|
|
793
|
+
const notificationId = await this.createAndSendNotificationImmediately(
|
|
794
|
+
notificationData as Notification
|
|
795
|
+
);
|
|
796
|
+
console.log(
|
|
797
|
+
`[NotificationsAdmin] Created APPOINTMENT_RESCHEDULED_REMINDER notification ${notificationId} for patient ${patientUserId}. Reminder count: ${reminderCount ?? 1}.`
|
|
798
|
+
);
|
|
799
|
+
return notificationId;
|
|
800
|
+
} catch (error) {
|
|
801
|
+
console.error(
|
|
802
|
+
`[NotificationsAdmin] Error creating APPOINTMENT_RESCHEDULED_REMINDER notification for patient ${patientUserId}:`,
|
|
803
|
+
error
|
|
804
|
+
);
|
|
805
|
+
return null;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
710
808
|
}
|