@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.
@@ -0,0 +1,335 @@
1
+ import { Firestore, getDoc, updateDoc, serverTimestamp } from 'firebase/firestore';
2
+ import {
3
+ ZoneItemData,
4
+ AppointmentMetadata,
5
+ FinalBilling,
6
+ Appointment,
7
+ APPOINTMENTS_COLLECTION,
8
+ } from '../../../types/appointment';
9
+ import { getAppointmentByIdUtil } from './appointment.utils';
10
+ import { doc } from 'firebase/firestore';
11
+
12
+ /**
13
+ * Validates that a zone key follows the category.zone format
14
+ * @param zoneKey Zone key to validate
15
+ * @throws Error if format is invalid
16
+ */
17
+ export function validateZoneKeyFormat(zoneKey: string): void {
18
+ const parts = zoneKey.split('.');
19
+ if (parts.length !== 2) {
20
+ throw new Error(
21
+ `Invalid zone key format: "${zoneKey}". Must be "category.zone" (e.g., "face.forehead")`
22
+ );
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Calculates subtotal for a zone item
28
+ * @param item Zone item data
29
+ * @returns Calculated subtotal
30
+ */
31
+ export function calculateItemSubtotal(item: Partial<ZoneItemData>): number {
32
+ if (item.type === 'note') {
33
+ return 0;
34
+ }
35
+
36
+ // If price override amount is set, use it
37
+ if (item.priceOverrideAmount !== undefined && item.priceOverrideAmount !== null) {
38
+ return item.priceOverrideAmount;
39
+ }
40
+
41
+ // Calculate normally: price * quantity
42
+ const price = item.price || 0;
43
+ const quantity = item.quantity || 0;
44
+ return price * quantity;
45
+ }
46
+
47
+ /**
48
+ * Recalculates final billing based on all zone items
49
+ * @param zonesData Zone items data
50
+ * @param taxRate Tax rate (e.g., 0.20 for 20%)
51
+ * @returns Calculated final billing
52
+ */
53
+ export function calculateFinalBilling(
54
+ zonesData: Record<string, ZoneItemData[]>,
55
+ taxRate: number = 0.20
56
+ ): FinalBilling {
57
+ let subtotalAll = 0;
58
+
59
+ // Sum up all zone items
60
+ Object.values(zonesData).forEach(items => {
61
+ items.forEach(item => {
62
+ if (item.type === 'item' && item.subtotal) {
63
+ subtotalAll += item.subtotal;
64
+ }
65
+ });
66
+ });
67
+
68
+ const taxPrice = subtotalAll * taxRate;
69
+ const finalPrice = subtotalAll + taxPrice;
70
+
71
+ // Get currency from first item (assuming all same currency)
72
+ let currency: any = 'CHF'; // Default
73
+
74
+ for (const items of Object.values(zonesData)) {
75
+ const firstItem = items.find(i => i.type === 'item');
76
+ if (firstItem) {
77
+ currency = firstItem.currency || currency;
78
+ break;
79
+ }
80
+ }
81
+
82
+ return {
83
+ subtotalAll,
84
+ taxRate,
85
+ taxPrice,
86
+ finalPrice,
87
+ currency,
88
+ };
89
+ }
90
+
91
+ /**
92
+ * Gets appointment and validates it exists
93
+ * @param db Firestore instance
94
+ * @param appointmentId Appointment ID
95
+ * @returns Appointment document
96
+ */
97
+ export async function getAppointmentOrThrow(
98
+ db: Firestore,
99
+ appointmentId: string
100
+ ): Promise<Appointment> {
101
+ const appointment = await getAppointmentByIdUtil(db, appointmentId);
102
+ if (!appointment) {
103
+ throw new Error(`Appointment with ID ${appointmentId} not found`);
104
+ }
105
+ return appointment;
106
+ }
107
+
108
+ /**
109
+ * Initializes appointment metadata if it doesn't exist
110
+ * @param appointment Appointment document
111
+ * @returns Initialized metadata
112
+ */
113
+ export function initializeMetadata(
114
+ appointment: Appointment
115
+ ): AppointmentMetadata {
116
+ return appointment.metadata || {
117
+ selectedZones: null,
118
+ zonePhotos: null,
119
+ zonesData: null,
120
+ appointmentProducts: [],
121
+ extendedProcedures: [],
122
+ finalbilling: null,
123
+ finalizationNotes: null,
124
+ };
125
+ }
126
+
127
+ /**
128
+ * Adds an item to a specific zone
129
+ * @param db Firestore instance
130
+ * @param appointmentId Appointment ID
131
+ * @param zoneId Zone ID (must be category.zone format)
132
+ * @param item Zone item data to add (without parentZone)
133
+ * @returns Updated appointment
134
+ */
135
+ export async function addItemToZoneUtil(
136
+ db: Firestore,
137
+ appointmentId: string,
138
+ zoneId: string,
139
+ item: Omit<ZoneItemData, 'subtotal' | 'parentZone'>
140
+ ): Promise<Appointment> {
141
+ // Validate zone key format
142
+ validateZoneKeyFormat(zoneId);
143
+
144
+ // Get appointment
145
+ const appointment = await getAppointmentOrThrow(db, appointmentId);
146
+ const metadata = initializeMetadata(appointment);
147
+
148
+ // Initialize zonesData if needed
149
+ const zonesData = metadata.zonesData || {};
150
+
151
+ // Initialize zone array if needed
152
+ if (!zonesData[zoneId]) {
153
+ zonesData[zoneId] = [];
154
+ }
155
+
156
+ // Calculate subtotal for the item
157
+ const itemWithSubtotal: ZoneItemData = {
158
+ ...item,
159
+ parentZone: zoneId, // Set parentZone to the zone key
160
+ subtotal: calculateItemSubtotal(item),
161
+ };
162
+
163
+ // Add item to zone
164
+ zonesData[zoneId].push(itemWithSubtotal);
165
+
166
+ // Recalculate final billing
167
+ const finalbilling = calculateFinalBilling(zonesData); //TODO: add correct amount of tax
168
+
169
+ // Update appointment
170
+ const appointmentRef = doc(db, APPOINTMENTS_COLLECTION, appointmentId);
171
+ await updateDoc(appointmentRef, {
172
+ 'metadata.zonesData': zonesData,
173
+ 'metadata.finalbilling': finalbilling,
174
+ updatedAt: serverTimestamp(),
175
+ });
176
+
177
+ // Return updated appointment
178
+ return getAppointmentOrThrow(db, appointmentId);
179
+ }
180
+
181
+ /**
182
+ * Removes an item from a specific zone
183
+ * @param db Firestore instance
184
+ * @param appointmentId Appointment ID
185
+ * @param zoneId Zone ID
186
+ * @param itemIndex Index of item to remove
187
+ * @returns Updated appointment
188
+ */
189
+ export async function removeItemFromZoneUtil(
190
+ db: Firestore,
191
+ appointmentId: string,
192
+ zoneId: string,
193
+ itemIndex: number
194
+ ): Promise<Appointment> {
195
+ validateZoneKeyFormat(zoneId);
196
+
197
+ const appointment = await getAppointmentOrThrow(db, appointmentId);
198
+ const metadata = initializeMetadata(appointment);
199
+
200
+ if (!metadata.zonesData || !metadata.zonesData[zoneId]) {
201
+ throw new Error(`No items found for zone ${zoneId}`);
202
+ }
203
+
204
+ const items = metadata.zonesData[zoneId];
205
+ if (itemIndex < 0 || itemIndex >= items.length) {
206
+ throw new Error(`Invalid item index ${itemIndex} for zone ${zoneId}`);
207
+ }
208
+
209
+ // Remove item
210
+ items.splice(itemIndex, 1);
211
+
212
+ // If zone is now empty, remove it
213
+ if (items.length === 0) {
214
+ delete metadata.zonesData[zoneId];
215
+ }
216
+
217
+ // Recalculate final billing
218
+ const finalbilling = calculateFinalBilling(metadata.zonesData); //TODO: add correct amount of tax
219
+
220
+ // Update appointment
221
+ const appointmentRef = doc(db, APPOINTMENTS_COLLECTION, appointmentId);
222
+ await updateDoc(appointmentRef, {
223
+ 'metadata.zonesData': metadata.zonesData,
224
+ 'metadata.finalbilling': finalbilling,
225
+ updatedAt: serverTimestamp(),
226
+ });
227
+
228
+ return getAppointmentOrThrow(db, appointmentId);
229
+ }
230
+
231
+ /**
232
+ * Updates a specific item in a zone
233
+ * @param db Firestore instance
234
+ * @param appointmentId Appointment ID
235
+ * @param zoneId Zone ID
236
+ * @param itemIndex Index of item to update
237
+ * @param updates Partial updates to apply
238
+ * @returns Updated appointment
239
+ */
240
+ export async function updateZoneItemUtil(
241
+ db: Firestore,
242
+ appointmentId: string,
243
+ zoneId: string,
244
+ itemIndex: number,
245
+ updates: Partial<ZoneItemData>
246
+ ): Promise<Appointment> {
247
+ validateZoneKeyFormat(zoneId);
248
+
249
+ const appointment = await getAppointmentOrThrow(db, appointmentId);
250
+ const metadata = initializeMetadata(appointment);
251
+
252
+ if (!metadata.zonesData || !metadata.zonesData[zoneId]) {
253
+ throw new Error(`No items found for zone ${zoneId}`);
254
+ }
255
+
256
+ const items = metadata.zonesData[zoneId];
257
+ if (itemIndex < 0 || itemIndex >= items.length) {
258
+ throw new Error(`Invalid item index ${itemIndex} for zone ${zoneId}`);
259
+ }
260
+
261
+ // Update item
262
+ items[itemIndex] = {
263
+ ...items[itemIndex],
264
+ ...updates,
265
+ };
266
+
267
+ // Recalculate subtotal for this item
268
+ items[itemIndex].subtotal = calculateItemSubtotal(items[itemIndex]);
269
+
270
+ // Recalculate final billing
271
+ const finalbilling = calculateFinalBilling(metadata.zonesData); //TODO: add correct amount of tax
272
+
273
+ // Update appointment
274
+ const appointmentRef = doc(db, APPOINTMENTS_COLLECTION, appointmentId);
275
+ await updateDoc(appointmentRef, {
276
+ 'metadata.zonesData': metadata.zonesData,
277
+ 'metadata.finalbilling': finalbilling,
278
+ updatedAt: serverTimestamp(),
279
+ });
280
+
281
+ return getAppointmentOrThrow(db, appointmentId);
282
+ }
283
+
284
+ /**
285
+ * Overrides price for a specific zone item
286
+ * @param db Firestore instance
287
+ * @param appointmentId Appointment ID
288
+ * @param zoneId Zone ID
289
+ * @param itemIndex Index of item
290
+ * @param newPrice New price amount
291
+ * @returns Updated appointment
292
+ */
293
+ export async function overridePriceForZoneItemUtil(
294
+ db: Firestore,
295
+ appointmentId: string,
296
+ zoneId: string,
297
+ itemIndex: number,
298
+ newPrice: number
299
+ ): Promise<Appointment> {
300
+ return updateZoneItemUtil(db, appointmentId, zoneId, itemIndex, {
301
+ priceOverrideAmount: newPrice,
302
+ });
303
+ }
304
+
305
+ /**
306
+ * Updates subzones for a specific zone item
307
+ * @param db Firestore instance
308
+ * @param appointmentId Appointment ID
309
+ * @param zoneId Zone ID
310
+ * @param itemIndex Index of item
311
+ * @param subzones Array of subzone keys (empty array = entire zone)
312
+ * @returns Updated appointment
313
+ */
314
+ export async function updateSubzonesUtil(
315
+ db: Firestore,
316
+ appointmentId: string,
317
+ zoneId: string,
318
+ itemIndex: number,
319
+ subzones: string[]
320
+ ): Promise<Appointment> {
321
+ // Validate subzone format if provided
322
+ subzones.forEach(subzone => {
323
+ const parts = subzone.split('.');
324
+ if (parts.length !== 3) {
325
+ throw new Error(
326
+ `Invalid subzone format: "${subzone}". Must be "category.zone.subzone" (e.g., "face.forehead.left")`
327
+ );
328
+ }
329
+ });
330
+
331
+ return updateZoneItemUtil(db, appointmentId, zoneId, itemIndex, {
332
+ subzones,
333
+ });
334
+ }
335
+
@@ -93,6 +93,17 @@ import {
93
93
  getActiveInviteTokensByPatientUtil,
94
94
  } from './utils';
95
95
 
96
+ import {
97
+ getAestheticAnalysisUtil,
98
+ createOrUpdateAestheticAnalysisUtil,
99
+ } from './utils/aesthetic-analysis.utils';
100
+
101
+ import {
102
+ AestheticAnalysis,
103
+ CreateAestheticAnalysisData,
104
+ UpdateAestheticAnalysisData,
105
+ } from '../../types/patient';
106
+
96
107
  import { CreatePatientTokenData, PatientToken } from '../../types/patient/token.types';
97
108
 
98
109
  export class PatientService extends BaseService {
@@ -834,4 +845,39 @@ export class PatientService extends BaseService {
834
845
  // the admin has permission to view this patient's tokens.
835
846
  return getActiveInviteTokensByPatientUtil(this.db, patientId);
836
847
  }
848
+
849
+ async getAestheticAnalysis(patientId: string): Promise<AestheticAnalysis | null> {
850
+ const currentUser = await this.getCurrentUser();
851
+ return getAestheticAnalysisUtil(this.db, patientId, currentUser.uid, currentUser.roles);
852
+ }
853
+
854
+ async createAestheticAnalysis(
855
+ patientId: string,
856
+ data: CreateAestheticAnalysisData
857
+ ): Promise<void> {
858
+ const currentUser = await this.getCurrentUser();
859
+ return createOrUpdateAestheticAnalysisUtil(
860
+ this.db,
861
+ patientId,
862
+ data,
863
+ currentUser.uid,
864
+ currentUser.roles,
865
+ false
866
+ );
867
+ }
868
+
869
+ async updateAestheticAnalysis(
870
+ patientId: string,
871
+ data: UpdateAestheticAnalysisData
872
+ ): Promise<void> {
873
+ const currentUser = await this.getCurrentUser();
874
+ return createOrUpdateAestheticAnalysisUtil(
875
+ this.db,
876
+ patientId,
877
+ data,
878
+ currentUser.uid,
879
+ currentUser.roles,
880
+ true
881
+ );
882
+ }
837
883
  }
@@ -1508,4 +1508,58 @@ export class ProcedureService extends BaseService {
1508
1508
  });
1509
1509
  return proceduresForMap;
1510
1510
  }
1511
+
1512
+ /**
1513
+ * Gets procedures filtered by clinic and practitioner with optional family filter
1514
+ * @param clinicBranchId Clinic branch ID to filter by
1515
+ * @param practitionerId Practitioner ID to filter by
1516
+ * @param filterByFamily If true, shows only procedures of the same family as the default procedure
1517
+ * @param defaultProcedureId Optional default procedure ID to determine the family
1518
+ * @returns Array of procedures
1519
+ */
1520
+ async getProceduresForConsultation(
1521
+ clinicBranchId: string,
1522
+ practitionerId: string,
1523
+ filterByFamily: boolean = true,
1524
+ defaultProcedureId?: string
1525
+ ): Promise<Procedure[]> {
1526
+ let familyToFilter: ProcedureFamily | null = null;
1527
+
1528
+ // If family filtering is enabled and we have a default procedure, get its family
1529
+ if (filterByFamily && defaultProcedureId) {
1530
+ const defaultProcedureRef = doc(this.db, PROCEDURES_COLLECTION, defaultProcedureId);
1531
+ const defaultProcedureSnap = await getDoc(defaultProcedureRef);
1532
+
1533
+ if (defaultProcedureSnap.exists()) {
1534
+ const defaultProcedure = defaultProcedureSnap.data() as Procedure;
1535
+ familyToFilter = defaultProcedure.family;
1536
+ }
1537
+ }
1538
+
1539
+ // Build query constraints
1540
+ const constraints: QueryConstraint[] = [
1541
+ where('clinicBranchId', '==', clinicBranchId),
1542
+ where('practitionerId', '==', practitionerId),
1543
+ where('isActive', '==', true),
1544
+ ];
1545
+
1546
+ // Add family filter if applicable
1547
+ if (filterByFamily && familyToFilter) {
1548
+ constraints.push(where('family', '==', familyToFilter));
1549
+ }
1550
+
1551
+ // Execute query
1552
+ const proceduresQuery = query(
1553
+ collection(this.db, PROCEDURES_COLLECTION),
1554
+ ...constraints,
1555
+ orderBy('name', 'asc')
1556
+ );
1557
+
1558
+ const querySnapshot = await getDocs(proceduresQuery);
1559
+
1560
+ return querySnapshot.docs.map(doc => ({
1561
+ id: doc.id,
1562
+ ...doc.data(),
1563
+ } as Procedure));
1564
+ }
1511
1565
  }
@@ -76,8 +76,12 @@ export interface ProcedureExtendedInfo {
76
76
  procedureTechnologyName: string;
77
77
  procedureProductBrandId: string;
78
78
  procedureProductBrandName: string;
79
- procedureProductId: string; // We need to have more than one product allowed in this data, this is coming from aggregation, so please update aggregation and this data type
80
- procedureProductName: string; // We need to have more than one product allowed in this data, this is coming from aggregation, so please update aggregation and this data type
79
+ procedureProducts: Array<{
80
+ productId: string;
81
+ productName: string;
82
+ brandId: string;
83
+ brandName: string;
84
+ }>;
81
85
  }
82
86
 
83
87
  /**
@@ -132,26 +136,39 @@ export interface ZonePhotoUploadData {
132
136
  }
133
137
 
134
138
  /**
135
- * Interface for billing information per zone
139
+ * Interface for zone item data (products or notes per zone)
136
140
  */
137
- export interface BillingPerZone { // We should rename this to ZoneItemData and we should expend it as per our documentations (will be used for both zone items in step 3 and summary and pricing in the step 4)
138
- /** Product name/description */
141
+ export interface ZoneItemData {
142
+ productId?: string;
143
+ productName?: string;
144
+ productBrandId?: string;
145
+ productBrandName?: string;
146
+ belongingProcedureId: string;
147
+ type: 'item' | 'note';
148
+ price?: number;
149
+ currency?: Currency;
150
+ unitOfMeasurement?: PricingMeasure;
151
+ priceOverrideAmount?: number; // If set, takes precedence over price
152
+ quantity?: number;
153
+ parentZone: string; // Zone key in format "category.zone" (e.g., "face.forehead")
154
+ subzones: string[];
155
+ notes?: string;
156
+ subtotal?: number;
157
+ ionNumber?: string;
158
+ }
159
+
160
+ /**
161
+ * @deprecated Use ZoneItemData instead
162
+ */
163
+ export interface BillingPerZone {
139
164
  Product: string;
140
- /** Product ID */
141
165
  ProductId: string | null;
142
- /** Quantity used (can be decimal) */
143
166
  Quantity: number;
144
- /** Unit of measurement */
145
167
  UnitOfMeasurement: PricingMeasure;
146
- /** Unit price for the product */
147
168
  UnitPrice: number;
148
- /** Currency for the unit price */
149
169
  UnitCurency: Currency;
150
- /** Calculated subtotal */
151
170
  Subtotal: number;
152
- /** Optional billing note */
153
171
  Note: string | null;
154
- /** Ion/Batch number for traceability */
155
172
  IonNumber: string | null;
156
173
  }
157
174
 
@@ -167,29 +184,61 @@ export interface FinalBilling {
167
184
  taxPrice: number;
168
185
  /** Final price including tax */
169
186
  finalPrice: number;
170
- /** Total final quantity across all zones */
171
- finalQuantity: number; // Not sure we should keep this as well, we keep track of this per item
172
187
  /** Currency for the final billing */
173
188
  currency: Currency;
174
- /** Unit of measurement for the final billing */
175
- unitOfMeasurement: PricingMeasure; // NO need to keep this, we keep track of this per item
189
+ }
190
+
191
+ /**
192
+ * Interface for product metadata in appointment
193
+ */
194
+ export interface AppointmentProductMetadata {
195
+ productId: string;
196
+ productName: string;
197
+ brandId: string;
198
+ brandName: string;
199
+ procedureId: string;
200
+ price: number;
201
+ currency: Currency;
202
+ unitOfMeasurement: PricingMeasure;
203
+ }
204
+
205
+ /**
206
+ * Interface for extended procedures in appointment
207
+ */
208
+ export interface ExtendedProcedureInfo {
209
+ procedureId: string;
210
+ procedureName: string;
211
+ procedureFamily?: ProcedureFamily;
212
+ procedureCategoryId: string;
213
+ procedureCategoryName: string;
214
+ procedureSubCategoryId: string;
215
+ procedureSubCategoryName: string;
216
+ procedureTechnologyId: string;
217
+ procedureTechnologyName: string;
218
+ procedureProducts: Array<{
219
+ productId: string;
220
+ productName: string;
221
+ brandId: string;
222
+ brandName: string;
223
+ }>;
176
224
  }
177
225
 
178
226
  /**
179
227
  * Interface for appointment metadata containing zone-specific information
180
228
  */
181
229
  export interface AppointmentMetadata {
182
- /** Array of selected zones for the appointment */
183
- selectedZones: string[] | null; // We are not going to accept any structure (category.zone.subzone), here we will only accept zone (category.zone), nothing more, nothing less
184
- /** Map of zone photos with before/after images and notes */
185
- zonePhotos: Record<string, BeforeAfterPerZone> | null; // head.forhead: {object}, head: {object}, but it can't have head.forhead.forhedLeft: {object}
186
- /** Map of billing information per zone */
187
- zoneBilling: Record<string, BillingPerZone> | null; // This is going to change, name it zonesData, and expand this to accept more than one item per key (which is basically zone like category.zone)
188
- /** Final billing calculations for the appointment */
230
+ selectedZones: string[] | null;
231
+ zonePhotos: Record<string, BeforeAfterPerZone> | null;
232
+ zonesData?: Record<string, ZoneItemData[]> | null;
233
+ appointmentProducts?: AppointmentProductMetadata[];
234
+ extendedProcedures?: ExtendedProcedureInfo[];
189
235
  finalbilling: FinalBilling | null;
190
- /** Final note for the appointment */
191
236
  finalizationNotes: string | null;
192
- // TO-DO: We need to add extended procedures info (when we add multiple procedures, different than default one defined above)
237
+
238
+ /**
239
+ * @deprecated Use zonesData instead
240
+ */
241
+ zoneBilling?: Record<string, BillingPerZone> | null;
193
242
  }
194
243
 
195
244
  /**