@blackcode_sa/metaestetics-api 1.14.58 → 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.
@@ -505,6 +505,7 @@ var NotificationType = /* @__PURE__ */ ((NotificationType2) => {
505
505
  NotificationType2["APPOINTMENT_REMINDER"] = "appointmentReminder";
506
506
  NotificationType2["APPOINTMENT_STATUS_CHANGE"] = "appointmentStatusChange";
507
507
  NotificationType2["APPOINTMENT_RESCHEDULED_PROPOSAL"] = "appointmentRescheduledProposal";
508
+ NotificationType2["APPOINTMENT_RESCHEDULED_REMINDER"] = "appointmentRescheduledReminder";
508
509
  NotificationType2["APPOINTMENT_CANCELLED"] = "appointmentCancelled";
509
510
  NotificationType2["PRE_REQUIREMENT_INSTRUCTION_DUE"] = "preRequirementInstructionDue";
510
511
  NotificationType2["POST_REQUIREMENT_INSTRUCTION_DUE"] = "postRequirementInstructionDue";
@@ -1200,6 +1201,50 @@ var NotificationsAdmin = class {
1200
1201
  return null;
1201
1202
  }
1202
1203
  }
1204
+ /**
1205
+ * Sends a reminder push notification for a pending reschedule request.
1206
+ * Used when a clinic has proposed a reschedule and the patient hasn't responded.
1207
+ * @param appointment The appointment with pending reschedule.
1208
+ * @param patientUserId The ID of the patient.
1209
+ * @param patientExpoTokens Array of Expo push tokens for the patient.
1210
+ * @param reminderCount Optional count of reminders already sent (for tracking).
1211
+ */
1212
+ async sendRescheduleReminderPush(appointment, patientUserId, patientExpoTokens, reminderCount) {
1213
+ if (!patientExpoTokens || patientExpoTokens.length === 0) {
1214
+ console.log(
1215
+ `[NotificationsAdmin] No expo tokens for patient ${patientUserId} for appointment ${appointment.id} reschedule reminder. Skipping push.`
1216
+ );
1217
+ return null;
1218
+ }
1219
+ const title = "Reminder: Reschedule Request Pending";
1220
+ const body = `You have a pending reschedule request for your ${appointment.procedureInfo.name} appointment. Please respond in the app.`;
1221
+ const notificationTimestampForDb = admin2.firestore.Timestamp.now();
1222
+ const notificationData = {
1223
+ userId: patientUserId,
1224
+ userRole: "patient" /* PATIENT */,
1225
+ notificationType: "appointmentRescheduledReminder" /* APPOINTMENT_RESCHEDULED_REMINDER */,
1226
+ notificationTime: notificationTimestampForDb,
1227
+ notificationTokens: patientExpoTokens,
1228
+ title,
1229
+ body,
1230
+ appointmentId: appointment.id
1231
+ };
1232
+ try {
1233
+ const notificationId = await this.createNotification(
1234
+ notificationData
1235
+ );
1236
+ console.log(
1237
+ `[NotificationsAdmin] Created APPOINTMENT_RESCHEDULED_REMINDER notification ${notificationId} for patient ${patientUserId}. Reminder count: ${reminderCount != null ? reminderCount : 1}.`
1238
+ );
1239
+ return notificationId;
1240
+ } catch (error) {
1241
+ console.error(
1242
+ `[NotificationsAdmin] Error creating APPOINTMENT_RESCHEDULED_REMINDER notification for patient ${patientUserId}:`,
1243
+ error
1244
+ );
1245
+ return null;
1246
+ }
1247
+ }
1203
1248
  };
1204
1249
 
1205
1250
  // src/admin/requirements/patient-requirements.admin.service.ts
@@ -2363,6 +2408,305 @@ var clinicAppointmentRequestedTemplate = `
2363
2408
  </body>
2364
2409
  </html>
2365
2410
  `;
2411
+ var appointmentCancelledTemplate = `
2412
+ <!DOCTYPE html>
2413
+ <html lang="en">
2414
+ <head>
2415
+ <meta charset="UTF-8">
2416
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
2417
+ <title>Appointment Cancelled</title>
2418
+ <style>
2419
+ body {
2420
+ margin: 0;
2421
+ padding: 0;
2422
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
2423
+ background: linear-gradient(135deg, #d4736c 0%, #b85450 100%);
2424
+ min-height: 100vh;
2425
+ }
2426
+ .email-container {
2427
+ max-width: 600px;
2428
+ margin: 0 auto;
2429
+ background: #ffffff;
2430
+ border-radius: 20px;
2431
+ overflow: hidden;
2432
+ box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
2433
+ margin-top: 40px;
2434
+ margin-bottom: 40px;
2435
+ }
2436
+ .header {
2437
+ background: linear-gradient(135deg, #d4736c 0%, #b85450 100%);
2438
+ padding: 40px 30px;
2439
+ text-align: center;
2440
+ color: white;
2441
+ }
2442
+ .header h1 {
2443
+ margin: 0;
2444
+ font-size: 28px;
2445
+ font-weight: 300;
2446
+ letter-spacing: 1px;
2447
+ }
2448
+ .header .subtitle {
2449
+ margin: 10px 0 0 0;
2450
+ font-size: 16px;
2451
+ opacity: 0.9;
2452
+ font-weight: 300;
2453
+ }
2454
+ .content {
2455
+ padding: 40px 30px;
2456
+ }
2457
+ .greeting {
2458
+ font-size: 18px;
2459
+ color: #333;
2460
+ margin-bottom: 25px;
2461
+ font-weight: 400;
2462
+ }
2463
+ .cancellation-notice {
2464
+ background: linear-gradient(135deg, #ffebee 0%, #ffcdd2 100%);
2465
+ border-radius: 15px;
2466
+ padding: 25px;
2467
+ margin: 25px 0;
2468
+ border-left: 5px solid #d4736c;
2469
+ }
2470
+ .cancellation-notice p {
2471
+ margin: 0;
2472
+ color: #c62828;
2473
+ font-size: 15px;
2474
+ font-weight: 500;
2475
+ line-height: 1.6;
2476
+ }
2477
+ .cancelled-by-info {
2478
+ background: #fafafa;
2479
+ border-radius: 10px;
2480
+ padding: 15px 20px;
2481
+ margin-top: 15px;
2482
+ }
2483
+ .cancelled-by-info .label {
2484
+ font-size: 12px;
2485
+ color: #757575;
2486
+ text-transform: uppercase;
2487
+ letter-spacing: 0.5px;
2488
+ margin-bottom: 5px;
2489
+ }
2490
+ .cancelled-by-info .value {
2491
+ font-size: 14px;
2492
+ color: #424242;
2493
+ font-weight: 500;
2494
+ }
2495
+ .reason-box {
2496
+ background: linear-gradient(135deg, #fff8e1 0%, #ffecb3 100%);
2497
+ border-radius: 15px;
2498
+ padding: 20px;
2499
+ margin: 20px 0;
2500
+ border-left: 5px solid #ffa000;
2501
+ }
2502
+ .reason-box .label {
2503
+ font-size: 14px;
2504
+ font-weight: 600;
2505
+ color: #e65100;
2506
+ margin-bottom: 8px;
2507
+ }
2508
+ .reason-box .reason-text {
2509
+ font-size: 15px;
2510
+ color: #424242;
2511
+ line-height: 1.6;
2512
+ font-style: italic;
2513
+ }
2514
+ .appointment-card {
2515
+ background: linear-gradient(135deg, #f5f5f5 0%, #eeeeee 100%);
2516
+ border-radius: 15px;
2517
+ padding: 30px;
2518
+ margin: 25px 0;
2519
+ border-left: 5px solid #9e9e9e;
2520
+ opacity: 0.9;
2521
+ }
2522
+ .appointment-title {
2523
+ font-size: 20px;
2524
+ color: #757575;
2525
+ margin-bottom: 20px;
2526
+ font-weight: 600;
2527
+ }
2528
+ .appointment-details {
2529
+ display: grid;
2530
+ gap: 15px;
2531
+ }
2532
+ .detail-row {
2533
+ display: flex;
2534
+ align-items: center;
2535
+ padding: 8px 0;
2536
+ }
2537
+ .detail-label {
2538
+ font-weight: 600;
2539
+ color: #757575;
2540
+ min-width: 120px;
2541
+ font-size: 14px;
2542
+ }
2543
+ .detail-value {
2544
+ color: #616161;
2545
+ font-size: 16px;
2546
+ font-weight: 500;
2547
+ text-decoration: line-through;
2548
+ }
2549
+ .procedure-name {
2550
+ color: #757575;
2551
+ font-weight: 600;
2552
+ }
2553
+ .clinic-name {
2554
+ color: #9e9e9e;
2555
+ font-weight: 600;
2556
+ }
2557
+ .rebook-section {
2558
+ background: linear-gradient(135deg, #e8f5e9 0%, #c8e6c9 100%);
2559
+ border-radius: 15px;
2560
+ padding: 25px;
2561
+ margin: 30px 0;
2562
+ text-align: center;
2563
+ border-left: 5px solid #4caf50;
2564
+ }
2565
+ .rebook-section h3 {
2566
+ margin: 0 0 15px 0;
2567
+ color: #2e7d32;
2568
+ font-weight: 600;
2569
+ font-size: 18px;
2570
+ }
2571
+ .rebook-section p {
2572
+ margin: 0;
2573
+ color: #555;
2574
+ font-size: 15px;
2575
+ line-height: 1.6;
2576
+ }
2577
+ .support-section {
2578
+ background: #f8f9fa;
2579
+ border-radius: 15px;
2580
+ padding: 20px;
2581
+ margin: 25px 0;
2582
+ text-align: center;
2583
+ }
2584
+ .support-section h4 {
2585
+ margin: 0 0 10px 0;
2586
+ color: #555;
2587
+ font-weight: 600;
2588
+ font-size: 16px;
2589
+ }
2590
+ .support-section p {
2591
+ margin: 0;
2592
+ color: #757575;
2593
+ font-size: 14px;
2594
+ line-height: 1.6;
2595
+ }
2596
+ .footer {
2597
+ background: #f8f9fa;
2598
+ padding: 25px 30px;
2599
+ text-align: center;
2600
+ color: #666;
2601
+ font-size: 14px;
2602
+ border-top: 1px solid #eee;
2603
+ }
2604
+ .logo {
2605
+ font-size: 24px;
2606
+ font-weight: 700;
2607
+ color: white;
2608
+ margin-bottom: 5px;
2609
+ }
2610
+ .divider {
2611
+ height: 2px;
2612
+ background: linear-gradient(90deg, #d4736c, #b85450);
2613
+ margin: 25px 0;
2614
+ border-radius: 1px;
2615
+ }
2616
+ .icon {
2617
+ text-align: center;
2618
+ margin: 20px 0;
2619
+ font-size: 48px;
2620
+ }
2621
+ </style>
2622
+ </head>
2623
+ <body>
2624
+ <div class="email-container">
2625
+ <div class="header">
2626
+ <div class="logo">MetaEstetics</div>
2627
+ <h1>Appointment Cancelled</h1>
2628
+ <div class="subtitle">We're Sorry to See This Change</div>
2629
+ </div>
2630
+
2631
+ <div class="content">
2632
+ <div class="icon">&#10060;</div>
2633
+
2634
+ <div class="greeting">
2635
+ Dear <strong>{{recipientName}}</strong>,
2636
+ </div>
2637
+
2638
+ <div class="cancellation-notice">
2639
+ <p><strong>Your appointment has been cancelled.</strong> We wanted to let you know that the following appointment is no longer scheduled.</p>
2640
+ <div class="cancelled-by-info">
2641
+ <div class="label">Cancelled By</div>
2642
+ <div class="value">{{cancelledByDisplay}}</div>
2643
+ </div>
2644
+ </div>
2645
+
2646
+ {{#if cancellationReason}}
2647
+ <div class="reason-box">
2648
+ <div class="label">Reason for Cancellation</div>
2649
+ <div class="reason-text">"{{cancellationReason}}"</div>
2650
+ </div>
2651
+ {{/if}}
2652
+
2653
+ <div class="appointment-card">
2654
+ <div class="appointment-title">Cancelled Appointment Details</div>
2655
+ <div class="appointment-details">
2656
+ <div class="detail-row">
2657
+ <div class="detail-label">Procedure:</div>
2658
+ <div class="detail-value procedure-name">{{procedureName}}</div>
2659
+ </div>
2660
+ <div class="detail-row">
2661
+ <div class="detail-label">Date:</div>
2662
+ <div class="detail-value">{{appointmentDate}}</div>
2663
+ </div>
2664
+ <div class="detail-row">
2665
+ <div class="detail-label">Time:</div>
2666
+ <div class="detail-value">{{appointmentTime}}</div>
2667
+ </div>
2668
+ <div class="detail-row">
2669
+ <div class="detail-label">Practitioner:</div>
2670
+ <div class="detail-value">{{practitionerName}}</div>
2671
+ </div>
2672
+ <div class="detail-row">
2673
+ <div class="detail-label">Location:</div>
2674
+ <div class="detail-value clinic-name">{{clinicName}}</div>
2675
+ </div>
2676
+ </div>
2677
+ </div>
2678
+
2679
+ <div class="divider"></div>
2680
+
2681
+ <div class="rebook-section">
2682
+ <h3>Would You Like to Reschedule?</h3>
2683
+ <p>
2684
+ We'd love to see you! If you'd like to book a new appointment,
2685
+ simply open the MetaEstetics app and browse available times that work for you.
2686
+ </p>
2687
+ </div>
2688
+
2689
+ <div class="support-section">
2690
+ <h4>Need Assistance?</h4>
2691
+ <p>
2692
+ If you have any questions about this cancellation or need help rebooking,
2693
+ please contact {{clinicName}} directly through the app or reach out to our support team.
2694
+ </p>
2695
+ </div>
2696
+ </div>
2697
+
2698
+ <div class="footer">
2699
+ <p style="margin: 0 0 10px 0;">
2700
+ <strong>MetaEstetics</strong> - Premium Aesthetic Services
2701
+ </p>
2702
+ <p style="margin: 0; font-size: 12px; color: #999;">
2703
+ This is an automated message. Please do not reply to this email.
2704
+ </p>
2705
+ </div>
2706
+ </div>
2707
+ </body>
2708
+ </html>
2709
+ `;
2366
2710
  var appointmentRescheduledProposalTemplate = `
2367
2711
  <!DOCTYPE html>
2368
2712
  <html lang="en">
@@ -2523,6 +2867,42 @@ var appointmentRescheduledProposalTemplate = `
2523
2867
  font-size: 15px;
2524
2868
  line-height: 1.6;
2525
2869
  }
2870
+ .action-required-box {
2871
+ background: linear-gradient(135deg, #fff3e0 0%, #ffecb3 100%);
2872
+ border: 2px solid #ff9800;
2873
+ border-radius: 15px;
2874
+ padding: 25px;
2875
+ margin: 25px 0;
2876
+ text-align: center;
2877
+ }
2878
+ .action-required-box h3 {
2879
+ margin: 0 0 15px 0;
2880
+ color: #e65100;
2881
+ font-weight: 700;
2882
+ font-size: 18px;
2883
+ }
2884
+ .action-required-box p {
2885
+ margin: 0 0 12px 0;
2886
+ color: #bf360c;
2887
+ font-size: 15px;
2888
+ line-height: 1.6;
2889
+ }
2890
+ .action-required-box p:last-child {
2891
+ margin-bottom: 0;
2892
+ }
2893
+ .pending-notice {
2894
+ background: #fff8e1;
2895
+ border-radius: 8px;
2896
+ padding: 12px 15px;
2897
+ margin-top: 15px;
2898
+ display: inline-block;
2899
+ }
2900
+ .pending-notice p {
2901
+ margin: 0;
2902
+ color: #f57c00;
2903
+ font-size: 14px;
2904
+ font-weight: 600;
2905
+ }
2526
2906
  .footer {
2527
2907
  background: #f8f9fa;
2528
2908
  padding: 25px 30px;
@@ -2614,16 +2994,29 @@ var appointmentRescheduledProposalTemplate = `
2614
2994
  </div>
2615
2995
 
2616
2996
  <div class="divider"></div>
2617
-
2997
+
2998
+ <div class="action-required-box">
2999
+ <h3>Your Response is Required</h3>
3000
+ <p>
3001
+ <strong>Missed our notification?</strong> Please open the MetaEstetics app to confirm or reject this reschedule request.
3002
+ </p>
3003
+ <p>
3004
+ Please respond as soon as possible so we can finalize your appointment.
3005
+ </p>
3006
+ <div class="pending-notice">
3007
+ <p>Your appointment will remain pending until you respond.</p>
3008
+ </div>
3009
+ </div>
3010
+
2618
3011
  <div class="action-section">
2619
- <h3>What's Next?</h3>
3012
+ <h3>How to Respond</h3>
2620
3013
  <p>
2621
- Please open the MetaEstetics app to accept or reject this reschedule proposal.
2622
- If the new time works for you, simply tap "Accept Reschedule".
3014
+ Open the MetaEstetics app and navigate to your appointments.
3015
+ If the new time works for you, simply tap "Accept Reschedule".
2623
3016
  If not, you can reject it and we'll work with you to find an alternative time.
2624
3017
  </p>
2625
3018
  </div>
2626
-
3019
+
2627
3020
  <p style="color: #555; font-size: 14px; line-height: 1.6; margin-top: 25px;">
2628
3021
  <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}}.
2629
3022
  </p>
@@ -2839,11 +3232,121 @@ var AppointmentMailingService = class extends BaseMailingService {
2839
3232
  throw error;
2840
3233
  }
2841
3234
  }
3235
+ /**
3236
+ * Gets a user-friendly display text for who cancelled the appointment
3237
+ * @param cancelledBy - The entity that cancelled the appointment
3238
+ * @param clinicName - The clinic name for context
3239
+ * @returns User-friendly cancellation source text
3240
+ */
3241
+ getCancelledByDisplayText(cancelledBy, clinicName) {
3242
+ switch (cancelledBy) {
3243
+ case "patient":
3244
+ return "Patient Request";
3245
+ case "clinic":
3246
+ return `${clinicName} (Clinic)`;
3247
+ case "practitioner":
3248
+ return "Your Practitioner";
3249
+ case "system":
3250
+ return "System (Automatic)";
3251
+ default:
3252
+ return "Unknown";
3253
+ }
3254
+ }
3255
+ /**
3256
+ * Sends an appointment cancellation email to the recipient
3257
+ * @param data - Appointment cancellation email data
3258
+ * @returns Promise with the sending result
3259
+ */
2842
3260
  async sendAppointmentCancelledEmail(data) {
3261
+ var _a, _b, _c, _d;
2843
3262
  Logger.info(
2844
- `[AppointmentMailingService] Placeholder for sendAppointmentCancelledEmail for ${data.recipientRole}: ${data.recipientProfile.id}`
3263
+ `[AppointmentMailingService] Preparing to send appointment cancellation email to ${data.recipientRole}: ${data.recipientProfile.id}`
2845
3264
  );
2846
- return Promise.resolve();
3265
+ const recipientEmail = data.recipientProfile.email;
3266
+ if (!recipientEmail) {
3267
+ Logger.error("[AppointmentMailingService] Recipient email not found for cancellation.", {
3268
+ recipientId: data.recipientProfile.id,
3269
+ role: data.recipientRole
3270
+ });
3271
+ throw new Error("Recipient email address is missing.");
3272
+ }
3273
+ const clinicTimezone = data.appointment.clinic_tz || "UTC";
3274
+ Logger.debug("[AppointmentMailingService] Formatting appointment time for cancellation", {
3275
+ clinicTimezone,
3276
+ utcTime: data.appointment.appointmentStartTime.toDate().toISOString()
3277
+ });
3278
+ const formattedTime = this.formatTimestampInClinicTimezone(
3279
+ data.appointment.appointmentStartTime,
3280
+ clinicTimezone,
3281
+ "time"
3282
+ );
3283
+ const timezoneName = this.getTimezoneDisplayName(clinicTimezone);
3284
+ const cancelledBy = data.appointment.canceledBy || "system";
3285
+ const cancelledByDisplay = this.getCancelledByDisplayText(
3286
+ cancelledBy,
3287
+ data.appointment.clinicInfo.name
3288
+ );
3289
+ const recipientName = data.recipientRole === "patient" ? data.appointment.patientInfo.fullName : data.appointment.practitionerInfo.name;
3290
+ const templateVariables = {
3291
+ recipientName,
3292
+ procedureName: data.appointment.procedureInfo.name,
3293
+ appointmentDate: this.formatTimestampInClinicTimezone(
3294
+ data.appointment.appointmentStartTime,
3295
+ clinicTimezone,
3296
+ "date"
3297
+ ),
3298
+ appointmentTime: `${formattedTime} (${timezoneName})`,
3299
+ practitionerName: data.appointment.practitionerInfo.name,
3300
+ clinicName: data.appointment.clinicInfo.name,
3301
+ cancelledByDisplay
3302
+ };
3303
+ const cancellationReason = data.cancellationReason || data.appointment.cancellationReason;
3304
+ let html = appointmentCancelledTemplate;
3305
+ if (cancellationReason) {
3306
+ templateVariables.cancellationReason = cancellationReason;
3307
+ html = html.replace(
3308
+ /\{\{#if cancellationReason\}\}([\s\S]*?)\{\{\/if\}\}/g,
3309
+ "$1"
3310
+ );
3311
+ } else {
3312
+ html = html.replace(/\{\{#if cancellationReason\}\}[\s\S]*?\{\{\/if\}\}/g, "");
3313
+ }
3314
+ html = this.renderTemplate(html, templateVariables);
3315
+ const subject = ((_a = data.options) == null ? void 0 : _a.customSubject) || `Appointment Cancelled: ${data.appointment.procedureInfo.name}`;
3316
+ const fromAddress = ((_b = data.options) == null ? void 0 : _b.fromAddress) || `MetaEstetics <no-reply@${((_c = data.options) == null ? void 0 : _c.mailgunDomain) || this.DEFAULT_MAILGUN_DOMAIN}>`;
3317
+ const domainToSendFrom = ((_d = data.options) == null ? void 0 : _d.mailgunDomain) || this.DEFAULT_MAILGUN_DOMAIN;
3318
+ const mailgunSendData = {
3319
+ to: recipientEmail,
3320
+ from: fromAddress,
3321
+ subject,
3322
+ html
3323
+ };
3324
+ try {
3325
+ const result = await this.sendEmail(domainToSendFrom, mailgunSendData);
3326
+ await this.logEmailAttempt(
3327
+ { to: recipientEmail, subject, templateName: "appointment_cancelled" },
3328
+ true
3329
+ );
3330
+ Logger.info(
3331
+ `[AppointmentMailingService] Successfully sent cancellation email to ${recipientEmail}`
3332
+ );
3333
+ return result;
3334
+ } catch (error) {
3335
+ await this.logEmailAttempt(
3336
+ {
3337
+ to: recipientEmail,
3338
+ subject,
3339
+ templateName: "appointment_cancelled"
3340
+ },
3341
+ false,
3342
+ error
3343
+ );
3344
+ Logger.error(
3345
+ `[AppointmentMailingService] Error sending cancellation email to ${recipientEmail}:`,
3346
+ error
3347
+ );
3348
+ throw error;
3349
+ }
2847
3350
  }
2848
3351
  /**
2849
3352
  * Sends a reschedule proposal email to the patient
@@ -3308,6 +3811,16 @@ var AppointmentAggregationService = class {
3308
3811
  // TODO: Properly import PatientProfileInfo and types
3309
3812
  );
3310
3813
  }
3814
+ if ((patientProfile == null ? void 0 : patientProfile.expoTokens) && patientProfile.expoTokens.length > 0) {
3815
+ Logger.info(
3816
+ `[AggService] Sending reschedule proposal push notification to patient ${after.patientId}`
3817
+ );
3818
+ await this.notificationsAdmin.sendAppointmentRescheduledProposalPush(
3819
+ after,
3820
+ after.patientId,
3821
+ patientProfile.expoTokens
3822
+ );
3823
+ }
3311
3824
  Logger.info(
3312
3825
  `[AggService] TODO: Send reschedule proposal notifications to practitioner as well.`
3313
3826
  );