@blackcode_sa/metaestetics-api 1.6.4 → 1.6.6

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.
Files changed (30) hide show
  1. package/dist/admin/index.d.mts +236 -2
  2. package/dist/admin/index.d.ts +236 -2
  3. package/dist/admin/index.js +11251 -10447
  4. package/dist/admin/index.mjs +11251 -10447
  5. package/dist/backoffice/index.d.mts +2 -0
  6. package/dist/backoffice/index.d.ts +2 -0
  7. package/dist/index.d.mts +50 -77
  8. package/dist/index.d.ts +50 -77
  9. package/dist/index.js +77 -305
  10. package/dist/index.mjs +78 -306
  11. package/package.json +1 -1
  12. package/src/admin/aggregation/appointment/README.md +128 -0
  13. package/src/admin/aggregation/appointment/appointment.aggregation.service.ts +1053 -0
  14. package/src/admin/booking/README.md +125 -0
  15. package/src/admin/booking/booking.admin.ts +638 -3
  16. package/src/admin/calendar/calendar.admin.service.ts +183 -0
  17. package/src/admin/documentation-templates/document-manager.admin.ts +131 -0
  18. package/src/admin/mailing/appointment/appointment.mailing.service.ts +264 -0
  19. package/src/admin/mailing/appointment/templates/patient/appointment-confirmed.html +40 -0
  20. package/src/admin/mailing/base.mailing.service.ts +1 -1
  21. package/src/admin/mailing/index.ts +2 -0
  22. package/src/admin/notifications/notifications.admin.ts +397 -1
  23. package/src/backoffice/types/product.types.ts +2 -0
  24. package/src/services/appointment/appointment.service.ts +89 -182
  25. package/src/services/procedure/procedure.service.ts +1 -0
  26. package/src/types/appointment/index.ts +3 -1
  27. package/src/types/notifications/index.ts +4 -2
  28. package/src/types/procedure/index.ts +7 -0
  29. package/src/validations/appointment.schema.ts +2 -3
  30. package/src/validations/procedure.schema.ts +3 -0
@@ -0,0 +1,1053 @@
1
+ import * as admin from "firebase-admin";
2
+ import {
3
+ Appointment,
4
+ AppointmentStatus,
5
+ // APPOINTMENTS_COLLECTION, // Not directly used in this file after refactor
6
+ } from "../../../types/appointment";
7
+ import {
8
+ PatientRequirementInstance,
9
+ PatientRequirementOverallStatus,
10
+ PatientInstructionStatus,
11
+ PATIENT_REQUIREMENTS_SUBCOLLECTION_NAME,
12
+ PatientRequirementInstruction, // Added import
13
+ } from "../../../types/patient/patient-requirements";
14
+ import {
15
+ Requirement as RequirementTemplate,
16
+ // REQUIREMENTS_COLLECTION as REQUIREMENTS_TEMPLATES_COLLECTION, // Not used directly after refactor
17
+ RequirementType,
18
+ TimeUnit, // Added import
19
+ } from "../../../backoffice/types/requirement.types";
20
+ import {
21
+ PATIENTS_COLLECTION,
22
+ PatientProfile,
23
+ PatientSensitiveInfo,
24
+ PATIENT_SENSITIVE_INFO_COLLECTION,
25
+ } from "../../../types/patient";
26
+ import {
27
+ Practitioner,
28
+ PRACTITIONERS_COLLECTION,
29
+ } from "../../../types/practitioner";
30
+ import { Clinic, CLINICS_COLLECTION } from "../../../types/clinic";
31
+ // import { UserRole } from "../../../types"; // Not directly used
32
+
33
+ // Dependent Admin Services
34
+ import { PatientRequirementsAdminService } from "../../requirements/patient-requirements.admin.service";
35
+ import { NotificationsAdmin } from "../../notifications/notifications.admin";
36
+ import { CalendarAdminService } from "../../calendar/calendar.admin.service";
37
+ import { AppointmentMailingService } from "../../mailing/appointment/appointment.mailing.service";
38
+ import { Logger } from "../../logger";
39
+ import { UserRole } from "../../../types";
40
+
41
+ // Mailgun client will be injected via constructor
42
+
43
+ /**
44
+ * @class AppointmentAggregationService
45
+ * @description Handles aggregation tasks and side effects related to appointment lifecycle events.
46
+ * This service is intended to be used primarily by background functions (e.g., Cloud Functions)
47
+ * triggered by changes in the appointments collection.
48
+ */
49
+ export class AppointmentAggregationService {
50
+ private db: admin.firestore.Firestore;
51
+ private appointmentMailingService: AppointmentMailingService;
52
+ private notificationsAdmin: NotificationsAdmin;
53
+ private calendarAdminService: CalendarAdminService;
54
+ private patientRequirementsAdminService: PatientRequirementsAdminService;
55
+
56
+ /**
57
+ * Constructor for AppointmentAggregationService.
58
+ * @param mailgunClient - An initialized Mailgun client instance.
59
+ * @param firestore Optional Firestore instance. If not provided, it uses the default admin SDK instance.
60
+ */
61
+ constructor(
62
+ mailgunClient: any, // Type as 'any' for now, to be provided by the calling Cloud Function
63
+ firestore?: admin.firestore.Firestore
64
+ ) {
65
+ this.db = firestore || admin.firestore();
66
+ this.appointmentMailingService = new AppointmentMailingService(
67
+ this.db,
68
+ mailgunClient // Pass the injected client
69
+ );
70
+ this.notificationsAdmin = new NotificationsAdmin(this.db);
71
+ this.calendarAdminService = new CalendarAdminService(this.db);
72
+ this.patientRequirementsAdminService = new PatientRequirementsAdminService(
73
+ this.db
74
+ );
75
+ Logger.info("[AppointmentAggregationService] Initialized.");
76
+ }
77
+
78
+ /**
79
+ * Handles side effects when an appointment is first created.
80
+ * This function would typically be called by an Firestore onCreate trigger.
81
+ * @param {Appointment} appointment - The newly created Appointment object.
82
+ * @returns {Promise<void>}
83
+ */
84
+ async handleAppointmentCreate(appointment: Appointment): Promise<void> {
85
+ Logger.info(
86
+ `[AggService] Handling CREATE for appointment: ${appointment.id}, patient: ${appointment.patientId}, status: ${appointment.status}`
87
+ );
88
+
89
+ try {
90
+ // 1. Manage Patient-Clinic-Practitioner Links (non-critical for core flow, can run in parallel or first)
91
+ // No need to await if other operations don't depend on its immediate completion, but awaiting for simplicity here.
92
+ await this.managePatientClinicPractitionerLinks(appointment, "create");
93
+
94
+ // 2. Fetch necessary profiles for notifications and context
95
+ // These can be fetched in parallel
96
+ const [
97
+ patientProfile,
98
+ patientSensitiveInfo,
99
+ practitionerProfile,
100
+ clinicInfo,
101
+ ] = await Promise.all([
102
+ this.fetchPatientProfile(appointment.patientId),
103
+ this.fetchPatientSensitiveInfo(appointment.patientId),
104
+ this.fetchPractitionerProfile(appointment.practitionerId), // Needed for practitioner notifications
105
+ this.fetchClinicInfo(appointment.clinicBranchId), // Needed for clinic admin notifications
106
+ ]);
107
+
108
+ // 3. Initial State Handling based on appointment status
109
+ if (appointment.status === AppointmentStatus.CONFIRMED) {
110
+ Logger.info(
111
+ `[AggService] Appt ${appointment.id} created as CONFIRMED.`
112
+ );
113
+ // Create pre-appointment requirements for confirmed appointments
114
+ await this.createPreAppointmentRequirementInstances(appointment);
115
+
116
+ // Send confirmation notifications
117
+ if (patientSensitiveInfo?.email && patientProfile) {
118
+ Logger.info(
119
+ `[AggService] Sending appointment confirmed email to patient ${patientSensitiveInfo.email}`
120
+ );
121
+ // Construct the data object for the mailing service
122
+ const emailData = {
123
+ appointment: appointment,
124
+ recipientProfile: appointment.patientInfo,
125
+ recipientRole: "patient" as const, // Use 'as const' for literal type
126
+ };
127
+ // The type cast here might still be an issue if PatientProfileInfo is not imported.
128
+ // However, the structure should be compatible enough for the call.
129
+ await this.appointmentMailingService.sendAppointmentConfirmedEmail(
130
+ emailData as any // Using 'as any' temporarily to bypass strict type checking if PatientProfileInfo is not imported
131
+ // TODO: Properly import PatientProfileInfo and ensure type compatibility
132
+ );
133
+ } else {
134
+ Logger.warn(
135
+ `[AggService] Cannot send confirmation email to patient ${appointment.patientId}: email missing.`
136
+ );
137
+ }
138
+
139
+ if (
140
+ patientProfile?.expoTokens &&
141
+ patientProfile.expoTokens.length > 0
142
+ ) {
143
+ Logger.info(
144
+ `[AggService] TODO: Send appointment confirmed push to patient ${appointment.patientId}`
145
+ );
146
+ await this.notificationsAdmin.sendAppointmentConfirmedPush(
147
+ appointment,
148
+ appointment.patientId,
149
+ patientProfile.expoTokens,
150
+ UserRole.PATIENT
151
+ );
152
+ }
153
+
154
+ if (practitionerProfile?.basicInfo?.email) {
155
+ Logger.info(
156
+ `[AggService] Sending appointment confirmation email to practitioner ${practitionerProfile.basicInfo.email}`
157
+ );
158
+ const practitionerEmailData = {
159
+ appointment: appointment,
160
+ recipientProfile: appointment.practitionerInfo,
161
+ recipientRole: "practitioner" as const,
162
+ };
163
+ await this.appointmentMailingService.sendAppointmentConfirmedEmail(
164
+ practitionerEmailData // TODO: Properly import PractitionerProfileInfo and ensure type compatibility
165
+ );
166
+ }
167
+ // TODO: Add push notification for practitioner if they have expoTokens
168
+ } else if (appointment.status === AppointmentStatus.PENDING) {
169
+ Logger.info(`[AggService] Appt ${appointment.id} created as PENDING.`);
170
+ // Notify clinic admin about the pending appointment
171
+ if (clinicInfo?.contactInfo?.email) {
172
+ Logger.info(
173
+ `[AggService] TODO: Send pending appointment notification email to clinic admin ${clinicInfo.contactInfo.email}`
174
+ );
175
+ const clinicEmailData = {
176
+ appointment: appointment,
177
+ clinicProfile: appointment.clinicInfo, // clinicInfo should be compatible with ClinicInfo type
178
+ };
179
+ await this.appointmentMailingService.sendAppointmentRequestedEmailToClinic(
180
+ clinicEmailData // TODO: Properly import ClinicInfo if stricter typing is needed here and ensure compatibility
181
+ );
182
+ } else {
183
+ Logger.warn(
184
+ `[AggService] Cannot send pending appointment email to clinic ${appointment.clinicBranchId}: email missing.`
185
+ );
186
+ }
187
+ // TODO: Push notification for clinic admin if applicable (they usually don't have tokens)
188
+ }
189
+
190
+ // Calendar events are noted as being handled by BookingAdmin.orchestrateAppointmentCreation during the booking process itself.
191
+ Logger.info(
192
+ `[AggService] Successfully processed CREATE for appointment: ${appointment.id}`
193
+ );
194
+ } catch (error) {
195
+ Logger.error(
196
+ `[AggService] Critical error in handleAppointmentCreate for appointment ${appointment.id}:`,
197
+ error
198
+ );
199
+ // Depending on the error, you might want to re-throw or handle specific cases
200
+ // (e.g., update appointment status to an error state if a critical part failed)
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Handles side effects when an appointment is updated.
206
+ * This function would typically be called by an Firestore onUpdate trigger.
207
+ * @param {Appointment} before - The Appointment object before the update.
208
+ * @param {Appointment} after - The Appointment object after the update.
209
+ * @returns {Promise<void>}
210
+ */
211
+ async handleAppointmentUpdate(
212
+ before: Appointment,
213
+ after: Appointment
214
+ ): Promise<void> {
215
+ Logger.info(
216
+ `[AggService] Handling UPDATE for appointment: ${after.id}. Status ${before.status} -> ${after.status}`
217
+ );
218
+
219
+ try {
220
+ const statusChanged = before.status !== after.status;
221
+ const timeChanged =
222
+ before.appointmentStartTime.toMillis() !==
223
+ after.appointmentStartTime.toMillis() ||
224
+ before.appointmentEndTime.toMillis() !==
225
+ after.appointmentEndTime.toMillis();
226
+ // const paymentStatusChanged = before.paymentStatus !== after.paymentStatus; // TODO: Handle later
227
+ // const reviewAdded = !before.reviewInfo && after.reviewInfo; // TODO: Handle later
228
+
229
+ // Fetch profiles for notifications - could be conditional based on changes
230
+ // For simplicity, fetching upfront, but optimize if performance is an issue.
231
+ const [
232
+ patientProfile,
233
+ patientSensitiveInfo,
234
+ practitionerProfile,
235
+ clinicInfo,
236
+ ] = await Promise.all([
237
+ this.fetchPatientProfile(after.patientId),
238
+ this.fetchPatientSensitiveInfo(after.patientId),
239
+ this.fetchPractitionerProfile(after.practitionerId),
240
+ this.fetchClinicInfo(after.clinicBranchId),
241
+ ]);
242
+
243
+ if (statusChanged) {
244
+ Logger.info(
245
+ `[AggService] Status changed for ${after.id}: ${before.status} -> ${after.status}`
246
+ );
247
+
248
+ // --- PENDING -> CONFIRMED ---
249
+ if (
250
+ before.status === AppointmentStatus.PENDING &&
251
+ after.status === AppointmentStatus.CONFIRMED
252
+ ) {
253
+ Logger.info(`[AggService] Appt ${after.id} PENDING -> CONFIRMED.`);
254
+ await this.createPreAppointmentRequirementInstances(after);
255
+ // Send confirmation notifications
256
+ if (patientSensitiveInfo?.email && patientProfile) {
257
+ Logger.info(
258
+ `[AggService] Sending appointment confirmed email to patient ${patientSensitiveInfo.email}`
259
+ );
260
+ const emailData = {
261
+ appointment: after,
262
+ recipientProfile: after.patientInfo,
263
+ recipientRole: "patient" as const,
264
+ };
265
+ await this.appointmentMailingService.sendAppointmentConfirmedEmail(
266
+ emailData as any
267
+ );
268
+ } else {
269
+ Logger.warn(
270
+ `[AggService] Cannot send confirmation email to patient ${after.patientId}: email missing.`
271
+ );
272
+ }
273
+
274
+ if (
275
+ patientProfile?.expoTokens &&
276
+ patientProfile.expoTokens.length > 0
277
+ ) {
278
+ Logger.info(
279
+ `[AggService] TODO: Send appointment confirmed push to patient ${after.patientId}`
280
+ );
281
+ await this.notificationsAdmin.sendAppointmentConfirmedPush(
282
+ after,
283
+ after.patientId,
284
+ patientProfile.expoTokens,
285
+ UserRole.PATIENT
286
+ );
287
+ }
288
+
289
+ if (practitionerProfile?.basicInfo?.email) {
290
+ Logger.info(
291
+ `[AggService] Sending appointment confirmation email to practitioner ${practitionerProfile.basicInfo.email}`
292
+ );
293
+ const practitionerEmailData = {
294
+ appointment: after,
295
+ recipientProfile: after.practitionerInfo,
296
+ recipientRole: "practitioner" as const,
297
+ };
298
+ await this.appointmentMailingService.sendAppointmentConfirmedEmail(
299
+ practitionerEmailData as any // TODO: Properly import PractitionerProfileInfo and ensure type compatibility
300
+ );
301
+ }
302
+ // TODO: Add push notification for practitioner if they have expoTokens
303
+ }
304
+ // --- Any -> CANCELLED_* ---
305
+ else if (
306
+ after.status === AppointmentStatus.CANCELED_CLINIC ||
307
+ after.status === AppointmentStatus.CANCELED_PATIENT ||
308
+ after.status === AppointmentStatus.CANCELED_PATIENT_RESCHEDULED ||
309
+ after.status === AppointmentStatus.NO_SHOW
310
+ ) {
311
+ Logger.info(
312
+ `[AggService] Appt ${after.id} status -> ${after.status}. Processing as cancellation.`
313
+ );
314
+ await this.updateRelatedPatientRequirementInstances(
315
+ after,
316
+ PatientRequirementOverallStatus.CANCELLED_APPOINTMENT
317
+ );
318
+ await this.managePatientClinicPractitionerLinks(after, "cancel");
319
+
320
+ // Send cancellation email to Patient
321
+ if (patientSensitiveInfo?.email && patientProfile) {
322
+ Logger.info(
323
+ `[AggService] Sending appointment cancellation email to patient ${patientSensitiveInfo.email}`
324
+ );
325
+ const patientCancellationData = {
326
+ appointment: after,
327
+ recipientProfile: after.patientInfo,
328
+ recipientRole: "patient" as const,
329
+ // cancellationReason: after.cancellationReason, // TODO: Add if cancellationReason is available on 'after' Appointment
330
+ };
331
+ await this.appointmentMailingService.sendAppointmentCancelledEmail(
332
+ patientCancellationData as any // TODO: Properly import types
333
+ );
334
+ }
335
+
336
+ // Send cancellation email to Practitioner
337
+ if (practitionerProfile?.basicInfo?.email) {
338
+ Logger.info(
339
+ `[AggService] Sending appointment cancellation email to practitioner ${practitionerProfile.basicInfo.email}`
340
+ );
341
+ const practitionerCancellationData = {
342
+ appointment: after,
343
+ recipientProfile: after.practitionerInfo,
344
+ recipientRole: "practitioner" as const,
345
+ // cancellationReason: after.cancellationReason, // TODO: Add if cancellationReason is available on 'after' Appointment
346
+ };
347
+ await this.appointmentMailingService.sendAppointmentCancelledEmail(
348
+ practitionerCancellationData as any // TODO: Properly import types
349
+ );
350
+ }
351
+
352
+ // TODO: Send cancellation push notifications (patient, practitioner) via notificationsAdmin
353
+ // TODO: Update/cancel calendar event via calendarAdminService.updateAppointmentCalendarEventStatus(after, CalendarEventStatus.CANCELED)
354
+ }
355
+ // --- Any -> COMPLETED ---
356
+ else if (after.status === AppointmentStatus.COMPLETED) {
357
+ Logger.info(`[AggService] Appt ${after.id} status -> COMPLETED.`);
358
+ await this.createPostAppointmentRequirementInstances(after);
359
+
360
+ // Send review request email to patient
361
+ if (patientSensitiveInfo?.email && patientProfile) {
362
+ Logger.info(
363
+ `[AggService] Sending review request email to patient ${patientSensitiveInfo.email}`
364
+ );
365
+ const reviewRequestData = {
366
+ appointment: after,
367
+ patientProfile: after.patientInfo,
368
+ reviewLink: "TODO: Generate actual review link", // Placeholder
369
+ };
370
+ await this.appointmentMailingService.sendReviewRequestEmail(
371
+ reviewRequestData as any // TODO: Properly import PatientProfileInfo and define reviewLink generation
372
+ );
373
+ }
374
+ // TODO: Send review request push notification to patient
375
+ }
376
+ // --- RESCHEDULE Scenarios (e.g., PENDING/CONFIRMED -> RESCHEDULED_BY_CLINIC) ---
377
+ else if (after.status === AppointmentStatus.RESCHEDULED_BY_CLINIC) {
378
+ Logger.info(
379
+ `[AggService] Appt ${after.id} status -> RESCHEDULED_BY_CLINIC.`
380
+ );
381
+ await this.updateRelatedPatientRequirementInstances(
382
+ before, // Pass the 'before' state for old requirements
383
+ PatientRequirementOverallStatus.SUPERSEDED_RESCHEDULE
384
+ );
385
+ Logger.info(
386
+ `[AggService] TODO: Handle RESCHEDULE logic for requirements carefully based on confirmation flow.`
387
+ );
388
+
389
+ // Send reschedule proposal email to patient
390
+ if (patientSensitiveInfo?.email && patientProfile) {
391
+ Logger.info(
392
+ `[AggService] Sending reschedule proposal email to patient ${patientSensitiveInfo.email}`
393
+ );
394
+ const rescheduleEmailData = {
395
+ appointment: after, // The new state of the appointment
396
+ patientProfile: after.patientInfo,
397
+ previousStartTime: before.appointmentStartTime,
398
+ previousEndTime: before.appointmentEndTime,
399
+ };
400
+ await this.appointmentMailingService.sendAppointmentRescheduledProposalEmail(
401
+ rescheduleEmailData as any // TODO: Properly import PatientProfileInfo and types
402
+ );
403
+ }
404
+
405
+ Logger.info(
406
+ `[AggService] TODO: Send reschedule proposal notifications to practitioner as well.`
407
+ );
408
+ // TODO: Update calendar event to reflect proposed new time via calendarAdminService.
409
+ }
410
+ // TODO: Add more specific status change handlers as needed
411
+ }
412
+
413
+ // --- Independent Time Change (if not tied to a status that already handled it) ---
414
+ if (timeChanged && !statusChanged) {
415
+ // Or if status change didn't fully cover reschedule implications
416
+ Logger.info(`[AggService] Appointment ${after.id} time changed.`);
417
+ // This scenario is tricky. If time changes, pre-requirements might need to be re-evaluated.
418
+ // Typically, a time change would also involve a status change to some form of "rescheduled".
419
+ // If it can happen independently while, e.g., staying CONFIRMED, then:
420
+ // await this.updateRelatedPatientRequirementInstances(before, PatientRequirementOverallStatus.SUPERSEDED_RESCHEDULE);
421
+ // await this.createPreAppointmentRequirementInstances(after);
422
+ // TODO: Send reschedule notifications
423
+ // TODO: Update calendar event via calendarAdminService
424
+ Logger.warn(
425
+ `[AggService] Independent time change detected for ${after.id}. Review implications for requirements and calendar.`
426
+ );
427
+ }
428
+
429
+ // TODO: Handle Payment Status Change
430
+ // const paymentStatusChanged = before.paymentStatus !== after.paymentStatus;
431
+ // if (paymentStatusChanged && after.paymentStatus === PaymentStatus.PAID) { ... }
432
+
433
+ // TODO: Handle Review Added
434
+ // const reviewAdded = !before.reviewInfo && after.reviewInfo;
435
+ // if (reviewAdded) { ... }
436
+
437
+ Logger.info(
438
+ `[AggService] Successfully processed UPDATE for appointment: ${after.id}`
439
+ );
440
+ } catch (error) {
441
+ Logger.error(
442
+ `[AggService] Critical error in handleAppointmentUpdate for appointment ${after.id}:`,
443
+ error
444
+ );
445
+ }
446
+ }
447
+
448
+ /**
449
+ * Handles side effects when an appointment is deleted.
450
+ * @param deletedAppointment - The Appointment object that was deleted.
451
+ * @returns {Promise<void>}
452
+ */
453
+ async handleAppointmentDelete(
454
+ deletedAppointment: Appointment
455
+ ): Promise<void> {
456
+ Logger.info(
457
+ `[AggService] Handling DELETE for appointment: ${deletedAppointment.id}`
458
+ );
459
+ // Similar to cancellation
460
+ await this.updateRelatedPatientRequirementInstances(
461
+ deletedAppointment,
462
+ PatientRequirementOverallStatus.CANCELLED_APPOINTMENT
463
+ );
464
+ // await this.calendarAdminService.deleteAppointmentCalendarEvents(deletedAppointment);
465
+ await this.managePatientClinicPractitionerLinks(
466
+ deletedAppointment,
467
+ "cancel"
468
+ );
469
+ // TODO: Send cancellation/deletion notifications if appropriate (though data is gone)
470
+ }
471
+
472
+ // --- Helper Methods for Aggregation Logic ---
473
+
474
+ /**
475
+ * Creates PRE_APPOINTMENT PatientRequirementInstance documents for a given appointment.
476
+ * Uses the `appointment.preProcedureRequirements` array, which should contain relevant Requirement templates.
477
+ * For each active PRE requirement template, it constructs a new PatientRequirementInstance document
478
+ * with derived instructions and batch writes them to Firestore under the patient's `patient_requirements` subcollection.
479
+ *
480
+ * @param {Appointment} appointment - The appointment for which to create pre-requirement instances.
481
+ * @returns {Promise<void>} A promise that resolves when the operation is complete.
482
+ */
483
+ private async createPreAppointmentRequirementInstances(
484
+ appointment: Appointment
485
+ ): Promise<void> {
486
+ Logger.info(
487
+ `[AggService] Creating PRE-appointment requirement instances for appt: ${appointment.id}, patient: ${appointment.patientId}`
488
+ );
489
+
490
+ if (!appointment.procedureId) {
491
+ Logger.warn(
492
+ `[AggService] Appointment ${appointment.id} has no procedureId. Cannot create pre-requirement instances.`
493
+ );
494
+ return;
495
+ }
496
+
497
+ if (
498
+ !appointment.preProcedureRequirements ||
499
+ appointment.preProcedureRequirements.length === 0
500
+ ) {
501
+ Logger.info(
502
+ `[AggService] No preProcedureRequirements found on appointment ${appointment.id}. Nothing to create.`
503
+ );
504
+ return;
505
+ }
506
+
507
+ try {
508
+ const batch = this.db.batch();
509
+ let instancesCreatedCount = 0;
510
+
511
+ for (const template of appointment.preProcedureRequirements) {
512
+ if (!template) continue; // Skip if template is null or undefined in the array
513
+
514
+ // Ensure it's an active, PRE-type requirement
515
+ if (template.type !== RequirementType.PRE || !template.isActive) {
516
+ Logger.debug(
517
+ `[AggService] Skipping template ${template.id} (${template.name}): not an active PRE requirement.`
518
+ );
519
+ continue;
520
+ }
521
+
522
+ Logger.debug(
523
+ `[AggService] Processing template ${template.id} (${template.name}) for appt ${appointment.id}`
524
+ );
525
+
526
+ const newInstanceRef = this.db
527
+ .collection(PATIENTS_COLLECTION)
528
+ .doc(appointment.patientId)
529
+ .collection(PATIENT_REQUIREMENTS_SUBCOLLECTION_NAME)
530
+ .doc(); // Auto-generate ID for the new instance
531
+
532
+ const instructions: PatientRequirementInstruction[] = (
533
+ template.timeframe?.notifyAt || []
534
+ ).map((notifyAtValue) => {
535
+ let dueTime: any = appointment.appointmentStartTime;
536
+ if (template.timeframe && typeof notifyAtValue === "number") {
537
+ const dueDateTime = new Date(
538
+ appointment.appointmentStartTime.toMillis()
539
+ );
540
+ if (template.timeframe.unit === TimeUnit.DAYS) {
541
+ dueDateTime.setDate(dueDateTime.getDate() - notifyAtValue);
542
+ } else if (template.timeframe.unit === TimeUnit.HOURS) {
543
+ dueDateTime.setHours(dueDateTime.getHours() - notifyAtValue);
544
+ }
545
+ dueTime = admin.firestore.Timestamp.fromDate(dueDateTime);
546
+ }
547
+
548
+ // TODO: Determine source or default for 'actionableWindow' - consult requirements for PatientRequirementInstruction
549
+ const actionableWindowHours =
550
+ template.importance === "high"
551
+ ? 1
552
+ : template.importance === "medium"
553
+ ? 4
554
+ : 15; // Default to 15 hours for low importance; // Placeholder default, TODO: Define source
555
+
556
+ const instructionObject: PatientRequirementInstruction = {
557
+ instructionId:
558
+ `${template.id}_${notifyAtValue}_${newInstanceRef.id}`.replace(
559
+ /[^a-zA-Z0-9_]/g,
560
+ "_"
561
+ ),
562
+ instructionText: template.description || template.name,
563
+ dueTime: dueTime as any,
564
+ actionableWindow: actionableWindowHours, // Directly assigning the placeholder default value
565
+ status: PatientInstructionStatus.PENDING_NOTIFICATION,
566
+ originalNotifyAtValue: notifyAtValue,
567
+ originalTimeframeUnit: template.timeframe.unit,
568
+ updatedAt: admin.firestore.FieldValue.serverTimestamp() as any,
569
+ notificationId: undefined,
570
+ actionTakenAt: undefined,
571
+ };
572
+ return instructionObject;
573
+ });
574
+
575
+ const newInstanceData: Omit<PatientRequirementInstance, "id"> = {
576
+ patientId: appointment.patientId,
577
+ appointmentId: appointment.id,
578
+ originalRequirementId: template.id,
579
+ requirementName: template.name,
580
+ requirementDescription: template.description,
581
+ requirementType: template.type, // Should be RequirementType.PRE
582
+ requirementImportance: template.importance,
583
+ overallStatus: PatientRequirementOverallStatus.ACTIVE,
584
+ instructions: instructions,
585
+ // Timestamps - cast to any to satisfy client-side Timestamp type for now
586
+ createdAt: admin.firestore.FieldValue.serverTimestamp() as any,
587
+ updatedAt: admin.firestore.FieldValue.serverTimestamp() as any,
588
+ };
589
+
590
+ batch.set(newInstanceRef, newInstanceData);
591
+ instancesCreatedCount++;
592
+ Logger.debug(
593
+ `[AggService] Added PatientRequirementInstance ${newInstanceRef.id} to batch for template ${template.id}.`
594
+ );
595
+ }
596
+
597
+ if (instancesCreatedCount > 0) {
598
+ await batch.commit();
599
+ Logger.info(
600
+ `[AggService] Successfully created ${instancesCreatedCount} PRE_APPOINTMENT requirement instances for appointment ${appointment.id}.`
601
+ );
602
+ } else {
603
+ Logger.info(
604
+ `[AggService] No new PRE_APPOINTMENT requirement instances were prepared for batch commit for appointment ${appointment.id}.`
605
+ );
606
+ }
607
+ } catch (error) {
608
+ Logger.error(
609
+ `[AggService] Error creating PRE_APPOINTMENT requirement instances for appointment ${appointment.id}:`,
610
+ error
611
+ );
612
+ // Rethrow or handle as per error policy, e.g., if this failure should halt further processing.
613
+ // For now, just logging.
614
+ }
615
+ }
616
+
617
+ /**
618
+ * Creates POST_APPOINTMENT PatientRequirementInstance documents for a given appointment.
619
+ * Uses the `appointment.postProcedureRequirements` array.
620
+ * For each active POST requirement template, it constructs a new PatientRequirementInstance document
621
+ * with derived instructions and batch writes them to Firestore under the patient's `patient_requirements` subcollection.
622
+ *
623
+ * @param {Appointment} appointment - The appointment for which to create post-requirement instances.
624
+ * @returns {Promise<void>} A promise that resolves when the operation is complete.
625
+ */
626
+ private async createPostAppointmentRequirementInstances(
627
+ appointment: Appointment
628
+ ): Promise<void> {
629
+ Logger.info(
630
+ `[AggService] Creating POST-appointment requirement instances for appt: ${appointment.id}, patient: ${appointment.patientId}`
631
+ );
632
+
633
+ if (!appointment.procedureId) {
634
+ Logger.warn(
635
+ `[AggService] Appointment ${appointment.id} has no procedureId. Cannot create post-requirement instances.`
636
+ );
637
+ return;
638
+ }
639
+
640
+ if (
641
+ !appointment.postProcedureRequirements ||
642
+ appointment.postProcedureRequirements.length === 0
643
+ ) {
644
+ Logger.info(
645
+ `[AggService] No postProcedureRequirements found on appointment ${appointment.id}. Nothing to create.`
646
+ );
647
+ return;
648
+ }
649
+
650
+ try {
651
+ const batch = this.db.batch();
652
+ let instancesCreatedCount = 0;
653
+
654
+ for (const template of appointment.postProcedureRequirements) {
655
+ if (!template) continue;
656
+
657
+ if (template.type !== RequirementType.POST || !template.isActive) {
658
+ Logger.debug(
659
+ `[AggService] Skipping template ${template.id} (${template.name}): not an active POST requirement.`
660
+ );
661
+ continue;
662
+ }
663
+
664
+ Logger.debug(
665
+ `[AggService] Processing POST template ${template.id} (${template.name}) for appt ${appointment.id}`
666
+ );
667
+
668
+ const newInstanceRef = this.db
669
+ .collection(PATIENTS_COLLECTION)
670
+ .doc(appointment.patientId)
671
+ .collection(PATIENT_REQUIREMENTS_SUBCOLLECTION_NAME)
672
+ .doc();
673
+
674
+ const instructions: PatientRequirementInstruction[] = (
675
+ template.timeframe?.notifyAt || []
676
+ ).map((notifyAtValue) => {
677
+ let dueTime: any = appointment.appointmentEndTime; // Default to appointment end time
678
+ if (template.timeframe && typeof notifyAtValue === "number") {
679
+ const dueDateTime = new Date(
680
+ appointment.appointmentEndTime.toMillis()
681
+ );
682
+ // For POST requirements, notifyAtValue usually means AFTER the event
683
+ if (template.timeframe.unit === TimeUnit.DAYS) {
684
+ dueDateTime.setDate(dueDateTime.getDate() + notifyAtValue);
685
+ } else if (template.timeframe.unit === TimeUnit.HOURS) {
686
+ dueDateTime.setHours(dueDateTime.getHours() + notifyAtValue);
687
+ }
688
+ dueTime = admin.firestore.Timestamp.fromDate(dueDateTime);
689
+ }
690
+
691
+ const actionableWindowHours =
692
+ template.importance === "high"
693
+ ? 1
694
+ : template.importance === "medium"
695
+ ? 4
696
+ : 15; // Default to 15 hours for low importance; // Placeholder default, TODO: Define source
697
+
698
+ return {
699
+ instructionId:
700
+ `${template.id}_${notifyAtValue}_${newInstanceRef.id}`.replace(
701
+ /[^a-zA-Z0-9_]/g,
702
+ "_"
703
+ ),
704
+ instructionText: template.description || template.name,
705
+ dueTime: dueTime as any,
706
+ actionableWindow: actionableWindowHours,
707
+ status: PatientInstructionStatus.PENDING_NOTIFICATION,
708
+ originalNotifyAtValue: notifyAtValue,
709
+ originalTimeframeUnit: template.timeframe.unit,
710
+ updatedAt: admin.firestore.FieldValue.serverTimestamp() as any,
711
+ notificationId: undefined,
712
+ actionTakenAt: undefined,
713
+ } as PatientRequirementInstruction;
714
+ });
715
+
716
+ const newInstanceData: Omit<PatientRequirementInstance, "id"> = {
717
+ patientId: appointment.patientId,
718
+ appointmentId: appointment.id,
719
+ originalRequirementId: template.id,
720
+ requirementName: template.name,
721
+ requirementDescription: template.description,
722
+ requirementType: template.type, // Should be RequirementType.POST
723
+ requirementImportance: template.importance,
724
+ overallStatus: PatientRequirementOverallStatus.ACTIVE,
725
+ instructions: instructions,
726
+ createdAt: admin.firestore.FieldValue.serverTimestamp() as any,
727
+ updatedAt: admin.firestore.FieldValue.serverTimestamp() as any,
728
+ };
729
+
730
+ batch.set(newInstanceRef, newInstanceData);
731
+ instancesCreatedCount++;
732
+ Logger.debug(
733
+ `[AggService] Added POST PatientRequirementInstance ${newInstanceRef.id} to batch for template ${template.id}.`
734
+ );
735
+ }
736
+
737
+ if (instancesCreatedCount > 0) {
738
+ await batch.commit();
739
+ Logger.info(
740
+ `[AggService] Successfully created ${instancesCreatedCount} POST_APPOINTMENT requirement instances for appointment ${appointment.id}.`
741
+ );
742
+ } else {
743
+ Logger.info(
744
+ `[AggService] No new POST_APPOINTMENT requirement instances were prepared for batch commit for appointment ${appointment.id}.`
745
+ );
746
+ }
747
+ } catch (error) {
748
+ Logger.error(
749
+ `[AggService] Error creating POST_APPOINTMENT requirement instances for appointment ${appointment.id}:`,
750
+ error
751
+ );
752
+ }
753
+ }
754
+
755
+ /**
756
+ * Updates the overallStatus of all PatientRequirementInstance documents associated with a given appointment.
757
+ * This is typically used when an appointment is cancelled or rescheduled, making existing requirements void.
758
+ *
759
+ * @param {Appointment} appointment - The appointment whose requirement instances need updating.
760
+ * @param {PatientRequirementOverallStatus} newOverallStatus - The new status to set (e.g., CANCELLED_APPOINTMENT).
761
+ * @returns {Promise<void>} A promise that resolves when the operation is complete.
762
+ */
763
+ private async updateRelatedPatientRequirementInstances(
764
+ appointment: Appointment,
765
+ newOverallStatus: PatientRequirementOverallStatus,
766
+ _previousAppointmentData?: Appointment // Not used in this basic implementation, but kept for signature consistency
767
+ ): Promise<void> {
768
+ Logger.info(
769
+ `[AggService] Updating related patient req instances for appt ${appointment.id} (patient: ${appointment.patientId}) to ${newOverallStatus}`
770
+ );
771
+
772
+ if (!appointment.id || !appointment.patientId) {
773
+ Logger.error(
774
+ "[AggService] updateRelatedPatientRequirementInstances called with missing appointmentId or patientId.",
775
+ { appointmentId: appointment.id, patientId: appointment.patientId }
776
+ );
777
+ return;
778
+ }
779
+
780
+ try {
781
+ const instancesSnapshot = await this.db
782
+ .collection(PATIENTS_COLLECTION)
783
+ .doc(appointment.patientId)
784
+ .collection(PATIENT_REQUIREMENTS_SUBCOLLECTION_NAME)
785
+ .where("appointmentId", "==", appointment.id)
786
+ .get();
787
+
788
+ if (instancesSnapshot.empty) {
789
+ Logger.info(
790
+ `[AggService] No patient requirement instances found for appointment ${appointment.id} to update.`
791
+ );
792
+ return;
793
+ }
794
+
795
+ const batch = this.db.batch();
796
+ let instancesUpdatedCount = 0;
797
+
798
+ instancesSnapshot.docs.forEach((doc) => {
799
+ const instance = doc.data() as PatientRequirementInstance;
800
+ // Update only if the status is actually different and not already in a terminal state like FAILED_TO_PROCESS
801
+ if (
802
+ instance.overallStatus !== newOverallStatus &&
803
+ instance.overallStatus !==
804
+ PatientRequirementOverallStatus.FAILED_TO_PROCESS
805
+ ) {
806
+ batch.update(doc.ref, {
807
+ overallStatus: newOverallStatus,
808
+ updatedAt: admin.firestore.FieldValue.serverTimestamp() as any, // Cast for now
809
+ // Potentially also cancel individual instructions if not handled by another trigger
810
+ // instructions: instance.instructions.map(instr => ({ ...instr, status: PatientInstructionStatus.CANCELLED, updatedAt: admin.firestore.FieldValue.serverTimestamp() as any }))
811
+ });
812
+ instancesUpdatedCount++;
813
+ Logger.debug(
814
+ `[AggService] Added update for PatientRequirementInstance ${doc.id} to batch. New status: ${newOverallStatus}`
815
+ );
816
+ }
817
+ });
818
+
819
+ if (instancesUpdatedCount > 0) {
820
+ await batch.commit();
821
+ Logger.info(
822
+ `[AggService] Successfully updated ${instancesUpdatedCount} patient requirement instances for appointment ${appointment.id} to status ${newOverallStatus}.`
823
+ );
824
+ } else {
825
+ Logger.info(
826
+ `[AggService] No patient requirement instances needed an update for appointment ${appointment.id}.`
827
+ );
828
+ }
829
+ } catch (error) {
830
+ Logger.error(
831
+ `[AggService] Error updating patient requirement instances for appointment ${appointment.id}:`,
832
+ error
833
+ );
834
+ }
835
+ }
836
+
837
+ /**
838
+ * Manages denormalized links between a patient and the clinic/practitioner associated with an appointment.
839
+ * Adds patientId to arrays on clinic and practitioner documents on appointment creation.
840
+ * Removes patientId on appointment cancellation (basic removal, can be enhanced).
841
+ *
842
+ * @param {Appointment} appointment - The appointment object.
843
+ * @param {"create" | "cancel"} action - 'create' to add links, 'cancel' to remove them.
844
+ * @returns {Promise<void>} A promise that resolves when the operation is complete.
845
+ */
846
+ private async managePatientClinicPractitionerLinks(
847
+ appointment: Appointment,
848
+ action: "create" | "cancel"
849
+ ): Promise<void> {
850
+ Logger.info(
851
+ `[AggService] Managing patient-clinic-practitioner links for appt ${appointment.id} (patient: ${appointment.patientId}), action: ${action}`
852
+ );
853
+
854
+ if (
855
+ !appointment.patientId ||
856
+ !appointment.practitionerId ||
857
+ !appointment.clinicBranchId
858
+ ) {
859
+ Logger.error(
860
+ "[AggService] managePatientClinicPractitionerLinks called with missing IDs.",
861
+ {
862
+ patientId: appointment.patientId,
863
+ practitionerId: appointment.practitionerId,
864
+ clinicBranchId: appointment.clinicBranchId,
865
+ }
866
+ );
867
+ return;
868
+ }
869
+
870
+ const batch = this.db.batch();
871
+ const patientId = appointment.patientId;
872
+
873
+ // Practitioner link
874
+ const practitionerRef = this.db
875
+ .collection(PRACTITIONERS_COLLECTION)
876
+ .doc(appointment.practitionerId);
877
+ // Clinic link
878
+ const clinicRef = this.db
879
+ .collection(CLINICS_COLLECTION)
880
+ .doc(appointment.clinicBranchId);
881
+
882
+ if (action === "create") {
883
+ // Add patient to practitioner's list of patients (if field exists, e.g., 'patientIds')
884
+ // Assuming a field named 'patientIds' on the practitioner document
885
+ batch.update(practitionerRef, {
886
+ patientIds: admin.firestore.FieldValue.arrayUnion(patientId),
887
+ // Optionally, update a count or last interaction timestamp
888
+ updatedAt: admin.firestore.FieldValue.serverTimestamp() as any,
889
+ });
890
+ Logger.debug(
891
+ `[AggService] Adding patient ${patientId} to practitioner ${appointment.practitionerId} patientIds.`
892
+ );
893
+
894
+ // Add patient to clinic's list of patients (if field exists, e.g., 'patientIds')
895
+ // Assuming a field named 'patientIds' on the clinic document
896
+ batch.update(clinicRef, {
897
+ patientIds: admin.firestore.FieldValue.arrayUnion(patientId),
898
+ updatedAt: admin.firestore.FieldValue.serverTimestamp() as any,
899
+ });
900
+ Logger.debug(
901
+ `[AggService] Adding patient ${patientId} to clinic ${appointment.clinicBranchId} patientIds.`
902
+ );
903
+ } else if (action === "cancel") {
904
+ // Basic removal.
905
+ // TODO: Implement more robust removal logic: only remove if this was the last active/confirmed appointment
906
+ // linking this patient to this practitioner/clinic.
907
+ batch.update(practitionerRef, {
908
+ patientIds: admin.firestore.FieldValue.arrayRemove(patientId),
909
+ updatedAt: admin.firestore.FieldValue.serverTimestamp() as any,
910
+ });
911
+ Logger.debug(
912
+ `[AggService] Removing patient ${patientId} from practitioner ${appointment.practitionerId} patientIds.`
913
+ );
914
+
915
+ batch.update(clinicRef, {
916
+ patientIds: admin.firestore.FieldValue.arrayRemove(patientId),
917
+ updatedAt: admin.firestore.FieldValue.serverTimestamp() as any,
918
+ });
919
+ Logger.debug(
920
+ `[AggService] Removing patient ${patientId} from clinic ${appointment.clinicBranchId} patientIds.`
921
+ );
922
+ }
923
+
924
+ try {
925
+ await batch.commit();
926
+ Logger.info(
927
+ `[AggService] Successfully updated patient-clinic-practitioner links for appointment ${appointment.id}, action: ${action}.`
928
+ );
929
+ } catch (error) {
930
+ Logger.error(
931
+ `[AggService] Error managing patient-clinic-practitioner links for appointment ${appointment.id}:`,
932
+ error
933
+ );
934
+ }
935
+ }
936
+
937
+ // --- Data Fetching Helpers (Consider moving to a data access layer or using existing services if available) ---
938
+ private async fetchPatientProfile(
939
+ patientId: string
940
+ ): Promise<PatientProfile | null> {
941
+ try {
942
+ const doc = await this.db
943
+ .collection(PATIENTS_COLLECTION)
944
+ .doc(patientId)
945
+ .get();
946
+ return doc.exists ? (doc.data() as PatientProfile) : null;
947
+ } catch (error) {
948
+ Logger.error(
949
+ `[AggService] Error fetching patient profile ${patientId}:`,
950
+ error
951
+ );
952
+ return null;
953
+ }
954
+ }
955
+
956
+ /**
957
+ * Fetches the sensitive information for a given patient ID.
958
+ * @param patientId The ID of the patient to fetch sensitive information for.
959
+ * @returns {Promise<PatientSensitiveInfo | null>} The patient sensitive information or null if not found or an error occurs.
960
+ */
961
+ private async fetchPatientSensitiveInfo(
962
+ patientId: string
963
+ ): Promise<PatientSensitiveInfo | null> {
964
+ try {
965
+ // Assuming sensitive info is in a subcollection PATIENT_SENSITIVE_INFO_COLLECTION
966
+ // under the patient's document, and the sensitive info document ID is the patientId itself.
967
+ // If the document ID is fixed (e.g., 'details'), this path should be adjusted.
968
+ const doc = await this.db
969
+ .collection(PATIENTS_COLLECTION)
970
+ .doc(patientId)
971
+ .collection(PATIENT_SENSITIVE_INFO_COLLECTION)
972
+ .doc(patientId) // CONFIRM THIS DOCUMENT ID PATTERN
973
+ .get();
974
+ if (!doc.exists) {
975
+ Logger.warn(
976
+ `[AggService] No sensitive info found for patient ${patientId}`
977
+ );
978
+ return null;
979
+ }
980
+ return doc.data() as PatientSensitiveInfo;
981
+ } catch (error) {
982
+ Logger.error(
983
+ `[AggService] Error fetching patient sensitive info ${patientId}:`,
984
+ error
985
+ );
986
+ return null;
987
+ }
988
+ }
989
+
990
+ /**
991
+ * Fetches the profile for a given practitioner ID.
992
+ * @param practitionerId The ID of the practitioner to fetch.
993
+ * @returns {Promise<Practitioner | null>} The practitioner profile or null if not found or an error occurs.
994
+ */
995
+ private async fetchPractitionerProfile(
996
+ practitionerId: string
997
+ ): Promise<Practitioner | null> {
998
+ if (!practitionerId) {
999
+ Logger.warn(
1000
+ "[AggService] fetchPractitionerProfile called with no practitionerId."
1001
+ );
1002
+ return null;
1003
+ }
1004
+ try {
1005
+ const doc = await this.db
1006
+ .collection(PRACTITIONERS_COLLECTION)
1007
+ .doc(practitionerId)
1008
+ .get();
1009
+ if (!doc.exists) {
1010
+ Logger.warn(
1011
+ `[AggService] No practitioner profile found for ID ${practitionerId}`
1012
+ );
1013
+ return null;
1014
+ }
1015
+ return doc.data() as Practitioner;
1016
+ } catch (error) {
1017
+ Logger.error(
1018
+ `[AggService] Error fetching practitioner profile ${practitionerId}:`,
1019
+ error
1020
+ );
1021
+ return null;
1022
+ }
1023
+ }
1024
+
1025
+ /**
1026
+ * Fetches the information for a given clinic ID.
1027
+ * @param clinicId The ID of the clinic to fetch.
1028
+ * @returns {Promise<Clinic | null>} The clinic information or null if not found or an error occurs.
1029
+ */
1030
+ private async fetchClinicInfo(clinicId: string): Promise<Clinic | null> {
1031
+ if (!clinicId) {
1032
+ Logger.warn("[AggService] fetchClinicInfo called with no clinicId.");
1033
+ return null;
1034
+ }
1035
+ try {
1036
+ const doc = await this.db
1037
+ .collection(CLINICS_COLLECTION)
1038
+ .doc(clinicId)
1039
+ .get();
1040
+ if (!doc.exists) {
1041
+ Logger.warn(`[AggService] No clinic info found for ID ${clinicId}`);
1042
+ return null;
1043
+ }
1044
+ return doc.data() as Clinic;
1045
+ } catch (error) {
1046
+ Logger.error(
1047
+ `[AggService] Error fetching clinic info ${clinicId}:`,
1048
+ error
1049
+ );
1050
+ return null;
1051
+ }
1052
+ }
1053
+ }