@blackcode_sa/metaestetics-api 1.12.46 → 1.12.48
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/dist/admin/index.d.mts +5 -4
- package/dist/admin/index.d.ts +5 -4
- package/dist/admin/index.js +3 -26
- package/dist/admin/index.mjs +3 -26
- package/dist/index.d.mts +6 -6
- package/dist/index.d.ts +6 -6
- package/dist/index.js +81 -55
- package/dist/index.mjs +81 -55
- package/package.json +1 -1
- package/src/admin/booking/booking.admin.ts +21 -17
- package/src/admin/free-consultation/free-consultation-utils.admin.ts +4 -31
- package/src/services/appointment/utils/appointment.utils.ts +2 -2
- package/src/services/appointment/utils/extended-procedure.utils.ts +82 -53
- package/src/services/appointment/utils/recommended-procedure.utils.ts +8 -6
- package/src/services/practitioner/practitioner.service.ts +2 -10
- package/src/services/procedure/procedure.service.ts +67 -30
- package/src/types/procedure/index.ts +5 -5
- package/src/validations/appointment.schema.ts +60 -53
- package/src/validations/procedure.schema.ts +4 -4
|
@@ -7,7 +7,10 @@ import {
|
|
|
7
7
|
} from '../../../types/appointment';
|
|
8
8
|
import { getAppointmentOrThrow, initializeMetadata } from './zone-management.utils';
|
|
9
9
|
import { PROCEDURES_COLLECTION } from '../../../types/procedure';
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
initializeFormsForExtendedProcedure,
|
|
12
|
+
removeFormsForExtendedProcedure,
|
|
13
|
+
} from './form-initialization.utils';
|
|
11
14
|
|
|
12
15
|
/**
|
|
13
16
|
* Aggregates products from a procedure into appointmentProducts
|
|
@@ -19,7 +22,7 @@ import { initializeFormsForExtendedProcedure, removeFormsForExtendedProcedure }
|
|
|
19
22
|
async function aggregateProductsFromProcedure(
|
|
20
23
|
db: Firestore,
|
|
21
24
|
procedureId: string,
|
|
22
|
-
existingProducts: AppointmentProductMetadata[]
|
|
25
|
+
existingProducts: AppointmentProductMetadata[],
|
|
23
26
|
): Promise<AppointmentProductMetadata[]> {
|
|
24
27
|
const procedureRef = doc(db, PROCEDURES_COLLECTION, procedureId);
|
|
25
28
|
const procedureSnap = await getDoc(procedureRef);
|
|
@@ -34,21 +37,24 @@ async function aggregateProductsFromProcedure(
|
|
|
34
37
|
const productsMetadata = procedureData.productsMetadata || [];
|
|
35
38
|
|
|
36
39
|
// Map procedure products to AppointmentProductMetadata
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
40
|
+
// Filter out any entries without products (safety check for product-free procedures like consultations)
|
|
41
|
+
const newProducts: AppointmentProductMetadata[] = productsMetadata
|
|
42
|
+
.filter((pp: any) => pp && pp.product)
|
|
43
|
+
.map((pp: any) => {
|
|
44
|
+
// Each item in productsMetadata is a ProcedureProduct with embedded Product
|
|
45
|
+
const product = pp.product;
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
productId: product.id,
|
|
49
|
+
productName: product.name,
|
|
50
|
+
brandId: product.brandId,
|
|
51
|
+
brandName: product.brandName,
|
|
52
|
+
procedureId: procedureId,
|
|
53
|
+
price: pp.price, // Price from ProcedureProduct
|
|
54
|
+
currency: pp.currency, // Currency from ProcedureProduct
|
|
55
|
+
unitOfMeasurement: pp.pricingMeasure, // PricingMeasure from ProcedureProduct
|
|
56
|
+
};
|
|
57
|
+
});
|
|
52
58
|
|
|
53
59
|
// Merge with existing products, avoiding duplicates
|
|
54
60
|
const productMap = new Map<string, AppointmentProductMetadata>();
|
|
@@ -78,7 +84,7 @@ async function aggregateProductsFromProcedure(
|
|
|
78
84
|
*/
|
|
79
85
|
async function createExtendedProcedureInfo(
|
|
80
86
|
db: Firestore,
|
|
81
|
-
procedureId: string
|
|
87
|
+
procedureId: string,
|
|
82
88
|
): Promise<ExtendedProcedureInfo> {
|
|
83
89
|
const procedureRef = doc(db, PROCEDURES_COLLECTION, procedureId);
|
|
84
90
|
const procedureSnap = await getDoc(procedureRef);
|
|
@@ -92,19 +98,21 @@ async function createExtendedProcedureInfo(
|
|
|
92
98
|
return {
|
|
93
99
|
procedureId: procedureId,
|
|
94
100
|
procedureName: data.name,
|
|
95
|
-
procedureFamily: data.family,
|
|
96
|
-
procedureCategoryId: data.category.id,
|
|
97
|
-
procedureCategoryName: data.category.name,
|
|
98
|
-
procedureSubCategoryId: data.subcategory.id,
|
|
101
|
+
procedureFamily: data.family, // Use embedded family object
|
|
102
|
+
procedureCategoryId: data.category.id, // Access embedded category
|
|
103
|
+
procedureCategoryName: data.category.name, // Access embedded category
|
|
104
|
+
procedureSubCategoryId: data.subcategory.id, // Access embedded subcategory
|
|
99
105
|
procedureSubCategoryName: data.subcategory.name, // Access embedded subcategory
|
|
100
|
-
procedureTechnologyId: data.technology.id,
|
|
101
|
-
procedureTechnologyName: data.technology.name,
|
|
102
|
-
procedureProducts: (data.productsMetadata || [])
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
106
|
+
procedureTechnologyId: data.technology.id, // Access embedded technology
|
|
107
|
+
procedureTechnologyName: data.technology.name, // Access embedded technology
|
|
108
|
+
procedureProducts: (data.productsMetadata || [])
|
|
109
|
+
.filter((pp: any) => pp && pp.product) // Safety check for product-free procedures
|
|
110
|
+
.map((pp: any) => ({
|
|
111
|
+
productId: pp.product.id, // Access embedded product
|
|
112
|
+
productName: pp.product.name, // Access embedded product
|
|
113
|
+
brandId: pp.product.brandId, // Access embedded product
|
|
114
|
+
brandName: pp.product.brandName, // Access embedded product
|
|
115
|
+
})),
|
|
108
116
|
};
|
|
109
117
|
}
|
|
110
118
|
|
|
@@ -119,15 +127,13 @@ async function createExtendedProcedureInfo(
|
|
|
119
127
|
export async function addExtendedProcedureUtil(
|
|
120
128
|
db: Firestore,
|
|
121
129
|
appointmentId: string,
|
|
122
|
-
procedureId: string
|
|
130
|
+
procedureId: string,
|
|
123
131
|
): Promise<Appointment> {
|
|
124
132
|
const appointment = await getAppointmentOrThrow(db, appointmentId);
|
|
125
133
|
const metadata = initializeMetadata(appointment);
|
|
126
134
|
|
|
127
135
|
// Check if procedure is already added
|
|
128
|
-
const existingProcedure = metadata.extendedProcedures?.find(
|
|
129
|
-
p => p.procedureId === procedureId
|
|
130
|
-
);
|
|
136
|
+
const existingProcedure = metadata.extendedProcedures?.find(p => p.procedureId === procedureId);
|
|
131
137
|
if (existingProcedure) {
|
|
132
138
|
throw new Error(`Procedure ${procedureId} is already added to this appointment`);
|
|
133
139
|
}
|
|
@@ -138,18 +144,18 @@ export async function addExtendedProcedureUtil(
|
|
|
138
144
|
// Get procedure data for forms and products
|
|
139
145
|
const procedureRef = doc(db, PROCEDURES_COLLECTION, procedureId);
|
|
140
146
|
const procedureSnap = await getDoc(procedureRef);
|
|
141
|
-
|
|
147
|
+
|
|
142
148
|
if (!procedureSnap.exists()) {
|
|
143
149
|
throw new Error(`Procedure with ID ${procedureId} not found`);
|
|
144
150
|
}
|
|
145
|
-
|
|
151
|
+
|
|
146
152
|
const procedureData = procedureSnap.data();
|
|
147
153
|
|
|
148
154
|
// Aggregate products
|
|
149
155
|
const updatedProducts = await aggregateProductsFromProcedure(
|
|
150
156
|
db,
|
|
151
157
|
procedureId,
|
|
152
|
-
metadata.appointmentProducts || []
|
|
158
|
+
metadata.appointmentProducts || [],
|
|
153
159
|
);
|
|
154
160
|
|
|
155
161
|
// Initialize forms for extended procedure
|
|
@@ -165,13 +171,16 @@ export async function addExtendedProcedureUtil(
|
|
|
165
171
|
procedureData.documentationTemplates,
|
|
166
172
|
appointment.patientId,
|
|
167
173
|
appointment.practitionerId,
|
|
168
|
-
appointment.clinicBranchId
|
|
174
|
+
appointment.clinicBranchId,
|
|
169
175
|
);
|
|
170
176
|
|
|
171
177
|
// Merge form IDs and info
|
|
172
178
|
updatedLinkedFormIds = [...updatedLinkedFormIds, ...formInitResult.allLinkedFormIds];
|
|
173
179
|
updatedLinkedForms = [...updatedLinkedForms, ...formInitResult.initializedFormsInfo];
|
|
174
|
-
updatedPendingUserFormsIds = [
|
|
180
|
+
updatedPendingUserFormsIds = [
|
|
181
|
+
...updatedPendingUserFormsIds,
|
|
182
|
+
...formInitResult.pendingUserFormsIds,
|
|
183
|
+
];
|
|
175
184
|
}
|
|
176
185
|
|
|
177
186
|
// Add extended procedure
|
|
@@ -193,7 +202,10 @@ export async function addExtendedProcedureUtil(
|
|
|
193
202
|
|
|
194
203
|
/**
|
|
195
204
|
* Removes an extended procedure from an appointment
|
|
196
|
-
* Also removes
|
|
205
|
+
* Also removes:
|
|
206
|
+
* - Associated products from appointmentProducts
|
|
207
|
+
* - Associated products from zonesData (all zones)
|
|
208
|
+
* - Associated forms
|
|
197
209
|
* @param db Firestore instance
|
|
198
210
|
* @param appointmentId Appointment ID
|
|
199
211
|
* @param procedureId Procedure ID to remove
|
|
@@ -202,7 +214,7 @@ export async function addExtendedProcedureUtil(
|
|
|
202
214
|
export async function removeExtendedProcedureUtil(
|
|
203
215
|
db: Firestore,
|
|
204
216
|
appointmentId: string,
|
|
205
|
-
procedureId: string
|
|
217
|
+
procedureId: string,
|
|
206
218
|
): Promise<Appointment> {
|
|
207
219
|
const appointment = await getAppointmentOrThrow(db, appointmentId);
|
|
208
220
|
const metadata = initializeMetadata(appointment);
|
|
@@ -212,9 +224,7 @@ export async function removeExtendedProcedureUtil(
|
|
|
212
224
|
}
|
|
213
225
|
|
|
214
226
|
// Find and remove the procedure
|
|
215
|
-
const procedureIndex = metadata.extendedProcedures.findIndex(
|
|
216
|
-
p => p.procedureId === procedureId
|
|
217
|
-
);
|
|
227
|
+
const procedureIndex = metadata.extendedProcedures.findIndex(p => p.procedureId === procedureId);
|
|
218
228
|
if (procedureIndex === -1) {
|
|
219
229
|
throw new Error(`Extended procedure ${procedureId} not found in this appointment`);
|
|
220
230
|
}
|
|
@@ -222,23 +232,42 @@ export async function removeExtendedProcedureUtil(
|
|
|
222
232
|
// Remove procedure
|
|
223
233
|
metadata.extendedProcedures.splice(procedureIndex, 1);
|
|
224
234
|
|
|
225
|
-
// Remove products associated with this procedure
|
|
235
|
+
// Remove products associated with this procedure from appointmentProducts
|
|
226
236
|
const updatedProducts = (metadata.appointmentProducts || []).filter(
|
|
227
|
-
p => p.procedureId !== procedureId
|
|
237
|
+
p => p.procedureId !== procedureId,
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
// Remove products from zonesData that belong to this procedure
|
|
241
|
+
const updatedZonesData = { ...(metadata.zonesData || {}) };
|
|
242
|
+
let productsRemovedFromZones = 0;
|
|
243
|
+
|
|
244
|
+
Object.keys(updatedZonesData).forEach(zoneId => {
|
|
245
|
+
const originalLength = updatedZonesData[zoneId].length;
|
|
246
|
+
updatedZonesData[zoneId] = updatedZonesData[zoneId].filter(item => {
|
|
247
|
+
// Keep notes and items that don't belong to this procedure
|
|
248
|
+
if (item.type === 'note') return true;
|
|
249
|
+
if (item.type === 'item' && item.belongingProcedureId !== procedureId) return true;
|
|
250
|
+
return false;
|
|
251
|
+
});
|
|
252
|
+
productsRemovedFromZones += originalLength - updatedZonesData[zoneId].length;
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
console.log(
|
|
256
|
+
`🗑️ [removeExtendedProcedure] Removed ${productsRemovedFromZones} products from zones for procedure ${procedureId}`,
|
|
228
257
|
);
|
|
229
258
|
|
|
230
259
|
// Remove forms associated with this procedure
|
|
231
260
|
const removedFormIds = await removeFormsForExtendedProcedure(db, appointmentId, procedureId);
|
|
232
|
-
|
|
261
|
+
|
|
233
262
|
// Update appointment form arrays
|
|
234
263
|
const updatedLinkedFormIds = (appointment.linkedFormIds || []).filter(
|
|
235
|
-
formId => !removedFormIds.includes(formId)
|
|
264
|
+
formId => !removedFormIds.includes(formId),
|
|
236
265
|
);
|
|
237
266
|
const updatedLinkedForms = (appointment.linkedForms || []).filter(
|
|
238
|
-
form => !removedFormIds.includes(form.formId)
|
|
267
|
+
form => !removedFormIds.includes(form.formId),
|
|
239
268
|
);
|
|
240
269
|
const updatedPendingUserFormsIds = (appointment.pendingUserFormsIds || []).filter(
|
|
241
|
-
formId => !removedFormIds.includes(formId)
|
|
270
|
+
formId => !removedFormIds.includes(formId),
|
|
242
271
|
);
|
|
243
272
|
|
|
244
273
|
// Update appointment
|
|
@@ -246,6 +275,7 @@ export async function removeExtendedProcedureUtil(
|
|
|
246
275
|
await updateDoc(appointmentRef, {
|
|
247
276
|
'metadata.extendedProcedures': metadata.extendedProcedures,
|
|
248
277
|
'metadata.appointmentProducts': updatedProducts,
|
|
278
|
+
'metadata.zonesData': updatedZonesData,
|
|
249
279
|
linkedFormIds: updatedLinkedFormIds,
|
|
250
280
|
linkedForms: updatedLinkedForms,
|
|
251
281
|
pendingUserFormsIds: updatedPendingUserFormsIds,
|
|
@@ -263,7 +293,7 @@ export async function removeExtendedProcedureUtil(
|
|
|
263
293
|
*/
|
|
264
294
|
export async function getExtendedProceduresUtil(
|
|
265
295
|
db: Firestore,
|
|
266
|
-
appointmentId: string
|
|
296
|
+
appointmentId: string,
|
|
267
297
|
): Promise<ExtendedProcedureInfo[]> {
|
|
268
298
|
const appointment = await getAppointmentOrThrow(db, appointmentId);
|
|
269
299
|
return appointment.metadata?.extendedProcedures || [];
|
|
@@ -277,9 +307,8 @@ export async function getExtendedProceduresUtil(
|
|
|
277
307
|
*/
|
|
278
308
|
export async function getAppointmentProductsUtil(
|
|
279
309
|
db: Firestore,
|
|
280
|
-
appointmentId: string
|
|
310
|
+
appointmentId: string,
|
|
281
311
|
): Promise<AppointmentProductMetadata[]> {
|
|
282
312
|
const appointment = await getAppointmentOrThrow(db, appointmentId);
|
|
283
313
|
return appointment.metadata?.appointmentProducts || [];
|
|
284
314
|
}
|
|
285
|
-
|
|
@@ -38,12 +38,14 @@ async function createExtendedProcedureInfoForRecommended(
|
|
|
38
38
|
procedureSubCategoryName: data.subcategory.name,
|
|
39
39
|
procedureTechnologyId: data.technology.id,
|
|
40
40
|
procedureTechnologyName: data.technology.name,
|
|
41
|
-
procedureProducts: (data.productsMetadata || [])
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
41
|
+
procedureProducts: (data.productsMetadata || [])
|
|
42
|
+
.filter((pp: any) => pp && pp.product) // Safety check for product-free procedures
|
|
43
|
+
.map((pp: any) => ({
|
|
44
|
+
productId: pp.product.id,
|
|
45
|
+
productName: pp.product.name,
|
|
46
|
+
brandId: pp.product.brandId,
|
|
47
|
+
brandName: pp.product.brandName,
|
|
48
|
+
})),
|
|
47
49
|
};
|
|
48
50
|
}
|
|
49
51
|
|
|
@@ -1553,7 +1553,7 @@ export class PractitionerService extends BaseService {
|
|
|
1553
1553
|
}
|
|
1554
1554
|
}
|
|
1555
1555
|
|
|
1556
|
-
// Create procedure data for free consultation (without productId)
|
|
1556
|
+
// Create procedure data for free consultation (without productId or productsMetadata)
|
|
1557
1557
|
const consultationData: Omit<CreateProcedureData, "productId"> = {
|
|
1558
1558
|
name: "Free Consultation",
|
|
1559
1559
|
nameLower: "free consultation",
|
|
@@ -1566,15 +1566,7 @@ export class PractitionerService extends BaseService {
|
|
|
1566
1566
|
price: 0,
|
|
1567
1567
|
currency: Currency.EUR,
|
|
1568
1568
|
pricingMeasure: PricingMeasure.PER_SESSION,
|
|
1569
|
-
productsMetadata:
|
|
1570
|
-
{
|
|
1571
|
-
productId: "free-consultation-product",
|
|
1572
|
-
price: 0,
|
|
1573
|
-
currency: Currency.EUR,
|
|
1574
|
-
pricingMeasure: PricingMeasure.PER_SESSION,
|
|
1575
|
-
isDefault: true,
|
|
1576
|
-
},
|
|
1577
|
-
],
|
|
1569
|
+
productsMetadata: undefined, // No products needed for consultations
|
|
1578
1570
|
duration: 30, // 30 minutes consultation
|
|
1579
1571
|
practitionerId: practitionerId,
|
|
1580
1572
|
clinicBranchId: clinicId,
|
|
@@ -143,7 +143,7 @@ export class ProcedureService extends BaseService {
|
|
|
143
143
|
|
|
144
144
|
/**
|
|
145
145
|
* Transforms validated procedure product data (with productId) to ProcedureProduct objects (with full product)
|
|
146
|
-
* @param productsMetadata Array of validated procedure product data
|
|
146
|
+
* @param productsMetadata Array of validated procedure product data (optional)
|
|
147
147
|
* @param technologyId Technology ID to fetch products from
|
|
148
148
|
* @returns Array of ProcedureProduct objects with full product information
|
|
149
149
|
*/
|
|
@@ -154,9 +154,14 @@ export class ProcedureService extends BaseService {
|
|
|
154
154
|
currency: Currency;
|
|
155
155
|
pricingMeasure: PricingMeasure;
|
|
156
156
|
isDefault?: boolean;
|
|
157
|
-
}[],
|
|
157
|
+
}[] | undefined,
|
|
158
158
|
technologyId: string,
|
|
159
159
|
): Promise<ProcedureProduct[]> {
|
|
160
|
+
// Return empty array if no products metadata provided (for product-free procedures like consultations)
|
|
161
|
+
if (!productsMetadata || productsMetadata.length === 0) {
|
|
162
|
+
return [];
|
|
163
|
+
}
|
|
164
|
+
|
|
160
165
|
const transformedProducts: ProcedureProduct[] = [];
|
|
161
166
|
|
|
162
167
|
for (const productData of productsMetadata) {
|
|
@@ -189,21 +194,41 @@ export class ProcedureService extends BaseService {
|
|
|
189
194
|
async createProcedure(data: CreateProcedureData): Promise<Procedure> {
|
|
190
195
|
const validatedData = createProcedureSchema.parse(data);
|
|
191
196
|
|
|
197
|
+
// Check if this is a product-free procedure (e.g., free consultation)
|
|
198
|
+
const isProductFree = !validatedData.productId;
|
|
199
|
+
|
|
192
200
|
// Generate procedure ID first so we can use it for media uploads
|
|
193
201
|
const procedureId = this.generateId();
|
|
194
202
|
|
|
195
|
-
// Get references to related entities (Category, Subcategory, Technology, Product)
|
|
196
|
-
const
|
|
203
|
+
// Get references to related entities (Category, Subcategory, Technology, and optionally Product)
|
|
204
|
+
const baseEntitiesPromises = [
|
|
197
205
|
this.categoryService.getById(validatedData.categoryId),
|
|
198
206
|
this.subcategoryService.getById(validatedData.categoryId, validatedData.subcategoryId),
|
|
199
207
|
this.technologyService.getById(validatedData.technologyId),
|
|
200
|
-
|
|
201
|
-
|
|
208
|
+
];
|
|
209
|
+
|
|
210
|
+
// Only fetch product if productId is provided
|
|
211
|
+
if (!isProductFree) {
|
|
212
|
+
baseEntitiesPromises.push(
|
|
213
|
+
this.productService.getById(validatedData.technologyId, validatedData.productId!)
|
|
214
|
+
);
|
|
215
|
+
}
|
|
202
216
|
|
|
203
|
-
|
|
217
|
+
const results = await Promise.all(baseEntitiesPromises);
|
|
218
|
+
const category = results[0] as Category | null;
|
|
219
|
+
const subcategory = results[1] as Subcategory | null;
|
|
220
|
+
const technology = results[2] as Technology | null;
|
|
221
|
+
const product = isProductFree ? undefined : ((results[3] as Product | null) || undefined);
|
|
222
|
+
|
|
223
|
+
if (!category || !subcategory || !technology) {
|
|
204
224
|
throw new Error('One or more required base entities not found');
|
|
205
225
|
}
|
|
206
226
|
|
|
227
|
+
// For regular procedures, validate product exists
|
|
228
|
+
if (!isProductFree && !product) {
|
|
229
|
+
throw new Error('Product not found for regular procedure');
|
|
230
|
+
}
|
|
231
|
+
|
|
207
232
|
// Get clinic and practitioner information for aggregation
|
|
208
233
|
const clinicRef = doc(this.db, CLINICS_COLLECTION, validatedData.clinicBranchId);
|
|
209
234
|
const clinicSnapshot = await getDoc(clinicRef);
|
|
@@ -333,23 +358,50 @@ export class ProcedureService extends BaseService {
|
|
|
333
358
|
throw new Error('Practitioner IDs array cannot be empty.');
|
|
334
359
|
}
|
|
335
360
|
|
|
361
|
+
// Check if this is a product-free procedure
|
|
362
|
+
const isProductFree = !baseData.productId;
|
|
363
|
+
|
|
336
364
|
// Add a dummy practitionerId for the validation schema to pass
|
|
337
365
|
const validationData = { ...baseData, practitionerId: practitionerIds[0] };
|
|
338
366
|
const validatedData = createProcedureSchema.parse(validationData);
|
|
339
367
|
|
|
340
368
|
// 2. Fetch common data once to avoid redundant reads
|
|
341
|
-
const
|
|
369
|
+
const baseEntitiesPromises = [
|
|
342
370
|
this.categoryService.getById(validatedData.categoryId),
|
|
343
371
|
this.subcategoryService.getById(validatedData.categoryId, validatedData.subcategoryId),
|
|
344
372
|
this.technologyService.getById(validatedData.technologyId),
|
|
345
|
-
|
|
346
|
-
|
|
373
|
+
];
|
|
374
|
+
|
|
375
|
+
// Only fetch product if productId is provided
|
|
376
|
+
if (!isProductFree) {
|
|
377
|
+
baseEntitiesPromises.push(
|
|
378
|
+
this.productService.getById(validatedData.technologyId, validatedData.productId!)
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Fetch clinic separately to maintain type safety
|
|
383
|
+
const clinicSnapshotPromise = getDoc(doc(this.db, CLINICS_COLLECTION, validatedData.clinicBranchId));
|
|
384
|
+
|
|
385
|
+
const [baseResults, clinicSnapshot] = await Promise.all([
|
|
386
|
+
Promise.all(baseEntitiesPromises),
|
|
387
|
+
clinicSnapshotPromise
|
|
347
388
|
]);
|
|
389
|
+
|
|
390
|
+
const category = baseResults[0] as Category | null;
|
|
391
|
+
const subcategory = baseResults[1] as Subcategory | null;
|
|
392
|
+
const technology = baseResults[2] as Technology | null;
|
|
393
|
+
const product = isProductFree ? undefined : ((baseResults[3] as Product | null) || undefined);
|
|
348
394
|
|
|
349
|
-
if (!category || !subcategory || !technology
|
|
395
|
+
if (!category || !subcategory || !technology) {
|
|
350
396
|
throw new Error('One or more required base entities not found');
|
|
351
397
|
}
|
|
352
|
-
|
|
398
|
+
|
|
399
|
+
// For regular procedures, validate product exists
|
|
400
|
+
if (!isProductFree && !product) {
|
|
401
|
+
throw new Error('Product not found for regular procedure');
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (!clinicSnapshot || !clinicSnapshot.exists()) {
|
|
353
405
|
throw new Error(`Clinic with ID ${validatedData.clinicBranchId} not found`);
|
|
354
406
|
}
|
|
355
407
|
const clinic = clinicSnapshot.data() as Clinic;
|
|
@@ -1416,6 +1468,7 @@ export class ProcedureService extends BaseService {
|
|
|
1416
1468
|
}
|
|
1417
1469
|
|
|
1418
1470
|
// Transform productsMetadata from validation format to ProcedureProduct format
|
|
1471
|
+
// For consultations, this will return empty array since no products are provided
|
|
1419
1472
|
const transformedProductsMetadata = await this.transformProductsMetadata(
|
|
1420
1473
|
data.productsMetadata,
|
|
1421
1474
|
data.technologyId,
|
|
@@ -1451,22 +1504,6 @@ export class ProcedureService extends BaseService {
|
|
|
1451
1504
|
services: practitioner.procedures || [],
|
|
1452
1505
|
};
|
|
1453
1506
|
|
|
1454
|
-
// Create a placeholder product for consultation procedures
|
|
1455
|
-
const consultationProduct: Product = {
|
|
1456
|
-
id: 'consultation-no-product',
|
|
1457
|
-
name: 'No Product Required',
|
|
1458
|
-
description: 'Consultation procedures do not require specific products',
|
|
1459
|
-
brandId: 'consultation-brand',
|
|
1460
|
-
brandName: 'Consultation',
|
|
1461
|
-
technologyId: data.technologyId,
|
|
1462
|
-
technologyName: technology.name,
|
|
1463
|
-
categoryId: technology.categoryId,
|
|
1464
|
-
subcategoryId: technology.subcategoryId,
|
|
1465
|
-
isActive: true,
|
|
1466
|
-
createdAt: new Date(),
|
|
1467
|
-
updatedAt: new Date(),
|
|
1468
|
-
};
|
|
1469
|
-
|
|
1470
1507
|
// Create the procedure object
|
|
1471
1508
|
const { productsMetadata: _, ...dataWithoutProductsMetadata } = data;
|
|
1472
1509
|
const newProcedure: Omit<Procedure, 'createdAt' | 'updatedAt'> = {
|
|
@@ -1477,8 +1514,8 @@ export class ProcedureService extends BaseService {
|
|
|
1477
1514
|
category,
|
|
1478
1515
|
subcategory,
|
|
1479
1516
|
technology,
|
|
1480
|
-
product:
|
|
1481
|
-
productsMetadata: transformedProductsMetadata, //
|
|
1517
|
+
product: undefined, // No product needed for consultations
|
|
1518
|
+
productsMetadata: transformedProductsMetadata, // Empty array for consultations
|
|
1482
1519
|
blockingConditions: technology.blockingConditions,
|
|
1483
1520
|
contraindications: technology.contraindications || [],
|
|
1484
1521
|
contraindicationIds: technology.contraindications?.map(c => c.id) || [],
|
|
@@ -45,8 +45,8 @@ export interface Procedure {
|
|
|
45
45
|
subcategory: Subcategory;
|
|
46
46
|
/** Technology used in this procedure */
|
|
47
47
|
technology: Technology;
|
|
48
|
-
/** Default product used in this procedure */
|
|
49
|
-
product
|
|
48
|
+
/** Default product used in this procedure (optional for consultations) */
|
|
49
|
+
product?: Product;
|
|
50
50
|
/** Default price of the procedure */
|
|
51
51
|
price: number;
|
|
52
52
|
/** Currency for the price */
|
|
@@ -54,7 +54,7 @@ export interface Procedure {
|
|
|
54
54
|
/** How the price is measured (per ml, per zone, etc.) - for default product*/
|
|
55
55
|
pricingMeasure: PricingMeasure;
|
|
56
56
|
/** Duration of the procedure in minutes */
|
|
57
|
-
productsMetadata
|
|
57
|
+
productsMetadata?: ProcedureProduct[];
|
|
58
58
|
duration: number;
|
|
59
59
|
/** Blocking conditions that prevent this procedure */
|
|
60
60
|
blockingConditions: BlockingCondition[];
|
|
@@ -104,9 +104,9 @@ export interface CreateProcedureData {
|
|
|
104
104
|
categoryId: string;
|
|
105
105
|
subcategoryId: string;
|
|
106
106
|
technologyId: string;
|
|
107
|
-
productId
|
|
107
|
+
productId?: string;
|
|
108
108
|
price: number;
|
|
109
|
-
productsMetadata
|
|
109
|
+
productsMetadata?: {
|
|
110
110
|
productId: string;
|
|
111
111
|
price: number;
|
|
112
112
|
currency: Currency;
|
|
@@ -59,7 +59,7 @@ export const procedureExtendedInfoSchema = z.object({
|
|
|
59
59
|
productName: z.string().min(MIN_STRING_LENGTH, 'Product name is required'),
|
|
60
60
|
brandId: z.string().min(MIN_STRING_LENGTH, 'Brand ID is required'),
|
|
61
61
|
brandName: z.string().min(MIN_STRING_LENGTH, 'Brand name is required'),
|
|
62
|
-
})
|
|
62
|
+
}),
|
|
63
63
|
),
|
|
64
64
|
});
|
|
65
65
|
|
|
@@ -176,59 +176,66 @@ export const finalBillingSchema = z.object({
|
|
|
176
176
|
/**
|
|
177
177
|
* Schema for zone item data (product or note per zone)
|
|
178
178
|
*/
|
|
179
|
-
export const zoneItemDataSchema = z
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
179
|
+
export const zoneItemDataSchema = z
|
|
180
|
+
.object({
|
|
181
|
+
productId: z.string().optional(),
|
|
182
|
+
productName: z.string().optional(),
|
|
183
|
+
productBrandId: z.string().optional(),
|
|
184
|
+
productBrandName: z.string().optional(),
|
|
185
|
+
belongingProcedureId: z.string().min(MIN_STRING_LENGTH, 'Belonging procedure ID is required'),
|
|
186
|
+
type: z.enum(['item', 'note'], {
|
|
187
|
+
required_error: 'Type must be either "item" or "note"',
|
|
188
|
+
}),
|
|
189
|
+
stage: z
|
|
190
|
+
.enum(['before', 'after'], {
|
|
191
|
+
required_error: 'Stage must be either "before" or "after"',
|
|
192
|
+
})
|
|
193
|
+
.optional(),
|
|
194
|
+
price: z.number().min(0, 'Price must be non-negative').optional(),
|
|
195
|
+
currency: z.nativeEnum(Currency).optional(),
|
|
196
|
+
unitOfMeasurement: z.nativeEnum(PricingMeasure).optional(),
|
|
197
|
+
priceOverrideAmount: z.number().min(0, 'Price override amount must be non-negative').optional(),
|
|
198
|
+
quantity: z.number().min(0, 'Quantity must be non-negative').optional(),
|
|
199
|
+
parentZone: z
|
|
200
|
+
.string()
|
|
201
|
+
.min(MIN_STRING_LENGTH, 'Parent zone is required')
|
|
202
|
+
.refine(
|
|
203
|
+
val => {
|
|
204
|
+
const parts = val.split('.');
|
|
205
|
+
return parts.length === 2;
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
message: 'Parent zone must be in "category.zone" format (e.g., "face.forehead")',
|
|
209
|
+
},
|
|
210
|
+
),
|
|
211
|
+
subzones: z.array(z.string()).refine(
|
|
212
|
+
val => {
|
|
213
|
+
return val.every(subzone => {
|
|
214
|
+
const parts = subzone.split('.');
|
|
215
|
+
return parts.length === 3;
|
|
216
|
+
});
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
message: 'Subzones must be in "category.zone.subzone" format (e.g., "face.forehead.left")',
|
|
220
|
+
},
|
|
221
|
+
),
|
|
222
|
+
notes: z.string().max(MAX_STRING_LENGTH_LONG, 'Notes too long').optional(),
|
|
223
|
+
subtotal: z.number().min(0, 'Subtotal must be non-negative').optional(),
|
|
224
|
+
ionNumber: z.string().optional(),
|
|
225
|
+
createdAt: z.string().optional(),
|
|
226
|
+
updatedAt: z.string().optional(),
|
|
227
|
+
})
|
|
228
|
+
.refine(
|
|
229
|
+
data => {
|
|
230
|
+
if (data.type === 'item') {
|
|
231
|
+
return !!(data.productId && data.productName && (data.price || data.priceOverrideAmount));
|
|
232
|
+
}
|
|
233
|
+
return true;
|
|
200
234
|
},
|
|
201
235
|
{
|
|
202
|
-
message: '
|
|
203
|
-
}
|
|
204
|
-
),
|
|
205
|
-
subzones: z.array(z.string()).refine(
|
|
206
|
-
val => {
|
|
207
|
-
return val.every(subzone => {
|
|
208
|
-
const parts = subzone.split('.');
|
|
209
|
-
return parts.length === 3;
|
|
210
|
-
});
|
|
236
|
+
message: 'Item type requires productId, productName, and either price or priceOverrideAmount',
|
|
211
237
|
},
|
|
212
|
-
|
|
213
|
-
message: 'Subzones must be in "category.zone.subzone" format (e.g., "face.forehead.left")',
|
|
214
|
-
}
|
|
215
|
-
),
|
|
216
|
-
notes: z.string().max(MAX_STRING_LENGTH_LONG, 'Notes too long').optional(),
|
|
217
|
-
subtotal: z.number().min(0, 'Subtotal must be non-negative').optional(),
|
|
218
|
-
ionNumber: z.string().optional(),
|
|
219
|
-
createdAt: z.string().optional(),
|
|
220
|
-
updatedAt: z.string().optional(),
|
|
221
|
-
}).refine(
|
|
222
|
-
data => {
|
|
223
|
-
if (data.type === 'item') {
|
|
224
|
-
return !!(data.productId && data.productName && (data.price || data.priceOverrideAmount));
|
|
225
|
-
}
|
|
226
|
-
return true;
|
|
227
|
-
},
|
|
228
|
-
{
|
|
229
|
-
message: 'Item type requires productId, productName, and either price or priceOverrideAmount',
|
|
230
|
-
}
|
|
231
|
-
);
|
|
238
|
+
);
|
|
232
239
|
|
|
233
240
|
/**
|
|
234
241
|
* Schema for appointment product metadata
|
|
@@ -263,7 +270,7 @@ export const extendedProcedureInfoSchema = z.object({
|
|
|
263
270
|
productName: z.string().min(MIN_STRING_LENGTH, 'Product name is required'),
|
|
264
271
|
brandId: z.string().min(MIN_STRING_LENGTH, 'Brand ID is required'),
|
|
265
272
|
brandName: z.string().min(MIN_STRING_LENGTH, 'Brand name is required'),
|
|
266
|
-
})
|
|
273
|
+
}),
|
|
267
274
|
),
|
|
268
275
|
});
|
|
269
276
|
|
|
@@ -277,7 +284,7 @@ export const recommendedProcedureTimeframeSchema = z.object({
|
|
|
277
284
|
|
|
278
285
|
export const recommendedProcedureSchema = z.object({
|
|
279
286
|
procedure: extendedProcedureInfoSchema,
|
|
280
|
-
note: z.string().
|
|
287
|
+
note: z.string().max(MAX_STRING_LENGTH_LONG, 'Note too long'), // Note is now optional (no min length)
|
|
281
288
|
timeframe: recommendedProcedureTimeframeSchema,
|
|
282
289
|
});
|
|
283
290
|
|