@blackcode_sa/metaestetics-api 1.7.5 → 1.7.7

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@blackcode_sa/metaestetics-api",
3
3
  "private": false,
4
- "version": "1.7.5",
4
+ "version": "1.7.7",
5
5
  "description": "Firebase authentication service with anonymous upgrade support",
6
6
  "main": "./dist/index.js",
7
7
  "module": "./dist/index.mjs",
@@ -457,7 +457,12 @@ export class BookingAdmin {
457
457
  const clinicInfo: ClinicInfo = {
458
458
  id: clinicSnap.id,
459
459
  name: clinicData.name,
460
- featuredPhoto: clinicData.coverPhoto || clinicData.logo || "",
460
+ featuredPhoto:
461
+ (typeof clinicData.coverPhoto === "string"
462
+ ? clinicData.coverPhoto
463
+ : "") ||
464
+ (typeof clinicData.logo === "string" ? clinicData.logo : "") ||
465
+ "",
461
466
  description: clinicData.description,
462
467
  location: clinicData.location,
463
468
  contactInfo: clinicData.contactInfo,
@@ -71,6 +71,7 @@ import {
71
71
  import { PractitionerService } from "./practitioner/practitioner.service";
72
72
  import { practitionerSignupSchema } from "../validations/practitioner.schema";
73
73
  import { CertificationLevel } from "../backoffice/types/static/certification.types";
74
+ import { MediaService } from "./media/media.service";
74
75
 
75
76
  export class AuthService extends BaseService {
76
77
  private googleProvider = new GoogleAuthProvider();
@@ -199,12 +200,14 @@ export class AuthService extends BaseService {
199
200
  this.app,
200
201
  clinicAdminService
201
202
  );
203
+ const mediaService = new MediaService(this.db, this.auth, this.app);
202
204
  const clinicService = new ClinicService(
203
205
  this.db,
204
206
  this.auth,
205
207
  this.app,
206
208
  clinicGroupService,
207
- clinicAdminService
209
+ clinicAdminService,
210
+ mediaService
208
211
  );
209
212
 
210
213
  // Set services to resolve circular dependencies
@@ -510,12 +513,14 @@ export class AuthService extends BaseService {
510
513
  this.app,
511
514
  clinicAdminService
512
515
  );
516
+ const mediaService = new MediaService(this.db, this.auth, this.app);
513
517
  const clinicService = new ClinicService(
514
518
  this.db,
515
519
  this.auth,
516
520
  this.app,
517
521
  clinicGroupService,
518
- clinicAdminService
522
+ clinicAdminService,
523
+ mediaService
519
524
  );
520
525
 
521
526
  // Set services to resolve circular dependencies
@@ -43,6 +43,8 @@ import {
43
43
  import {
44
44
  clinicSchema,
45
45
  createClinicSchema,
46
+ updateClinicSchema,
47
+ clinicBranchSetupSchema,
46
48
  } from "../../validations/clinic.schema";
47
49
  import { z } from "zod";
48
50
  import { Auth } from "firebase/auth";
@@ -55,41 +57,175 @@ import * as AdminUtils from "./utils/admin.utils";
55
57
  import * as FilterUtils from "./utils/filter.utils";
56
58
  import { ClinicReviewInfo } from "../../types/reviews";
57
59
  import { PRACTITIONERS_COLLECTION } from "../../types/practitioner";
60
+ import { MediaService, MediaAccessLevel } from "../media/media.service";
58
61
 
59
62
  export class ClinicService extends BaseService {
60
63
  private clinicGroupService: ClinicGroupService;
61
64
  private clinicAdminService: ClinicAdminService;
65
+ private mediaService: MediaService;
62
66
 
63
67
  constructor(
64
68
  db: Firestore,
65
69
  auth: Auth,
66
70
  app: FirebaseApp,
67
71
  clinicGroupService: ClinicGroupService,
68
- clinicAdminService: ClinicAdminService
72
+ clinicAdminService: ClinicAdminService,
73
+ mediaService: MediaService
69
74
  ) {
70
75
  super(db, auth, app);
71
76
  this.clinicAdminService = clinicAdminService;
72
77
  this.clinicGroupService = clinicGroupService;
78
+ this.mediaService = mediaService;
79
+ }
80
+
81
+ /**
82
+ * Process media resource (string URL or File object)
83
+ * @param media String URL or File object
84
+ * @param ownerId Owner ID for the media (usually clinicId)
85
+ * @param collectionName Collection name for organizing files
86
+ * @returns URL string after processing
87
+ */
88
+ private async processMedia(
89
+ media: string | File | Blob | null | undefined,
90
+ ownerId: string,
91
+ collectionName: string
92
+ ): Promise<string | null> {
93
+ if (!media) return null;
94
+
95
+ // If already a string URL, return it directly
96
+ if (typeof media === "string") {
97
+ return media;
98
+ }
99
+
100
+ // If it's a File, upload it using MediaService
101
+ if (media instanceof File || media instanceof Blob) {
102
+ console.log(
103
+ `[ClinicService] Uploading ${collectionName} media for ${ownerId}`
104
+ );
105
+ const metadata = await this.mediaService.uploadMedia(
106
+ media,
107
+ ownerId,
108
+ MediaAccessLevel.PUBLIC,
109
+ collectionName
110
+ );
111
+ return metadata.url;
112
+ }
113
+
114
+ return null;
115
+ }
116
+
117
+ /**
118
+ * Process array of media resources (strings or Files)
119
+ * @param mediaArray Array of string URLs or File objects
120
+ * @param ownerId Owner ID for the media
121
+ * @param collectionName Collection name for organizing files
122
+ * @returns Array of URL strings after processing
123
+ */
124
+ private async processMediaArray(
125
+ mediaArray: (string | File | Blob)[] | undefined,
126
+ ownerId: string,
127
+ collectionName: string
128
+ ): Promise<string[]> {
129
+ if (!mediaArray || mediaArray.length === 0) return [];
130
+
131
+ const result: string[] = [];
132
+
133
+ for (const media of mediaArray) {
134
+ const processedUrl = await this.processMedia(
135
+ media,
136
+ ownerId,
137
+ collectionName
138
+ );
139
+ if (processedUrl) {
140
+ result.push(processedUrl);
141
+ }
142
+ }
143
+
144
+ return result;
145
+ }
146
+
147
+ /**
148
+ * Process photos with tags array
149
+ * @param photosWithTags Array of objects containing media and tag
150
+ * @param ownerId Owner ID for the media
151
+ * @param collectionName Collection name for organizing files
152
+ * @returns Processed array with URL strings
153
+ */
154
+ private async processPhotosWithTags(
155
+ photosWithTags: { url: string | File | Blob; tag: string }[] | undefined,
156
+ ownerId: string,
157
+ collectionName: string
158
+ ): Promise<{ url: string; tag: string }[]> {
159
+ if (!photosWithTags || photosWithTags.length === 0) return [];
160
+
161
+ const result: { url: string; tag: string }[] = [];
162
+
163
+ for (const item of photosWithTags) {
164
+ const processedUrl = await this.processMedia(
165
+ item.url,
166
+ ownerId,
167
+ collectionName
168
+ );
169
+ if (processedUrl) {
170
+ result.push({
171
+ url: processedUrl,
172
+ tag: item.tag,
173
+ });
174
+ }
175
+ }
176
+
177
+ return result;
73
178
  }
74
179
 
75
180
  /**
76
181
  * Creates a new clinic.
182
+ * Handles both URL strings and File uploads for media fields.
77
183
  */
78
184
  async createClinic(
79
185
  data: CreateClinicData,
80
186
  creatorAdminId: string
81
187
  ): Promise<Clinic> {
82
188
  try {
189
+ // Generate ID first so we can use it for media uploads
190
+ const clinicId = this.generateId();
191
+
192
+ // Validate data - this now works because mediaResourceSchema has been updated to support Files/Blobs
83
193
  const validatedData = createClinicSchema.parse(data);
194
+
84
195
  const group = await this.clinicGroupService.getClinicGroup(
85
196
  validatedData.clinicGroupId
86
197
  );
87
- if (!group)
198
+ if (!group) {
88
199
  throw new Error(
89
200
  `Clinic group ${validatedData.clinicGroupId} not found`
90
201
  );
202
+ }
203
+
204
+ // Process media fields - convert File/Blob objects to URLs
205
+ const logoUrl = await this.processMedia(
206
+ validatedData.logo,
207
+ clinicId,
208
+ "clinic-logos"
209
+ );
210
+ const coverPhotoUrl = await this.processMedia(
211
+ validatedData.coverPhoto,
212
+ clinicId,
213
+ "clinic-cover-photos"
214
+ );
215
+ const featuredPhotos = await this.processMediaArray(
216
+ validatedData.featuredPhotos,
217
+ clinicId,
218
+ "clinic-featured-photos"
219
+ );
220
+ const photosWithTags = await this.processPhotosWithTags(
221
+ validatedData.photosWithTags,
222
+ clinicId,
223
+ "clinic-gallery"
224
+ );
225
+
91
226
  const location = validatedData.location;
92
227
  const hash = geohashForLocation([location.latitude, location.longitude]);
228
+
93
229
  const defaultReviewInfo: ClinicReviewInfo = {
94
230
  totalReviews: 0,
95
231
  averageRating: 0,
@@ -100,7 +236,6 @@ export class ClinicService extends BaseService {
100
236
  accessibility: 0,
101
237
  recommendationPercentage: 0,
102
238
  };
103
- const clinicId = this.generateId();
104
239
 
105
240
  const clinicData: Omit<Clinic, "createdAt" | "updatedAt"> & {
106
241
  createdAt: FieldValue;
@@ -114,34 +249,41 @@ export class ClinicService extends BaseService {
114
249
  contactInfo: validatedData.contactInfo,
115
250
  workingHours: validatedData.workingHours,
116
251
  tags: validatedData.tags,
117
- featuredPhotos: validatedData.featuredPhotos || [],
118
- coverPhoto: validatedData.coverPhoto,
119
- photosWithTags: validatedData.photosWithTags,
120
- doctors: [],
121
- procedures: [],
252
+ featuredPhotos: featuredPhotos,
253
+ coverPhoto: coverPhotoUrl,
254
+ photosWithTags: photosWithTags,
255
+ doctors: validatedData.doctors || [],
256
+ procedures: validatedData.procedures || [],
122
257
  doctorsInfo: [],
123
- proceduresInfo: [],
258
+ proceduresInfo: validatedData.proceduresInfo || [],
124
259
  reviewInfo: defaultReviewInfo,
125
260
  admins: [creatorAdminId],
126
- isActive: validatedData.isActive,
127
- isVerified: validatedData.isVerified,
128
- logo: validatedData.logo,
261
+ isActive:
262
+ validatedData.isActive !== undefined ? validatedData.isActive : true,
263
+ isVerified:
264
+ validatedData.isVerified !== undefined
265
+ ? validatedData.isVerified
266
+ : false,
267
+ logo: logoUrl,
129
268
  createdAt: serverTimestamp(),
130
269
  updatedAt: serverTimestamp(),
131
270
  };
132
271
 
133
- // Re-validate before saving (ensure schema matches the final object)
272
+ // We can validate the final object with URLs using clinicSchema which also supports mediaResourceSchema
273
+ // However, we need to be careful with timestamps
274
+ // The validation below is optional and can be uncommented if needed
275
+ /*
134
276
  clinicSchema.parse({
135
277
  ...clinicData,
136
278
  createdAt: Timestamp.now(),
137
279
  updatedAt: Timestamp.now(),
138
280
  });
281
+ */
139
282
 
140
283
  const batch = writeBatch(this.db);
141
284
  const clinicRef = doc(this.db, CLINICS_COLLECTION, clinicId);
142
285
  batch.set(clinicRef, clinicData);
143
286
 
144
- // Update admin relationship - this part is still needed
145
287
  const adminRef = doc(this.db, CLINIC_ADMINS_COLLECTION, creatorAdminId);
146
288
  batch.update(adminRef, {
147
289
  clinicsManaged: arrayUnion(clinicId),
@@ -149,6 +291,8 @@ export class ClinicService extends BaseService {
149
291
  });
150
292
 
151
293
  await batch.commit();
294
+ console.log(`[ClinicService] Clinic created successfully: ${clinicId}`);
295
+
152
296
  const savedClinic = await this.getClinic(clinicId);
153
297
  if (!savedClinic) throw new Error("Failed to retrieve created clinic");
154
298
  return savedClinic;
@@ -163,66 +307,106 @@ export class ClinicService extends BaseService {
163
307
 
164
308
  /**
165
309
  * Updates a clinic.
310
+ * Handles both URL strings and File uploads for media fields.
166
311
  */
167
312
  async updateClinic(
168
313
  clinicId: string,
169
- data: Partial<Omit<Clinic, "id" | "createdAt" | "clinicGroupId">>,
314
+ data: Partial<CreateClinicData>,
170
315
  adminId: string
171
316
  ): Promise<Clinic> {
172
- const clinicRef = doc(this.db, CLINICS_COLLECTION, clinicId);
173
- const clinicDoc = await getDoc(clinicRef);
174
- if (!clinicDoc.exists()) {
175
- throw new Error(`Clinic ${clinicId} not found`);
176
- }
177
-
178
317
  try {
318
+ // First check if clinic exists
319
+ const clinicRef = doc(this.db, CLINICS_COLLECTION, clinicId);
320
+ const clinicDoc = await getDoc(clinicRef);
321
+
322
+ if (!clinicDoc.exists()) {
323
+ throw new Error(`Clinic ${clinicId} not found`);
324
+ }
325
+
179
326
  const currentClinic = clinicDoc.data() as Clinic;
180
- // Explicitly Omit fields managed by other services or internally
181
- const { doctorsInfo, proceduresInfo, ...updatePayload } =
182
- data as Partial<Clinic>;
183
327
 
184
- if (updatePayload.location) {
185
- const loc = updatePayload.location;
328
+ // Validate update data - this works because updateClinicSchema supports Files/Blobs
329
+ const validatedData = updateClinicSchema.parse(data);
330
+
331
+ const updatePayload: Record<string, any> = {};
332
+
333
+ // Process media fields if provided
334
+ if (validatedData.logo !== undefined) {
335
+ updatePayload.logo = await this.processMedia(
336
+ validatedData.logo,
337
+ clinicId,
338
+ "clinic-logos"
339
+ );
340
+ }
341
+
342
+ if (validatedData.coverPhoto !== undefined) {
343
+ updatePayload.coverPhoto = await this.processMedia(
344
+ validatedData.coverPhoto,
345
+ clinicId,
346
+ "clinic-cover-photos"
347
+ );
348
+ }
349
+
350
+ if (validatedData.featuredPhotos !== undefined) {
351
+ updatePayload.featuredPhotos = await this.processMediaArray(
352
+ validatedData.featuredPhotos,
353
+ clinicId,
354
+ "clinic-featured-photos"
355
+ );
356
+ }
357
+
358
+ if (validatedData.photosWithTags !== undefined) {
359
+ updatePayload.photosWithTags = await this.processPhotosWithTags(
360
+ validatedData.photosWithTags,
361
+ clinicId,
362
+ "clinic-gallery"
363
+ );
364
+ }
365
+
366
+ // Process non-media fields
367
+ const fieldsToUpdate = [
368
+ "name",
369
+ "description",
370
+ "contactInfo",
371
+ "workingHours",
372
+ "tags",
373
+ "doctors",
374
+ "procedures",
375
+ "proceduresInfo",
376
+ "isActive",
377
+ "isVerified",
378
+ ];
379
+
380
+ for (const field of fieldsToUpdate) {
381
+ if (validatedData[field as keyof typeof validatedData] !== undefined) {
382
+ updatePayload[field] =
383
+ validatedData[field as keyof typeof validatedData];
384
+ }
385
+ }
386
+
387
+ // Handle location update with geohash
388
+ if (validatedData.location) {
389
+ const loc = validatedData.location;
186
390
  updatePayload.location = {
187
391
  ...loc,
188
392
  geohash: geohashForLocation([loc.latitude, loc.longitude]),
189
393
  };
190
394
  }
191
395
 
192
- // Merge with current data for validation and aggregation, preserving arrays managed elsewhere
193
- const finalStateForValidation = {
194
- ...currentClinic,
195
- ...updatePayload, // Apply safe updates
196
- // Explicitly keep arrays managed by other services from current state
197
- doctorsInfo: currentClinic.doctorsInfo,
198
- proceduresInfo: currentClinic.proceduresInfo,
199
- };
200
-
201
- // Ensure required fields for validation are present
202
- clinicSchema.parse({
203
- ...finalStateForValidation,
204
- updatedAt: Timestamp.now(), // Use current time for validation
205
- });
206
-
207
- // Prepare final update data for Firestore, including timestamp
208
- const updateDataForFirestore = {
209
- ...updatePayload,
210
- updatedAt: serverTimestamp(),
211
- };
396
+ // Add timestamp
397
+ updatePayload.updatedAt = serverTimestamp();
212
398
 
213
- await updateDoc(clinicRef, updateDataForFirestore);
399
+ // Update the clinic
400
+ await updateDoc(clinicRef, updatePayload);
401
+ console.log(`[ClinicService] Clinic ${clinicId} updated successfully`);
214
402
 
403
+ // Return the updated clinic
215
404
  const updatedClinic = await this.getClinic(clinicId);
216
405
  if (!updatedClinic) throw new Error("Failed to retrieve updated clinic");
217
406
  return updatedClinic;
218
407
  } catch (error) {
219
408
  if (error instanceof z.ZodError) {
220
- throw new Error(
221
- "Invalid clinic update data: " +
222
- error.errors
223
- .map((e) => `${e.path.join(".")} - ${e.message}`)
224
- .join(", ")
225
- );
409
+ throw new Error("Invalid clinic update data: " + error.message);
226
410
  }
227
411
  console.error(`Error updating clinic ${clinicId}:`, error);
228
412
  throw error;
@@ -334,6 +518,10 @@ export class ClinicService extends BaseService {
334
518
  );
335
519
  }
336
520
 
521
+ /**
522
+ * Creates a clinic branch from setup data.
523
+ * Handles both URL strings and File uploads for media fields.
524
+ */
337
525
  async createClinicBranch(
338
526
  clinicGroupId: string,
339
527
  setupData: ClinicBranchSetupData,
@@ -344,7 +532,7 @@ export class ClinicService extends BaseService {
344
532
  adminId,
345
533
  });
346
534
 
347
- // Validate that the clinic group exists
535
+ // Verify clinic group exists
348
536
  const clinicGroup = await this.clinicGroupService.getClinicGroup(
349
537
  clinicGroupId
350
538
  );
@@ -356,47 +544,42 @@ export class ClinicService extends BaseService {
356
544
  }
357
545
  console.log("[CLINIC_SERVICE] Clinic group verified");
358
546
 
359
- // TODO: Handle logo, coverPhoto, featuredPhotos, photosWithTags and upload them to storage
360
- // Use storage service to upload files, and get the download URL
361
- // Use path 'clinics/{clinicId}/{photoType}/{filename}' for storing the files
362
- // Photo types: logo, coverPhoto, featuredPhotos, photosWithTags
363
- // Storage can be accessed by using the storage service like app.getStorage()
547
+ // Validate branch setup data first
548
+ const validatedSetupData = clinicBranchSetupSchema.parse(setupData);
364
549
 
365
- // Create the clinic data
550
+ // Convert validated setup data to CreateClinicData
366
551
  const createClinicData: CreateClinicData = {
367
552
  clinicGroupId,
368
- name: setupData.name,
369
- description: setupData.description,
370
- location: setupData.location,
371
- contactInfo: setupData.contactInfo,
372
- workingHours: setupData.workingHours,
373
- tags: setupData.tags,
374
- coverPhoto: setupData.coverPhoto || null,
375
- photosWithTags: setupData.photosWithTags || [],
553
+ name: validatedSetupData.name,
554
+ description: validatedSetupData.description,
555
+ location: validatedSetupData.location,
556
+ contactInfo: validatedSetupData.contactInfo,
557
+ workingHours: validatedSetupData.workingHours,
558
+ tags: validatedSetupData.tags || [],
559
+ // Pass the media fields which can be string URLs or File objects
560
+ logo: validatedSetupData.logo,
561
+ coverPhoto: validatedSetupData.coverPhoto,
562
+ featuredPhotos: validatedSetupData.featuredPhotos,
563
+ photosWithTags: validatedSetupData.photosWithTags,
376
564
  doctors: [],
377
565
  procedures: [],
378
566
  admins: [adminId],
379
567
  isActive: true,
380
568
  isVerified: false,
381
- logo: setupData.logo,
382
- featuredPhotos: setupData.featuredPhotos || [],
383
569
  };
384
570
 
385
- console.log("[CLINIC_SERVICE] Creating clinic branch with data", {
571
+ console.log("[CLINIC_SERVICE] Creating clinic branch", {
386
572
  name: createClinicData.name,
387
573
  hasLogo: !!createClinicData.logo,
388
574
  hasCoverPhoto: !!createClinicData.coverPhoto,
389
- featuredPhotosCount: createClinicData.featuredPhotos?.length || 0,
390
- photosWithTagsCount: createClinicData.photosWithTags?.length || 0,
391
575
  });
392
576
 
393
- // Create the clinic - the createClinic method will handle photo uploads
577
+ // Use createClinic which now handles validation and media uploads
394
578
  const clinic = await this.createClinic(createClinicData, adminId);
579
+
395
580
  console.log("[CLINIC_SERVICE] Clinic branch created successfully", {
396
581
  clinicId: clinic.id,
397
582
  });
398
-
399
- // Note: The createClinic method already adds the clinic to the admin's managed clinics
400
583
  return clinic;
401
584
  }
402
585