@blackcode_sa/metaestetics-api 1.12.20 → 1.12.21

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
  }
@@ -8,21 +8,23 @@ import {
8
8
  setDoc,
9
9
  deleteDoc,
10
10
  serverTimestamp,
11
- } from 'firebase/firestore';
12
- import { BaseService } from '../base.service';
11
+ } from "firebase/firestore";
12
+ import { BaseService } from "../base.service";
13
13
  import {
14
14
  Review,
15
15
  ClinicReview,
16
16
  PractitionerReview,
17
17
  ProcedureReview,
18
18
  REVIEWS_COLLECTION,
19
- } from '../../types/reviews';
20
- import { createReviewSchema, reviewSchema } from '../../validations/reviews.schema';
21
- import { z } from 'zod';
22
- import { Auth } from 'firebase/auth';
23
- import { Firestore } from 'firebase/firestore';
24
- import { FirebaseApp } from 'firebase/app';
25
- import { Appointment, APPOINTMENTS_COLLECTION } from '../../types/appointment';
19
+ } from "../../types/reviews";
20
+ import {
21
+ createReviewSchema,
22
+ reviewSchema,
23
+ } from "../../validations/reviews.schema";
24
+ import { z } from "zod";
25
+ import { Auth } from "firebase/auth";
26
+ import { Firestore } from "firebase/firestore";
27
+ import { FirebaseApp } from "firebase/app";
26
28
 
27
29
  export class ReviewService extends BaseService {
28
30
  constructor(db: Firestore, auth: Auth, app: FirebaseApp) {
@@ -36,8 +38,11 @@ export class ReviewService extends BaseService {
36
38
  * @returns The created review
37
39
  */
38
40
  async createReview(
39
- data: Omit<Review, 'id' | 'createdAt' | 'updatedAt' | 'appointmentId' | 'overallRating'>,
40
- appointmentId: string,
41
+ data: Omit<
42
+ Review,
43
+ "id" | "createdAt" | "updatedAt" | "appointmentId" | "overallRating"
44
+ >,
45
+ appointmentId: string
41
46
  ): Promise<Review> {
42
47
  try {
43
48
  // Validate input data
@@ -161,63 +166,17 @@ export class ReviewService extends BaseService {
161
166
  }
162
167
 
163
168
  /**
164
- * Gets all reviews for a specific patient with enhanced entity names
169
+ * Gets all reviews for a specific patient
165
170
  * @param patientId The ID of the patient
166
- * @returns Array of reviews for the patient with clinic, practitioner, and procedure names
171
+ * @returns Array of reviews for the patient
167
172
  */
168
173
  async getReviewsByPatient(patientId: string): Promise<Review[]> {
169
- const q = query(collection(this.db, REVIEWS_COLLECTION), where('patientId', '==', patientId));
170
- const snapshot = await getDocs(q);
171
- const reviews = snapshot.docs.map(doc => doc.data() as Review);
172
-
173
- // Enhance reviews with entity names from appointments
174
- const enhancedReviews = await Promise.all(
175
- reviews.map(async review => {
176
- try {
177
- // Fetch the associated appointment
178
- const appointmentDoc = await getDoc(
179
- doc(this.db, APPOINTMENTS_COLLECTION, review.appointmentId),
180
- );
181
-
182
- if (appointmentDoc.exists()) {
183
- const appointment = appointmentDoc.data() as Appointment;
184
-
185
- // Create enhanced review with entity names
186
- const enhancedReview = { ...review };
187
-
188
- if (enhancedReview.clinicReview && appointment.clinicInfo) {
189
- enhancedReview.clinicReview = {
190
- ...enhancedReview.clinicReview,
191
- clinicName: appointment.clinicInfo.name,
192
- };
193
- }
194
-
195
- if (enhancedReview.practitionerReview && appointment.practitionerInfo) {
196
- enhancedReview.practitionerReview = {
197
- ...enhancedReview.practitionerReview,
198
- practitionerName: appointment.practitionerInfo.name,
199
- };
200
- }
201
-
202
- if (enhancedReview.procedureReview && appointment.procedureInfo) {
203
- enhancedReview.procedureReview = {
204
- ...enhancedReview.procedureReview,
205
- procedureName: appointment.procedureInfo.name,
206
- };
207
- }
208
-
209
- return enhancedReview;
210
- }
211
-
212
- return review;
213
- } catch (error) {
214
- console.warn(`Failed to enhance review ${review.id} with entity names:`, error);
215
- return review;
216
- }
217
- }),
174
+ const q = query(
175
+ collection(this.db, REVIEWS_COLLECTION),
176
+ where("patientId", "==", patientId)
218
177
  );
219
-
220
- return enhancedReviews;
178
+ const snapshot = await getDocs(q);
179
+ return snapshot.docs.map((doc) => doc.data() as Review);
221
180
  }
222
181
 
223
182
  /**
@@ -228,10 +187,10 @@ export class ReviewService extends BaseService {
228
187
  async getReviewsByClinic(clinicId: string): Promise<Review[]> {
229
188
  const q = query(
230
189
  collection(this.db, REVIEWS_COLLECTION),
231
- where('clinicReview.clinicId', '==', clinicId),
190
+ where("clinicReview.clinicId", "==", clinicId)
232
191
  );
233
192
  const snapshot = await getDocs(q);
234
- return snapshot.docs.map(doc => doc.data() as Review);
193
+ return snapshot.docs.map((doc) => doc.data() as Review);
235
194
  }
236
195
 
237
196
  /**
@@ -242,10 +201,10 @@ export class ReviewService extends BaseService {
242
201
  async getReviewsByPractitioner(practitionerId: string): Promise<Review[]> {
243
202
  const q = query(
244
203
  collection(this.db, REVIEWS_COLLECTION),
245
- where('practitionerReview.practitionerId', '==', practitionerId),
204
+ where("practitionerReview.practitionerId", "==", practitionerId)
246
205
  );
247
206
  const snapshot = await getDocs(q);
248
- return snapshot.docs.map(doc => doc.data() as Review);
207
+ return snapshot.docs.map((doc) => doc.data() as Review);
249
208
  }
250
209
 
251
210
  /**
@@ -256,10 +215,10 @@ export class ReviewService extends BaseService {
256
215
  async getReviewsByProcedure(procedureId: string): Promise<Review[]> {
257
216
  const q = query(
258
217
  collection(this.db, REVIEWS_COLLECTION),
259
- where('procedureReview.procedureId', '==', procedureId),
218
+ where("procedureReview.procedureId", "==", procedureId)
260
219
  );
261
220
  const snapshot = await getDocs(q);
262
- return snapshot.docs.map(doc => doc.data() as Review);
221
+ return snapshot.docs.map((doc) => doc.data() as Review);
263
222
  }
264
223
 
265
224
  /**
@@ -270,7 +229,7 @@ export class ReviewService extends BaseService {
270
229
  async getReviewByAppointment(appointmentId: string): Promise<Review | null> {
271
230
  const q = query(
272
231
  collection(this.db, REVIEWS_COLLECTION),
273
- where('appointmentId', '==', appointmentId),
232
+ where("appointmentId", "==", appointmentId)
274
233
  );
275
234
  const snapshot = await getDocs(q);
276
235
 
@@ -120,6 +120,17 @@ export interface BeforeAfterPerZone {
120
120
  beforeNote?: string | null;
121
121
  }
122
122
 
123
+ /**
124
+ * Interface for zone photo upload data
125
+ */
126
+ export interface ZonePhotoUploadData {
127
+ appointmentId: string;
128
+ zoneId: string;
129
+ photoType: 'before' | 'after';
130
+ file: File | Blob;
131
+ notes?: string;
132
+ }
133
+
123
134
  /**
124
135
  * Interface for billing information per zone
125
136
  */
@@ -22,7 +22,6 @@ interface BaseReview {
22
22
  */
23
23
  export interface ClinicReview extends BaseReview {
24
24
  clinicId: string;
25
- clinicName?: string; // Enhanced field: clinic name from appointment
26
25
  cleanliness: number; // 1-5 stars
27
26
  facilities: number; // 1-5 stars
28
27
  staffFriendliness: number; // 1-5 stars
@@ -38,7 +37,6 @@ export interface ClinicReview extends BaseReview {
38
37
  */
39
38
  export interface PractitionerReview extends BaseReview {
40
39
  practitionerId: string;
41
- practitionerName?: string; // Enhanced field: practitioner name from appointment
42
40
  knowledgeAndExpertise: number; // 1-5 stars
43
41
  communicationSkills: number; // 1-5 stars
44
42
  bedSideManner: number; // 1-5 stars
@@ -54,7 +52,6 @@ export interface PractitionerReview extends BaseReview {
54
52
  */
55
53
  export interface ProcedureReview extends BaseReview {
56
54
  procedureId: string;
57
- procedureName?: string; // Enhanced field: procedure name from appointment
58
55
  effectivenessOfTreatment: number; // 1-5 stars
59
56
  outcomeExplanation: number; // 1-5 stars
60
57
  painManagement: number; // 1-5 stars