@blackcode_sa/metaestetics-api 1.7.3 → 1.7.5

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.
@@ -37,6 +37,7 @@ import { CalendarAdminService } from "../../calendar/calendar.admin.service";
37
37
  import { AppointmentMailingService } from "../../mailing/appointment/appointment.mailing.service";
38
38
  import { Logger } from "../../logger";
39
39
  import { UserRole } from "../../../types";
40
+ import { CalendarEventStatus } from "../../../types/calendar";
40
41
 
41
42
  // Mailgun client will be injected via constructor
42
43
 
@@ -87,11 +88,7 @@ export class AppointmentAggregationService {
87
88
  );
88
89
 
89
90
  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
91
+ // 1. Fetch necessary profiles for notifications and context
95
92
  // These can be fetched in parallel
96
93
  const [
97
94
  patientProfile,
@@ -105,6 +102,17 @@ export class AppointmentAggregationService {
105
102
  this.fetchClinicInfo(appointment.clinicBranchId), // Needed for clinic admin notifications
106
103
  ]);
107
104
 
105
+ // 2. Manage Patient-Clinic-Practitioner Links (moved from beginning to here)
106
+ // Now we can pass the already fetched patient profile
107
+ if (patientProfile) {
108
+ await this.managePatientClinicPractitionerLinks(
109
+ patientProfile,
110
+ appointment.practitionerId,
111
+ appointment.clinicBranchId,
112
+ "create"
113
+ );
114
+ }
115
+
108
116
  // 3. Initial State Handling based on appointment status
109
117
  if (appointment.status === AppointmentStatus.CONFIRMED) {
110
118
  Logger.info(
@@ -252,6 +260,13 @@ export class AppointmentAggregationService {
252
260
  ) {
253
261
  Logger.info(`[AggService] Appt ${after.id} PENDING -> CONFIRMED.`);
254
262
  await this.createPreAppointmentRequirementInstances(after);
263
+
264
+ // Update calendar events to CONFIRMED status
265
+ await this.calendarAdminService.updateAppointmentCalendarEventsStatus(
266
+ after,
267
+ CalendarEventStatus.CONFIRMED
268
+ );
269
+
255
270
  // Send confirmation notifications
256
271
  if (patientSensitiveInfo?.email && patientProfile) {
257
272
  Logger.info(
@@ -296,10 +311,72 @@ export class AppointmentAggregationService {
296
311
  recipientRole: "practitioner" as const,
297
312
  };
298
313
  await this.appointmentMailingService.sendAppointmentConfirmedEmail(
299
- practitionerEmailData as any // TODO: Properly import PractitionerProfileInfo and ensure type compatibility
314
+ practitionerEmailData as any
315
+ );
316
+ }
317
+ }
318
+ // --- RESCHEDULED_BY_CLINIC -> CONFIRMED (Reschedule Acceptance) ---
319
+ else if (
320
+ before.status === AppointmentStatus.RESCHEDULED_BY_CLINIC &&
321
+ after.status === AppointmentStatus.CONFIRMED
322
+ ) {
323
+ Logger.info(
324
+ `[AggService] Appt ${after.id} RESCHEDULED_BY_CLINIC -> CONFIRMED.`
325
+ );
326
+
327
+ // Update existing requirements as superseded and create new ones
328
+ await this.updateRelatedPatientRequirementInstances(
329
+ before,
330
+ PatientRequirementOverallStatus.SUPERSEDED_RESCHEDULE
331
+ );
332
+ await this.createPreAppointmentRequirementInstances(after);
333
+
334
+ // Update calendar events to CONFIRMED status and update times
335
+ await this.calendarAdminService.updateAppointmentCalendarEventsStatus(
336
+ after,
337
+ CalendarEventStatus.CONFIRMED
338
+ );
339
+
340
+ // Send confirmation notifications (similar to PENDING -> CONFIRMED)
341
+ if (patientSensitiveInfo?.email && patientProfile) {
342
+ Logger.info(
343
+ `[AggService] Sending appointment confirmed email to patient ${patientSensitiveInfo.email}`
344
+ );
345
+ const emailData = {
346
+ appointment: after,
347
+ recipientProfile: after.patientInfo,
348
+ recipientRole: "patient" as const,
349
+ };
350
+ await this.appointmentMailingService.sendAppointmentConfirmedEmail(
351
+ emailData as any
352
+ );
353
+ }
354
+
355
+ if (
356
+ patientProfile?.expoTokens &&
357
+ patientProfile.expoTokens.length > 0
358
+ ) {
359
+ await this.notificationsAdmin.sendAppointmentConfirmedPush(
360
+ after,
361
+ after.patientId,
362
+ patientProfile.expoTokens,
363
+ UserRole.PATIENT
364
+ );
365
+ }
366
+
367
+ if (practitionerProfile?.basicInfo?.email) {
368
+ Logger.info(
369
+ `[AggService] Sending appointment confirmation email to practitioner ${practitionerProfile.basicInfo.email}`
370
+ );
371
+ const practitionerEmailData = {
372
+ appointment: after,
373
+ recipientProfile: after.practitionerInfo,
374
+ recipientRole: "practitioner" as const,
375
+ };
376
+ await this.appointmentMailingService.sendAppointmentConfirmedEmail(
377
+ practitionerEmailData as any
300
378
  );
301
379
  }
302
- // TODO: Add push notification for practitioner if they have expoTokens
303
380
  }
304
381
  // --- Any -> CANCELLED_* ---
305
382
  else if (
@@ -315,7 +392,37 @@ export class AppointmentAggregationService {
315
392
  after,
316
393
  PatientRequirementOverallStatus.CANCELLED_APPOINTMENT
317
394
  );
318
- await this.managePatientClinicPractitionerLinks(after, "cancel");
395
+
396
+ // Update patient-clinic-practitioner links if patient profile exists
397
+ if (patientProfile) {
398
+ await this.managePatientClinicPractitionerLinks(
399
+ patientProfile,
400
+ after.practitionerId,
401
+ after.clinicBranchId,
402
+ "cancel",
403
+ after.status
404
+ );
405
+ }
406
+
407
+ const calendarStatus = (status: AppointmentStatus) => {
408
+ switch (status) {
409
+ case AppointmentStatus.NO_SHOW:
410
+ return CalendarEventStatus.NO_SHOW;
411
+ case AppointmentStatus.CANCELED_CLINIC:
412
+ return CalendarEventStatus.REJECTED;
413
+ case AppointmentStatus.CANCELED_PATIENT:
414
+ return CalendarEventStatus.CANCELED;
415
+ case AppointmentStatus.CANCELED_PATIENT_RESCHEDULED:
416
+ return CalendarEventStatus.REJECTED;
417
+ default:
418
+ return CalendarEventStatus.CANCELED;
419
+ }
420
+ };
421
+
422
+ await this.calendarAdminService.updateAppointmentCalendarEventsStatus(
423
+ after,
424
+ calendarStatus(after.status)
425
+ );
319
426
 
320
427
  // Send cancellation email to Patient
321
428
  if (patientSensitiveInfo?.email && patientProfile) {
@@ -357,6 +464,12 @@ export class AppointmentAggregationService {
357
464
  Logger.info(`[AggService] Appt ${after.id} status -> COMPLETED.`);
358
465
  await this.createPostAppointmentRequirementInstances(after);
359
466
 
467
+ // Update calendar events to COMPLETED status
468
+ await this.calendarAdminService.updateAppointmentCalendarEventsStatus(
469
+ after,
470
+ CalendarEventStatus.COMPLETED
471
+ );
472
+
360
473
  // Send review request email to patient
361
474
  if (patientSensitiveInfo?.email && patientProfile) {
362
475
  Logger.info(
@@ -382,8 +495,20 @@ export class AppointmentAggregationService {
382
495
  before, // Pass the 'before' state for old requirements
383
496
  PatientRequirementOverallStatus.SUPERSEDED_RESCHEDULE
384
497
  );
385
- Logger.info(
386
- `[AggService] TODO: Handle RESCHEDULE logic for requirements carefully based on confirmation flow.`
498
+
499
+ // First update the calendar event times with new proposed times
500
+ await this.calendarAdminService.updateAppointmentCalendarEventsTime(
501
+ after,
502
+ {
503
+ start: after.appointmentStartTime,
504
+ end: after.appointmentEndTime,
505
+ }
506
+ );
507
+
508
+ // Then update calendar events to PENDING status (waiting for patient confirmation)
509
+ await this.calendarAdminService.updateAppointmentCalendarEventsStatus(
510
+ after,
511
+ CalendarEventStatus.PENDING
387
512
  );
388
513
 
389
514
  // Send reschedule proposal email to patient
@@ -414,16 +539,29 @@ export class AppointmentAggregationService {
414
539
  if (timeChanged && !statusChanged) {
415
540
  // Or if status change didn't fully cover reschedule implications
416
541
  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
- );
542
+
543
+ // If confirmed appointment has time change, we need to update requirements
544
+ if (after.status === AppointmentStatus.CONFIRMED) {
545
+ // Update existing requirements as superseded and create new ones
546
+ await this.updateRelatedPatientRequirementInstances(
547
+ before,
548
+ PatientRequirementOverallStatus.SUPERSEDED_RESCHEDULE
549
+ );
550
+ await this.createPreAppointmentRequirementInstances(after);
551
+
552
+ // Update calendar event times with new times
553
+ await this.calendarAdminService.updateAppointmentCalendarEventsTime(
554
+ after,
555
+ {
556
+ start: after.appointmentStartTime,
557
+ end: after.appointmentEndTime,
558
+ }
559
+ );
560
+ } else {
561
+ Logger.warn(
562
+ `[AggService] Independent time change detected for ${after.id} with status ${after.status}. Review implications for requirements and calendar.`
563
+ );
564
+ }
427
565
  }
428
566
 
429
567
  // TODO: Handle Payment Status Change
@@ -461,11 +599,27 @@ export class AppointmentAggregationService {
461
599
  deletedAppointment,
462
600
  PatientRequirementOverallStatus.CANCELLED_APPOINTMENT
463
601
  );
464
- // await this.calendarAdminService.deleteAppointmentCalendarEvents(deletedAppointment);
465
- await this.managePatientClinicPractitionerLinks(
466
- deletedAppointment,
467
- "cancel"
602
+
603
+ // Fetch patient profile first
604
+ const patientProfile = await this.fetchPatientProfile(
605
+ deletedAppointment.patientId
468
606
  );
607
+
608
+ // Update relationship links if patient profile exists
609
+ if (patientProfile) {
610
+ await this.managePatientClinicPractitionerLinks(
611
+ patientProfile,
612
+ deletedAppointment.practitionerId,
613
+ deletedAppointment.clinicBranchId,
614
+ "cancel"
615
+ );
616
+ }
617
+
618
+ // Delete all associated calendar events
619
+ await this.calendarAdminService.deleteAppointmentCalendarEvents(
620
+ deletedAppointment
621
+ );
622
+
469
623
  // TODO: Send cancellation/deletion notifications if appropriate (though data is gone)
470
624
  }
471
625
 
@@ -812,10 +966,34 @@ export class AppointmentAggregationService {
812
966
  try {
813
967
  const batch = this.db.batch();
814
968
  let instancesCreatedCount = 0;
969
+ // Store created instances for fallback direct creation if needed
970
+ let createdInstances = [];
971
+
972
+ // Log more details about the post-requirements
973
+ Logger.info(
974
+ `[AggService] Found ${
975
+ appointment.postProcedureRequirements.length
976
+ } post-requirements to process: ${JSON.stringify(
977
+ appointment.postProcedureRequirements.map((r) => ({
978
+ id: r.id,
979
+ name: r.name,
980
+ type: r.type,
981
+ isActive: r.isActive,
982
+ hasTimeframe: !!r.timeframe,
983
+ notifyAtLength: r.timeframe?.notifyAt?.length || 0,
984
+ }))
985
+ )}`
986
+ );
815
987
 
816
988
  for (const template of appointment.postProcedureRequirements) {
817
- if (!template) continue;
989
+ if (!template) {
990
+ Logger.warn(
991
+ `[AggService] Found null/undefined template in postProcedureRequirements array`
992
+ );
993
+ continue;
994
+ }
818
995
 
996
+ // Ensure it's an active, POST-type requirement
819
997
  if (template.type !== RequirementType.POST || !template.isActive) {
820
998
  Logger.debug(
821
999
  `[AggService] Skipping template ${template.id} (${template.name}): not an active POST requirement.`
@@ -823,25 +1001,40 @@ export class AppointmentAggregationService {
823
1001
  continue;
824
1002
  }
825
1003
 
1004
+ if (
1005
+ !template.timeframe ||
1006
+ !template.timeframe.notifyAt ||
1007
+ template.timeframe.notifyAt.length === 0
1008
+ ) {
1009
+ Logger.warn(
1010
+ `[AggService] Template ${template.id} (${template.name}) has no timeframe.notifyAt values. Creating with empty instructions.`
1011
+ );
1012
+ }
1013
+
826
1014
  Logger.debug(
827
- `[AggService] Processing POST template ${template.id} (${template.name}) for appt ${appointment.id}`
1015
+ `[AggService] Processing template ${template.id} (${template.name}) for appt ${appointment.id}`
828
1016
  );
829
1017
 
830
1018
  const newInstanceRef = this.db
831
1019
  .collection(PATIENTS_COLLECTION)
832
1020
  .doc(appointment.patientId)
833
1021
  .collection(PATIENT_REQUIREMENTS_SUBCOLLECTION_NAME)
834
- .doc();
1022
+ .doc(); // Auto-generate ID for the new instance
1023
+
1024
+ // Log the path for debugging
1025
+ Logger.debug(
1026
+ `[AggService] Created doc reference: ${newInstanceRef.path}`
1027
+ );
835
1028
 
836
1029
  const instructions: PatientRequirementInstruction[] = (
837
1030
  template.timeframe?.notifyAt || []
838
1031
  ).map((notifyAtValue) => {
839
- let dueTime: any = appointment.appointmentEndTime; // Default to appointment end time
1032
+ let dueTime: any = appointment.appointmentEndTime;
840
1033
  if (template.timeframe && typeof notifyAtValue === "number") {
841
1034
  const dueDateTime = new Date(
842
1035
  appointment.appointmentEndTime.toMillis()
843
1036
  );
844
- // For POST requirements, notifyAtValue usually means AFTER the event
1037
+ // For POST requirements, notifyAtValue means AFTER the event
845
1038
  if (template.timeframe.unit === TimeUnit.DAYS) {
846
1039
  dueDateTime.setDate(dueDateTime.getDate() + notifyAtValue);
847
1040
  } else if (template.timeframe.unit === TimeUnit.HOURS) {
@@ -855,9 +1048,9 @@ export class AppointmentAggregationService {
855
1048
  ? 1
856
1049
  : template.importance === "medium"
857
1050
  ? 4
858
- : 15; // Default to 15 hours for low importance; // Placeholder default, TODO: Define source
1051
+ : 15; // Default to 15 hours for low importance
859
1052
 
860
- return {
1053
+ const instructionObject: PatientRequirementInstruction = {
861
1054
  instructionId:
862
1055
  `${template.id}_${notifyAtValue}_${newInstanceRef.id}`.replace(
863
1056
  /[^a-zA-Z0-9_]/g,
@@ -869,19 +1062,21 @@ export class AppointmentAggregationService {
869
1062
  status: PatientInstructionStatus.PENDING_NOTIFICATION,
870
1063
  originalNotifyAtValue: notifyAtValue,
871
1064
  originalTimeframeUnit: template.timeframe.unit,
872
- updatedAt: admin.firestore.Timestamp.now() as any, // Use current server timestamp
1065
+ updatedAt: admin.firestore.Timestamp.now() as any,
873
1066
  notificationId: undefined,
874
1067
  actionTakenAt: undefined,
875
- } as PatientRequirementInstruction;
1068
+ };
1069
+ return instructionObject;
876
1070
  });
877
1071
 
878
- const newInstanceData: Omit<PatientRequirementInstance, "id"> = {
1072
+ const newInstanceData: PatientRequirementInstance = {
1073
+ id: newInstanceRef.id,
879
1074
  patientId: appointment.patientId,
880
1075
  appointmentId: appointment.id,
881
1076
  originalRequirementId: template.id,
882
1077
  requirementName: template.name,
883
1078
  requirementDescription: template.description,
884
- requirementType: template.type, // Should be RequirementType.POST
1079
+ requirementType: template.type,
885
1080
  requirementImportance: template.importance,
886
1081
  overallStatus: PatientRequirementOverallStatus.ACTIVE,
887
1082
  instructions: instructions,
@@ -889,18 +1084,144 @@ export class AppointmentAggregationService {
889
1084
  updatedAt: admin.firestore.FieldValue.serverTimestamp() as any,
890
1085
  };
891
1086
 
1087
+ // Log the data being set
1088
+ Logger.debug(
1089
+ `[AggService] Setting data for requirement: ${JSON.stringify({
1090
+ id: newInstanceRef.id,
1091
+ patientId: newInstanceData.patientId,
1092
+ appointmentId: newInstanceData.appointmentId,
1093
+ requirementName: newInstanceData.requirementName,
1094
+ instructionsCount: newInstanceData.instructions.length,
1095
+ })}`
1096
+ );
1097
+
892
1098
  batch.set(newInstanceRef, newInstanceData);
1099
+ // Store for potential fallback
1100
+ createdInstances.push({
1101
+ ref: newInstanceRef,
1102
+ data: newInstanceData,
1103
+ });
1104
+
893
1105
  instancesCreatedCount++;
894
1106
  Logger.debug(
895
- `[AggService] Added POST PatientRequirementInstance ${newInstanceRef.id} to batch for template ${template.id}.`
1107
+ `[AggService] Added PatientRequirementInstance ${newInstanceRef.id} to batch for template ${template.id}.`
896
1108
  );
897
1109
  }
898
1110
 
899
1111
  if (instancesCreatedCount > 0) {
900
- await batch.commit();
901
- Logger.info(
902
- `[AggService] Successfully created ${instancesCreatedCount} POST_APPOINTMENT requirement instances for appointment ${appointment.id}.`
903
- );
1112
+ try {
1113
+ await batch.commit();
1114
+ Logger.info(
1115
+ `[AggService] Successfully created ${instancesCreatedCount} POST_APPOINTMENT requirement instances for appointment ${appointment.id}.`
1116
+ );
1117
+
1118
+ // Verify creation success
1119
+ try {
1120
+ const verifySnapshot = await this.db
1121
+ .collection(PATIENTS_COLLECTION)
1122
+ .doc(appointment.patientId)
1123
+ .collection(PATIENT_REQUIREMENTS_SUBCOLLECTION_NAME)
1124
+ .where("appointmentId", "==", appointment.id)
1125
+ .get();
1126
+
1127
+ if (verifySnapshot.empty) {
1128
+ Logger.warn(
1129
+ `[AggService] Batch commit reported success but documents not found! Attempting direct creation as fallback...`
1130
+ );
1131
+
1132
+ // Fallback to direct creation if batch worked but docs aren't there
1133
+ const fallbackPromises = createdInstances.map(
1134
+ async ({ ref, data }) => {
1135
+ try {
1136
+ await ref.set(data);
1137
+ Logger.info(
1138
+ `[AggService] Fallback direct creation success for ${ref.id}`
1139
+ );
1140
+ return true;
1141
+ } catch (fallbackError) {
1142
+ Logger.error(
1143
+ `[AggService] Fallback direct creation failed for ${ref.id}:`,
1144
+ fallbackError
1145
+ );
1146
+ return false;
1147
+ }
1148
+ }
1149
+ );
1150
+
1151
+ const fallbackResults = await Promise.allSettled(
1152
+ fallbackPromises
1153
+ );
1154
+ const successCount = fallbackResults.filter(
1155
+ (r) => r.status === "fulfilled" && r.value === true
1156
+ ).length;
1157
+
1158
+ if (successCount > 0) {
1159
+ Logger.info(
1160
+ `[AggService] Fallback mechanism created ${successCount} out of ${createdInstances.length} requirements`
1161
+ );
1162
+ } else {
1163
+ Logger.error(
1164
+ `[AggService] Both batch and fallback mechanisms failed to create requirements`
1165
+ );
1166
+ throw new Error(
1167
+ "Failed to create patient requirements through both batch and direct methods"
1168
+ );
1169
+ }
1170
+ } else {
1171
+ Logger.info(
1172
+ `[AggService] Verification confirmed ${verifySnapshot.size} requirement documents created`
1173
+ );
1174
+ }
1175
+ } catch (verifyError) {
1176
+ Logger.error(
1177
+ `[AggService] Error during verification of created requirements:`,
1178
+ verifyError
1179
+ );
1180
+ }
1181
+ } catch (commitError) {
1182
+ Logger.error(
1183
+ `[AggService] Error committing batch for POST_APPOINTMENT requirement instances for appointment ${appointment.id}:`,
1184
+ commitError
1185
+ );
1186
+
1187
+ // Try direct creation as fallback
1188
+ Logger.info(`[AggService] Attempting direct creation as fallback...`);
1189
+ const fallbackPromises = createdInstances.map(
1190
+ async ({ ref, data }) => {
1191
+ try {
1192
+ await ref.set(data);
1193
+ Logger.info(
1194
+ `[AggService] Fallback direct creation success for ${ref.id}`
1195
+ );
1196
+ return true;
1197
+ } catch (fallbackError) {
1198
+ Logger.error(
1199
+ `[AggService] Fallback direct creation failed for ${ref.id}:`,
1200
+ fallbackError
1201
+ );
1202
+ return false;
1203
+ }
1204
+ }
1205
+ );
1206
+
1207
+ const fallbackResults = await Promise.allSettled(fallbackPromises);
1208
+ const successCount = fallbackResults.filter(
1209
+ (r) => r.status === "fulfilled" && r.value === true
1210
+ ).length;
1211
+
1212
+ if (successCount > 0) {
1213
+ Logger.info(
1214
+ `[AggService] Fallback mechanism created ${successCount} out of ${createdInstances.length} requirements`
1215
+ );
1216
+ } else {
1217
+ Logger.error(
1218
+ `[AggService] Both batch and fallback mechanisms failed to create requirements`
1219
+ );
1220
+ throw new Error(
1221
+ "Failed to create patient requirements through both batch and direct methods"
1222
+ );
1223
+ }
1224
+ }
904
1225
  } else {
905
1226
  Logger.info(
906
1227
  `[AggService] No new POST_APPOINTMENT requirement instances were prepared for batch commit for appointment ${appointment.id}.`
@@ -911,6 +1232,7 @@ export class AppointmentAggregationService {
911
1232
  `[AggService] Error creating POST_APPOINTMENT requirement instances for appointment ${appointment.id}:`,
912
1233
  error
913
1234
  );
1235
+ throw error; // Re-throw to ensure the caller knows there was a problem
914
1236
  }
915
1237
  }
916
1238
 
@@ -997,102 +1319,221 @@ export class AppointmentAggregationService {
997
1319
  }
998
1320
 
999
1321
  /**
1000
- * Manages denormalized links between a patient and the clinic/practitioner associated with an appointment.
1001
- * Adds patientId to arrays on clinic and practitioner documents on appointment creation.
1002
- * Removes patientId on appointment cancellation (basic removal, can be enhanced).
1322
+ * Manages relationships between a patient and clinics/practitioners.
1323
+ * Only updates the patient profile with doctorIds and clinicIds.
1003
1324
  *
1004
- * @param {Appointment} appointment - The appointment object.
1005
- * @param {"create" | "cancel"} action - 'create' to add links, 'cancel' to remove them.
1325
+ * @param {PatientProfile} patientProfile - The patient profile to update
1326
+ * @param {string} practitionerId - The practitioner ID
1327
+ * @param {string} clinicId - The clinic ID
1328
+ * @param {"create" | "cancel"} action - 'create' to add IDs, 'cancel' to potentially remove them
1329
+ * @param {AppointmentStatus} [cancelStatus] - The appointment status if action is 'cancel'
1006
1330
  * @returns {Promise<void>} A promise that resolves when the operation is complete.
1007
1331
  */
1008
1332
  private async managePatientClinicPractitionerLinks(
1009
- appointment: Appointment,
1010
- action: "create" | "cancel"
1333
+ patientProfile: PatientProfile,
1334
+ practitionerId: string,
1335
+ clinicId: string,
1336
+ action: "create" | "cancel",
1337
+ cancelStatus?: AppointmentStatus
1011
1338
  ): Promise<void> {
1012
1339
  Logger.info(
1013
- `[AggService] Managing patient-clinic-practitioner links for appt ${appointment.id} (patient: ${appointment.patientId}), action: ${action}`
1340
+ `[AggService] Managing patient-clinic-practitioner links for patient ${patientProfile.id}, action: ${action}`
1014
1341
  );
1015
1342
 
1016
- if (
1017
- !appointment.patientId ||
1018
- !appointment.practitionerId ||
1019
- !appointment.clinicBranchId
1020
- ) {
1343
+ try {
1344
+ if (action === "create") {
1345
+ await this.addPatientLinks(patientProfile, practitionerId, clinicId);
1346
+ } else if (action === "cancel") {
1347
+ await this.removePatientLinksIfNoActiveAppointments(
1348
+ patientProfile,
1349
+ practitionerId,
1350
+ clinicId
1351
+ );
1352
+ }
1353
+ } catch (error) {
1021
1354
  Logger.error(
1022
- "[AggService] managePatientClinicPractitionerLinks called with missing IDs.",
1023
- {
1024
- patientId: appointment.patientId,
1025
- practitionerId: appointment.practitionerId,
1026
- clinicBranchId: appointment.clinicBranchId,
1027
- }
1355
+ `[AggService] Error managing patient-clinic-practitioner links for patient ${patientProfile.id}:`,
1356
+ error
1028
1357
  );
1029
- return;
1030
1358
  }
1359
+ }
1031
1360
 
1032
- const batch = this.db.batch();
1033
- const patientId = appointment.patientId;
1034
-
1035
- // Practitioner link
1036
- const practitionerRef = this.db
1037
- .collection(PRACTITIONERS_COLLECTION)
1038
- .doc(appointment.practitionerId);
1039
- // Clinic link
1040
- const clinicRef = this.db
1041
- .collection(CLINICS_COLLECTION)
1042
- .doc(appointment.clinicBranchId);
1043
-
1044
- if (action === "create") {
1045
- // Add patient to practitioner's list of patients (if field exists, e.g., 'patientIds')
1046
- // Assuming a field named 'patientIds' on the practitioner document
1047
- batch.update(practitionerRef, {
1048
- patientIds: admin.firestore.FieldValue.arrayUnion(patientId),
1049
- // Optionally, update a count or last interaction timestamp
1050
- updatedAt: admin.firestore.FieldValue.serverTimestamp() as any,
1051
- });
1052
- Logger.debug(
1053
- `[AggService] Adding patient ${patientId} to practitioner ${appointment.practitionerId} patientIds.`
1054
- );
1361
+ /**
1362
+ * Adds practitioner and clinic IDs to the patient profile.
1363
+ *
1364
+ * @param {PatientProfile} patientProfile - The patient profile to update
1365
+ * @param {string} practitionerId - The practitioner ID to add
1366
+ * @param {string} clinicId - The clinic ID to add
1367
+ * @returns {Promise<void>} A promise that resolves when the operation is complete.
1368
+ */
1369
+ private async addPatientLinks(
1370
+ patientProfile: PatientProfile,
1371
+ practitionerId: string,
1372
+ clinicId: string
1373
+ ): Promise<void> {
1374
+ try {
1375
+ // Check if the IDs already exist in the arrays
1376
+ const hasDoctor =
1377
+ patientProfile.doctorIds?.includes(practitionerId) || false;
1378
+ const hasClinic = patientProfile.clinicIds?.includes(clinicId) || false;
1379
+
1380
+ // Only update if necessary
1381
+ if (!hasDoctor || !hasClinic) {
1382
+ const patientRef = this.db
1383
+ .collection(PATIENTS_COLLECTION)
1384
+ .doc(patientProfile.id);
1385
+ const updateData: Record<string, any> = {
1386
+ updatedAt: admin.firestore.FieldValue.serverTimestamp(),
1387
+ };
1055
1388
 
1056
- // Add patient to clinic's list of patients (if field exists, e.g., 'patientIds')
1057
- // Assuming a field named 'patientIds' on the clinic document
1058
- batch.update(clinicRef, {
1059
- patientIds: admin.firestore.FieldValue.arrayUnion(patientId),
1060
- updatedAt: admin.firestore.FieldValue.serverTimestamp() as any,
1061
- });
1062
- Logger.debug(
1063
- `[AggService] Adding patient ${patientId} to clinic ${appointment.clinicBranchId} patientIds.`
1064
- );
1065
- } else if (action === "cancel") {
1066
- // Basic removal.
1067
- // TODO: Implement more robust removal logic: only remove if this was the last active/confirmed appointment
1068
- // linking this patient to this practitioner/clinic.
1069
- batch.update(practitionerRef, {
1070
- patientIds: admin.firestore.FieldValue.arrayRemove(patientId),
1071
- updatedAt: admin.firestore.FieldValue.serverTimestamp() as any,
1072
- });
1073
- Logger.debug(
1074
- `[AggService] Removing patient ${patientId} from practitioner ${appointment.practitionerId} patientIds.`
1075
- );
1389
+ if (!hasDoctor) {
1390
+ Logger.debug(
1391
+ `[AggService] Adding practitioner ${practitionerId} to patient ${patientProfile.id}`
1392
+ );
1393
+ updateData.doctorIds =
1394
+ admin.firestore.FieldValue.arrayUnion(practitionerId);
1395
+ }
1076
1396
 
1077
- batch.update(clinicRef, {
1078
- patientIds: admin.firestore.FieldValue.arrayRemove(patientId),
1079
- updatedAt: admin.firestore.FieldValue.serverTimestamp() as any,
1080
- });
1081
- Logger.debug(
1082
- `[AggService] Removing patient ${patientId} from clinic ${appointment.clinicBranchId} patientIds.`
1397
+ if (!hasClinic) {
1398
+ Logger.debug(
1399
+ `[AggService] Adding clinic ${clinicId} to patient ${patientProfile.id}`
1400
+ );
1401
+ updateData.clinicIds =
1402
+ admin.firestore.FieldValue.arrayUnion(clinicId);
1403
+ }
1404
+
1405
+ await patientRef.update(updateData);
1406
+ Logger.info(
1407
+ `[AggService] Successfully updated patient ${patientProfile.id} with new links.`
1408
+ );
1409
+ } else {
1410
+ Logger.info(
1411
+ `[AggService] Patient ${patientProfile.id} already has links to both practitioner ${practitionerId} and clinic ${clinicId}.`
1412
+ );
1413
+ }
1414
+ } catch (error) {
1415
+ Logger.error(
1416
+ `[AggService] Error updating patient ${patientProfile.id} with new links:`,
1417
+ error
1083
1418
  );
1419
+ throw error;
1084
1420
  }
1421
+ }
1085
1422
 
1423
+ /**
1424
+ * Removes practitioner and clinic IDs from the patient profile if there are no more active appointments.
1425
+ *
1426
+ * @param {PatientProfile} patientProfile - The patient profile to update
1427
+ * @param {string} practitionerId - The practitioner ID to remove
1428
+ * @param {string} clinicId - The clinic ID to remove
1429
+ * @returns {Promise<void>} A promise that resolves when the operation is complete.
1430
+ */
1431
+ private async removePatientLinksIfNoActiveAppointments(
1432
+ patientProfile: PatientProfile,
1433
+ practitionerId: string,
1434
+ clinicId: string
1435
+ ): Promise<void> {
1086
1436
  try {
1087
- await batch.commit();
1437
+ // Check for active appointments with this practitioner and clinic
1438
+ const activePractitionerAppointments = await this.checkActiveAppointments(
1439
+ patientProfile.id,
1440
+ "practitionerId",
1441
+ practitionerId
1442
+ );
1443
+
1444
+ const activeClinicAppointments = await this.checkActiveAppointments(
1445
+ patientProfile.id,
1446
+ "clinicBranchId",
1447
+ clinicId
1448
+ );
1449
+
1088
1450
  Logger.info(
1089
- `[AggService] Successfully updated patient-clinic-practitioner links for appointment ${appointment.id}, action: ${action}.`
1451
+ `[AggService] Active appointment count for patient ${patientProfile.id}: With practitioner ${practitionerId}: ${activePractitionerAppointments}, With clinic ${clinicId}: ${activeClinicAppointments}`
1090
1452
  );
1453
+
1454
+ // Only update if there are no active appointments
1455
+ const patientRef = this.db
1456
+ .collection(PATIENTS_COLLECTION)
1457
+ .doc(patientProfile.id);
1458
+ const updateData: Record<string, any> = {};
1459
+ let updateNeeded = false;
1460
+
1461
+ if (
1462
+ activePractitionerAppointments === 0 &&
1463
+ patientProfile.doctorIds?.includes(practitionerId)
1464
+ ) {
1465
+ Logger.debug(
1466
+ `[AggService] Removing practitioner ${practitionerId} from patient ${patientProfile.id}`
1467
+ );
1468
+ updateData.doctorIds =
1469
+ admin.firestore.FieldValue.arrayRemove(practitionerId);
1470
+ updateNeeded = true;
1471
+ }
1472
+
1473
+ if (
1474
+ activeClinicAppointments === 0 &&
1475
+ patientProfile.clinicIds?.includes(clinicId)
1476
+ ) {
1477
+ Logger.debug(
1478
+ `[AggService] Removing clinic ${clinicId} from patient ${patientProfile.id}`
1479
+ );
1480
+ updateData.clinicIds = admin.firestore.FieldValue.arrayRemove(clinicId);
1481
+ updateNeeded = true;
1482
+ }
1483
+
1484
+ if (updateNeeded) {
1485
+ updateData.updatedAt = admin.firestore.FieldValue.serverTimestamp();
1486
+ await patientRef.update(updateData);
1487
+ Logger.info(
1488
+ `[AggService] Successfully removed links from patient ${patientProfile.id}`
1489
+ );
1490
+ } else {
1491
+ Logger.info(
1492
+ `[AggService] No links need to be removed from patient ${patientProfile.id}`
1493
+ );
1494
+ }
1091
1495
  } catch (error) {
1092
1496
  Logger.error(
1093
- `[AggService] Error managing patient-clinic-practitioner links for appointment ${appointment.id}:`,
1497
+ `[AggService] Error removing links from patient profile:`,
1094
1498
  error
1095
1499
  );
1500
+ throw error;
1501
+ }
1502
+ }
1503
+
1504
+ /**
1505
+ * Checks if there are active appointments between a patient and another entity (practitioner or clinic).
1506
+ *
1507
+ * @param {string} patientId - The patient ID.
1508
+ * @param {"practitionerId" | "clinicBranchId"} entityField - The field to check for the entity ID.
1509
+ * @param {string} entityId - The entity ID (practitioner or clinic).
1510
+ * @returns {Promise<number>} The number of active appointments found.
1511
+ */
1512
+ private async checkActiveAppointments(
1513
+ patientId: string,
1514
+ entityField: "practitionerId" | "clinicBranchId",
1515
+ entityId: string
1516
+ ): Promise<number> {
1517
+ try {
1518
+ // Define all cancelled/inactive appointment statuses
1519
+ const inactiveStatuses = [
1520
+ AppointmentStatus.CANCELED_CLINIC,
1521
+ AppointmentStatus.CANCELED_PATIENT,
1522
+ AppointmentStatus.CANCELED_PATIENT_RESCHEDULED,
1523
+ AppointmentStatus.NO_SHOW,
1524
+ ];
1525
+
1526
+ const snapshot = await this.db
1527
+ .collection("appointments")
1528
+ .where("patientId", "==", patientId)
1529
+ .where(entityField, "==", entityId)
1530
+ .where("status", "not-in", inactiveStatuses)
1531
+ .get();
1532
+
1533
+ return snapshot.size;
1534
+ } catch (error) {
1535
+ Logger.error(`[AggService] Error checking active appointments:`, error);
1536
+ throw error;
1096
1537
  }
1097
1538
  }
1098
1539