@blackcode_sa/metaestetics-api 1.12.20 → 1.12.22

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.
@@ -28,12 +28,15 @@ import {
28
28
  AppointmentMediaItem,
29
29
  PatientReviewInfo,
30
30
  type CreateAppointmentHttpData,
31
+ type ZonePhotoUploadData,
32
+ BeforeAfterPerZone,
31
33
  APPOINTMENTS_COLLECTION,
32
34
  } from '../../types/appointment';
33
35
  import {
34
36
  updateAppointmentSchema,
35
37
  searchAppointmentsSchema,
36
38
  rescheduleAppointmentSchema,
39
+ zonePhotoUploadSchema,
37
40
  } from '../../validations/appointment.schema';
38
41
 
39
42
  // Import other services needed (dependency injection pattern)
@@ -42,6 +45,7 @@ import { PatientService } from '../patient/patient.service';
42
45
  import { PractitionerService } from '../practitioner/practitioner.service';
43
46
  import { ClinicService } from '../clinic/clinic.service';
44
47
  import { FilledDocumentService } from '../documentation-templates/filled-document.service';
48
+ import { MediaService, MediaAccessLevel, MediaMetadata } from '../media/media.service';
45
49
 
46
50
  // Import utility functions
47
51
  import {
@@ -68,6 +72,7 @@ export class AppointmentService extends BaseService {
68
72
  private practitionerService: PractitionerService;
69
73
  private clinicService: ClinicService;
70
74
  private filledDocumentService: FilledDocumentService;
75
+ private mediaService: MediaService;
71
76
  private functions: Functions;
72
77
 
73
78
  /**
@@ -80,6 +85,7 @@ export class AppointmentService extends BaseService {
80
85
  * @param patientService Patient service instance
81
86
  * @param practitionerService Practitioner service instance
82
87
  * @param clinicService Clinic service instance
88
+ * @param filledDocumentService Filled document service instance
83
89
  */
84
90
  constructor(
85
91
  db: Firestore,
@@ -97,6 +103,7 @@ export class AppointmentService extends BaseService {
97
103
  this.practitionerService = practitionerService;
98
104
  this.clinicService = clinicService;
99
105
  this.filledDocumentService = filledDocumentService;
106
+ this.mediaService = new MediaService(db, auth, app);
100
107
  this.functions = getFunctions(app, 'europe-west6'); // Initialize Firebase Functions with the correct region
101
108
  }
102
109
 
@@ -1144,4 +1151,294 @@ export class AppointmentService extends BaseService {
1144
1151
  throw error;
1145
1152
  }
1146
1153
  }
1154
+
1155
+ /**
1156
+ * Uploads a zone photo and updates appointment metadata
1157
+ *
1158
+ * @param uploadData Zone photo upload data containing appointment ID, zone ID, photo type, file, and optional notes
1159
+ * @returns The uploaded media metadata
1160
+ */
1161
+ async uploadZonePhoto(uploadData: ZonePhotoUploadData): Promise<MediaMetadata> {
1162
+ try {
1163
+ console.log(
1164
+ `[APPOINTMENT_SERVICE] Uploading ${uploadData.photoType} photo for zone ${uploadData.zoneId} in appointment ${uploadData.appointmentId}`,
1165
+ );
1166
+
1167
+ // Validate input data
1168
+ const validatedData = await zonePhotoUploadSchema.parseAsync(uploadData);
1169
+
1170
+ // Check if user is authenticated
1171
+ const currentUser = this.auth.currentUser;
1172
+ if (!currentUser) {
1173
+ throw new Error('User must be authenticated to upload zone photos');
1174
+ }
1175
+
1176
+ // Get the appointment to verify it exists and user has access
1177
+ const appointment = await this.getAppointmentById(validatedData.appointmentId);
1178
+ if (!appointment) {
1179
+ throw new Error(`Appointment with ID ${validatedData.appointmentId} not found`);
1180
+ }
1181
+
1182
+ // Generate collection name for the media
1183
+ const collectionName = `appointment_${validatedData.appointmentId}_zone_photos`;
1184
+
1185
+ // Generate filename with zone and photo type info
1186
+ const timestamp = Date.now();
1187
+ const fileExtension = validatedData.file.type?.split('/')[1] || 'jpg';
1188
+ const fileName = `${validatedData.photoType}_${validatedData.zoneId}_${timestamp}.${fileExtension}`;
1189
+
1190
+ console.log(
1191
+ `[APPOINTMENT_SERVICE] Uploading file: ${fileName} to collection: ${collectionName}`,
1192
+ );
1193
+
1194
+ // Upload the media file using MediaService
1195
+ const uploadedMedia = await this.mediaService.uploadMedia(
1196
+ validatedData.file,
1197
+ validatedData.appointmentId, // ownerId is the appointment ID
1198
+ MediaAccessLevel.PRIVATE, // Zone photos are private
1199
+ collectionName,
1200
+ fileName,
1201
+ );
1202
+
1203
+ console.log(`[APPOINTMENT_SERVICE] Media uploaded successfully with ID: ${uploadedMedia.id}`);
1204
+
1205
+ // Update appointment metadata with the new photo
1206
+ await this.updateAppointmentZonePhoto(
1207
+ validatedData.appointmentId,
1208
+ validatedData.zoneId,
1209
+ validatedData.photoType,
1210
+ uploadedMedia,
1211
+ validatedData.notes,
1212
+ );
1213
+
1214
+ console.log(
1215
+ `[APPOINTMENT_SERVICE] Successfully uploaded and linked ${validatedData.photoType} photo for zone ${validatedData.zoneId}`,
1216
+ );
1217
+
1218
+ return uploadedMedia;
1219
+ } catch (error) {
1220
+ console.error('[APPOINTMENT_SERVICE] Error uploading zone photo:', error);
1221
+ throw error;
1222
+ }
1223
+ }
1224
+
1225
+ /**
1226
+ * Updates appointment metadata with zone photo information
1227
+ *
1228
+ * @param appointmentId ID of the appointment
1229
+ * @param zoneId ID of the zone
1230
+ * @param photoType Type of photo ('before' or 'after')
1231
+ * @param mediaMetadata Uploaded media metadata
1232
+ * @param notes Optional notes for the photo
1233
+ * @returns The updated appointment
1234
+ */
1235
+ private async updateAppointmentZonePhoto(
1236
+ appointmentId: string,
1237
+ zoneId: string,
1238
+ photoType: 'before' | 'after',
1239
+ mediaMetadata: MediaMetadata,
1240
+ notes?: string,
1241
+ ): Promise<Appointment> {
1242
+ try {
1243
+ console.log(
1244
+ `[APPOINTMENT_SERVICE] Updating appointment metadata for ${photoType} photo in zone ${zoneId}`,
1245
+ );
1246
+
1247
+ // Get current appointment
1248
+ const appointment = await this.getAppointmentById(appointmentId);
1249
+ if (!appointment) {
1250
+ throw new Error(`Appointment with ID ${appointmentId} not found`);
1251
+ }
1252
+
1253
+ // Initialize metadata if it doesn't exist
1254
+ const currentMetadata = appointment.metadata || {
1255
+ selectedZones: null,
1256
+ zonePhotos: null,
1257
+ zoneBilling: null,
1258
+ finalbilling: null,
1259
+ finalizationNotes: null,
1260
+ };
1261
+
1262
+ // Initialize zonePhotos if it doesn't exist
1263
+ const currentZonePhotos = currentMetadata.zonePhotos || {};
1264
+
1265
+ // Initialize the zone entry if it doesn't exist
1266
+ if (!currentZonePhotos[zoneId]) {
1267
+ currentZonePhotos[zoneId] = {
1268
+ before: null,
1269
+ after: null,
1270
+ beforeNote: null,
1271
+ afterNote: null,
1272
+ };
1273
+ }
1274
+
1275
+ // Update the specific photo type
1276
+ if (photoType === 'before') {
1277
+ currentZonePhotos[zoneId].before = mediaMetadata.url;
1278
+ if (notes) {
1279
+ currentZonePhotos[zoneId].beforeNote = notes;
1280
+ }
1281
+ } else {
1282
+ currentZonePhotos[zoneId].after = mediaMetadata.url;
1283
+ if (notes) {
1284
+ currentZonePhotos[zoneId].afterNote = notes;
1285
+ }
1286
+ }
1287
+
1288
+ // Update the appointment with new metadata
1289
+ const updateData: UpdateAppointmentData = {
1290
+ metadata: {
1291
+ selectedZones: currentMetadata.selectedZones,
1292
+ zonePhotos: currentZonePhotos,
1293
+ zoneBilling: currentMetadata.zoneBilling,
1294
+ finalbilling: currentMetadata.finalbilling,
1295
+ finalizationNotes: currentMetadata.finalizationNotes,
1296
+ },
1297
+ updatedAt: serverTimestamp(),
1298
+ };
1299
+
1300
+ const updatedAppointment = await this.updateAppointment(appointmentId, updateData);
1301
+
1302
+ console.log(
1303
+ `[APPOINTMENT_SERVICE] Successfully updated appointment metadata for ${photoType} photo in zone ${zoneId}`,
1304
+ );
1305
+
1306
+ return updatedAppointment;
1307
+ } catch (error) {
1308
+ console.error(
1309
+ `[APPOINTMENT_SERVICE] Error updating appointment metadata for zone photo:`,
1310
+ error,
1311
+ );
1312
+ throw error;
1313
+ }
1314
+ }
1315
+
1316
+ /**
1317
+ * Gets zone photos for a specific appointment and zone
1318
+ *
1319
+ * @param appointmentId ID of the appointment
1320
+ * @param zoneId ID of the zone (optional - if not provided, returns all zones)
1321
+ * @returns Zone photos data
1322
+ */
1323
+ async getZonePhotos(
1324
+ appointmentId: string,
1325
+ zoneId?: string,
1326
+ ): Promise<Record<string, BeforeAfterPerZone> | BeforeAfterPerZone | null> {
1327
+ try {
1328
+ console.log(`[APPOINTMENT_SERVICE] Getting zone photos for appointment ${appointmentId}`);
1329
+
1330
+ const appointment = await this.getAppointmentById(appointmentId);
1331
+ if (!appointment) {
1332
+ throw new Error(`Appointment with ID ${appointmentId} not found`);
1333
+ }
1334
+
1335
+ const zonePhotos = appointment.metadata?.zonePhotos;
1336
+ if (!zonePhotos) {
1337
+ return null;
1338
+ }
1339
+
1340
+ // If specific zone requested, return only that zone's photos
1341
+ if (zoneId) {
1342
+ return zonePhotos[zoneId] || null;
1343
+ }
1344
+
1345
+ // Return all zone photos
1346
+ return zonePhotos;
1347
+ } catch (error) {
1348
+ console.error(`[APPOINTMENT_SERVICE] Error getting zone photos:`, error);
1349
+ throw error;
1350
+ }
1351
+ }
1352
+
1353
+ /**
1354
+ * Deletes a zone photo and updates appointment metadata
1355
+ *
1356
+ * @param appointmentId ID of the appointment
1357
+ * @param zoneId ID of the zone
1358
+ * @param photoType Type of photo to delete ('before' or 'after')
1359
+ * @returns The updated appointment
1360
+ */
1361
+ async deleteZonePhoto(
1362
+ appointmentId: string,
1363
+ zoneId: string,
1364
+ photoType: 'before' | 'after',
1365
+ ): Promise<Appointment> {
1366
+ try {
1367
+ console.log(
1368
+ `[APPOINTMENT_SERVICE] Deleting ${photoType} photo for zone ${zoneId} in appointment ${appointmentId}`,
1369
+ );
1370
+
1371
+ // Get current appointment
1372
+ const appointment = await this.getAppointmentById(appointmentId);
1373
+ if (!appointment) {
1374
+ throw new Error(`Appointment with ID ${appointmentId} not found`);
1375
+ }
1376
+
1377
+ const zonePhotos = appointment.metadata?.zonePhotos;
1378
+ if (!zonePhotos || !zonePhotos[zoneId]) {
1379
+ throw new Error(`No photos found for zone ${zoneId} in appointment ${appointmentId}`);
1380
+ }
1381
+
1382
+ const photoUrl =
1383
+ photoType === 'before' ? zonePhotos[zoneId].before : zonePhotos[zoneId].after;
1384
+ if (!photoUrl) {
1385
+ throw new Error(`No ${photoType} photo found for zone ${zoneId}`);
1386
+ }
1387
+
1388
+ // Try to find and delete the media from storage
1389
+ try {
1390
+ // Only try to delete if photoUrl is a string (URL)
1391
+ if (typeof photoUrl === 'string') {
1392
+ const mediaMetadata = await this.mediaService.getMediaMetadataByUrl(photoUrl);
1393
+ if (mediaMetadata) {
1394
+ await this.mediaService.deleteMedia(mediaMetadata.id);
1395
+ console.log(`[APPOINTMENT_SERVICE] Deleted media file with ID: ${mediaMetadata.id}`);
1396
+ }
1397
+ }
1398
+ } catch (mediaError) {
1399
+ console.warn(
1400
+ `[APPOINTMENT_SERVICE] Could not delete media file for URL ${photoUrl}:`,
1401
+ mediaError,
1402
+ );
1403
+ // Continue with metadata update even if media deletion fails
1404
+ }
1405
+
1406
+ // Update appointment metadata to remove the photo reference
1407
+ const updatedZonePhotos = { ...zonePhotos };
1408
+ if (photoType === 'before') {
1409
+ updatedZonePhotos[zoneId].before = null;
1410
+ updatedZonePhotos[zoneId].beforeNote = null;
1411
+ } else {
1412
+ updatedZonePhotos[zoneId].after = null;
1413
+ updatedZonePhotos[zoneId].afterNote = null;
1414
+ }
1415
+
1416
+ // If both photos are null, we could optionally remove the zone entry entirely
1417
+ if (!updatedZonePhotos[zoneId].before && !updatedZonePhotos[zoneId].after) {
1418
+ delete updatedZonePhotos[zoneId];
1419
+ }
1420
+
1421
+ const updateData: UpdateAppointmentData = {
1422
+ metadata: {
1423
+ selectedZones: appointment.metadata?.selectedZones || null,
1424
+ zonePhotos: updatedZonePhotos,
1425
+ zoneBilling: appointment.metadata?.zoneBilling || null,
1426
+ finalbilling: appointment.metadata?.finalbilling || null,
1427
+ finalizationNotes: appointment.metadata?.finalizationNotes || null,
1428
+ },
1429
+ updatedAt: serverTimestamp(),
1430
+ };
1431
+
1432
+ const updatedAppointment = await this.updateAppointment(appointmentId, updateData);
1433
+
1434
+ console.log(
1435
+ `[APPOINTMENT_SERVICE] Successfully deleted ${photoType} photo for zone ${zoneId}`,
1436
+ );
1437
+
1438
+ return updatedAppointment;
1439
+ } catch (error) {
1440
+ console.error(`[APPOINTMENT_SERVICE] Error deleting zone photo:`, error);
1441
+ throw error;
1442
+ }
1443
+ }
1147
1444
  }
@@ -0,0 +1,129 @@
1
+ # Reviews Service - Enhanced with Entity Names
2
+
3
+ ## Overview
4
+
5
+ The Reviews Service has been enhanced to automatically include entity names (clinic, practitioner, procedure, and patient names) in all review responses. This eliminates the need for frontend applications to make additional API calls to fetch entity details.
6
+
7
+ ## Enhanced Methods
8
+
9
+ All review retrieval methods now include entity names from associated appointments:
10
+
11
+ ### 1. `getReview(reviewId: string)`
12
+ - **Enhanced**: ✅ Returns single review with entity names
13
+ - **Entity Names**: Clinic name, practitioner name, procedure name, patient name
14
+ - **Source**: Fetched from associated appointment data
15
+
16
+ ### 2. `getReviewsByPatient(patientId: string)`
17
+ - **Enhanced**: ✅ Returns patient reviews with entity names
18
+ - **Entity Names**: Clinic name, practitioner name, procedure name, patient name
19
+ - **Use Case**: Patient app - "My Reviews" screen
20
+
21
+ ### 3. `getReviewsByPractitioner(practitionerId: string)`
22
+ - **Enhanced**: ✅ Returns practitioner reviews with entity names
23
+ - **Entity Names**: Clinic name, practitioner name, procedure name, patient name
24
+ - **Use Case**: Doctor app - "Reviews" screen
25
+
26
+ ### 4. `getReviewsByClinic(clinicId: string)`
27
+ - **Enhanced**: ✅ Returns clinic reviews with entity names
28
+ - **Entity Names**: Clinic name, practitioner name, procedure name, patient name
29
+ - **Use Case**: Clinic dashboard - "Reviews" section
30
+
31
+ ### 5. `getReviewsByProcedure(procedureId: string)`
32
+ - **Enhanced**: ✅ Returns procedure reviews with entity names
33
+ - **Entity Names**: Clinic name, practitioner name, procedure name, patient name
34
+ - **Use Case**: Procedure analytics and reporting
35
+
36
+ ## Enhanced Review Structure
37
+
38
+ ```typescript
39
+ interface Review {
40
+ id: string;
41
+ appointmentId: string;
42
+ patientId: string;
43
+ patientName?: string; // 🆕 Enhanced field
44
+ createdAt: Date;
45
+ updatedAt: Date;
46
+ clinicReview?: {
47
+ // ... existing fields
48
+ clinicName?: string; // 🆕 Enhanced field
49
+ };
50
+ practitionerReview?: {
51
+ // ... existing fields
52
+ practitionerName?: string; // 🆕 Enhanced field
53
+ };
54
+ procedureReview?: {
55
+ // ... existing fields
56
+ procedureName?: string; // 🆕 Enhanced field
57
+ };
58
+ overallComment: string;
59
+ overallRating: number;
60
+ }
61
+ ```
62
+
63
+ ## Data Sources
64
+
65
+ Entity names are fetched from the associated appointment's aggregated information:
66
+
67
+ - **Clinic Name**: `appointment.clinicInfo.name`
68
+ - **Practitioner Name**: `appointment.practitionerInfo.name`
69
+ - **Procedure Name**: `appointment.procedureInfo.name`
70
+ - **Patient Name**: `appointment.patientInfo.fullName`
71
+
72
+ ## Error Handling
73
+
74
+ - If appointment is not found, review is returned without enhancement
75
+ - Individual enhancement failures are logged but don't break the entire operation
76
+ - Graceful fallback ensures API reliability
77
+
78
+ ## Logging
79
+
80
+ Enhanced logging provides detailed information about:
81
+ - Query parameters
82
+ - Number of reviews found
83
+ - Enhancement success/failure
84
+ - Entity names availability
85
+
86
+ ## Performance Considerations
87
+
88
+ - Each review requires one additional Firestore read for appointment data
89
+ - Enhancement is performed in parallel for multiple reviews
90
+ - Consider caching strategies for high-volume applications
91
+
92
+ ## Migration Notes
93
+
94
+ - **Backward Compatible**: Existing code continues to work
95
+ - **Optional Fields**: All enhanced fields are optional
96
+ - **No Breaking Changes**: API signature remains the same
97
+
98
+ ## Usage Examples
99
+
100
+ ### Doctor App - Reviews Screen
101
+ ```typescript
102
+ const reviews = await reviewService.getReviewsByPractitioner(practitionerId);
103
+ // Now includes practitioner names, clinic names, procedure names, and patient names
104
+ ```
105
+
106
+ ### Patient App - My Reviews
107
+ ```typescript
108
+ const reviews = await reviewService.getReviewsByPatient(patientId);
109
+ // Now includes all entity names for better UX
110
+ ```
111
+
112
+ ### Clinic Dashboard
113
+ ```typescript
114
+ const reviews = await reviewService.getReviewsByClinic(clinicId);
115
+ // Now includes practitioner and procedure names for each review
116
+ ```
117
+
118
+ ## Testing
119
+
120
+ All enhanced methods include comprehensive logging for debugging:
121
+ - Input parameters
122
+ - Query results
123
+ - Enhancement status
124
+ - Entity name availability
125
+
126
+ Monitor logs with:
127
+ ```bash
128
+ firebase functions:log --only reviewService
129
+ ```