@blackcode_sa/metaestetics-api 1.7.12 → 1.7.14

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,316 @@
1
+ import * as admin from "firebase-admin";
2
+ import {
3
+ FilledDocument,
4
+ FilledDocumentStatus,
5
+ USER_FORMS_SUBCOLLECTION,
6
+ DOCTOR_FORMS_SUBCOLLECTION,
7
+ } from "../../../types/documentation-templates";
8
+ import {
9
+ Appointment,
10
+ LinkedFormInfo,
11
+ APPOINTMENTS_COLLECTION,
12
+ } from "../../../types/appointment";
13
+ import { Logger } from "../../logger";
14
+ import { Timestamp } from "firebase/firestore";
15
+
16
+ /**
17
+ * @class FilledFormsAggregationService
18
+ * @description Handles aggregation tasks related to filled forms data updates.
19
+ * Updates appointment documents with LinkedFormInfo when forms are created, updated, or deleted.
20
+ */
21
+ export class FilledFormsAggregationService {
22
+ private db: admin.firestore.Firestore;
23
+
24
+ /**
25
+ * Constructor for FilledFormsAggregationService.
26
+ * @param firestore Optional Firestore instance. If not provided, it uses the default admin SDK instance.
27
+ */
28
+ constructor(firestore?: admin.firestore.Firestore) {
29
+ this.db = firestore || admin.firestore();
30
+ Logger.info("[FilledFormsAggregationService] Initialized");
31
+ }
32
+
33
+ /**
34
+ * Handles side effects when a filled form is created or updated.
35
+ * This function would typically be called by a Firestore onCreate or onUpdate trigger.
36
+ * @param filledDocument The filled document that was created or updated.
37
+ * @returns {Promise<void>}
38
+ */
39
+ async handleFilledFormCreateOrUpdate(
40
+ filledDocument: FilledDocument
41
+ ): Promise<void> {
42
+ Logger.info(
43
+ `[FilledFormsAggregationService] Handling CREATE/UPDATE for filled form: ${filledDocument.id}, appointment: ${filledDocument.appointmentId}`
44
+ );
45
+
46
+ try {
47
+ // 1. Get the appointment document
48
+ const appointmentRef = this.db
49
+ .collection(APPOINTMENTS_COLLECTION)
50
+ .doc(filledDocument.appointmentId);
51
+
52
+ const appointmentDoc = await appointmentRef.get();
53
+ if (!appointmentDoc.exists) {
54
+ Logger.error(
55
+ `[FilledFormsAggregationService] Appointment ${filledDocument.appointmentId} not found.`
56
+ );
57
+ return;
58
+ }
59
+
60
+ const appointment = appointmentDoc.data() as Appointment;
61
+
62
+ // 2. Prepare the LinkedFormInfo object
63
+ const formSubcollection = filledDocument.isUserForm
64
+ ? USER_FORMS_SUBCOLLECTION
65
+ : DOCTOR_FORMS_SUBCOLLECTION;
66
+
67
+ const linkedFormInfo: LinkedFormInfo = {
68
+ formId: filledDocument.id,
69
+ templateId: filledDocument.templateId,
70
+ templateVersion: filledDocument.templateVersion,
71
+ title: filledDocument.isUserForm
72
+ ? "User Form" // Default title if not available
73
+ : "Doctor Form", // Default title if not available
74
+ isUserForm: filledDocument.isUserForm,
75
+ isRequired: filledDocument.isRequired,
76
+ status: filledDocument.status,
77
+ path: `${APPOINTMENTS_COLLECTION}/${filledDocument.appointmentId}/${formSubcollection}/${filledDocument.id}`,
78
+ };
79
+
80
+ // Add timestamps if available
81
+ if (filledDocument.updatedAt) {
82
+ // Convert to admin.firestore.Timestamp to ensure compatibility
83
+ linkedFormInfo.submittedAt = admin.firestore.Timestamp.fromMillis(
84
+ filledDocument.updatedAt
85
+ ) as unknown as Timestamp;
86
+ }
87
+
88
+ if (
89
+ filledDocument.status === FilledDocumentStatus.COMPLETED ||
90
+ filledDocument.status === FilledDocumentStatus.SIGNED
91
+ ) {
92
+ linkedFormInfo.completedAt = admin.firestore.Timestamp.fromMillis(
93
+ filledDocument.updatedAt
94
+ ) as unknown as Timestamp;
95
+ }
96
+
97
+ // 3. Check if the appointment already has linkedForms array and this form is in it
98
+ let updateData: any = {};
99
+ let existingFormIndex = -1;
100
+
101
+ if (appointment.linkedForms && appointment.linkedForms.length > 0) {
102
+ existingFormIndex = appointment.linkedForms.findIndex(
103
+ (form) => form.formId === filledDocument.id
104
+ );
105
+ }
106
+
107
+ // 4. Update the appointment with the new/updated LinkedFormInfo
108
+ if (existingFormIndex >= 0 && appointment.linkedForms) {
109
+ // Form exists, update it using arrayRemove + arrayUnion
110
+ updateData = {
111
+ linkedForms: admin.firestore.FieldValue.arrayRemove(
112
+ appointment.linkedForms[existingFormIndex]
113
+ ),
114
+ updatedAt: admin.firestore.FieldValue.serverTimestamp(),
115
+ };
116
+
117
+ // First remove the old form info
118
+ await appointmentRef.update(updateData);
119
+
120
+ // Then add the updated form info
121
+ updateData = {
122
+ linkedForms: admin.firestore.FieldValue.arrayUnion(linkedFormInfo),
123
+ updatedAt: admin.firestore.FieldValue.serverTimestamp(),
124
+ };
125
+ } else {
126
+ // Form doesn't exist, add it to the array or create the array
127
+ updateData = {
128
+ linkedForms: appointment.linkedForms
129
+ ? admin.firestore.FieldValue.arrayUnion(linkedFormInfo)
130
+ : [linkedFormInfo], // If linkedForms doesn't exist, create a new array
131
+ linkedFormIds: appointment.linkedFormIds
132
+ ? admin.firestore.FieldValue.arrayUnion(filledDocument.id)
133
+ : [filledDocument.id], // If linkedFormIds doesn't exist, create a new array
134
+ updatedAt: admin.firestore.FieldValue.serverTimestamp(),
135
+ };
136
+ }
137
+
138
+ // 5. Handle pendingUserFormsIds for required user forms
139
+ if (
140
+ filledDocument.isUserForm &&
141
+ filledDocument.isRequired &&
142
+ (filledDocument.status === FilledDocumentStatus.COMPLETED ||
143
+ filledDocument.status === FilledDocumentStatus.SIGNED)
144
+ ) {
145
+ // Check if the form ID is in pendingUserFormsIds
146
+ if (
147
+ appointment.pendingUserFormsIds &&
148
+ appointment.pendingUserFormsIds.includes(filledDocument.id)
149
+ ) {
150
+ // Remove the form ID from pendingUserFormsIds
151
+ updateData.pendingUserFormsIds =
152
+ admin.firestore.FieldValue.arrayRemove(filledDocument.id);
153
+ Logger.info(
154
+ `[FilledFormsAggregationService] Removing form ${filledDocument.id} from pendingUserFormsIds`
155
+ );
156
+ }
157
+ }
158
+
159
+ // 6. Update the appointment
160
+ await appointmentRef.update(updateData);
161
+ Logger.info(
162
+ `[FilledFormsAggregationService] Successfully updated appointment ${filledDocument.appointmentId} with form info for ${filledDocument.id}`
163
+ );
164
+ } catch (error) {
165
+ Logger.error(
166
+ `[FilledFormsAggregationService] Error updating appointment for filled form ${filledDocument.id}:`,
167
+ error
168
+ );
169
+ throw error;
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Handles side effects when a filled form is deleted.
175
+ * This function would typically be called by a Firestore onDelete trigger.
176
+ * @param filledDocument The filled document that was deleted.
177
+ * @returns {Promise<void>}
178
+ */
179
+ async handleFilledFormDelete(filledDocument: FilledDocument): Promise<void> {
180
+ Logger.info(
181
+ `[FilledFormsAggregationService] Handling DELETE for filled form: ${filledDocument.id}, appointment: ${filledDocument.appointmentId}`
182
+ );
183
+
184
+ try {
185
+ // 1. Get the appointment document
186
+ const appointmentRef = this.db
187
+ .collection(APPOINTMENTS_COLLECTION)
188
+ .doc(filledDocument.appointmentId);
189
+
190
+ const appointmentDoc = await appointmentRef.get();
191
+ if (!appointmentDoc.exists) {
192
+ Logger.error(
193
+ `[FilledFormsAggregationService] Appointment ${filledDocument.appointmentId} not found.`
194
+ );
195
+ return;
196
+ }
197
+
198
+ const appointment = appointmentDoc.data() as Appointment;
199
+
200
+ // 2. Prepare the update data
201
+ let updateData: any = {};
202
+
203
+ // 3. Remove the form from linkedForms if it exists
204
+ if (appointment.linkedForms && appointment.linkedForms.length > 0) {
205
+ const formToRemove = appointment.linkedForms.find(
206
+ (form) => form.formId === filledDocument.id
207
+ );
208
+
209
+ if (formToRemove) {
210
+ updateData.linkedForms =
211
+ admin.firestore.FieldValue.arrayRemove(formToRemove);
212
+ }
213
+ }
214
+
215
+ // 4. Remove the form ID from linkedFormIds
216
+ if (
217
+ appointment.linkedFormIds &&
218
+ appointment.linkedFormIds.includes(filledDocument.id)
219
+ ) {
220
+ updateData.linkedFormIds = admin.firestore.FieldValue.arrayRemove(
221
+ filledDocument.id
222
+ );
223
+ }
224
+
225
+ // 5. For required user forms, handle pendingUserFormsIds
226
+ if (filledDocument.isUserForm && filledDocument.isRequired) {
227
+ if (
228
+ filledDocument.status !== FilledDocumentStatus.COMPLETED &&
229
+ filledDocument.status !== FilledDocumentStatus.SIGNED
230
+ ) {
231
+ // If the form was not completed or signed, ensure it's added back to pending
232
+ if (
233
+ !appointment.pendingUserFormsIds ||
234
+ !appointment.pendingUserFormsIds.includes(filledDocument.id)
235
+ ) {
236
+ updateData.pendingUserFormsIds = appointment.pendingUserFormsIds
237
+ ? admin.firestore.FieldValue.arrayUnion(filledDocument.id)
238
+ : [filledDocument.id];
239
+ }
240
+ }
241
+ }
242
+
243
+ // 6. Only update if there's something to update
244
+ if (Object.keys(updateData).length > 0) {
245
+ updateData.updatedAt = admin.firestore.FieldValue.serverTimestamp();
246
+ await appointmentRef.update(updateData);
247
+ Logger.info(
248
+ `[FilledFormsAggregationService] Successfully updated appointment ${filledDocument.appointmentId} after form deletion`
249
+ );
250
+ } else {
251
+ Logger.info(
252
+ `[FilledFormsAggregationService] No updates needed for appointment ${filledDocument.appointmentId}`
253
+ );
254
+ }
255
+ } catch (error) {
256
+ Logger.error(
257
+ `[FilledFormsAggregationService] Error updating appointment after form deletion for ${filledDocument.id}:`,
258
+ error
259
+ );
260
+ throw error;
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Updates the appointment's pendingUserFormsIds for a new required user form.
266
+ * This should be called when a required user form is first created.
267
+ * @param filledDocument The newly created filled document.
268
+ * @returns {Promise<void>}
269
+ */
270
+ async handleRequiredUserFormCreate(
271
+ filledDocument: FilledDocument
272
+ ): Promise<void> {
273
+ if (!filledDocument.isUserForm || !filledDocument.isRequired) {
274
+ return; // Only process required user forms
275
+ }
276
+
277
+ Logger.info(
278
+ `[FilledFormsAggregationService] Handling new required user form creation: ${filledDocument.id}`
279
+ );
280
+
281
+ try {
282
+ const appointmentRef = this.db
283
+ .collection(APPOINTMENTS_COLLECTION)
284
+ .doc(filledDocument.appointmentId);
285
+
286
+ // Skip adding to pendingUserFormsIds if already completed/signed
287
+ if (
288
+ filledDocument.status === FilledDocumentStatus.COMPLETED ||
289
+ filledDocument.status === FilledDocumentStatus.SIGNED
290
+ ) {
291
+ Logger.info(
292
+ `[FilledFormsAggregationService] Form ${filledDocument.id} is already completed/signed, not adding to pendingUserFormsIds`
293
+ );
294
+ return;
295
+ }
296
+
297
+ // Add to pendingUserFormsIds
298
+ await appointmentRef.update({
299
+ pendingUserFormsIds: admin.firestore.FieldValue.arrayUnion(
300
+ filledDocument.id
301
+ ),
302
+ updatedAt: admin.firestore.FieldValue.serverTimestamp(),
303
+ });
304
+
305
+ Logger.info(
306
+ `[FilledFormsAggregationService] Successfully added form ${filledDocument.id} to pendingUserFormsIds for appointment ${filledDocument.appointmentId}`
307
+ );
308
+ } catch (error) {
309
+ Logger.error(
310
+ `[FilledFormsAggregationService] Error handling required user form creation for ${filledDocument.id}:`,
311
+ error
312
+ );
313
+ throw error;
314
+ }
315
+ }
316
+ }
@@ -22,6 +22,8 @@ import { PractitionerAggregationService } from "./aggregation/practitioner/pract
22
22
  import { ProcedureAggregationService } from "./aggregation/procedure/procedure.aggregation.service";
23
23
  import { PatientAggregationService } from "./aggregation/patient/patient.aggregation.service";
24
24
  import { AppointmentAggregationService } from "./aggregation/appointment/appointment.aggregation.service";
25
+ import { FilledFormsAggregationService } from "./aggregation/forms/filled-forms.aggregation.service";
26
+ import { ReviewsAggregationService } from "./aggregation/reviews/reviews.aggregation.service";
25
27
 
26
28
  // Import mailing services
27
29
  import { BaseMailingService } from "./mailing/base.mailing.service";
@@ -50,6 +52,8 @@ export type { DoctorInfo } from "../types/clinic";
50
52
  export type { Procedure, ProcedureSummaryInfo } from "../types/procedure";
51
53
  export type { PatientProfile as Patient } from "../types/patient";
52
54
  export type { Appointment } from "../types/appointment";
55
+ export type { FilledDocument } from "../types/documentation-templates";
56
+ export type { Review } from "../types/reviews";
53
57
 
54
58
  // Re-export enums/consts
55
59
  export {
@@ -70,6 +74,8 @@ export {
70
74
  PatientAggregationService,
71
75
  AppointmentAggregationService,
72
76
  PatientRequirementsAdminService,
77
+ FilledFormsAggregationService,
78
+ ReviewsAggregationService,
73
79
  };
74
80
 
75
81
  // Export mailing services
@@ -128,7 +134,8 @@ export * from "./mailing/appointment/appointment.mailing.service";
128
134
  export * from "./notifications/notifications.admin";
129
135
  export * from "./requirements/patient-requirements.admin.service";
130
136
  export * from "./aggregation/appointment/appointment.aggregation.service";
131
-
137
+ export * from "./aggregation/forms/filled-forms.aggregation.service";
138
+ export * from "./aggregation/reviews/reviews.aggregation.service";
132
139
  // Re-export types that Cloud Functions might need direct access to
133
140
  export * from "../types/appointment";
134
141
  // Add other types as needed
@@ -70,12 +70,14 @@ import { Clinic, CLINICS_COLLECTION } from "../../types/clinic";
70
70
  import { ProcedureReviewInfo } from "../../types/reviews";
71
71
  import { distanceBetween, geohashQueryBounds } from "geofire-common";
72
72
  import { TreatmentBenefit } from "../../backoffice/types/static/treatment-benefit.types";
73
+ import { MediaService, MediaAccessLevel } from "../media/media.service";
73
74
 
74
75
  export class ProcedureService extends BaseService {
75
76
  private categoryService: CategoryService;
76
77
  private subcategoryService: SubcategoryService;
77
78
  private technologyService: TechnologyService;
78
79
  private productService: ProductService;
80
+ private mediaService: MediaService;
79
81
 
80
82
  constructor(
81
83
  db: Firestore,
@@ -84,13 +86,81 @@ export class ProcedureService extends BaseService {
84
86
  categoryService: CategoryService,
85
87
  subcategoryService: SubcategoryService,
86
88
  technologyService: TechnologyService,
87
- productService: ProductService
89
+ productService: ProductService,
90
+ mediaService: MediaService
88
91
  ) {
89
92
  super(db, auth, app);
90
93
  this.categoryService = categoryService;
91
94
  this.subcategoryService = subcategoryService;
92
95
  this.technologyService = technologyService;
93
96
  this.productService = productService;
97
+ this.mediaService = mediaService;
98
+ }
99
+
100
+ /**
101
+ * Process media resource (string URL or File object)
102
+ * @param media String URL or File object
103
+ * @param ownerId Owner ID for the media (usually procedureId)
104
+ * @param collectionName Collection name for organizing files
105
+ * @returns URL string after processing
106
+ */
107
+ private async processMedia(
108
+ media: string | File | Blob | null | undefined,
109
+ ownerId: string,
110
+ collectionName: string
111
+ ): Promise<string | null> {
112
+ if (!media) return null;
113
+
114
+ // If already a string URL, return it directly
115
+ if (typeof media === "string") {
116
+ return media;
117
+ }
118
+
119
+ // If it's a File, upload it using MediaService
120
+ if (media instanceof File || media instanceof Blob) {
121
+ console.log(
122
+ `[ProcedureService] Uploading ${collectionName} media for ${ownerId}`
123
+ );
124
+ const metadata = await this.mediaService.uploadMedia(
125
+ media,
126
+ ownerId,
127
+ MediaAccessLevel.PUBLIC,
128
+ collectionName
129
+ );
130
+ return metadata.url;
131
+ }
132
+
133
+ return null;
134
+ }
135
+
136
+ /**
137
+ * Process array of media resources (strings or Files)
138
+ * @param mediaArray Array of string URLs or File objects
139
+ * @param ownerId Owner ID for the media
140
+ * @param collectionName Collection name for organizing files
141
+ * @returns Array of URL strings after processing
142
+ */
143
+ private async processMediaArray(
144
+ mediaArray: (string | File | Blob)[] | undefined,
145
+ ownerId: string,
146
+ collectionName: string
147
+ ): Promise<string[]> {
148
+ if (!mediaArray || mediaArray.length === 0) return [];
149
+
150
+ const result: string[] = [];
151
+
152
+ for (const media of mediaArray) {
153
+ const processedUrl = await this.processMedia(
154
+ media,
155
+ ownerId,
156
+ collectionName
157
+ );
158
+ if (processedUrl) {
159
+ result.push(processedUrl);
160
+ }
161
+ }
162
+
163
+ return result;
94
164
  }
95
165
 
96
166
  /**
@@ -101,6 +171,9 @@ export class ProcedureService extends BaseService {
101
171
  async createProcedure(data: CreateProcedureData): Promise<Procedure> {
102
172
  const validatedData = createProcedureSchema.parse(data);
103
173
 
174
+ // Generate procedure ID first so we can use it for media uploads
175
+ const procedureId = this.generateId();
176
+
104
177
  // Get references to related entities (Category, Subcategory, Technology, Product)
105
178
  const [category, subcategory, technology, product] = await Promise.all([
106
179
  this.categoryService.getById(validatedData.categoryId),
@@ -146,6 +219,16 @@ export class ProcedureService extends BaseService {
146
219
  }
147
220
  const practitioner = practitionerSnapshot.data() as Practitioner; // Assert type
148
221
 
222
+ // Process photos if provided
223
+ let processedPhotos: string[] = [];
224
+ if (validatedData.photos && validatedData.photos.length > 0) {
225
+ processedPhotos = await this.processMediaArray(
226
+ validatedData.photos,
227
+ procedureId,
228
+ "procedure-photos"
229
+ );
230
+ }
231
+
149
232
  // Create aggregated clinic info for the procedure document
150
233
  const clinicInfo = {
151
234
  id: clinicSnapshot.id,
@@ -174,10 +257,10 @@ export class ProcedureService extends BaseService {
174
257
  };
175
258
 
176
259
  // Create the procedure object
177
- const procedureId = this.generateId();
178
260
  const newProcedure: Omit<Procedure, "createdAt" | "updatedAt"> = {
179
261
  id: procedureId,
180
262
  ...validatedData,
263
+ photos: processedPhotos,
181
264
  category, // Embed full objects
182
265
  subcategory,
183
266
  technology,
@@ -296,6 +379,15 @@ export class ProcedureService extends BaseService {
296
379
  let newPractitioner: Practitioner | null = null;
297
380
  let newClinic: Clinic | null = null;
298
381
 
382
+ // Process photos if provided
383
+ if (validatedData.photos !== undefined) {
384
+ updatedProcedureData.photos = await this.processMediaArray(
385
+ validatedData.photos,
386
+ id,
387
+ "procedure-photos"
388
+ );
389
+ }
390
+
299
391
  // --- Prepare updates and fetch new related data if IDs change ---
300
392
 
301
393
  // Handle Practitioner Change
@@ -20,6 +20,7 @@ import { DoctorInfo } from "../clinic";
20
20
  import { PRACTITIONERS_COLLECTION } from "../practitioner";
21
21
  import { ProcedureReviewInfo } from "../reviews";
22
22
  import type { Contraindication } from "../../backoffice/types/static/contraindication.types";
23
+ import { MediaResource } from "../../services/media/media.service";
23
24
 
24
25
  /**
25
26
  * Procedure represents a specific medical procedure that can be performed by a practitioner in a clinic
@@ -31,7 +32,7 @@ export interface Procedure {
31
32
  /** Name of the procedure */
32
33
  name: string;
33
34
  /** Photos of the procedure */
34
- photos?: string[];
35
+ photos?: MediaResource[];
35
36
  /** Detailed description of the procedure */
36
37
  description: string;
37
38
  /** Family of procedures this belongs to (aesthetics/surgery) */
@@ -101,7 +102,7 @@ export interface CreateProcedureData {
101
102
  duration: number;
102
103
  practitionerId: string;
103
104
  clinicBranchId: string;
104
- photos?: string[];
105
+ photos?: MediaResource[];
105
106
  }
106
107
 
107
108
  /**
@@ -121,7 +122,7 @@ export interface UpdateProcedureData {
121
122
  technologyId?: string;
122
123
  productId?: string;
123
124
  clinicBranchId?: string;
124
- photos?: string[];
125
+ photos?: MediaResource[];
125
126
  }
126
127
 
127
128
  /**
@@ -6,7 +6,7 @@ import {
6
6
  } from "../backoffice/types/static/pricing.types";
7
7
  import { clinicInfoSchema, doctorInfoSchema } from "./shared.schema";
8
8
  import { procedureReviewInfoSchema } from "./reviews.schema";
9
-
9
+ import { mediaResourceSchema } from "./media.schema";
10
10
  /**
11
11
  * Schema for creating a new procedure
12
12
  */
@@ -24,7 +24,7 @@ export const createProcedureSchema = z.object({
24
24
  duration: z.number().min(1).max(480), // Max 8 hours
25
25
  practitionerId: z.string().min(1),
26
26
  clinicBranchId: z.string().min(1),
27
- photos: z.array(z.string()).optional(),
27
+ photos: z.array(mediaResourceSchema).optional(),
28
28
  });
29
29
 
30
30
  /**
@@ -44,7 +44,7 @@ export const updateProcedureSchema = z.object({
44
44
  technologyId: z.string().optional(),
45
45
  productId: z.string().optional(),
46
46
  clinicBranchId: z.string().optional(),
47
- photos: z.array(z.string()).optional(),
47
+ photos: z.array(mediaResourceSchema).optional(),
48
48
  });
49
49
 
50
50
  /**