@blackcode_sa/metaestetics-api 1.14.56 → 1.14.58

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@blackcode_sa/metaestetics-api",
3
3
  "private": false,
4
- "version": "1.14.56",
4
+ "version": "1.14.58",
5
5
  "description": "Firebase authentication service with anonymous upgrade support",
6
6
  "main": "dist/index.js",
7
7
  "module": "dist/index.mjs",
@@ -411,6 +411,285 @@ const clinicAppointmentRequestedTemplate = `
411
411
  </html>
412
412
  `;
413
413
 
414
+ const appointmentRescheduledProposalTemplate = `
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 Reschedule Proposal</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, #a48a76 0%, #67574A 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, #ff9800 0%, #f57c00 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
+ .info-box {
467
+ background: linear-gradient(135deg, #fff3e0 0%, #ffe0b2 100%);
468
+ border-radius: 15px;
469
+ padding: 25px;
470
+ margin: 25px 0;
471
+ border-left: 5px solid #ff9800;
472
+ }
473
+ .info-box p {
474
+ margin: 0;
475
+ color: #e65100;
476
+ font-size: 15px;
477
+ font-weight: 500;
478
+ line-height: 1.6;
479
+ }
480
+ .time-comparison {
481
+ display: grid;
482
+ gap: 20px;
483
+ margin: 25px 0;
484
+ }
485
+ .time-card {
486
+ background: linear-gradient(135deg, #f8f6f5 0%, #f5f3f2 100%);
487
+ border-radius: 15px;
488
+ padding: 25px;
489
+ border-left: 5px solid #a48a76;
490
+ }
491
+ .time-card.old-time {
492
+ border-left-color: #9e9e9e;
493
+ opacity: 0.8;
494
+ }
495
+ .time-card.new-time {
496
+ border-left-color: #ff9800;
497
+ background: linear-gradient(135deg, #fff8e1 0%, #ffe0b2 100%);
498
+ }
499
+ .time-label {
500
+ font-size: 14px;
501
+ font-weight: 600;
502
+ color: #666;
503
+ text-transform: uppercase;
504
+ letter-spacing: 0.5px;
505
+ margin-bottom: 10px;
506
+ }
507
+ .time-label.old {
508
+ color: #757575;
509
+ }
510
+ .time-label.new {
511
+ color: #f57c00;
512
+ }
513
+ .appointment-card {
514
+ background: linear-gradient(135deg, #f8f6f5 0%, #f5f3f2 100%);
515
+ border-radius: 15px;
516
+ padding: 30px;
517
+ margin: 25px 0;
518
+ border-left: 5px solid #a48a76;
519
+ }
520
+ .appointment-title {
521
+ font-size: 20px;
522
+ color: #a48a76;
523
+ margin-bottom: 20px;
524
+ font-weight: 600;
525
+ }
526
+ .appointment-details {
527
+ display: grid;
528
+ gap: 15px;
529
+ }
530
+ .detail-row {
531
+ display: flex;
532
+ align-items: center;
533
+ padding: 8px 0;
534
+ }
535
+ .detail-label {
536
+ font-weight: 600;
537
+ color: #555;
538
+ min-width: 120px;
539
+ font-size: 14px;
540
+ }
541
+ .detail-value {
542
+ color: #333;
543
+ font-size: 16px;
544
+ font-weight: 500;
545
+ }
546
+ .procedure-name {
547
+ color: #67574A;
548
+ font-weight: 600;
549
+ }
550
+ .clinic-name {
551
+ color: #a48a76;
552
+ font-weight: 600;
553
+ }
554
+ .action-section {
555
+ background: linear-gradient(135deg, #e8f5e9 0%, #c8e6c9 100%);
556
+ border-radius: 15px;
557
+ padding: 30px;
558
+ margin: 30px 0;
559
+ text-align: center;
560
+ border-left: 5px solid #4caf50;
561
+ }
562
+ .action-section h3 {
563
+ margin: 0 0 15px 0;
564
+ color: #2e7d32;
565
+ font-weight: 600;
566
+ font-size: 18px;
567
+ }
568
+ .action-section p {
569
+ margin: 0 0 20px 0;
570
+ color: #555;
571
+ font-size: 15px;
572
+ line-height: 1.6;
573
+ }
574
+ .footer {
575
+ background: #f8f9fa;
576
+ padding: 25px 30px;
577
+ text-align: center;
578
+ color: #666;
579
+ font-size: 14px;
580
+ border-top: 1px solid #eee;
581
+ }
582
+ .logo {
583
+ font-size: 24px;
584
+ font-weight: 700;
585
+ color: white;
586
+ margin-bottom: 5px;
587
+ }
588
+ .divider {
589
+ height: 2px;
590
+ background: linear-gradient(90deg, #a48a76, #67574A);
591
+ margin: 25px 0;
592
+ border-radius: 1px;
593
+ }
594
+ .icon {
595
+ text-align: center;
596
+ margin: 20px 0;
597
+ font-size: 48px;
598
+ }
599
+ .arrow {
600
+ text-align: center;
601
+ font-size: 32px;
602
+ color: #ff9800;
603
+ margin: 10px 0;
604
+ }
605
+ </style>
606
+ </head>
607
+ <body>
608
+ <div class="email-container">
609
+ <div class="header">
610
+ <div class="logo">MetaEstetics</div>
611
+ <h1>Appointment Reschedule Proposal</h1>
612
+ <div class="subtitle">Action Required</div>
613
+ </div>
614
+
615
+ <div class="content">
616
+ <div class="icon">📅</div>
617
+
618
+ <div class="greeting">
619
+ Dear <strong>{{patientName}}</strong>,
620
+ </div>
621
+
622
+ <p style="color: #555; font-size: 16px; line-height: 1.6; margin-bottom: 25px;">
623
+ 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.
624
+ </p>
625
+
626
+ <div class="info-box">
627
+ <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>
628
+ </div>
629
+
630
+ <div class="appointment-card">
631
+ <div class="appointment-title">📋 Appointment Details</div>
632
+ <div class="appointment-details">
633
+ <div class="detail-row">
634
+ <div class="detail-label">Procedure:</div>
635
+ <div class="detail-value procedure-name">{{procedureName}}</div>
636
+ </div>
637
+ <div class="detail-row">
638
+ <div class="detail-label">Practitioner:</div>
639
+ <div class="detail-value">{{practitionerName}}</div>
640
+ </div>
641
+ <div class="detail-row">
642
+ <div class="detail-label">Location:</div>
643
+ <div class="detail-value clinic-name">{{clinicName}}</div>
644
+ </div>
645
+ </div>
646
+ </div>
647
+
648
+ <div class="time-comparison">
649
+ <div class="time-card old-time">
650
+ <div class="time-label old">Previous Time</div>
651
+ <div style="font-size: 18px; font-weight: 600; color: #424242; margin-bottom: 8px;">{{previousDate}}</div>
652
+ <div style="font-size: 16px; color: #616161;">{{previousTime}}</div>
653
+ </div>
654
+
655
+ <div class="arrow">↓</div>
656
+
657
+ <div class="time-card new-time">
658
+ <div class="time-label new">Proposed New Time</div>
659
+ <div style="font-size: 18px; font-weight: 600; color: #e65100; margin-bottom: 8px;">{{newDate}}</div>
660
+ <div style="font-size: 16px; color: #f57c00; font-weight: 500;">{{newTime}}</div>
661
+ </div>
662
+ </div>
663
+
664
+ <div class="divider"></div>
665
+
666
+ <div class="action-section">
667
+ <h3>What's Next?</h3>
668
+ <p>
669
+ Please open the MetaEstetics app to accept or reject this reschedule proposal.
670
+ If the new time works for you, simply tap "Accept Reschedule".
671
+ If not, you can reject it and we'll work with you to find an alternative time.
672
+ </p>
673
+ </div>
674
+
675
+ <p style="color: #555; font-size: 14px; line-height: 1.6; margin-top: 25px;">
676
+ <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
+ </p>
678
+ </div>
679
+
680
+ <div class="footer">
681
+ <p style="margin: 0 0 10px 0;">
682
+ <strong>MetaEstetics</strong> - Premium Aesthetic Services
683
+ </p>
684
+ <p style="margin: 0; font-size: 12px; color: #999;">
685
+ This is an automated message. Please do not reply to this email.
686
+ </p>
687
+ </div>
688
+ </div>
689
+ </body>
690
+ </html>
691
+ `;
692
+
414
693
  // --- Interface Definitions for Email Data ---
415
694
 
416
695
  export interface AppointmentEmailDataBase {
@@ -707,13 +986,115 @@ export class AppointmentMailingService extends BaseMailingService {
707
986
  return Promise.resolve();
708
987
  }
709
988
 
989
+ /**
990
+ * Sends a reschedule proposal email to the patient
991
+ * @param data - Appointment reschedule proposal email data
992
+ * @returns Promise with the sending result
993
+ */
710
994
  async sendAppointmentRescheduledProposalEmail(
711
995
  data: AppointmentRescheduledProposalEmailData,
712
996
  ): Promise<any> {
713
997
  Logger.info(
714
- `[AppointmentMailingService] Placeholder for sendAppointmentRescheduledProposalEmail to patient: ${data.patientProfile.id}`,
998
+ `[AppointmentMailingService] Preparing to send reschedule proposal email to patient: ${data.patientProfile.id}`,
715
999
  );
716
- return Promise.resolve();
1000
+
1001
+ const recipientEmail = data.patientProfile.email;
1002
+
1003
+ if (!recipientEmail) {
1004
+ Logger.error('[AppointmentMailingService] Patient email not found for reschedule proposal.', {
1005
+ patientId: data.patientProfile.id,
1006
+ });
1007
+ throw new Error('Patient email address is missing.');
1008
+ }
1009
+
1010
+ // Get clinic timezone from appointment data, default to UTC if not available
1011
+ const clinicTimezone = data.appointment.clinic_tz || 'UTC';
1012
+
1013
+ Logger.debug('[AppointmentMailingService] Formatting appointment times for reschedule', {
1014
+ clinicTimezone,
1015
+ previousTime: data.previousStartTime.toDate().toISOString(),
1016
+ newTime: data.appointment.appointmentStartTime.toDate().toISOString(),
1017
+ });
1018
+
1019
+ // Format previous time
1020
+ const previousFormattedTime = this.formatTimestampInClinicTimezone(
1021
+ data.previousStartTime,
1022
+ clinicTimezone,
1023
+ 'time',
1024
+ );
1025
+ const previousFormattedDate = this.formatTimestampInClinicTimezone(
1026
+ data.previousStartTime,
1027
+ clinicTimezone,
1028
+ 'date',
1029
+ );
1030
+ const previousTimezoneName = this.getTimezoneDisplayName(clinicTimezone);
1031
+
1032
+ // Format new proposed time
1033
+ const newFormattedTime = this.formatTimestampInClinicTimezone(
1034
+ data.appointment.appointmentStartTime,
1035
+ clinicTimezone,
1036
+ 'time',
1037
+ );
1038
+ const newFormattedDate = this.formatTimestampInClinicTimezone(
1039
+ data.appointment.appointmentStartTime,
1040
+ clinicTimezone,
1041
+ 'date',
1042
+ );
1043
+ const newTimezoneName = this.getTimezoneDisplayName(clinicTimezone);
1044
+
1045
+ const templateVariables = {
1046
+ patientName: data.appointment.patientInfo.fullName,
1047
+ procedureName: data.appointment.procedureInfo.name,
1048
+ practitionerName: data.appointment.practitionerInfo.name,
1049
+ clinicName: data.appointment.clinicInfo.name,
1050
+ previousDate: previousFormattedDate,
1051
+ previousTime: `${previousFormattedTime} (${previousTimezoneName})`,
1052
+ newDate: newFormattedDate,
1053
+ newTime: `${newFormattedTime} (${newTimezoneName})`,
1054
+ };
1055
+
1056
+ const html = this.renderTemplate(appointmentRescheduledProposalTemplate, templateVariables);
1057
+ const subject =
1058
+ data.options?.customSubject ||
1059
+ `Action Required: Reschedule Proposal for Your ${data.appointment.procedureInfo.name} Appointment`;
1060
+ const fromAddress =
1061
+ data.options?.fromAddress ||
1062
+ `MetaEstetics <no-reply@${data.options?.mailgunDomain || this.DEFAULT_MAILGUN_DOMAIN}>`;
1063
+ const domainToSendFrom = data.options?.mailgunDomain || this.DEFAULT_MAILGUN_DOMAIN;
1064
+
1065
+ const mailgunSendData = {
1066
+ to: recipientEmail,
1067
+ from: fromAddress,
1068
+ subject,
1069
+ html,
1070
+ };
1071
+
1072
+ try {
1073
+ const result = await this.sendEmail(domainToSendFrom, mailgunSendData);
1074
+ await this.logEmailAttempt(
1075
+ { to: recipientEmail, subject, templateName: 'appointment_rescheduled_proposal' },
1076
+ true,
1077
+ );
1078
+ Logger.info(
1079
+ `[AppointmentMailingService] Successfully sent reschedule proposal email to ${recipientEmail}`,
1080
+ );
1081
+ return result;
1082
+ } catch (error) {
1083
+ await this.logEmailAttempt(
1084
+ {
1085
+ to: recipientEmail,
1086
+ subject,
1087
+ templateName: 'appointment_rescheduled_proposal',
1088
+ },
1089
+ false,
1090
+ error,
1091
+ );
1092
+ Logger.error(
1093
+ `[AppointmentMailingService] Error sending reschedule proposal email to ${recipientEmail}:`,
1094
+ error,
1095
+ );
1096
+ throw error;
1097
+ }
717
1098
  }
718
1099
 
719
1100
  async sendReviewRequestEmail(data: ReviewRequestEmailData): Promise<any> {
@@ -16,6 +16,7 @@ import {
16
16
  getCountFromServer,
17
17
  doc,
18
18
  getDoc,
19
+ updateDoc,
19
20
  } from 'firebase/firestore';
20
21
  import { Auth } from 'firebase/auth';
21
22
  import { FirebaseApp } from 'firebase/app';
@@ -2296,6 +2297,14 @@ export class AppointmentService extends BaseService {
2296
2297
  finalizationNotes: currentMetadata.finalizationNotes,
2297
2298
  });
2298
2299
 
2300
+ // Convert empty strings to null for proper Firestore storage
2301
+ const normalizedSharedNotes = sharedNotes !== undefined
2302
+ ? (sharedNotes === '' || sharedNotes === null ? null : sharedNotes)
2303
+ : currentMetadata.finalizationNotesShared;
2304
+ const normalizedInternalNotes = internalNotes !== undefined
2305
+ ? (internalNotes === '' || internalNotes === null ? null : internalNotes)
2306
+ : currentMetadata.finalizationNotesInternal;
2307
+
2299
2308
  const metadataUpdate = {
2300
2309
  selectedZones: currentMetadata.selectedZones,
2301
2310
  zonePhotos: currentMetadata.zonePhotos,
@@ -2304,14 +2313,12 @@ export class AppointmentService extends BaseService {
2304
2313
  extendedProcedures: currentMetadata.extendedProcedures || [],
2305
2314
  recommendedProcedures: currentMetadata.recommendedProcedures || [],
2306
2315
  finalbilling: currentMetadata.finalbilling,
2307
- finalizationNotesShared:
2308
- sharedNotes !== undefined ? sharedNotes : currentMetadata.finalizationNotesShared,
2309
- finalizationNotesInternal:
2310
- internalNotes !== undefined ? internalNotes : currentMetadata.finalizationNotesInternal,
2316
+ finalizationNotesShared: normalizedSharedNotes,
2317
+ finalizationNotesInternal: normalizedInternalNotes,
2311
2318
  // Keep deprecated field for backward compatibility during migration
2312
2319
  finalizationNotes:
2313
- sharedNotes !== undefined
2314
- ? sharedNotes
2320
+ normalizedSharedNotes !== null && normalizedSharedNotes !== undefined
2321
+ ? normalizedSharedNotes
2315
2322
  : currentMetadata.finalizationNotes || currentMetadata.finalizationNotesShared,
2316
2323
  };
2317
2324
 
@@ -2319,14 +2326,39 @@ export class AppointmentService extends BaseService {
2319
2326
  finalizationNotesShared: metadataUpdate.finalizationNotesShared,
2320
2327
  finalizationNotesInternal: metadataUpdate.finalizationNotesInternal,
2321
2328
  finalizationNotes: metadataUpdate.finalizationNotes,
2329
+ normalizedSharedNotes,
2330
+ normalizedInternalNotes,
2322
2331
  });
2323
2332
 
2324
- const updateData: UpdateAppointmentData = {
2325
- metadata: metadataUpdate,
2333
+ // Use direct Firestore update with dot notation to ensure fields are saved correctly
2334
+ const appointmentRef = doc(this.db, APPOINTMENTS_COLLECTION, appointmentId);
2335
+ const updateFields: any = {
2336
+ 'metadata.finalizationNotesShared': normalizedSharedNotes,
2337
+ 'metadata.finalizationNotesInternal': normalizedInternalNotes,
2338
+ 'metadata.finalizationNotes':
2339
+ normalizedSharedNotes !== null && normalizedSharedNotes !== undefined
2340
+ ? normalizedSharedNotes
2341
+ : currentMetadata.finalizationNotes || currentMetadata.finalizationNotesShared,
2326
2342
  updatedAt: serverTimestamp(),
2327
2343
  };
2328
2344
 
2329
- const result = await this.updateAppointment(appointmentId, updateData);
2345
+ console.log('🔍 [APPOINTMENT_SERVICE] Direct Firestore update with dot notation:', {
2346
+ appointmentId,
2347
+ updateFields: {
2348
+ 'metadata.finalizationNotesShared': updateFields['metadata.finalizationNotesShared'],
2349
+ 'metadata.finalizationNotesInternal': updateFields['metadata.finalizationNotesInternal'],
2350
+ 'metadata.finalizationNotes': updateFields['metadata.finalizationNotes'],
2351
+ },
2352
+ });
2353
+
2354
+ // Update using dot notation to ensure nested fields are saved correctly
2355
+ await updateDoc(appointmentRef, updateFields);
2356
+
2357
+ // Fetch and return the updated appointment
2358
+ const result = await this.getAppointmentById(appointmentId);
2359
+ if (!result) {
2360
+ throw new Error(`Failed to retrieve updated appointment ${appointmentId}`);
2361
+ }
2330
2362
 
2331
2363
  console.log('🔍 [APPOINTMENT_SERVICE] After update, result metadata:', {
2332
2364
  finalizationNotesShared: result.metadata?.finalizationNotesShared,
@@ -99,6 +99,7 @@ async function createExtendedProcedureInfo(
99
99
  procedureId: procedureId,
100
100
  procedureName: data.name,
101
101
  procedureDescription: data.description || '',
102
+ procedurePrice: data.price || 0,
102
103
  procedureFamily: data.family, // Use embedded family object
103
104
  procedureCategoryId: data.category.id, // Access embedded category
104
105
  procedureCategoryName: data.category.name, // Access embedded category
@@ -218,6 +218,7 @@ export async function initializeFormsForExtendedProcedure(
218
218
  sortingOrder: templateRef.sortingOrder,
219
219
  status: existingForm.status || FilledDocumentStatus.PENDING,
220
220
  path: `${APPOINTMENTS_COLLECTION}/${appointmentId}/${formSubcollectionPath}/${existingForm.id}`,
221
+ procedureId: procedureId, // Track which procedure this form belongs to
221
222
  };
222
223
  initializedFormsInfo.push(linkedForm);
223
224
 
@@ -288,6 +289,7 @@ export async function initializeFormsForExtendedProcedure(
288
289
  sortingOrder: templateRef.sortingOrder,
289
290
  status: FilledDocumentStatus.PENDING,
290
291
  path: docRef.path,
292
+ procedureId: procedureId, // Track which procedure this form belongs to
291
293
  };
292
294
  initializedFormsInfo.push(linkedForm);
293
295
 
@@ -278,19 +278,45 @@ export async function updateZoneItemUtil(
278
278
  throw new Error(`Invalid item index ${itemIndex} for zone ${zoneId}`);
279
279
  }
280
280
 
281
+ // Filter out undefined values from updates (Firestore doesn't store undefined)
282
+ // Keep null values as they're used to explicitly delete/clear fields
283
+ // Convert empty strings to null for consistency
284
+ const cleanUpdates = Object.fromEntries(
285
+ Object.entries(updates)
286
+ .filter(([_, value]) => value !== undefined)
287
+ .map(([key, value]) => [
288
+ key,
289
+ value === '' ? null : value, // Convert empty strings to null
290
+ ])
291
+ ) as Partial<ZoneItemData>;
292
+
293
+ console.log(`[updateZoneItemUtil] Updates received:`, {
294
+ itemIndex,
295
+ zoneId,
296
+ rawUpdates: updates,
297
+ cleanUpdates,
298
+ hasIonNumber: 'ionNumber' in cleanUpdates,
299
+ hasExpiryDate: 'expiryDate' in cleanUpdates,
300
+ ionNumberValue: cleanUpdates.ionNumber,
301
+ expiryDateValue: cleanUpdates.expiryDate,
302
+ });
303
+
281
304
  // Update item with updatedAt timestamp
282
305
  items[itemIndex] = {
283
306
  ...items[itemIndex],
284
- ...updates,
307
+ ...cleanUpdates,
285
308
  updatedAt: new Date().toISOString(),
286
309
  };
287
310
 
288
- console.log(`[updateZoneItemUtil] BEFORE recalculation:`, {
311
+ console.log(`[updateZoneItemUtil] Item after update:`, {
289
312
  itemIndex,
313
+ ionNumber: items[itemIndex].ionNumber,
314
+ expiryDate: items[itemIndex].expiryDate,
290
315
  quantity: items[itemIndex].quantity,
291
316
  priceOverrideAmount: items[itemIndex].priceOverrideAmount,
292
317
  price: items[itemIndex].price,
293
318
  oldSubtotal: items[itemIndex].subtotal,
319
+ allItemKeys: Object.keys(items[itemIndex]),
294
320
  });
295
321
 
296
322
  // Recalculate subtotal for this item
@@ -304,6 +330,18 @@ export async function updateZoneItemUtil(
304
330
  // Recalculate final billing with Swiss tax rate (8.1%)
305
331
  const finalbilling = calculateFinalBilling(metadata.zonesData, 0.081);
306
332
 
333
+ // Log what we're about to save to Firestore
334
+ console.log(`[updateZoneItemUtil] Saving to Firestore:`, {
335
+ appointmentId,
336
+ zoneId,
337
+ itemIndex,
338
+ itemToSave: items[itemIndex],
339
+ itemIonNumber: items[itemIndex].ionNumber,
340
+ itemExpiryDate: items[itemIndex].expiryDate,
341
+ zonesDataKeys: Object.keys(metadata.zonesData),
342
+ zoneItemsCount: metadata.zonesData[zoneId]?.length,
343
+ });
344
+
307
345
  // Update appointment
308
346
  const appointmentRef = doc(db, APPOINTMENTS_COLLECTION, appointmentId);
309
347
  await updateDoc(appointmentRef, {
@@ -312,7 +350,16 @@ export async function updateZoneItemUtil(
312
350
  updatedAt: serverTimestamp(),
313
351
  });
314
352
 
315
- return getAppointmentOrThrow(db, appointmentId);
353
+ // Verify what was actually saved
354
+ const savedAppointment = await getAppointmentOrThrow(db, appointmentId);
355
+ const savedItem = savedAppointment.metadata?.zonesData?.[zoneId]?.[itemIndex];
356
+ console.log(`[updateZoneItemUtil] Verification after save:`, {
357
+ savedItemIonNumber: savedItem?.ionNumber,
358
+ savedItemExpiryDate: savedItem?.expiryDate,
359
+ savedItemKeys: savedItem ? Object.keys(savedItem) : [],
360
+ });
361
+
362
+ return savedAppointment;
316
363
  }
317
364
 
318
365
  /**
@@ -206,6 +206,7 @@ export async function getZonePhotoEntryUtil(
206
206
 
207
207
  /**
208
208
  * Updates visibility of a photo pair (before AND after together)
209
+ * Note: If photo pair visibility is disabled, note visibility is automatically disabled as well
209
210
  *
210
211
  * @param db Firestore instance
211
212
  * @param appointmentId Appointment ID
@@ -223,18 +224,27 @@ export async function updateZonePhotoVisibilityUtil(
223
224
  showToPatient: boolean,
224
225
  doctorId: string,
225
226
  ): Promise<Appointment> {
227
+ const updates: Partial<BeforeAfterPerZone> = { showToPatient };
228
+
229
+ // If hiding photo pair from patient, also hide notes (notes can't be visible if photos aren't)
230
+ if (!showToPatient) {
231
+ updates.beforeNoteVisibleToPatient = false;
232
+ updates.afterNoteVisibleToPatient = false;
233
+ }
234
+
226
235
  return updateZonePhotoEntryUtil(
227
236
  db,
228
237
  appointmentId,
229
238
  zoneId,
230
239
  photoIndex,
231
- { showToPatient },
240
+ updates,
232
241
  doctorId,
233
242
  );
234
243
  }
235
244
 
236
245
  /**
237
246
  * Updates visibility of a photo note (before or after)
247
+ * Note: Notes can only be visible to patient if the photo pair itself is visible to patient
238
248
  *
239
249
  * @param db Firestore instance
240
250
  * @param appointmentId Appointment ID
@@ -244,6 +254,7 @@ export async function updateZonePhotoVisibilityUtil(
244
254
  * @param visibleToPatient Whether the note should be visible to patient
245
255
  * @param doctorId ID of the doctor making the change (for audit trail)
246
256
  * @returns Updated appointment
257
+ * @throws Error if trying to make note visible when photo pair is not visible
247
258
  */
248
259
  export async function updateZonePhotoNoteVisibilityUtil(
249
260
  db: Firestore,
@@ -254,6 +265,33 @@ export async function updateZonePhotoNoteVisibilityUtil(
254
265
  visibleToPatient: boolean,
255
266
  doctorId: string,
256
267
  ): Promise<Appointment> {
268
+ // Get current appointment to check if photo pair is visible
269
+ const appointment = await getAppointmentOrThrow(db, appointmentId);
270
+
271
+ const zonePhotos = appointment.metadata?.zonePhotos as
272
+ | Record<string, BeforeAfterPerZone[]>
273
+ | undefined
274
+ | null;
275
+
276
+ if (!zonePhotos || !zonePhotos[zoneId] || !Array.isArray(zonePhotos[zoneId])) {
277
+ throw new Error(`No photos found for zone ${zoneId} in appointment ${appointmentId}`);
278
+ }
279
+
280
+ const zoneArray = zonePhotos[zoneId];
281
+ if (photoIndex < 0 || photoIndex >= zoneArray.length) {
282
+ throw new Error(`Invalid photo index ${photoIndex} for zone ${zoneId}. Must be between 0 and ${zoneArray.length - 1}`);
283
+ }
284
+
285
+ const photoEntry = zoneArray[photoIndex];
286
+
287
+ // Validate: Notes can only be visible if photo pair is visible
288
+ if (visibleToPatient && !photoEntry.showToPatient) {
289
+ throw new Error(
290
+ 'Cannot make note visible to patient: The photo pair must be visible to the patient first. ' +
291
+ 'Please enable "Show to Patient" for the photo pair before making notes visible.'
292
+ );
293
+ }
294
+
257
295
  const updates: Partial<BeforeAfterPerZone> =
258
296
  noteType === 'before'
259
297
  ? { beforeNoteVisibleToPatient: visibleToPatient }