@blackcode_sa/metaestetics-api 1.12.45 → 1.12.47
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 +53 -48
- package/dist/index.mjs +53 -48
- 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/appointment/utils/zone-management.utils.ts +7 -7
- package/src/services/practitioner/practitioner.service.ts +2 -10
- package/src/services/procedure/procedure.service.ts +22 -22
- 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
|
|
|
@@ -53,7 +53,7 @@ export function calculateItemSubtotal(item: Partial<ZoneItemData>): number {
|
|
|
53
53
|
*/
|
|
54
54
|
export function calculateFinalBilling(
|
|
55
55
|
zonesData: Record<string, ZoneItemData[]>,
|
|
56
|
-
taxRate: number = 0.
|
|
56
|
+
taxRate: number = 0.081,
|
|
57
57
|
): FinalBilling {
|
|
58
58
|
let subtotalAll = 0;
|
|
59
59
|
|
|
@@ -168,8 +168,8 @@ export async function addItemToZoneUtil(
|
|
|
168
168
|
// Add item to zone
|
|
169
169
|
zonesData[zoneId].push(itemWithSubtotal);
|
|
170
170
|
|
|
171
|
-
// Recalculate final billing
|
|
172
|
-
const finalbilling = calculateFinalBilling(zonesData);
|
|
171
|
+
// Recalculate final billing with Swiss tax rate (8.1%)
|
|
172
|
+
const finalbilling = calculateFinalBilling(zonesData, 0.081);
|
|
173
173
|
|
|
174
174
|
// Update appointment
|
|
175
175
|
const appointmentRef = doc(db, APPOINTMENTS_COLLECTION, appointmentId);
|
|
@@ -219,8 +219,8 @@ export async function removeItemFromZoneUtil(
|
|
|
219
219
|
delete metadata.zonesData[zoneId];
|
|
220
220
|
}
|
|
221
221
|
|
|
222
|
-
// Recalculate final billing
|
|
223
|
-
const finalbilling = calculateFinalBilling(metadata.zonesData);
|
|
222
|
+
// Recalculate final billing with Swiss tax rate (8.1%)
|
|
223
|
+
const finalbilling = calculateFinalBilling(metadata.zonesData, 0.081);
|
|
224
224
|
|
|
225
225
|
// Update appointment
|
|
226
226
|
const appointmentRef = doc(db, APPOINTMENTS_COLLECTION, appointmentId);
|
|
@@ -286,8 +286,8 @@ export async function updateZoneItemUtil(
|
|
|
286
286
|
newSubtotal: items[itemIndex].subtotal,
|
|
287
287
|
});
|
|
288
288
|
|
|
289
|
-
// Recalculate final billing
|
|
290
|
-
const finalbilling = calculateFinalBilling(metadata.zonesData);
|
|
289
|
+
// Recalculate final billing with Swiss tax rate (8.1%)
|
|
290
|
+
const finalbilling = calculateFinalBilling(metadata.zonesData, 0.081);
|
|
291
291
|
|
|
292
292
|
// Update appointment
|
|
293
293
|
const appointmentRef = doc(db, APPOINTMENTS_COLLECTION, appointmentId);
|
|
@@ -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,6 +194,11 @@ export class ProcedureService extends BaseService {
|
|
|
189
194
|
async createProcedure(data: CreateProcedureData): Promise<Procedure> {
|
|
190
195
|
const validatedData = createProcedureSchema.parse(data);
|
|
191
196
|
|
|
197
|
+
// Validate that productId is provided (regular procedures require products)
|
|
198
|
+
if (!validatedData.productId) {
|
|
199
|
+
throw new Error('productId is required for regular procedures. Use createConsultationProcedure for product-free procedures.');
|
|
200
|
+
}
|
|
201
|
+
|
|
192
202
|
// Generate procedure ID first so we can use it for media uploads
|
|
193
203
|
const procedureId = this.generateId();
|
|
194
204
|
|
|
@@ -197,7 +207,7 @@ export class ProcedureService extends BaseService {
|
|
|
197
207
|
this.categoryService.getById(validatedData.categoryId),
|
|
198
208
|
this.subcategoryService.getById(validatedData.categoryId, validatedData.subcategoryId),
|
|
199
209
|
this.technologyService.getById(validatedData.technologyId),
|
|
200
|
-
this.productService.getById(validatedData.technologyId, validatedData.productId),
|
|
210
|
+
this.productService.getById(validatedData.technologyId, validatedData.productId!), // Safe: validated above
|
|
201
211
|
]);
|
|
202
212
|
|
|
203
213
|
if (!category || !subcategory || !technology || !product) {
|
|
@@ -333,6 +343,11 @@ export class ProcedureService extends BaseService {
|
|
|
333
343
|
throw new Error('Practitioner IDs array cannot be empty.');
|
|
334
344
|
}
|
|
335
345
|
|
|
346
|
+
// Validate that productId is provided (regular procedures require products)
|
|
347
|
+
if (!baseData.productId) {
|
|
348
|
+
throw new Error('productId is required for regular procedures. Use createConsultationProcedure for product-free procedures.');
|
|
349
|
+
}
|
|
350
|
+
|
|
336
351
|
// Add a dummy practitionerId for the validation schema to pass
|
|
337
352
|
const validationData = { ...baseData, practitionerId: practitionerIds[0] };
|
|
338
353
|
const validatedData = createProcedureSchema.parse(validationData);
|
|
@@ -342,7 +357,7 @@ export class ProcedureService extends BaseService {
|
|
|
342
357
|
this.categoryService.getById(validatedData.categoryId),
|
|
343
358
|
this.subcategoryService.getById(validatedData.categoryId, validatedData.subcategoryId),
|
|
344
359
|
this.technologyService.getById(validatedData.technologyId),
|
|
345
|
-
this.productService.getById(validatedData.technologyId, validatedData.productId),
|
|
360
|
+
this.productService.getById(validatedData.technologyId, validatedData.productId!), // Safe: validated above
|
|
346
361
|
getDoc(doc(this.db, CLINICS_COLLECTION, validatedData.clinicBranchId)),
|
|
347
362
|
]);
|
|
348
363
|
|
|
@@ -1416,6 +1431,7 @@ export class ProcedureService extends BaseService {
|
|
|
1416
1431
|
}
|
|
1417
1432
|
|
|
1418
1433
|
// Transform productsMetadata from validation format to ProcedureProduct format
|
|
1434
|
+
// For consultations, this will return empty array since no products are provided
|
|
1419
1435
|
const transformedProductsMetadata = await this.transformProductsMetadata(
|
|
1420
1436
|
data.productsMetadata,
|
|
1421
1437
|
data.technologyId,
|
|
@@ -1451,22 +1467,6 @@ export class ProcedureService extends BaseService {
|
|
|
1451
1467
|
services: practitioner.procedures || [],
|
|
1452
1468
|
};
|
|
1453
1469
|
|
|
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
1470
|
// Create the procedure object
|
|
1471
1471
|
const { productsMetadata: _, ...dataWithoutProductsMetadata } = data;
|
|
1472
1472
|
const newProcedure: Omit<Procedure, 'createdAt' | 'updatedAt'> = {
|
|
@@ -1477,8 +1477,8 @@ export class ProcedureService extends BaseService {
|
|
|
1477
1477
|
category,
|
|
1478
1478
|
subcategory,
|
|
1479
1479
|
technology,
|
|
1480
|
-
product:
|
|
1481
|
-
productsMetadata: transformedProductsMetadata, //
|
|
1480
|
+
product: undefined, // No product needed for consultations
|
|
1481
|
+
productsMetadata: transformedProductsMetadata, // Empty array for consultations
|
|
1482
1482
|
blockingConditions: technology.blockingConditions,
|
|
1483
1483
|
contraindications: technology.contraindications || [],
|
|
1484
1484
|
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
|
|
|
@@ -52,11 +52,11 @@ export const createProcedureSchema = z.object({
|
|
|
52
52
|
categoryId: z.string().min(1),
|
|
53
53
|
subcategoryId: z.string().min(1),
|
|
54
54
|
technologyId: z.string().min(1),
|
|
55
|
-
productId: z.string().min(1),
|
|
55
|
+
productId: z.string().min(1).optional(),
|
|
56
56
|
price: z.number().min(0),
|
|
57
57
|
currency: z.nativeEnum(Currency),
|
|
58
58
|
pricingMeasure: z.nativeEnum(PricingMeasure),
|
|
59
|
-
productsMetadata: z.array(procedureProductDataSchema).min(1),
|
|
59
|
+
productsMetadata: z.array(procedureProductDataSchema).min(1).optional(),
|
|
60
60
|
duration: z.number().min(1).max(480), // Max 8 hours
|
|
61
61
|
practitionerId: z.string().min(1),
|
|
62
62
|
clinicBranchId: z.string().min(1),
|
|
@@ -97,8 +97,8 @@ export const procedureSchema = z.object({
|
|
|
97
97
|
category: z.any(), // We'll validate the full category object separately
|
|
98
98
|
subcategory: z.any(), // We'll validate the full subcategory object separately
|
|
99
99
|
technology: z.any(), // We'll validate the full technology object separately
|
|
100
|
-
product: z.any(), // We'll validate the full product object separately
|
|
101
|
-
productsMetadata: z.array(storedProcedureProductSchema).
|
|
100
|
+
product: z.any().optional(), // We'll validate the full product object separately (optional for consultations)
|
|
101
|
+
productsMetadata: z.array(storedProcedureProductSchema).optional(), // Use stored format schema (optional for consultations)
|
|
102
102
|
price: z.number().min(0),
|
|
103
103
|
currency: z.nativeEnum(Currency),
|
|
104
104
|
pricingMeasure: z.nativeEnum(PricingMeasure),
|