@blackcode_sa/metaestetics-api 1.12.32 → 1.12.34

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.12.32",
4
+ "version": "1.12.34",
5
5
  "description": "Firebase authentication service with anonymous upgrade support",
6
6
  "main": "dist/index.js",
7
7
  "module": "dist/index.mjs",
@@ -840,6 +840,9 @@ export class BookingAdmin {
840
840
  allLinkedFormTemplateIds = formInitResult.allLinkedTemplateIds;
841
841
  }
842
842
 
843
+ // --- Generate appointment products from procedure ---
844
+ const appointmentProducts = this._generateAppointmentProductsFromProcedure(procedure);
845
+
843
846
  // --- Construct Appointment Object ---
844
847
  const newAppointmentData: Appointment = {
845
848
  id: newAppointmentId,
@@ -884,6 +887,15 @@ export class BookingAdmin {
884
887
  linkedForms: initializedFormsInfo,
885
888
  media: [],
886
889
  reviewInfo: null,
890
+ metadata: {
891
+ selectedZones: null,
892
+ zonePhotos: null,
893
+ zonesData: null,
894
+ appointmentProducts: appointmentProducts,
895
+ extendedProcedures: [],
896
+ finalbilling: null,
897
+ finalizationNotes: null,
898
+ },
887
899
  finalizedDetails: {
888
900
  by: "",
889
901
  at: adminTsNow as any,
@@ -974,6 +986,8 @@ export class BookingAdmin {
974
986
  const procedureSubCategory = procedure.subcategory as Subcategory;
975
987
  const procedureTechnology = procedure.technology as Technology;
976
988
  const procedureProduct = procedure.product as Product;
989
+ const productsMetadata = procedure.productsMetadata || [];
990
+
977
991
  return {
978
992
  id: procedure.id,
979
993
  name: procedure.name,
@@ -987,10 +1001,32 @@ export class BookingAdmin {
987
1001
  procedureSubCategoryName: procedureSubCategory?.name || "",
988
1002
  procedureTechnologyId: procedureTechnology?.id || "",
989
1003
  procedureTechnologyName: procedureTechnology?.name || "",
990
- procedureProductBrandId: (procedureProduct as any)?.brand?.id || "",
991
- procedureProductBrandName: (procedureProduct as any)?.brand?.name || "",
992
- procedureProductId: procedureProduct?.id || "",
993
- procedureProductName: procedureProduct?.name || "",
1004
+ procedureProductBrandId: procedureProduct?.brandId || "",
1005
+ procedureProductBrandName: procedureProduct?.brandName || "",
1006
+ procedureProducts: productsMetadata.map((pp: any) => ({
1007
+ productId: pp.product.id,
1008
+ productName: pp.product.name,
1009
+ brandId: pp.product.brandId,
1010
+ brandName: pp.product.brandName,
1011
+ })),
994
1012
  };
995
1013
  }
1014
+
1015
+ private _generateAppointmentProductsFromProcedure(procedure: Procedure): any[] {
1016
+ const productsMetadata = procedure.productsMetadata || [];
1017
+
1018
+ return productsMetadata.map((pp: any) => {
1019
+ const product = pp.product;
1020
+ return {
1021
+ productId: product.id,
1022
+ productName: product.name,
1023
+ brandId: product.brandId,
1024
+ brandName: product.brandName,
1025
+ procedureId: procedure.id,
1026
+ price: pp.price,
1027
+ currency: pp.currency,
1028
+ unitOfMeasurement: pp.pricingMeasure,
1029
+ };
1030
+ });
1031
+ }
996
1032
  }
@@ -30,6 +30,9 @@ import {
30
30
  type CreateAppointmentHttpData,
31
31
  type ZonePhotoUploadData,
32
32
  BeforeAfterPerZone,
33
+ ZoneItemData,
34
+ ExtendedProcedureInfo,
35
+ AppointmentProductMetadata,
33
36
  APPOINTMENTS_COLLECTION,
34
37
  } from '../../types/appointment';
35
38
  import {
@@ -53,6 +56,20 @@ import {
53
56
  getAppointmentByIdUtil,
54
57
  searchAppointmentsUtil,
55
58
  } from './utils/appointment.utils';
59
+ import {
60
+ addItemToZoneUtil,
61
+ removeItemFromZoneUtil,
62
+ updateZoneItemUtil,
63
+ overridePriceForZoneItemUtil,
64
+ updateSubzonesUtil,
65
+ calculateFinalBilling,
66
+ } from './utils/zone-management.utils';
67
+ import {
68
+ addExtendedProcedureUtil,
69
+ removeExtendedProcedureUtil,
70
+ getExtendedProceduresUtil,
71
+ getAppointmentProductsUtil,
72
+ } from './utils/extended-procedure.utils';
56
73
 
57
74
  /**
58
75
  * Interface for available booking slot
@@ -1254,6 +1271,9 @@ export class AppointmentService extends BaseService {
1254
1271
  const currentMetadata = appointment.metadata || {
1255
1272
  selectedZones: null,
1256
1273
  zonePhotos: null,
1274
+ zonesData: null,
1275
+ appointmentProducts: [],
1276
+ extendedProcedures: [],
1257
1277
  zoneBilling: null,
1258
1278
  finalbilling: null,
1259
1279
  finalizationNotes: null,
@@ -1290,6 +1310,9 @@ export class AppointmentService extends BaseService {
1290
1310
  metadata: {
1291
1311
  selectedZones: currentMetadata.selectedZones,
1292
1312
  zonePhotos: currentZonePhotos,
1313
+ zonesData: currentMetadata.zonesData || null,
1314
+ appointmentProducts: currentMetadata.appointmentProducts || [],
1315
+ extendedProcedures: currentMetadata.extendedProcedures || [],
1293
1316
  zoneBilling: currentMetadata.zoneBilling,
1294
1317
  finalbilling: currentMetadata.finalbilling,
1295
1318
  finalizationNotes: currentMetadata.finalizationNotes,
@@ -1422,6 +1445,9 @@ export class AppointmentService extends BaseService {
1422
1445
  metadata: {
1423
1446
  selectedZones: appointment.metadata?.selectedZones || null,
1424
1447
  zonePhotos: updatedZonePhotos,
1448
+ zonesData: appointment.metadata?.zonesData || null,
1449
+ appointmentProducts: appointment.metadata?.appointmentProducts || [],
1450
+ extendedProcedures: appointment.metadata?.extendedProcedures || [],
1425
1451
  zoneBilling: appointment.metadata?.zoneBilling || null,
1426
1452
  finalbilling: appointment.metadata?.finalbilling || null,
1427
1453
  finalizationNotes: appointment.metadata?.finalizationNotes || null,
@@ -1441,4 +1467,256 @@ export class AppointmentService extends BaseService {
1441
1467
  throw error;
1442
1468
  }
1443
1469
  }
1470
+
1471
+ /**
1472
+ * Adds an item (product or note) to a specific zone
1473
+ *
1474
+ * @param appointmentId ID of the appointment
1475
+ * @param zoneId Zone ID (must be category.zone format, e.g., "face.forehead")
1476
+ * @param item Zone item data to add (without parentZone - it's inferred from zoneId)
1477
+ * @returns The updated appointment
1478
+ */
1479
+ async addItemToZone(
1480
+ appointmentId: string,
1481
+ zoneId: string,
1482
+ item: Omit<ZoneItemData, 'subtotal' | 'parentZone'>
1483
+ ): Promise<Appointment> {
1484
+ try {
1485
+ console.log(
1486
+ `[APPOINTMENT_SERVICE] Adding item to zone ${zoneId} in appointment ${appointmentId}`,
1487
+ );
1488
+ return await addItemToZoneUtil(this.db, appointmentId, zoneId, item);
1489
+ } catch (error) {
1490
+ console.error(`[APPOINTMENT_SERVICE] Error adding item to zone:`, error);
1491
+ throw error;
1492
+ }
1493
+ }
1494
+
1495
+ /**
1496
+ * Removes an item from a specific zone
1497
+ *
1498
+ * @param appointmentId ID of the appointment
1499
+ * @param zoneId Zone ID
1500
+ * @param itemIndex Index of the item to remove in the zone's items array
1501
+ * @returns The updated appointment
1502
+ */
1503
+ async removeItemFromZone(
1504
+ appointmentId: string,
1505
+ zoneId: string,
1506
+ itemIndex: number,
1507
+ ): Promise<Appointment> {
1508
+ try {
1509
+ console.log(
1510
+ `[APPOINTMENT_SERVICE] Removing item ${itemIndex} from zone ${zoneId} in appointment ${appointmentId}`,
1511
+ );
1512
+ return await removeItemFromZoneUtil(this.db, appointmentId, zoneId, itemIndex);
1513
+ } catch (error) {
1514
+ console.error(`[APPOINTMENT_SERVICE] Error removing item from zone:`, error);
1515
+ throw error;
1516
+ }
1517
+ }
1518
+
1519
+ /**
1520
+ * Updates a specific item in a zone
1521
+ *
1522
+ * @param appointmentId ID of the appointment
1523
+ * @param zoneId Zone ID
1524
+ * @param itemIndex Index of the item to update
1525
+ * @param updates Partial updates to apply to the item
1526
+ * @returns The updated appointment
1527
+ */
1528
+ async updateZoneItem(
1529
+ appointmentId: string,
1530
+ zoneId: string,
1531
+ itemIndex: number,
1532
+ updates: Partial<ZoneItemData>,
1533
+ ): Promise<Appointment> {
1534
+ try {
1535
+ console.log(
1536
+ `[APPOINTMENT_SERVICE] Updating item ${itemIndex} in zone ${zoneId} in appointment ${appointmentId}`,
1537
+ );
1538
+ return await updateZoneItemUtil(this.db, appointmentId, zoneId, itemIndex, updates);
1539
+ } catch (error) {
1540
+ console.error(`[APPOINTMENT_SERVICE] Error updating zone item:`, error);
1541
+ throw error;
1542
+ }
1543
+ }
1544
+
1545
+ /**
1546
+ * Overrides the price for a specific zone item
1547
+ *
1548
+ * @param appointmentId ID of the appointment
1549
+ * @param zoneId Zone ID
1550
+ * @param itemIndex Index of the item
1551
+ * @param newPrice New price amount to set
1552
+ * @returns The updated appointment
1553
+ */
1554
+ async overridePriceForZoneItem(
1555
+ appointmentId: string,
1556
+ zoneId: string,
1557
+ itemIndex: number,
1558
+ newPrice: number,
1559
+ ): Promise<Appointment> {
1560
+ try {
1561
+ console.log(
1562
+ `[APPOINTMENT_SERVICE] Overriding price for item ${itemIndex} in zone ${zoneId} to ${newPrice}`,
1563
+ );
1564
+ return await overridePriceForZoneItemUtil(this.db, appointmentId, zoneId, itemIndex, newPrice);
1565
+ } catch (error) {
1566
+ console.error(`[APPOINTMENT_SERVICE] Error overriding price:`, error);
1567
+ throw error;
1568
+ }
1569
+ }
1570
+
1571
+ /**
1572
+ * Updates subzones for a specific zone item
1573
+ *
1574
+ * @param appointmentId ID of the appointment
1575
+ * @param zoneId Zone ID
1576
+ * @param itemIndex Index of the item
1577
+ * @param subzones Array of subzone keys (category.zone.subzone format)
1578
+ * @returns The updated appointment
1579
+ */
1580
+ async updateSubzones(
1581
+ appointmentId: string,
1582
+ zoneId: string,
1583
+ itemIndex: number,
1584
+ subzones: string[],
1585
+ ): Promise<Appointment> {
1586
+ try {
1587
+ console.log(
1588
+ `[APPOINTMENT_SERVICE] Updating subzones for item ${itemIndex} in zone ${zoneId}`,
1589
+ );
1590
+ return await updateSubzonesUtil(this.db, appointmentId, zoneId, itemIndex, subzones);
1591
+ } catch (error) {
1592
+ console.error(`[APPOINTMENT_SERVICE] Error updating subzones:`, error);
1593
+ throw error;
1594
+ }
1595
+ }
1596
+
1597
+ /**
1598
+ * Adds an extended procedure to an appointment
1599
+ * Automatically aggregates products into appointmentProducts
1600
+ *
1601
+ * @param appointmentId ID of the appointment
1602
+ * @param procedureId ID of the procedure to add
1603
+ * @returns The updated appointment
1604
+ */
1605
+ async addExtendedProcedure(appointmentId: string, procedureId: string): Promise<Appointment> {
1606
+ try {
1607
+ console.log(
1608
+ `[APPOINTMENT_SERVICE] Adding extended procedure ${procedureId} to appointment ${appointmentId}`,
1609
+ );
1610
+ return await addExtendedProcedureUtil(this.db, appointmentId, procedureId);
1611
+ } catch (error) {
1612
+ console.error(`[APPOINTMENT_SERVICE] Error adding extended procedure:`, error);
1613
+ throw error;
1614
+ }
1615
+ }
1616
+
1617
+ /**
1618
+ * Removes an extended procedure from an appointment
1619
+ * Also removes associated products from appointmentProducts
1620
+ *
1621
+ * @param appointmentId ID of the appointment
1622
+ * @param procedureId ID of the procedure to remove
1623
+ * @returns The updated appointment
1624
+ */
1625
+ async removeExtendedProcedure(appointmentId: string, procedureId: string): Promise<Appointment> {
1626
+ try {
1627
+ console.log(
1628
+ `[APPOINTMENT_SERVICE] Removing extended procedure ${procedureId} from appointment ${appointmentId}`,
1629
+ );
1630
+ return await removeExtendedProcedureUtil(this.db, appointmentId, procedureId);
1631
+ } catch (error) {
1632
+ console.error(`[APPOINTMENT_SERVICE] Error removing extended procedure:`, error);
1633
+ throw error;
1634
+ }
1635
+ }
1636
+
1637
+ /**
1638
+ * Gets all extended procedures for an appointment
1639
+ *
1640
+ * @param appointmentId ID of the appointment
1641
+ * @returns Array of extended procedures
1642
+ */
1643
+ async getExtendedProcedures(appointmentId: string): Promise<ExtendedProcedureInfo[]> {
1644
+ try {
1645
+ console.log(`[APPOINTMENT_SERVICE] Getting extended procedures for appointment ${appointmentId}`);
1646
+ return await getExtendedProceduresUtil(this.db, appointmentId);
1647
+ } catch (error) {
1648
+ console.error(`[APPOINTMENT_SERVICE] Error getting extended procedures:`, error);
1649
+ throw error;
1650
+ }
1651
+ }
1652
+
1653
+ /**
1654
+ * Gets all aggregated products for an appointment
1655
+ * Includes products from main procedure and extended procedures
1656
+ *
1657
+ * @param appointmentId ID of the appointment
1658
+ * @returns Array of appointment products
1659
+ */
1660
+ async getAppointmentProducts(appointmentId: string): Promise<AppointmentProductMetadata[]> {
1661
+ try {
1662
+ console.log(`[APPOINTMENT_SERVICE] Getting appointment products for appointment ${appointmentId}`);
1663
+ return await getAppointmentProductsUtil(this.db, appointmentId);
1664
+ } catch (error) {
1665
+ console.error(`[APPOINTMENT_SERVICE] Error getting appointment products:`, error);
1666
+ throw error;
1667
+ }
1668
+ }
1669
+
1670
+ /**
1671
+ * Recalculates final billing for an appointment based on zone items
1672
+ *
1673
+ * @param appointmentId ID of the appointment
1674
+ * @param taxRate Tax rate (e.g., 0.20 for 20%)
1675
+ * @returns The updated appointment with recalculated billing
1676
+ */
1677
+ async recalculateFinalBilling(appointmentId: string, taxRate?: number): Promise<Appointment> {
1678
+ try {
1679
+ console.log(`[APPOINTMENT_SERVICE] Recalculating final billing for appointment ${appointmentId}`);
1680
+
1681
+ const appointment = await this.getAppointmentById(appointmentId);
1682
+ if (!appointment) {
1683
+ throw new Error(`Appointment with ID ${appointmentId} not found`);
1684
+ }
1685
+
1686
+ const zonesData = appointment.metadata?.zonesData;
1687
+ if (!zonesData || Object.keys(zonesData).length === 0) {
1688
+ throw new Error('No zone data available for billing calculation');
1689
+ }
1690
+
1691
+ const finalbilling = calculateFinalBilling(zonesData, taxRate);
1692
+
1693
+ const currentMetadata = appointment.metadata || {
1694
+ selectedZones: null,
1695
+ zonePhotos: null,
1696
+ zonesData: null,
1697
+ appointmentProducts: [],
1698
+ extendedProcedures: [],
1699
+ finalbilling: null,
1700
+ finalizationNotes: null,
1701
+ };
1702
+
1703
+ const updateData: UpdateAppointmentData = {
1704
+ metadata: {
1705
+ selectedZones: currentMetadata.selectedZones,
1706
+ zonePhotos: currentMetadata.zonePhotos,
1707
+ zonesData: currentMetadata.zonesData,
1708
+ appointmentProducts: currentMetadata.appointmentProducts || [],
1709
+ extendedProcedures: currentMetadata.extendedProcedures || [],
1710
+ finalbilling,
1711
+ finalizationNotes: currentMetadata.finalizationNotes,
1712
+ },
1713
+ updatedAt: serverTimestamp(),
1714
+ };
1715
+
1716
+ return await this.updateAppointment(appointmentId, updateData);
1717
+ } catch (error) {
1718
+ console.error(`[APPOINTMENT_SERVICE] Error recalculating final billing:`, error);
1719
+ throw error;
1720
+ }
1721
+ }
1444
1722
  }
@@ -0,0 +1,285 @@
1
+ import { Firestore, updateDoc, serverTimestamp, doc, getDoc } from 'firebase/firestore';
2
+ import {
3
+ Appointment,
4
+ ExtendedProcedureInfo,
5
+ AppointmentProductMetadata,
6
+ APPOINTMENTS_COLLECTION,
7
+ } from '../../../types/appointment';
8
+ import { getAppointmentOrThrow, initializeMetadata } from './zone-management.utils';
9
+ import { PROCEDURES_COLLECTION } from '../../../types/procedure';
10
+ import { initializeFormsForExtendedProcedure, removeFormsForExtendedProcedure } from './form-initialization.utils';
11
+
12
+ /**
13
+ * Aggregates products from a procedure into appointmentProducts
14
+ * @param db Firestore instance
15
+ * @param procedureId Procedure ID to fetch
16
+ * @param existingProducts Current appointment products
17
+ * @returns Updated appointment products array
18
+ */
19
+ async function aggregateProductsFromProcedure(
20
+ db: Firestore,
21
+ procedureId: string,
22
+ existingProducts: AppointmentProductMetadata[]
23
+ ): Promise<AppointmentProductMetadata[]> {
24
+ const procedureRef = doc(db, PROCEDURES_COLLECTION, procedureId);
25
+ const procedureSnap = await getDoc(procedureRef);
26
+
27
+ if (!procedureSnap.exists()) {
28
+ throw new Error(`Procedure with ID ${procedureId} not found`);
29
+ }
30
+
31
+ const procedureData = procedureSnap.data();
32
+
33
+ // Get procedure products from productsMetadata array
34
+ const productsMetadata = procedureData.productsMetadata || [];
35
+
36
+ // Map procedure products to AppointmentProductMetadata
37
+ const newProducts: AppointmentProductMetadata[] = productsMetadata.map((pp: any) => {
38
+ // Each item in productsMetadata is a ProcedureProduct with embedded Product
39
+ const product = pp.product;
40
+
41
+ return {
42
+ productId: product.id,
43
+ productName: product.name,
44
+ brandId: product.brandId,
45
+ brandName: product.brandName,
46
+ procedureId: procedureId,
47
+ price: pp.price, // Price from ProcedureProduct
48
+ currency: pp.currency, // Currency from ProcedureProduct
49
+ unitOfMeasurement: pp.pricingMeasure, // PricingMeasure from ProcedureProduct
50
+ };
51
+ });
52
+
53
+ // Merge with existing products, avoiding duplicates
54
+ const productMap = new Map<string, AppointmentProductMetadata>();
55
+
56
+ // Add existing products
57
+ existingProducts.forEach(p => {
58
+ const key = `${p.productId}-${p.procedureId}`;
59
+ productMap.set(key, p);
60
+ });
61
+
62
+ // Add new products
63
+ newProducts.forEach(p => {
64
+ const key = `${p.productId}-${p.procedureId}`;
65
+ if (!productMap.has(key)) {
66
+ productMap.set(key, p);
67
+ }
68
+ });
69
+
70
+ return Array.from(productMap.values());
71
+ }
72
+
73
+ /**
74
+ * Creates ExtendedProcedureInfo from procedure document
75
+ * @param db Firestore instance
76
+ * @param procedureId Procedure ID
77
+ * @returns Extended procedure info
78
+ */
79
+ async function createExtendedProcedureInfo(
80
+ db: Firestore,
81
+ procedureId: string
82
+ ): Promise<ExtendedProcedureInfo> {
83
+ const procedureRef = doc(db, PROCEDURES_COLLECTION, procedureId);
84
+ const procedureSnap = await getDoc(procedureRef);
85
+
86
+ if (!procedureSnap.exists()) {
87
+ throw new Error(`Procedure with ID ${procedureId} not found`);
88
+ }
89
+
90
+ const data = procedureSnap.data();
91
+
92
+ return {
93
+ procedureId: procedureId,
94
+ procedureName: data.name,
95
+ procedureFamily: data.family, // Use embedded family object
96
+ procedureCategoryId: data.category.id, // Access embedded category
97
+ procedureCategoryName: data.category.name, // Access embedded category
98
+ procedureSubCategoryId: data.subcategory.id, // Access embedded subcategory
99
+ procedureSubCategoryName: data.subcategory.name, // Access embedded subcategory
100
+ procedureTechnologyId: data.technology.id, // Access embedded technology
101
+ procedureTechnologyName: data.technology.name, // Access embedded technology
102
+ procedureProducts: (data.productsMetadata || []).map((pp: any) => ({
103
+ productId: pp.product.id, // Access embedded product
104
+ productName: pp.product.name, // Access embedded product
105
+ brandId: pp.product.brandId, // Access embedded product
106
+ brandName: pp.product.brandName, // Access embedded product
107
+ })),
108
+ };
109
+ }
110
+
111
+ /**
112
+ * Adds an extended procedure to an appointment
113
+ * Automatically aggregates products into appointmentProducts
114
+ * @param db Firestore instance
115
+ * @param appointmentId Appointment ID
116
+ * @param procedureId Procedure ID to add
117
+ * @returns Updated appointment
118
+ */
119
+ export async function addExtendedProcedureUtil(
120
+ db: Firestore,
121
+ appointmentId: string,
122
+ procedureId: string
123
+ ): Promise<Appointment> {
124
+ const appointment = await getAppointmentOrThrow(db, appointmentId);
125
+ const metadata = initializeMetadata(appointment);
126
+
127
+ // Check if procedure is already added
128
+ const existingProcedure = metadata.extendedProcedures?.find(
129
+ p => p.procedureId === procedureId
130
+ );
131
+ if (existingProcedure) {
132
+ throw new Error(`Procedure ${procedureId} is already added to this appointment`);
133
+ }
134
+
135
+ // Create extended procedure info
136
+ const extendedProcedureInfo = await createExtendedProcedureInfo(db, procedureId);
137
+
138
+ // Get procedure data for forms and products
139
+ const procedureRef = doc(db, PROCEDURES_COLLECTION, procedureId);
140
+ const procedureSnap = await getDoc(procedureRef);
141
+
142
+ if (!procedureSnap.exists()) {
143
+ throw new Error(`Procedure with ID ${procedureId} not found`);
144
+ }
145
+
146
+ const procedureData = procedureSnap.data();
147
+
148
+ // Aggregate products
149
+ const updatedProducts = await aggregateProductsFromProcedure(
150
+ db,
151
+ procedureId,
152
+ metadata.appointmentProducts || []
153
+ );
154
+
155
+ // Initialize forms for extended procedure
156
+ let updatedLinkedFormIds = appointment.linkedFormIds || [];
157
+ let updatedLinkedForms = appointment.linkedForms || [];
158
+ let updatedPendingUserFormsIds = appointment.pendingUserFormsIds || [];
159
+
160
+ if (procedureData.documentationTemplates && procedureData.documentationTemplates.length > 0) {
161
+ const formInitResult = await initializeFormsForExtendedProcedure(
162
+ db,
163
+ appointmentId,
164
+ procedureId,
165
+ procedureData.documentationTemplates,
166
+ appointment.patientId,
167
+ appointment.practitionerId,
168
+ appointment.clinicBranchId
169
+ );
170
+
171
+ // Merge form IDs and info
172
+ updatedLinkedFormIds = [...updatedLinkedFormIds, ...formInitResult.allLinkedFormIds];
173
+ updatedLinkedForms = [...updatedLinkedForms, ...formInitResult.initializedFormsInfo];
174
+ updatedPendingUserFormsIds = [...updatedPendingUserFormsIds, ...formInitResult.pendingUserFormsIds];
175
+ }
176
+
177
+ // Add extended procedure
178
+ const extendedProcedures = [...(metadata.extendedProcedures || []), extendedProcedureInfo];
179
+
180
+ // Update appointment
181
+ const appointmentRef = doc(db, APPOINTMENTS_COLLECTION, appointmentId);
182
+ await updateDoc(appointmentRef, {
183
+ 'metadata.extendedProcedures': extendedProcedures,
184
+ 'metadata.appointmentProducts': updatedProducts,
185
+ linkedFormIds: updatedLinkedFormIds,
186
+ linkedForms: updatedLinkedForms,
187
+ pendingUserFormsIds: updatedPendingUserFormsIds,
188
+ updatedAt: serverTimestamp(),
189
+ });
190
+
191
+ return getAppointmentOrThrow(db, appointmentId);
192
+ }
193
+
194
+ /**
195
+ * Removes an extended procedure from an appointment
196
+ * Also removes associated products from appointmentProducts
197
+ * @param db Firestore instance
198
+ * @param appointmentId Appointment ID
199
+ * @param procedureId Procedure ID to remove
200
+ * @returns Updated appointment
201
+ */
202
+ export async function removeExtendedProcedureUtil(
203
+ db: Firestore,
204
+ appointmentId: string,
205
+ procedureId: string
206
+ ): Promise<Appointment> {
207
+ const appointment = await getAppointmentOrThrow(db, appointmentId);
208
+ const metadata = initializeMetadata(appointment);
209
+
210
+ if (!metadata.extendedProcedures || metadata.extendedProcedures.length === 0) {
211
+ throw new Error('No extended procedures found for this appointment');
212
+ }
213
+
214
+ // Find and remove the procedure
215
+ const procedureIndex = metadata.extendedProcedures.findIndex(
216
+ p => p.procedureId === procedureId
217
+ );
218
+ if (procedureIndex === -1) {
219
+ throw new Error(`Extended procedure ${procedureId} not found in this appointment`);
220
+ }
221
+
222
+ // Remove procedure
223
+ metadata.extendedProcedures.splice(procedureIndex, 1);
224
+
225
+ // Remove products associated with this procedure
226
+ const updatedProducts = (metadata.appointmentProducts || []).filter(
227
+ p => p.procedureId !== procedureId
228
+ );
229
+
230
+ // Remove forms associated with this procedure
231
+ const removedFormIds = await removeFormsForExtendedProcedure(db, appointmentId, procedureId);
232
+
233
+ // Update appointment form arrays
234
+ const updatedLinkedFormIds = (appointment.linkedFormIds || []).filter(
235
+ formId => !removedFormIds.includes(formId)
236
+ );
237
+ const updatedLinkedForms = (appointment.linkedForms || []).filter(
238
+ form => !removedFormIds.includes(form.formId)
239
+ );
240
+ const updatedPendingUserFormsIds = (appointment.pendingUserFormsIds || []).filter(
241
+ formId => !removedFormIds.includes(formId)
242
+ );
243
+
244
+ // Update appointment
245
+ const appointmentRef = doc(db, APPOINTMENTS_COLLECTION, appointmentId);
246
+ await updateDoc(appointmentRef, {
247
+ 'metadata.extendedProcedures': metadata.extendedProcedures,
248
+ 'metadata.appointmentProducts': updatedProducts,
249
+ linkedFormIds: updatedLinkedFormIds,
250
+ linkedForms: updatedLinkedForms,
251
+ pendingUserFormsIds: updatedPendingUserFormsIds,
252
+ updatedAt: serverTimestamp(),
253
+ });
254
+
255
+ return getAppointmentOrThrow(db, appointmentId);
256
+ }
257
+
258
+ /**
259
+ * Gets all extended procedures for an appointment
260
+ * @param db Firestore instance
261
+ * @param appointmentId Appointment ID
262
+ * @returns Array of extended procedures
263
+ */
264
+ export async function getExtendedProceduresUtil(
265
+ db: Firestore,
266
+ appointmentId: string
267
+ ): Promise<ExtendedProcedureInfo[]> {
268
+ const appointment = await getAppointmentOrThrow(db, appointmentId);
269
+ return appointment.metadata?.extendedProcedures || [];
270
+ }
271
+
272
+ /**
273
+ * Gets all aggregated products for an appointment
274
+ * @param db Firestore instance
275
+ * @param appointmentId Appointment ID
276
+ * @returns Array of appointment products
277
+ */
278
+ export async function getAppointmentProductsUtil(
279
+ db: Firestore,
280
+ appointmentId: string
281
+ ): Promise<AppointmentProductMetadata[]> {
282
+ const appointment = await getAppointmentOrThrow(db, appointmentId);
283
+ return appointment.metadata?.appointmentProducts || [];
284
+ }
285
+