@blackcode_sa/metaestetics-api 1.12.32 → 1.12.33

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.33",
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,232 @@
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
+
11
+ /**
12
+ * Aggregates products from a procedure into appointmentProducts
13
+ * @param db Firestore instance
14
+ * @param procedureId Procedure ID to fetch
15
+ * @param existingProducts Current appointment products
16
+ * @returns Updated appointment products array
17
+ */
18
+ async function aggregateProductsFromProcedure(
19
+ db: Firestore,
20
+ procedureId: string,
21
+ existingProducts: AppointmentProductMetadata[]
22
+ ): Promise<AppointmentProductMetadata[]> {
23
+ const procedureRef = doc(db, PROCEDURES_COLLECTION, procedureId);
24
+ const procedureSnap = await getDoc(procedureRef);
25
+
26
+ if (!procedureSnap.exists()) {
27
+ throw new Error(`Procedure with ID ${procedureId} not found`);
28
+ }
29
+
30
+ const procedureData = procedureSnap.data();
31
+
32
+ // Get procedure products from productsMetadata array
33
+ const productsMetadata = procedureData.productsMetadata || [];
34
+
35
+ // Map procedure products to AppointmentProductMetadata
36
+ const newProducts: AppointmentProductMetadata[] = productsMetadata.map((pp: any) => {
37
+ // Each item in productsMetadata is a ProcedureProduct with embedded Product
38
+ const product = pp.product;
39
+
40
+ return {
41
+ productId: product.id,
42
+ productName: product.name,
43
+ brandId: product.brandId,
44
+ brandName: product.brandName,
45
+ procedureId: procedureId,
46
+ price: pp.price, // Price from ProcedureProduct
47
+ currency: pp.currency, // Currency from ProcedureProduct
48
+ unitOfMeasurement: pp.pricingMeasure, // PricingMeasure from ProcedureProduct
49
+ };
50
+ });
51
+
52
+ // Merge with existing products, avoiding duplicates
53
+ const productMap = new Map<string, AppointmentProductMetadata>();
54
+
55
+ // Add existing products
56
+ existingProducts.forEach(p => {
57
+ const key = `${p.productId}-${p.procedureId}`;
58
+ productMap.set(key, p);
59
+ });
60
+
61
+ // Add new products
62
+ newProducts.forEach(p => {
63
+ const key = `${p.productId}-${p.procedureId}`;
64
+ if (!productMap.has(key)) {
65
+ productMap.set(key, p);
66
+ }
67
+ });
68
+
69
+ return Array.from(productMap.values());
70
+ }
71
+
72
+ /**
73
+ * Creates ExtendedProcedureInfo from procedure document
74
+ * @param db Firestore instance
75
+ * @param procedureId Procedure ID
76
+ * @returns Extended procedure info
77
+ */
78
+ async function createExtendedProcedureInfo(
79
+ db: Firestore,
80
+ procedureId: string
81
+ ): Promise<ExtendedProcedureInfo> {
82
+ const procedureRef = doc(db, PROCEDURES_COLLECTION, procedureId);
83
+ const procedureSnap = await getDoc(procedureRef);
84
+
85
+ if (!procedureSnap.exists()) {
86
+ throw new Error(`Procedure with ID ${procedureId} not found`);
87
+ }
88
+
89
+ const data = procedureSnap.data();
90
+
91
+ return {
92
+ procedureId: procedureId,
93
+ procedureName: data.name,
94
+ procedureFamily: data.family, // Use embedded family object
95
+ procedureCategoryId: data.category.id, // Access embedded category
96
+ procedureCategoryName: data.category.name, // Access embedded category
97
+ procedureSubCategoryId: data.subcategory.id, // Access embedded subcategory
98
+ procedureSubCategoryName: data.subcategory.name, // Access embedded subcategory
99
+ procedureTechnologyId: data.technology.id, // Access embedded technology
100
+ procedureTechnologyName: data.technology.name, // Access embedded technology
101
+ procedureProducts: (data.productsMetadata || []).map((pp: any) => ({
102
+ productId: pp.product.id, // Access embedded product
103
+ productName: pp.product.name, // Access embedded product
104
+ brandId: pp.product.brandId, // Access embedded product
105
+ brandName: pp.product.brandName, // Access embedded product
106
+ })),
107
+ };
108
+ }
109
+
110
+ /**
111
+ * Adds an extended procedure to an appointment
112
+ * Automatically aggregates products into appointmentProducts
113
+ * @param db Firestore instance
114
+ * @param appointmentId Appointment ID
115
+ * @param procedureId Procedure ID to add
116
+ * @returns Updated appointment
117
+ */
118
+ export async function addExtendedProcedureUtil(
119
+ db: Firestore,
120
+ appointmentId: string,
121
+ procedureId: string
122
+ ): Promise<Appointment> {
123
+ const appointment = await getAppointmentOrThrow(db, appointmentId);
124
+ const metadata = initializeMetadata(appointment);
125
+
126
+ // Check if procedure is already added
127
+ const existingProcedure = metadata.extendedProcedures?.find(
128
+ p => p.procedureId === procedureId
129
+ );
130
+ if (existingProcedure) {
131
+ throw new Error(`Procedure ${procedureId} is already added to this appointment`);
132
+ }
133
+
134
+ // Create extended procedure info
135
+ const extendedProcedureInfo = await createExtendedProcedureInfo(db, procedureId);
136
+
137
+ // Aggregate products
138
+ const updatedProducts = await aggregateProductsFromProcedure(
139
+ db,
140
+ procedureId,
141
+ metadata.appointmentProducts || []
142
+ );
143
+
144
+ // Add extended procedure
145
+ const extendedProcedures = [...(metadata.extendedProcedures || []), extendedProcedureInfo];
146
+
147
+ // Update appointment
148
+ const appointmentRef = doc(db, APPOINTMENTS_COLLECTION, appointmentId);
149
+ await updateDoc(appointmentRef, {
150
+ 'metadata.extendedProcedures': extendedProcedures,
151
+ 'metadata.appointmentProducts': updatedProducts,
152
+ updatedAt: serverTimestamp(),
153
+ });
154
+
155
+ return getAppointmentOrThrow(db, appointmentId);
156
+ }
157
+
158
+ /**
159
+ * Removes an extended procedure from an appointment
160
+ * Also removes associated products from appointmentProducts
161
+ * @param db Firestore instance
162
+ * @param appointmentId Appointment ID
163
+ * @param procedureId Procedure ID to remove
164
+ * @returns Updated appointment
165
+ */
166
+ export async function removeExtendedProcedureUtil(
167
+ db: Firestore,
168
+ appointmentId: string,
169
+ procedureId: string
170
+ ): Promise<Appointment> {
171
+ const appointment = await getAppointmentOrThrow(db, appointmentId);
172
+ const metadata = initializeMetadata(appointment);
173
+
174
+ if (!metadata.extendedProcedures || metadata.extendedProcedures.length === 0) {
175
+ throw new Error('No extended procedures found for this appointment');
176
+ }
177
+
178
+ // Find and remove the procedure
179
+ const procedureIndex = metadata.extendedProcedures.findIndex(
180
+ p => p.procedureId === procedureId
181
+ );
182
+ if (procedureIndex === -1) {
183
+ throw new Error(`Extended procedure ${procedureId} not found in this appointment`);
184
+ }
185
+
186
+ // Remove procedure
187
+ metadata.extendedProcedures.splice(procedureIndex, 1);
188
+
189
+ // Remove products associated with this procedure
190
+ const updatedProducts = (metadata.appointmentProducts || []).filter(
191
+ p => p.procedureId !== procedureId
192
+ );
193
+
194
+ // Update appointment
195
+ const appointmentRef = doc(db, APPOINTMENTS_COLLECTION, appointmentId);
196
+ await updateDoc(appointmentRef, {
197
+ 'metadata.extendedProcedures': metadata.extendedProcedures,
198
+ 'metadata.appointmentProducts': updatedProducts,
199
+ updatedAt: serverTimestamp(),
200
+ });
201
+
202
+ return getAppointmentOrThrow(db, appointmentId);
203
+ }
204
+
205
+ /**
206
+ * Gets all extended procedures for an appointment
207
+ * @param db Firestore instance
208
+ * @param appointmentId Appointment ID
209
+ * @returns Array of extended procedures
210
+ */
211
+ export async function getExtendedProceduresUtil(
212
+ db: Firestore,
213
+ appointmentId: string
214
+ ): Promise<ExtendedProcedureInfo[]> {
215
+ const appointment = await getAppointmentOrThrow(db, appointmentId);
216
+ return appointment.metadata?.extendedProcedures || [];
217
+ }
218
+
219
+ /**
220
+ * Gets all aggregated products for an appointment
221
+ * @param db Firestore instance
222
+ * @param appointmentId Appointment ID
223
+ * @returns Array of appointment products
224
+ */
225
+ export async function getAppointmentProductsUtil(
226
+ db: Firestore,
227
+ appointmentId: string
228
+ ): Promise<AppointmentProductMetadata[]> {
229
+ const appointment = await getAppointmentOrThrow(db, appointmentId);
230
+ return appointment.metadata?.appointmentProducts || [];
231
+ }
232
+