@blackcode_sa/metaestetics-api 1.12.36 → 1.12.39
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 +13 -1
- package/dist/admin/index.d.ts +13 -1
- package/dist/admin/index.js +45 -18
- package/dist/admin/index.mjs +45 -18
- package/dist/index.d.mts +106 -6
- package/dist/index.d.ts +106 -6
- package/dist/index.js +1989 -1615
- package/dist/index.mjs +1185 -811
- package/package.json +1 -1
- package/src/admin/aggregation/appointment/appointment.aggregation.service.ts +56 -25
- package/src/admin/booking/booking.admin.ts +1 -0
- package/src/services/appointment/appointment.service.ts +279 -45
- package/src/services/appointment/utils/recommended-procedure.utils.ts +193 -0
- package/src/services/appointment/utils/zone-management.utils.ts +27 -23
- package/src/services/appointment/utils/zone-photo.utils.ts +152 -0
- package/src/services/procedure/procedure.service.ts +42 -2
- package/src/types/appointment/index.ts +16 -2
- package/src/validations/appointment.schema.ts +24 -4
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { Firestore, updateDoc, serverTimestamp, doc } from 'firebase/firestore';
|
|
2
|
+
import {
|
|
3
|
+
Appointment,
|
|
4
|
+
RecommendedProcedure,
|
|
5
|
+
ExtendedProcedureInfo,
|
|
6
|
+
APPOINTMENTS_COLLECTION,
|
|
7
|
+
} from '../../../types/appointment';
|
|
8
|
+
import { getAppointmentOrThrow, initializeMetadata } from './zone-management.utils';
|
|
9
|
+
import { PROCEDURES_COLLECTION } from '../../../types/procedure';
|
|
10
|
+
import { getDoc } from 'firebase/firestore';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Creates ExtendedProcedureInfo from procedure document for recommended procedures
|
|
14
|
+
* @param db Firestore instance
|
|
15
|
+
* @param procedureId Procedure ID
|
|
16
|
+
* @returns Extended procedure info
|
|
17
|
+
*/
|
|
18
|
+
async function createExtendedProcedureInfoForRecommended(
|
|
19
|
+
db: Firestore,
|
|
20
|
+
procedureId: string
|
|
21
|
+
): Promise<ExtendedProcedureInfo> {
|
|
22
|
+
const procedureRef = doc(db, PROCEDURES_COLLECTION, procedureId);
|
|
23
|
+
const procedureSnap = await getDoc(procedureRef);
|
|
24
|
+
|
|
25
|
+
if (!procedureSnap.exists()) {
|
|
26
|
+
throw new Error(`Procedure with ID ${procedureId} not found`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const data = procedureSnap.data();
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
procedureId: procedureId,
|
|
33
|
+
procedureName: data.name,
|
|
34
|
+
procedureFamily: data.family,
|
|
35
|
+
procedureCategoryId: data.category.id,
|
|
36
|
+
procedureCategoryName: data.category.name,
|
|
37
|
+
procedureSubCategoryId: data.subcategory.id,
|
|
38
|
+
procedureSubCategoryName: data.subcategory.name,
|
|
39
|
+
procedureTechnologyId: data.technology.id,
|
|
40
|
+
procedureTechnologyName: data.technology.name,
|
|
41
|
+
procedureProducts: (data.productsMetadata || []).map((pp: any) => ({
|
|
42
|
+
productId: pp.product.id,
|
|
43
|
+
productName: pp.product.name,
|
|
44
|
+
brandId: pp.product.brandId,
|
|
45
|
+
brandName: pp.product.brandName,
|
|
46
|
+
})),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Adds a recommended procedure to an appointment
|
|
52
|
+
* Multiple recommendations of the same procedure are allowed (e.g., touch-up in 2 weeks, full treatment in 3 months)
|
|
53
|
+
* @param db Firestore instance
|
|
54
|
+
* @param appointmentId Appointment ID
|
|
55
|
+
* @param procedureId Procedure ID to recommend
|
|
56
|
+
* @param note Note explaining the recommendation
|
|
57
|
+
* @param timeframe Suggested timeframe for the procedure
|
|
58
|
+
* @returns Updated appointment
|
|
59
|
+
*/
|
|
60
|
+
export async function addRecommendedProcedureUtil(
|
|
61
|
+
db: Firestore,
|
|
62
|
+
appointmentId: string,
|
|
63
|
+
procedureId: string,
|
|
64
|
+
note: string,
|
|
65
|
+
timeframe: { value: number; unit: 'day' | 'week' | 'month' | 'year' }
|
|
66
|
+
): Promise<Appointment> {
|
|
67
|
+
const appointment = await getAppointmentOrThrow(db, appointmentId);
|
|
68
|
+
const metadata = initializeMetadata(appointment);
|
|
69
|
+
|
|
70
|
+
// Create extended procedure info
|
|
71
|
+
const procedureInfo = await createExtendedProcedureInfoForRecommended(db, procedureId);
|
|
72
|
+
|
|
73
|
+
// Create recommended procedure object
|
|
74
|
+
const recommendedProcedure: RecommendedProcedure = {
|
|
75
|
+
procedure: procedureInfo,
|
|
76
|
+
note,
|
|
77
|
+
timeframe,
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// Add to recommended procedures array
|
|
81
|
+
const recommendedProcedures = [...(metadata.recommendedProcedures || []), recommendedProcedure];
|
|
82
|
+
|
|
83
|
+
// Update appointment
|
|
84
|
+
const appointmentRef = doc(db, APPOINTMENTS_COLLECTION, appointmentId);
|
|
85
|
+
await updateDoc(appointmentRef, {
|
|
86
|
+
'metadata.recommendedProcedures': recommendedProcedures,
|
|
87
|
+
updatedAt: serverTimestamp(),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
return getAppointmentOrThrow(db, appointmentId);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Removes a recommended procedure from an appointment by index
|
|
95
|
+
* @param db Firestore instance
|
|
96
|
+
* @param appointmentId Appointment ID
|
|
97
|
+
* @param recommendationIndex Index of the recommendation to remove in the array
|
|
98
|
+
* @returns Updated appointment
|
|
99
|
+
*/
|
|
100
|
+
export async function removeRecommendedProcedureUtil(
|
|
101
|
+
db: Firestore,
|
|
102
|
+
appointmentId: string,
|
|
103
|
+
recommendationIndex: number
|
|
104
|
+
): Promise<Appointment> {
|
|
105
|
+
const appointment = await getAppointmentOrThrow(db, appointmentId);
|
|
106
|
+
const metadata = initializeMetadata(appointment);
|
|
107
|
+
|
|
108
|
+
if (!metadata.recommendedProcedures || metadata.recommendedProcedures.length === 0) {
|
|
109
|
+
throw new Error('No recommended procedures found for this appointment');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Validate index
|
|
113
|
+
if (recommendationIndex < 0 || recommendationIndex >= metadata.recommendedProcedures.length) {
|
|
114
|
+
throw new Error(`Invalid recommendation index ${recommendationIndex}. Must be between 0 and ${metadata.recommendedProcedures.length - 1}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Remove recommendation
|
|
118
|
+
const updatedRecommendedProcedures = [...metadata.recommendedProcedures];
|
|
119
|
+
updatedRecommendedProcedures.splice(recommendationIndex, 1);
|
|
120
|
+
|
|
121
|
+
// Update appointment
|
|
122
|
+
const appointmentRef = doc(db, APPOINTMENTS_COLLECTION, appointmentId);
|
|
123
|
+
await updateDoc(appointmentRef, {
|
|
124
|
+
'metadata.recommendedProcedures': updatedRecommendedProcedures,
|
|
125
|
+
updatedAt: serverTimestamp(),
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
return getAppointmentOrThrow(db, appointmentId);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Updates a recommended procedure in an appointment by index
|
|
133
|
+
* @param db Firestore instance
|
|
134
|
+
* @param appointmentId Appointment ID
|
|
135
|
+
* @param recommendationIndex Index of the recommendation to update
|
|
136
|
+
* @param updates Partial updates (note and/or timeframe)
|
|
137
|
+
* @returns Updated appointment
|
|
138
|
+
*/
|
|
139
|
+
export async function updateRecommendedProcedureUtil(
|
|
140
|
+
db: Firestore,
|
|
141
|
+
appointmentId: string,
|
|
142
|
+
recommendationIndex: number,
|
|
143
|
+
updates: {
|
|
144
|
+
note?: string;
|
|
145
|
+
timeframe?: { value: number; unit: 'day' | 'week' | 'month' | 'year' };
|
|
146
|
+
}
|
|
147
|
+
): Promise<Appointment> {
|
|
148
|
+
const appointment = await getAppointmentOrThrow(db, appointmentId);
|
|
149
|
+
const metadata = initializeMetadata(appointment);
|
|
150
|
+
|
|
151
|
+
if (!metadata.recommendedProcedures || metadata.recommendedProcedures.length === 0) {
|
|
152
|
+
throw new Error('No recommended procedures found for this appointment');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Validate index
|
|
156
|
+
if (recommendationIndex < 0 || recommendationIndex >= metadata.recommendedProcedures.length) {
|
|
157
|
+
throw new Error(`Invalid recommendation index ${recommendationIndex}. Must be between 0 and ${metadata.recommendedProcedures.length - 1}`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Update the recommendation
|
|
161
|
+
const updatedRecommendedProcedures = [...metadata.recommendedProcedures];
|
|
162
|
+
const existingRecommendation = updatedRecommendedProcedures[recommendationIndex];
|
|
163
|
+
|
|
164
|
+
updatedRecommendedProcedures[recommendationIndex] = {
|
|
165
|
+
...existingRecommendation,
|
|
166
|
+
...(updates.note !== undefined && { note: updates.note }),
|
|
167
|
+
...(updates.timeframe !== undefined && { timeframe: updates.timeframe }),
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
// Update appointment
|
|
171
|
+
const appointmentRef = doc(db, APPOINTMENTS_COLLECTION, appointmentId);
|
|
172
|
+
await updateDoc(appointmentRef, {
|
|
173
|
+
'metadata.recommendedProcedures': updatedRecommendedProcedures,
|
|
174
|
+
updatedAt: serverTimestamp(),
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
return getAppointmentOrThrow(db, appointmentId);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Gets all recommended procedures for an appointment
|
|
182
|
+
* @param db Firestore instance
|
|
183
|
+
* @param appointmentId Appointment ID
|
|
184
|
+
* @returns Array of recommended procedures
|
|
185
|
+
*/
|
|
186
|
+
export async function getRecommendedProceduresUtil(
|
|
187
|
+
db: Firestore,
|
|
188
|
+
appointmentId: string
|
|
189
|
+
): Promise<RecommendedProcedure[]> {
|
|
190
|
+
const appointment = await getAppointmentOrThrow(db, appointmentId);
|
|
191
|
+
return appointment.metadata?.recommendedProcedures || [];
|
|
192
|
+
}
|
|
193
|
+
|
|
@@ -18,7 +18,7 @@ export function validateZoneKeyFormat(zoneKey: string): void {
|
|
|
18
18
|
const parts = zoneKey.split('.');
|
|
19
19
|
if (parts.length !== 2) {
|
|
20
20
|
throw new Error(
|
|
21
|
-
`Invalid zone key format: "${zoneKey}". Must be "category.zone" (e.g., "face.forehead")
|
|
21
|
+
`Invalid zone key format: "${zoneKey}". Must be "category.zone" (e.g., "face.forehead")`,
|
|
22
22
|
);
|
|
23
23
|
}
|
|
24
24
|
}
|
|
@@ -52,7 +52,7 @@ export function calculateItemSubtotal(item: Partial<ZoneItemData>): number {
|
|
|
52
52
|
*/
|
|
53
53
|
export function calculateFinalBilling(
|
|
54
54
|
zonesData: Record<string, ZoneItemData[]>,
|
|
55
|
-
taxRate: number = 0.
|
|
55
|
+
taxRate: number = 0.2,
|
|
56
56
|
): FinalBilling {
|
|
57
57
|
let subtotalAll = 0;
|
|
58
58
|
|
|
@@ -96,7 +96,7 @@ export function calculateFinalBilling(
|
|
|
96
96
|
*/
|
|
97
97
|
export async function getAppointmentOrThrow(
|
|
98
98
|
db: Firestore,
|
|
99
|
-
appointmentId: string
|
|
99
|
+
appointmentId: string,
|
|
100
100
|
): Promise<Appointment> {
|
|
101
101
|
const appointment = await getAppointmentByIdUtil(db, appointmentId);
|
|
102
102
|
if (!appointment) {
|
|
@@ -110,18 +110,19 @@ export async function getAppointmentOrThrow(
|
|
|
110
110
|
* @param appointment Appointment document
|
|
111
111
|
* @returns Initialized metadata
|
|
112
112
|
*/
|
|
113
|
-
export function initializeMetadata(
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
113
|
+
export function initializeMetadata(appointment: Appointment): AppointmentMetadata {
|
|
114
|
+
return (
|
|
115
|
+
appointment.metadata || {
|
|
116
|
+
selectedZones: null,
|
|
117
|
+
zonePhotos: null,
|
|
118
|
+
zonesData: null,
|
|
119
|
+
appointmentProducts: [],
|
|
120
|
+
extendedProcedures: [],
|
|
121
|
+
recommendedProcedures: [],
|
|
122
|
+
finalbilling: null,
|
|
123
|
+
finalizationNotes: null,
|
|
124
|
+
}
|
|
125
|
+
);
|
|
125
126
|
}
|
|
126
127
|
|
|
127
128
|
/**
|
|
@@ -136,7 +137,7 @@ export async function addItemToZoneUtil(
|
|
|
136
137
|
db: Firestore,
|
|
137
138
|
appointmentId: string,
|
|
138
139
|
zoneId: string,
|
|
139
|
-
item: Omit<ZoneItemData, 'subtotal' | 'parentZone'
|
|
140
|
+
item: Omit<ZoneItemData, 'subtotal' | 'parentZone'>,
|
|
140
141
|
): Promise<Appointment> {
|
|
141
142
|
// Validate zone key format
|
|
142
143
|
validateZoneKeyFormat(zoneId);
|
|
@@ -154,10 +155,13 @@ export async function addItemToZoneUtil(
|
|
|
154
155
|
}
|
|
155
156
|
|
|
156
157
|
// Calculate subtotal for the item
|
|
158
|
+
const now = new Date().toISOString();
|
|
157
159
|
const itemWithSubtotal: ZoneItemData = {
|
|
158
160
|
...item,
|
|
159
161
|
parentZone: zoneId, // Set parentZone to the zone key
|
|
160
162
|
subtotal: calculateItemSubtotal(item),
|
|
163
|
+
createdAt: now,
|
|
164
|
+
updatedAt: now,
|
|
161
165
|
};
|
|
162
166
|
|
|
163
167
|
// Add item to zone
|
|
@@ -190,7 +194,7 @@ export async function removeItemFromZoneUtil(
|
|
|
190
194
|
db: Firestore,
|
|
191
195
|
appointmentId: string,
|
|
192
196
|
zoneId: string,
|
|
193
|
-
itemIndex: number
|
|
197
|
+
itemIndex: number,
|
|
194
198
|
): Promise<Appointment> {
|
|
195
199
|
validateZoneKeyFormat(zoneId);
|
|
196
200
|
|
|
@@ -242,7 +246,7 @@ export async function updateZoneItemUtil(
|
|
|
242
246
|
appointmentId: string,
|
|
243
247
|
zoneId: string,
|
|
244
248
|
itemIndex: number,
|
|
245
|
-
updates: Partial<ZoneItemData
|
|
249
|
+
updates: Partial<ZoneItemData>,
|
|
246
250
|
): Promise<Appointment> {
|
|
247
251
|
validateZoneKeyFormat(zoneId);
|
|
248
252
|
|
|
@@ -258,10 +262,11 @@ export async function updateZoneItemUtil(
|
|
|
258
262
|
throw new Error(`Invalid item index ${itemIndex} for zone ${zoneId}`);
|
|
259
263
|
}
|
|
260
264
|
|
|
261
|
-
// Update item
|
|
265
|
+
// Update item with updatedAt timestamp
|
|
262
266
|
items[itemIndex] = {
|
|
263
267
|
...items[itemIndex],
|
|
264
268
|
...updates,
|
|
269
|
+
updatedAt: new Date().toISOString(),
|
|
265
270
|
};
|
|
266
271
|
|
|
267
272
|
// Recalculate subtotal for this item
|
|
@@ -295,7 +300,7 @@ export async function overridePriceForZoneItemUtil(
|
|
|
295
300
|
appointmentId: string,
|
|
296
301
|
zoneId: string,
|
|
297
302
|
itemIndex: number,
|
|
298
|
-
newPrice: number
|
|
303
|
+
newPrice: number,
|
|
299
304
|
): Promise<Appointment> {
|
|
300
305
|
return updateZoneItemUtil(db, appointmentId, zoneId, itemIndex, {
|
|
301
306
|
priceOverrideAmount: newPrice,
|
|
@@ -316,14 +321,14 @@ export async function updateSubzonesUtil(
|
|
|
316
321
|
appointmentId: string,
|
|
317
322
|
zoneId: string,
|
|
318
323
|
itemIndex: number,
|
|
319
|
-
subzones: string[]
|
|
324
|
+
subzones: string[],
|
|
320
325
|
): Promise<Appointment> {
|
|
321
326
|
// Validate subzone format if provided
|
|
322
327
|
subzones.forEach(subzone => {
|
|
323
328
|
const parts = subzone.split('.');
|
|
324
329
|
if (parts.length !== 3) {
|
|
325
330
|
throw new Error(
|
|
326
|
-
`Invalid subzone format: "${subzone}". Must be "category.zone.subzone" (e.g., "face.forehead.left")
|
|
331
|
+
`Invalid subzone format: "${subzone}". Must be "category.zone.subzone" (e.g., "face.forehead.left")`,
|
|
327
332
|
);
|
|
328
333
|
}
|
|
329
334
|
});
|
|
@@ -332,4 +337,3 @@ export async function updateSubzonesUtil(
|
|
|
332
337
|
subzones,
|
|
333
338
|
});
|
|
334
339
|
}
|
|
335
|
-
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { Firestore, updateDoc, serverTimestamp, doc } from 'firebase/firestore';
|
|
2
|
+
import {
|
|
3
|
+
Appointment,
|
|
4
|
+
BeforeAfterPerZone,
|
|
5
|
+
APPOINTMENTS_COLLECTION,
|
|
6
|
+
} from '../../../types/appointment';
|
|
7
|
+
import { getAppointmentOrThrow } from './zone-management.utils';
|
|
8
|
+
import { MediaResource } from '../../media/media.service';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Updates a specific photo entry in a zone by index
|
|
12
|
+
* Can update before/after photos and their notes
|
|
13
|
+
*
|
|
14
|
+
* @param db Firestore instance
|
|
15
|
+
* @param appointmentId Appointment ID
|
|
16
|
+
* @param zoneId Zone ID
|
|
17
|
+
* @param photoIndex Index of the photo entry to update
|
|
18
|
+
* @param updates Partial updates to apply
|
|
19
|
+
* @returns Updated appointment
|
|
20
|
+
*/
|
|
21
|
+
export async function updateZonePhotoEntryUtil(
|
|
22
|
+
db: Firestore,
|
|
23
|
+
appointmentId: string,
|
|
24
|
+
zoneId: string,
|
|
25
|
+
photoIndex: number,
|
|
26
|
+
updates: Partial<BeforeAfterPerZone>
|
|
27
|
+
): Promise<Appointment> {
|
|
28
|
+
const appointment = await getAppointmentOrThrow(db, appointmentId);
|
|
29
|
+
|
|
30
|
+
const zonePhotos = appointment.metadata?.zonePhotos as
|
|
31
|
+
| Record<string, BeforeAfterPerZone[]>
|
|
32
|
+
| undefined
|
|
33
|
+
| null;
|
|
34
|
+
|
|
35
|
+
if (!zonePhotos || !zonePhotos[zoneId] || !Array.isArray(zonePhotos[zoneId])) {
|
|
36
|
+
throw new Error(`No photos found for zone ${zoneId} in appointment ${appointmentId}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const zoneArray = zonePhotos[zoneId];
|
|
40
|
+
if (photoIndex < 0 || photoIndex >= zoneArray.length) {
|
|
41
|
+
throw new Error(`Invalid photo index ${photoIndex} for zone ${zoneId}. Must be between 0 and ${zoneArray.length - 1}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Update the entry
|
|
45
|
+
const updatedZonePhotos = { ...zonePhotos };
|
|
46
|
+
updatedZonePhotos[zoneId] = [...zoneArray];
|
|
47
|
+
updatedZonePhotos[zoneId][photoIndex] = {
|
|
48
|
+
...zoneArray[photoIndex],
|
|
49
|
+
...updates,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// Update appointment
|
|
53
|
+
const appointmentRef = doc(db, APPOINTMENTS_COLLECTION, appointmentId);
|
|
54
|
+
await updateDoc(appointmentRef, {
|
|
55
|
+
'metadata.zonePhotos': updatedZonePhotos,
|
|
56
|
+
updatedAt: serverTimestamp(),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
return getAppointmentOrThrow(db, appointmentId);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Adds an after photo to an existing before photo entry
|
|
64
|
+
*
|
|
65
|
+
* @param db Firestore instance
|
|
66
|
+
* @param appointmentId Appointment ID
|
|
67
|
+
* @param zoneId Zone ID
|
|
68
|
+
* @param photoIndex Index of the entry to add after photo to
|
|
69
|
+
* @param afterPhotoUrl URL of the after photo
|
|
70
|
+
* @param afterNote Optional note for the after photo
|
|
71
|
+
* @returns Updated appointment
|
|
72
|
+
*/
|
|
73
|
+
export async function addAfterPhotoToEntryUtil(
|
|
74
|
+
db: Firestore,
|
|
75
|
+
appointmentId: string,
|
|
76
|
+
zoneId: string,
|
|
77
|
+
photoIndex: number,
|
|
78
|
+
afterPhotoUrl: MediaResource,
|
|
79
|
+
afterNote?: string
|
|
80
|
+
): Promise<Appointment> {
|
|
81
|
+
return updateZonePhotoEntryUtil(db, appointmentId, zoneId, photoIndex, {
|
|
82
|
+
after: afterPhotoUrl,
|
|
83
|
+
afterNote: afterNote || null,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Updates notes for a photo entry
|
|
89
|
+
*
|
|
90
|
+
* @param db Firestore instance
|
|
91
|
+
* @param appointmentId Appointment ID
|
|
92
|
+
* @param zoneId Zone ID
|
|
93
|
+
* @param photoIndex Index of the entry
|
|
94
|
+
* @param beforeNote Optional note for before photo
|
|
95
|
+
* @param afterNote Optional note for after photo
|
|
96
|
+
* @returns Updated appointment
|
|
97
|
+
*/
|
|
98
|
+
export async function updateZonePhotoNotesUtil(
|
|
99
|
+
db: Firestore,
|
|
100
|
+
appointmentId: string,
|
|
101
|
+
zoneId: string,
|
|
102
|
+
photoIndex: number,
|
|
103
|
+
beforeNote?: string,
|
|
104
|
+
afterNote?: string
|
|
105
|
+
): Promise<Appointment> {
|
|
106
|
+
const updates: Partial<BeforeAfterPerZone> = {};
|
|
107
|
+
|
|
108
|
+
if (beforeNote !== undefined) {
|
|
109
|
+
updates.beforeNote = beforeNote || null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (afterNote !== undefined) {
|
|
113
|
+
updates.afterNote = afterNote || null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return updateZonePhotoEntryUtil(db, appointmentId, zoneId, photoIndex, updates);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Gets a specific photo entry from a zone
|
|
121
|
+
*
|
|
122
|
+
* @param db Firestore instance
|
|
123
|
+
* @param appointmentId Appointment ID
|
|
124
|
+
* @param zoneId Zone ID
|
|
125
|
+
* @param photoIndex Index of the entry
|
|
126
|
+
* @returns Photo entry
|
|
127
|
+
*/
|
|
128
|
+
export async function getZonePhotoEntryUtil(
|
|
129
|
+
db: Firestore,
|
|
130
|
+
appointmentId: string,
|
|
131
|
+
zoneId: string,
|
|
132
|
+
photoIndex: number
|
|
133
|
+
): Promise<BeforeAfterPerZone> {
|
|
134
|
+
const appointment = await getAppointmentOrThrow(db, appointmentId);
|
|
135
|
+
|
|
136
|
+
const zonePhotos = appointment.metadata?.zonePhotos as
|
|
137
|
+
| Record<string, BeforeAfterPerZone[]>
|
|
138
|
+
| undefined
|
|
139
|
+
| null;
|
|
140
|
+
|
|
141
|
+
if (!zonePhotos || !zonePhotos[zoneId] || !Array.isArray(zonePhotos[zoneId])) {
|
|
142
|
+
throw new Error(`No photos found for zone ${zoneId} in appointment ${appointmentId}`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const zoneArray = zonePhotos[zoneId];
|
|
146
|
+
if (photoIndex < 0 || photoIndex >= zoneArray.length) {
|
|
147
|
+
throw new Error(`Invalid photo index ${photoIndex} for zone ${zoneId}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return zoneArray[photoIndex];
|
|
151
|
+
}
|
|
152
|
+
|
|
@@ -889,6 +889,8 @@ export class ProcedureService extends BaseService {
|
|
|
889
889
|
pagination?: number;
|
|
890
890
|
lastDoc?: any;
|
|
891
891
|
isActive?: boolean;
|
|
892
|
+
practitionerId?: string;
|
|
893
|
+
clinicId?: string;
|
|
892
894
|
}): Promise<{
|
|
893
895
|
procedures: (Procedure & { distance?: number })[];
|
|
894
896
|
lastDoc: any;
|
|
@@ -942,6 +944,12 @@ export class ProcedureService extends BaseService {
|
|
|
942
944
|
if (filters.procedureTechnology) {
|
|
943
945
|
constraints.push(where('technology.id', '==', filters.procedureTechnology));
|
|
944
946
|
}
|
|
947
|
+
if (filters.practitionerId) {
|
|
948
|
+
constraints.push(where('practitionerId', '==', filters.practitionerId));
|
|
949
|
+
}
|
|
950
|
+
if (filters.clinicId) {
|
|
951
|
+
constraints.push(where('clinicBranchId', '==', filters.clinicId));
|
|
952
|
+
}
|
|
945
953
|
if (filters.minPrice !== undefined) {
|
|
946
954
|
constraints.push(where('price', '>=', filters.minPrice));
|
|
947
955
|
}
|
|
@@ -1093,10 +1101,16 @@ export class ProcedureService extends BaseService {
|
|
|
1093
1101
|
try {
|
|
1094
1102
|
console.log('[PROCEDURE_SERVICE] Strategy 4: Minimal query fallback');
|
|
1095
1103
|
const constraints: QueryConstraint[] = [
|
|
1096
|
-
where('isActive', '==', true),
|
|
1104
|
+
where('isActive', '==', filters.isActive !== undefined ? filters.isActive : true),
|
|
1097
1105
|
orderBy('createdAt', 'desc'),
|
|
1098
|
-
limit(filters.pagination || 10),
|
|
1099
1106
|
];
|
|
1107
|
+
if (filters.practitionerId) {
|
|
1108
|
+
constraints.push(where('practitionerId', '==', filters.practitionerId));
|
|
1109
|
+
}
|
|
1110
|
+
if (filters.clinicId) {
|
|
1111
|
+
constraints.push(where('clinicBranchId', '==', filters.clinicId));
|
|
1112
|
+
}
|
|
1113
|
+
constraints.push(limit(filters.pagination || 10));
|
|
1100
1114
|
|
|
1101
1115
|
const q = query(collection(this.db, PROCEDURES_COLLECTION), ...constraints);
|
|
1102
1116
|
const querySnapshot = await getDocs(q);
|
|
@@ -1230,6 +1244,26 @@ export class ProcedureService extends BaseService {
|
|
|
1230
1244
|
);
|
|
1231
1245
|
}
|
|
1232
1246
|
|
|
1247
|
+
// Practitioner filtering
|
|
1248
|
+
if (filters.practitionerId) {
|
|
1249
|
+
filteredProcedures = filteredProcedures.filter(
|
|
1250
|
+
procedure => procedure.practitionerId === filters.practitionerId,
|
|
1251
|
+
);
|
|
1252
|
+
console.log(
|
|
1253
|
+
`[PROCEDURE_SERVICE] Applied practitioner filter, results: ${filteredProcedures.length}`,
|
|
1254
|
+
);
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
// Clinic filtering
|
|
1258
|
+
if (filters.clinicId) {
|
|
1259
|
+
filteredProcedures = filteredProcedures.filter(
|
|
1260
|
+
procedure => procedure.clinicBranchId === filters.clinicId,
|
|
1261
|
+
);
|
|
1262
|
+
console.log(
|
|
1263
|
+
`[PROCEDURE_SERVICE] Applied clinic filter, results: ${filteredProcedures.length}`,
|
|
1264
|
+
);
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1233
1267
|
// Geo-radius filter
|
|
1234
1268
|
if (filters.location && filters.radiusInKm && filters.radiusInKm > 0) {
|
|
1235
1269
|
const location = filters.location;
|
|
@@ -1281,6 +1315,12 @@ export class ProcedureService extends BaseService {
|
|
|
1281
1315
|
where('clinicInfo.location.geohash', '<=', b[1]),
|
|
1282
1316
|
where('isActive', '==', filters.isActive !== undefined ? filters.isActive : true),
|
|
1283
1317
|
];
|
|
1318
|
+
if (filters.practitionerId) {
|
|
1319
|
+
constraints.push(where('practitionerId', '==', filters.practitionerId));
|
|
1320
|
+
}
|
|
1321
|
+
if (filters.clinicId) {
|
|
1322
|
+
constraints.push(where('clinicBranchId', '==', filters.clinicId));
|
|
1323
|
+
}
|
|
1284
1324
|
return getDocs(query(collection(this.db, PROCEDURES_COLLECTION), ...constraints));
|
|
1285
1325
|
});
|
|
1286
1326
|
|
|
@@ -7,6 +7,7 @@ import { Requirement } from '../../backoffice/types/requirement.types';
|
|
|
7
7
|
import { FilledDocumentStatus } from '../documentation-templates';
|
|
8
8
|
import type { ContraindicationDynamic, ProcedureFamily } from '../../backoffice';
|
|
9
9
|
import type { MediaResource } from '../../services/media/media.service';
|
|
10
|
+
import { string } from 'zod/v4';
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Enum defining the possible statuses of an appointment.
|
|
@@ -145,6 +146,7 @@ export interface ZoneItemData {
|
|
|
145
146
|
productBrandName?: string;
|
|
146
147
|
belongingProcedureId: string;
|
|
147
148
|
type: 'item' | 'note';
|
|
149
|
+
stage?: 'before' | 'after'; // Stage of the note/item: 'before' for planning notes, 'after' for treatment notes
|
|
148
150
|
price?: number;
|
|
149
151
|
currency?: Currency;
|
|
150
152
|
unitOfMeasurement?: PricingMeasure;
|
|
@@ -155,6 +157,8 @@ export interface ZoneItemData {
|
|
|
155
157
|
notes?: string;
|
|
156
158
|
subtotal?: number;
|
|
157
159
|
ionNumber?: string;
|
|
160
|
+
createdAt?: string; // ISO timestamp
|
|
161
|
+
updatedAt?: string; // ISO timestamp
|
|
158
162
|
}
|
|
159
163
|
|
|
160
164
|
/**
|
|
@@ -223,18 +227,28 @@ export interface ExtendedProcedureInfo {
|
|
|
223
227
|
}>;
|
|
224
228
|
}
|
|
225
229
|
|
|
230
|
+
export interface RecommendedProcedure {
|
|
231
|
+
procedure: ExtendedProcedureInfo;
|
|
232
|
+
note: string;
|
|
233
|
+
timeframe: {
|
|
234
|
+
value: number;
|
|
235
|
+
unit: 'day' | 'week' | 'month' | 'year';
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
226
239
|
/**
|
|
227
240
|
* Interface for appointment metadata containing zone-specific information
|
|
228
241
|
*/
|
|
229
242
|
export interface AppointmentMetadata {
|
|
230
243
|
selectedZones: string[] | null;
|
|
231
|
-
zonePhotos: Record<string, BeforeAfterPerZone> | null;
|
|
244
|
+
zonePhotos: Record<string, BeforeAfterPerZone[]> | null;
|
|
232
245
|
zonesData?: Record<string, ZoneItemData[]> | null;
|
|
233
246
|
appointmentProducts?: AppointmentProductMetadata[];
|
|
234
247
|
extendedProcedures?: ExtendedProcedureInfo[];
|
|
248
|
+
recommendedProcedures: RecommendedProcedure[]
|
|
235
249
|
finalbilling: FinalBilling | null;
|
|
236
250
|
finalizationNotes: string | null;
|
|
237
|
-
|
|
251
|
+
|
|
238
252
|
/**
|
|
239
253
|
* @deprecated Use zonesData instead
|
|
240
254
|
*/
|
|
@@ -185,6 +185,9 @@ export const zoneItemDataSchema = z.object({
|
|
|
185
185
|
type: z.enum(['item', 'note'], {
|
|
186
186
|
required_error: 'Type must be either "item" or "note"',
|
|
187
187
|
}),
|
|
188
|
+
stage: z.enum(['before', 'after'], {
|
|
189
|
+
required_error: 'Stage must be either "before" or "after"',
|
|
190
|
+
}).optional(),
|
|
188
191
|
price: z.number().min(0, 'Price must be non-negative').optional(),
|
|
189
192
|
currency: z.nativeEnum(Currency).optional(),
|
|
190
193
|
unitOfMeasurement: z.nativeEnum(PricingMeasure).optional(),
|
|
@@ -213,6 +216,8 @@ export const zoneItemDataSchema = z.object({
|
|
|
213
216
|
notes: z.string().max(MAX_STRING_LENGTH_LONG, 'Notes too long').optional(),
|
|
214
217
|
subtotal: z.number().min(0, 'Subtotal must be non-negative').optional(),
|
|
215
218
|
ionNumber: z.string().optional(),
|
|
219
|
+
createdAt: z.string().optional(),
|
|
220
|
+
updatedAt: z.string().optional(),
|
|
216
221
|
}).refine(
|
|
217
222
|
data => {
|
|
218
223
|
if (data.type === 'item') {
|
|
@@ -262,15 +267,30 @@ export const extendedProcedureInfoSchema = z.object({
|
|
|
262
267
|
),
|
|
263
268
|
});
|
|
264
269
|
|
|
270
|
+
/**
|
|
271
|
+
* Schema for recommended procedure within metadata
|
|
272
|
+
*/
|
|
273
|
+
export const recommendedProcedureTimeframeSchema = z.object({
|
|
274
|
+
value: z.number().int().positive('Timeframe value must be a positive integer'),
|
|
275
|
+
unit: z.enum(['day', 'week', 'month', 'year']),
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
export const recommendedProcedureSchema = z.object({
|
|
279
|
+
procedure: extendedProcedureInfoSchema,
|
|
280
|
+
note: z.string().min(1, 'Note is required').max(MAX_STRING_LENGTH_LONG, 'Note too long'),
|
|
281
|
+
timeframe: recommendedProcedureTimeframeSchema,
|
|
282
|
+
});
|
|
283
|
+
|
|
265
284
|
/**
|
|
266
285
|
* Schema for appointment metadata containing zone-specific information
|
|
267
286
|
*/
|
|
268
287
|
export const appointmentMetadataSchema = z.object({
|
|
269
288
|
selectedZones: z.array(z.string()).nullable(),
|
|
270
|
-
zonePhotos: z.record(z.string(), beforeAfterPerZoneSchema).nullable(),
|
|
271
|
-
zonesData: z.record(z.string(), z.array(zoneItemDataSchema)).nullable(),
|
|
272
|
-
appointmentProducts: z.array(appointmentProductMetadataSchema),
|
|
273
|
-
extendedProcedures: z.array(extendedProcedureInfoSchema),
|
|
289
|
+
zonePhotos: z.record(z.string(), z.array(beforeAfterPerZoneSchema).max(10)).nullable(),
|
|
290
|
+
zonesData: z.record(z.string(), z.array(zoneItemDataSchema)).nullable().optional(),
|
|
291
|
+
appointmentProducts: z.array(appointmentProductMetadataSchema).optional().default([]),
|
|
292
|
+
extendedProcedures: z.array(extendedProcedureInfoSchema).optional().default([]),
|
|
293
|
+
recommendedProcedures: z.array(recommendedProcedureSchema).optional().default([]),
|
|
274
294
|
zoneBilling: z.record(z.string(), billingPerZoneSchema).nullable().optional(),
|
|
275
295
|
finalbilling: finalBillingSchema.nullable(),
|
|
276
296
|
finalizationNotes: z.string().nullable(),
|