@blackcode_sa/metaestetics-api 1.7.18 → 1.7.20

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.18",
4
+ "version": "1.7.20",
5
5
  "description": "Firebase authentication service with anonymous upgrade support",
6
6
  "main": "./dist/index.js",
7
7
  "module": "./dist/index.mjs",
@@ -613,7 +613,10 @@ export class BookingAdmin {
613
613
  };
614
614
  const practitionerInfo: PractitionerProfileInfo = {
615
615
  id: practitionerSnap.id,
616
- practitionerPhoto: practitionerData.basicInfo.profileImageUrl || null,
616
+ practitionerPhoto:
617
+ typeof practitionerData.basicInfo.profileImageUrl === "string"
618
+ ? practitionerData.basicInfo.profileImageUrl
619
+ : null,
617
620
  name: `${practitionerData.basicInfo.firstName} ${practitionerData.basicInfo.lastName}`,
618
621
  email: practitionerData.basicInfo.email,
619
622
  phone: practitionerData.basicInfo.phoneNumber || null,
@@ -478,55 +478,45 @@ export class FilledDocumentService extends BaseService {
478
478
  }
479
479
 
480
480
  /**
481
- * Upload a file and associate it with a filled document field.
482
- * @param appointmentId - ID of the appointment.
483
- * @param formId - ID of the filled document.
484
- * @param isUserForm - Boolean indicating if it's a user form or doctor form.
485
- * @param file - The file to upload.
486
- * @param fieldId - The ID of the field in the document to associate with this file.
487
- * @param accessLevel - Access level for the file, defaults to PRIVATE.
488
- * @returns The updated filled document with file information.
481
+ * Upload a file for a filled document field without updating the document.
482
+ * This method only handles the upload and returns the file value to be used by the UI.
483
+ *
484
+ * @param appointmentId - ID of the appointment
485
+ * @param formId - ID of the filled document
486
+ * @param isUserForm - Boolean indicating if it's a user form or doctor form
487
+ * @param file - The file to upload
488
+ * @returns The file value object to be stored in the document
489
489
  */
490
490
  async uploadFileForFilledDocument(
491
491
  appointmentId: string,
492
492
  formId: string,
493
493
  isUserForm: boolean,
494
- file: File | Blob,
495
- fieldId: string,
496
- accessLevel: MediaAccessLevel = MediaAccessLevel.PRIVATE
497
- ): Promise<FilledDocument> {
494
+ file: File | Blob
495
+ ): Promise<FilledDocumentFileValue> {
498
496
  console.log(
499
- `[FilledDocumentService] Uploading file for field ${fieldId} in form ${formId}`
497
+ `[FilledDocumentService] Uploading file for form ${formId} in appointment ${appointmentId}`
500
498
  );
501
499
 
502
- // 1. Get the existing document to verify it exists and to get the patientId
503
- const existingDoc = await this.getFilledDocumentFromAppointmentById(
504
- appointmentId,
505
- formId,
506
- isUserForm
507
- );
500
+ // Generate a unique file ID
501
+ const fileId = this.generateId();
508
502
 
509
- if (!existingDoc) {
510
- throw new Error(
511
- `Filled document with ID ${formId} not found in appointment ${appointmentId}`
512
- );
513
- }
503
+ // Set the path according to the specified structure
504
+ const formType = isUserForm ? "user-form" : "doctor-form";
505
+ const collectionName = `${formType}/${formId}`;
514
506
 
515
- // 2. Upload the file using MediaService
516
- const ownerId = existingDoc.patientId; // Using patientId as ownerId for permissions
517
- const collectionName = isUserForm
518
- ? "patient_forms_files"
519
- : "doctor_forms_files";
507
+ // Always use CONFIDENTIAL access level
508
+ const accessLevel = MediaAccessLevel.CONFIDENTIAL;
520
509
 
510
+ // Upload the file using MediaService
521
511
  const mediaMetadata = await this.mediaService.uploadMedia(
522
512
  file,
523
- ownerId,
513
+ appointmentId, // Using appointmentId as ownerId
524
514
  accessLevel,
525
515
  collectionName,
526
- file instanceof File ? file.name : `${fieldId}_file`
516
+ file instanceof File ? file.name : `file_${fileId}`
527
517
  );
528
518
 
529
- // 3. Create a file value object to store in the document
519
+ // Create and return a file value object
530
520
  const fileValue: FilledDocumentFileValue = {
531
521
  mediaId: mediaMetadata.id,
532
522
  url: mediaMetadata.url,
@@ -536,156 +526,62 @@ export class FilledDocumentService extends BaseService {
536
526
  uploadedAt: Date.now(),
537
527
  };
538
528
 
539
- // 4. Update the document with the file value
540
- const values = {
541
- [fieldId]: fileValue,
542
- };
543
-
544
- // 5. Use the existing update method to save the file reference
545
- return this.updateFilledDocumentInAppointment(
546
- appointmentId,
547
- formId,
548
- isUserForm,
549
- values
550
- );
529
+ return fileValue;
551
530
  }
552
531
 
553
532
  /**
554
- * Upload a signature image for a filled document field.
533
+ * Upload a signature image for a filled document.
555
534
  * This is a specialized version of uploadFileForFilledDocument specifically for signatures.
556
- * @param appointmentId - ID of the appointment.
557
- * @param formId - ID of the filled document.
558
- * @param isUserForm - Boolean indicating if it's a user form or doctor form.
559
- * @param signatureBlob - The signature image as a Blob.
560
- * @param fieldId - The ID of the signature field in the document.
561
- * @returns The updated filled document with signature information.
535
+ *
536
+ * @param appointmentId - ID of the appointment
537
+ * @param formId - ID of the filled document
538
+ * @param isUserForm - Boolean indicating if it's a user form or doctor form
539
+ * @param signatureBlob - The signature image as a Blob
540
+ * @returns The file value object to be stored in the document
562
541
  */
563
542
  async uploadSignatureForFilledDocument(
564
543
  appointmentId: string,
565
544
  formId: string,
566
545
  isUserForm: boolean,
567
- signatureBlob: Blob,
568
- fieldId: string
569
- ): Promise<FilledDocument> {
546
+ signatureBlob: Blob
547
+ ): Promise<FilledDocumentFileValue> {
570
548
  console.log(
571
- `[FilledDocumentService] Uploading signature for field ${fieldId} in form ${formId}`
549
+ `[FilledDocumentService] Uploading signature for form ${formId}`
572
550
  );
573
551
 
574
- // Use the general file upload method, but specify a fixed name and access level for signatures
552
+ // Generate a filename for the signature
553
+ const signatureId = this.generateId();
575
554
  const signatureFile = new File(
576
555
  [signatureBlob],
577
- `signature_${fieldId}.png`,
578
- {
579
- type: "image/png",
580
- }
556
+ `signature_${signatureId}.png`,
557
+ { type: "image/png" }
581
558
  );
582
559
 
560
+ // Use the general file upload method
583
561
  return this.uploadFileForFilledDocument(
584
562
  appointmentId,
585
563
  formId,
586
564
  isUserForm,
587
- signatureFile,
588
- fieldId,
589
- MediaAccessLevel.CONFIDENTIAL // Signatures should be confidential
590
- );
591
- }
592
-
593
- /**
594
- * Remove a file from a filled document field.
595
- * This will both update the document and delete the media file.
596
- * @param appointmentId - ID of the appointment.
597
- * @param formId - ID of the filled document.
598
- * @param isUserForm - Boolean indicating if it's a user form or doctor form.
599
- * @param fieldId - The ID of the field containing the file.
600
- * @returns The updated filled document with the file removed.
601
- */
602
- async removeFileFromFilledDocument(
603
- appointmentId: string,
604
- formId: string,
605
- isUserForm: boolean,
606
- fieldId: string
607
- ): Promise<FilledDocument> {
608
- console.log(
609
- `[FilledDocumentService] Removing file from field ${fieldId} in form ${formId}`
610
- );
611
-
612
- // 1. Get the existing document to verify it exists and to get the file info
613
- const existingDoc = await this.getFilledDocumentFromAppointmentById(
614
- appointmentId,
615
- formId,
616
- isUserForm
617
- );
618
-
619
- if (!existingDoc) {
620
- throw new Error(
621
- `Filled document with ID ${formId} not found in appointment ${appointmentId}`
622
- );
623
- }
624
-
625
- // 2. Check if the field has a file value
626
- const fileValue = existingDoc.values?.[fieldId] as FilledDocumentFileValue;
627
- if (fileValue && fileValue.mediaId) {
628
- // 3. Delete the file using MediaService
629
- try {
630
- await this.mediaService.deleteMedia(fileValue.mediaId);
631
- } catch (error) {
632
- console.error(
633
- `[FilledDocumentService] Error deleting media ${fileValue.mediaId}:`,
634
- error
635
- );
636
- // Continue with document update even if media deletion fails
637
- }
638
- }
639
-
640
- // 4. Update the document to remove the file reference
641
- const values = {
642
- [fieldId]: null,
643
- };
644
-
645
- // 5. Use the existing update method to save the changes
646
- return this.updateFilledDocumentInAppointment(
647
- appointmentId,
648
- formId,
649
- isUserForm,
650
- values
565
+ signatureFile
651
566
  );
652
567
  }
653
568
 
654
569
  /**
655
- * Get the download URL for a file in a filled document.
656
- * @param appointmentId - ID of the appointment.
657
- * @param formId - ID of the filled document.
658
- * @param isUserForm - Boolean indicating if it's a user form or doctor form.
659
- * @param fieldId - The ID of the field containing the file.
660
- * @returns The download URL for the file, or null if not found.
570
+ * Delete a file using its mediaId.
571
+ *
572
+ * @param mediaId - ID of the media to delete
573
+ * @returns Promise resolving when the deletion is complete
661
574
  */
662
- async getFileUrlFromFilledDocument(
663
- appointmentId: string,
664
- formId: string,
665
- isUserForm: boolean,
666
- fieldId: string
667
- ): Promise<string | null> {
575
+ async deleteFile(mediaId: string): Promise<void> {
668
576
  console.log(
669
- `[FilledDocumentService] Getting file URL for field ${fieldId} in form ${formId}`
577
+ `[FilledDocumentService] Deleting file with mediaId ${mediaId}`
670
578
  );
671
579
 
672
- // 1. Get the document
673
- const doc = await this.getFilledDocumentFromAppointmentById(
674
- appointmentId,
675
- formId,
676
- isUserForm
677
- );
678
-
679
- if (!doc) {
680
- return null;
681
- }
682
-
683
- // 2. Check if the field has a file value
684
- const fileValue = doc.values?.[fieldId] as FilledDocumentFileValue;
685
- if (fileValue && fileValue.url) {
686
- return fileValue.url;
580
+ if (!mediaId) {
581
+ throw new Error("MediaId is required to delete a file");
687
582
  }
688
583
 
689
- return null;
584
+ // Delete the file using MediaService
585
+ await this.mediaService.deleteMedia(mediaId);
690
586
  }
691
587
  }
@@ -34,6 +34,11 @@ import {
34
34
  } from "../../types/practitioner";
35
35
  import { ProcedureSummaryInfo } from "../../types/procedure";
36
36
  import { ClinicService } from "../clinic/clinic.service";
37
+ import {
38
+ MediaService,
39
+ MediaAccessLevel,
40
+ MediaResource,
41
+ } from "../media/media.service";
37
42
  import {
38
43
  practitionerSchema,
39
44
  createPractitionerSchema,
@@ -53,6 +58,7 @@ import { ClinicInfo } from "../../types/profile";
53
58
 
54
59
  export class PractitionerService extends BaseService {
55
60
  private clinicService?: ClinicService;
61
+ private mediaService: MediaService;
56
62
 
57
63
  constructor(
58
64
  db: Firestore,
@@ -62,6 +68,7 @@ export class PractitionerService extends BaseService {
62
68
  ) {
63
69
  super(db, auth, app);
64
70
  this.clinicService = clinicService;
71
+ this.mediaService = new MediaService(db, auth, app);
65
72
  }
66
73
 
67
74
  private getClinicService(): ClinicService {
@@ -75,6 +82,71 @@ export class PractitionerService extends BaseService {
75
82
  this.clinicService = clinicService;
76
83
  }
77
84
 
85
+ /**
86
+ * Handles profile photo upload for practitioners
87
+ * @param profilePhoto - MediaResource (File, Blob, or URL string)
88
+ * @param practitionerId - ID of the practitioner
89
+ * @returns URL string of the uploaded or existing photo
90
+ */
91
+ private async handleProfilePhotoUpload(
92
+ profilePhoto: MediaResource | undefined,
93
+ practitionerId: string
94
+ ): Promise<string | undefined> {
95
+ if (!profilePhoto) {
96
+ return undefined;
97
+ }
98
+
99
+ // If it's already a URL string, return it as is
100
+ if (typeof profilePhoto === "string") {
101
+ return profilePhoto;
102
+ }
103
+
104
+ // If it's a File or Blob, upload it
105
+ if (profilePhoto instanceof File || profilePhoto instanceof Blob) {
106
+ console.log(
107
+ `[PractitionerService] Uploading profile photo for practitioner ${practitionerId}`
108
+ );
109
+
110
+ const mediaMetadata = await this.mediaService.uploadMedia(
111
+ profilePhoto,
112
+ practitionerId, // Using practitionerId as ownerId
113
+ MediaAccessLevel.PUBLIC, // Profile photos should be public
114
+ "practitioner_profile_photos",
115
+ profilePhoto instanceof File
116
+ ? profilePhoto.name
117
+ : `profile_photo_${practitionerId}`
118
+ );
119
+
120
+ return mediaMetadata.url;
121
+ }
122
+
123
+ return undefined;
124
+ }
125
+
126
+ /**
127
+ * Processes BasicPractitionerInfo to handle profile photo uploads
128
+ * @param basicInfo - The basic info containing potential MediaResource profile photo
129
+ * @param practitionerId - ID of the practitioner
130
+ * @returns Processed basic info with URL string for profileImageUrl
131
+ */
132
+ private async processBasicInfo(
133
+ basicInfo: PractitionerBasicInfo & { profileImageUrl?: MediaResource },
134
+ practitionerId: string
135
+ ): Promise<PractitionerBasicInfo> {
136
+ const processedBasicInfo = { ...basicInfo };
137
+
138
+ // Handle profile photo upload if needed
139
+ if (basicInfo.profileImageUrl) {
140
+ const uploadedUrl = await this.handleProfilePhotoUpload(
141
+ basicInfo.profileImageUrl,
142
+ practitionerId
143
+ );
144
+ processedBasicInfo.profileImageUrl = uploadedUrl;
145
+ }
146
+
147
+ return processedBasicInfo;
148
+ }
149
+
78
150
  /**
79
151
  * Creates a new practitioner
80
152
  */
@@ -104,7 +176,10 @@ export class PractitionerService extends BaseService {
104
176
  } = {
105
177
  id: practitionerId,
106
178
  userRef: validData.userRef,
107
- basicInfo: validData.basicInfo,
179
+ basicInfo: await this.processBasicInfo(
180
+ validData.basicInfo,
181
+ practitionerId
182
+ ),
108
183
  certification: validData.certification,
109
184
  clinics: validData.clinics || [],
110
185
  clinicWorkingHours: validData.clinicWorkingHours || [],
@@ -254,7 +329,10 @@ export class PractitionerService extends BaseService {
254
329
  } = {
255
330
  id: practitionerId,
256
331
  userRef: "", // Prazno - biće popunjeno kada korisnik kreira nalog
257
- basicInfo: validatedData.basicInfo,
332
+ basicInfo: await this.processBasicInfo(
333
+ validatedData.basicInfo,
334
+ practitionerId
335
+ ),
258
336
  certification: validatedData.certification,
259
337
  clinics: clinics,
260
338
  clinicWorkingHours: validatedData.clinicWorkingHours || [],
@@ -619,9 +697,20 @@ export class PractitionerService extends BaseService {
619
697
 
620
698
  const currentPractitioner = practitionerDoc.data() as Practitioner;
621
699
 
700
+ // Process basicInfo if it's being updated to handle profile photo uploads
701
+ let processedData = { ...validData };
702
+ if (validData.basicInfo) {
703
+ processedData.basicInfo = await this.processBasicInfo(
704
+ validData.basicInfo as PractitionerBasicInfo & {
705
+ profileImageUrl?: MediaResource;
706
+ },
707
+ practitionerId
708
+ );
709
+ }
710
+
622
711
  // Prepare update data
623
712
  const updateData = {
624
- ...validData,
713
+ ...processedData,
625
714
  updatedAt: serverTimestamp(),
626
715
  };
627
716
 
@@ -251,7 +251,10 @@ export class ProcedureService extends BaseService {
251
251
  id: practitionerSnapshot.id,
252
252
  name: `${practitioner.basicInfo.firstName} ${practitioner.basicInfo.lastName}`,
253
253
  description: practitioner.basicInfo.bio || "",
254
- photo: practitioner.basicInfo.profileImageUrl || "",
254
+ photo:
255
+ typeof practitioner.basicInfo.profileImageUrl === "string"
256
+ ? practitioner.basicInfo.profileImageUrl
257
+ : "", // Default to empty string if not a processed URL
255
258
  rating: practitioner.reviewInfo?.averageRating || 0,
256
259
  services: practitioner.procedures || [],
257
260
  };
@@ -412,7 +415,10 @@ export class ProcedureService extends BaseService {
412
415
  id: newPractitioner.id,
413
416
  name: `${newPractitioner.basicInfo.firstName} ${newPractitioner.basicInfo.lastName}`,
414
417
  description: newPractitioner.basicInfo.bio || "",
415
- photo: newPractitioner.basicInfo.profileImageUrl || "",
418
+ photo:
419
+ typeof newPractitioner.basicInfo.profileImageUrl === "string"
420
+ ? newPractitioner.basicInfo.profileImageUrl
421
+ : "", // Default to empty string if not a processed URL
416
422
  rating: newPractitioner.reviewInfo?.averageRating || 0,
417
423
  services: newPractitioner.procedures || [],
418
424
  };
@@ -11,6 +11,7 @@ import {
11
11
  PricingMeasure,
12
12
  } from "../../backoffice/types/static/pricing.types";
13
13
  import { ProcedureSummaryInfo } from "../procedure";
14
+ import type { MediaResource } from "../../services/media/media.service";
14
15
 
15
16
  export const PRACTITIONERS_COLLECTION = "practitioners";
16
17
  export const REGISTER_TOKENS_COLLECTION = "register_tokens";
@@ -26,7 +27,7 @@ export interface PractitionerBasicInfo {
26
27
  phoneNumber: string;
27
28
  dateOfBirth: Timestamp | Date;
28
29
  gender: "male" | "female" | "other";
29
- profileImageUrl?: string;
30
+ profileImageUrl?: MediaResource;
30
31
  bio?: string;
31
32
  languages: string[];
32
33
  }
@@ -4,7 +4,7 @@ import { z } from "zod";
4
4
  * Schema for validating both URL strings and File objects
5
5
  */
6
6
  export const mediaResourceSchema = z.union([
7
- z.string(),
7
+ z.string().url(),
8
8
  z.instanceof(File),
9
9
  z.instanceof(Blob),
10
10
  ]);
@@ -15,6 +15,7 @@ import {
15
15
  PricingMeasure,
16
16
  } from "../backoffice/types/static/pricing.types";
17
17
  import { clinicInfoSchema, procedureSummaryInfoSchema } from "./shared.schema";
18
+ import { mediaResourceSchema } from "./media.schema";
18
19
 
19
20
  /**
20
21
  * Šema za validaciju osnovnih informacija o zdravstvenom radniku
@@ -27,7 +28,7 @@ export const practitionerBasicInfoSchema = z.object({
27
28
  phoneNumber: z.string().regex(/^\+?[1-9]\d{1,14}$/, "Invalid phone number"),
28
29
  dateOfBirth: z.instanceof(Timestamp).or(z.date()),
29
30
  gender: z.enum(["male", "female", "other"]),
30
- profileImageUrl: z.string().url().optional(),
31
+ profileImageUrl: mediaResourceSchema.optional(),
31
32
  bio: z.string().max(1000).optional(),
32
33
  languages: z.array(z.string()).min(1),
33
34
  });
@@ -204,7 +205,7 @@ export const practitionerSignupSchema = z.object({
204
205
  basicInfo: z
205
206
  .object({
206
207
  phoneNumber: z.string().optional(),
207
- profileImageUrl: z.string().optional(),
208
+ profileImageUrl: mediaResourceSchema.optional(),
208
209
  gender: z.enum(["male", "female", "other"]).optional(),
209
210
  bio: z.string().optional(),
210
211
  })