@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.
@@ -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.20
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
- appointment: Appointment
115
- ): AppointmentMetadata {
116
- return appointment.metadata || {
117
- selectedZones: null,
118
- zonePhotos: null,
119
- zonesData: null,
120
- appointmentProducts: [],
121
- extendedProcedures: [],
122
- finalbilling: null,
123
- finalizationNotes: null,
124
- };
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(),