@blackcode_sa/metaestetics-api 1.12.61 → 1.12.62

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 (269) hide show
  1. package/dist/admin/index.d.mts +2 -0
  2. package/dist/admin/index.d.ts +2 -0
  3. package/dist/admin/index.js +45 -4
  4. package/dist/admin/index.mjs +45 -4
  5. package/dist/index.d.mts +2 -0
  6. package/dist/index.d.ts +2 -0
  7. package/dist/index.js +53 -11
  8. package/dist/index.mjs +53 -11
  9. package/package.json +119 -119
  10. package/src/__mocks__/firstore.ts +10 -10
  11. package/src/admin/aggregation/README.md +79 -79
  12. package/src/admin/aggregation/appointment/README.md +128 -128
  13. package/src/admin/aggregation/appointment/appointment.aggregation.service.ts +1844 -1844
  14. package/src/admin/aggregation/appointment/index.ts +1 -1
  15. package/src/admin/aggregation/clinic/README.md +52 -52
  16. package/src/admin/aggregation/clinic/clinic.aggregation.service.ts +703 -703
  17. package/src/admin/aggregation/clinic/index.ts +1 -1
  18. package/src/admin/aggregation/forms/README.md +13 -13
  19. package/src/admin/aggregation/forms/filled-forms.aggregation.service.ts +322 -322
  20. package/src/admin/aggregation/forms/index.ts +1 -1
  21. package/src/admin/aggregation/index.ts +8 -8
  22. package/src/admin/aggregation/patient/README.md +27 -27
  23. package/src/admin/aggregation/patient/index.ts +1 -1
  24. package/src/admin/aggregation/patient/patient.aggregation.service.ts +141 -141
  25. package/src/admin/aggregation/practitioner/README.md +42 -42
  26. package/src/admin/aggregation/practitioner/index.ts +1 -1
  27. package/src/admin/aggregation/practitioner/practitioner.aggregation.service.ts +433 -433
  28. package/src/admin/aggregation/practitioner-invite/index.ts +1 -1
  29. package/src/admin/aggregation/practitioner-invite/practitioner-invite.aggregation.service.ts +961 -961
  30. package/src/admin/aggregation/procedure/README.md +43 -43
  31. package/src/admin/aggregation/procedure/index.ts +1 -1
  32. package/src/admin/aggregation/procedure/procedure.aggregation.service.ts +702 -702
  33. package/src/admin/aggregation/reviews/index.ts +1 -1
  34. package/src/admin/aggregation/reviews/reviews.aggregation.service.ts +689 -641
  35. package/src/admin/booking/README.md +125 -125
  36. package/src/admin/booking/booking.admin.ts +1037 -1037
  37. package/src/admin/booking/booking.calculator.ts +712 -712
  38. package/src/admin/booking/booking.types.ts +59 -59
  39. package/src/admin/booking/index.ts +3 -3
  40. package/src/admin/booking/timezones-problem.md +185 -185
  41. package/src/admin/calendar/README.md +7 -7
  42. package/src/admin/calendar/calendar.admin.service.ts +345 -345
  43. package/src/admin/calendar/index.ts +1 -1
  44. package/src/admin/documentation-templates/document-manager.admin.ts +260 -260
  45. package/src/admin/documentation-templates/index.ts +1 -1
  46. package/src/admin/free-consultation/free-consultation-utils.admin.ts +148 -148
  47. package/src/admin/free-consultation/index.ts +1 -1
  48. package/src/admin/index.ts +75 -75
  49. package/src/admin/logger/index.ts +78 -78
  50. package/src/admin/mailing/README.md +95 -95
  51. package/src/admin/mailing/appointment/appointment.mailing.service.ts +732 -732
  52. package/src/admin/mailing/appointment/index.ts +1 -1
  53. package/src/admin/mailing/appointment/templates/patient/appointment-confirmed.html +40 -40
  54. package/src/admin/mailing/base.mailing.service.ts +208 -208
  55. package/src/admin/mailing/index.ts +3 -3
  56. package/src/admin/mailing/practitionerInvite/existing-practitioner-invite.mailing.ts +611 -611
  57. package/src/admin/mailing/practitionerInvite/index.ts +2 -2
  58. package/src/admin/mailing/practitionerInvite/practitionerInvite.mailing.ts +395 -395
  59. package/src/admin/mailing/practitionerInvite/templates/existing-practitioner-invitation.template.ts +155 -155
  60. package/src/admin/mailing/practitionerInvite/templates/invitation.template.ts +101 -101
  61. package/src/admin/mailing/practitionerInvite/templates/invite-accepted-notification.template.ts +228 -228
  62. package/src/admin/mailing/practitionerInvite/templates/invite-rejected-notification.template.ts +242 -242
  63. package/src/admin/notifications/index.ts +1 -1
  64. package/src/admin/notifications/notifications.admin.ts +710 -710
  65. package/src/admin/requirements/README.md +128 -128
  66. package/src/admin/requirements/index.ts +1 -1
  67. package/src/admin/requirements/patient-requirements.admin.service.ts +475 -475
  68. package/src/admin/users/index.ts +1 -1
  69. package/src/admin/users/user-profile.admin.ts +405 -405
  70. package/src/backoffice/constants/certification.constants.ts +13 -13
  71. package/src/backoffice/constants/index.ts +1 -1
  72. package/src/backoffice/errors/backoffice.errors.ts +181 -181
  73. package/src/backoffice/errors/index.ts +1 -1
  74. package/src/backoffice/expo-safe/README.md +26 -26
  75. package/src/backoffice/expo-safe/index.ts +41 -41
  76. package/src/backoffice/index.ts +5 -5
  77. package/src/backoffice/services/FIXES_README.md +102 -102
  78. package/src/backoffice/services/README.md +40 -40
  79. package/src/backoffice/services/brand.service.ts +256 -256
  80. package/src/backoffice/services/category.service.ts +318 -318
  81. package/src/backoffice/services/constants.service.ts +385 -385
  82. package/src/backoffice/services/documentation-template.service.ts +202 -202
  83. package/src/backoffice/services/index.ts +8 -8
  84. package/src/backoffice/services/migrate-products.ts +116 -116
  85. package/src/backoffice/services/product.service.ts +553 -553
  86. package/src/backoffice/services/requirement.service.ts +235 -235
  87. package/src/backoffice/services/subcategory.service.ts +395 -395
  88. package/src/backoffice/services/technology.service.ts +1070 -1070
  89. package/src/backoffice/types/README.md +12 -12
  90. package/src/backoffice/types/admin-constants.types.ts +69 -69
  91. package/src/backoffice/types/brand.types.ts +29 -29
  92. package/src/backoffice/types/category.types.ts +62 -62
  93. package/src/backoffice/types/documentation-templates.types.ts +28 -28
  94. package/src/backoffice/types/index.ts +10 -10
  95. package/src/backoffice/types/procedure-product.types.ts +38 -38
  96. package/src/backoffice/types/product.types.ts +240 -240
  97. package/src/backoffice/types/requirement.types.ts +63 -63
  98. package/src/backoffice/types/static/README.md +18 -18
  99. package/src/backoffice/types/static/blocking-condition.types.ts +21 -21
  100. package/src/backoffice/types/static/certification.types.ts +37 -37
  101. package/src/backoffice/types/static/contraindication.types.ts +19 -19
  102. package/src/backoffice/types/static/index.ts +6 -6
  103. package/src/backoffice/types/static/pricing.types.ts +16 -16
  104. package/src/backoffice/types/static/procedure-family.types.ts +14 -14
  105. package/src/backoffice/types/static/treatment-benefit.types.ts +22 -22
  106. package/src/backoffice/types/subcategory.types.ts +34 -34
  107. package/src/backoffice/types/technology.types.ts +161 -161
  108. package/src/backoffice/validations/index.ts +1 -1
  109. package/src/backoffice/validations/schemas.ts +163 -163
  110. package/src/config/__mocks__/firebase.ts +99 -99
  111. package/src/config/firebase.ts +78 -78
  112. package/src/config/index.ts +9 -9
  113. package/src/errors/auth.error.ts +6 -6
  114. package/src/errors/auth.errors.ts +200 -200
  115. package/src/errors/clinic.errors.ts +32 -32
  116. package/src/errors/firebase.errors.ts +47 -47
  117. package/src/errors/user.errors.ts +99 -99
  118. package/src/index.backup.ts +407 -407
  119. package/src/index.ts +6 -6
  120. package/src/locales/en.ts +31 -31
  121. package/src/recommender/admin/index.ts +1 -1
  122. package/src/recommender/admin/services/recommender.service.admin.ts +5 -5
  123. package/src/recommender/front/index.ts +1 -1
  124. package/src/recommender/front/services/onboarding.service.ts +5 -5
  125. package/src/recommender/front/services/recommender.service.ts +3 -3
  126. package/src/recommender/index.ts +1 -1
  127. package/src/services/PATIENTAUTH.MD +197 -197
  128. package/src/services/README.md +106 -106
  129. package/src/services/__tests__/auth/auth.mock.test.ts +17 -17
  130. package/src/services/__tests__/auth/auth.setup.ts +293 -293
  131. package/src/services/__tests__/auth.service.test.ts +346 -346
  132. package/src/services/__tests__/base.service.test.ts +77 -77
  133. package/src/services/__tests__/user.service.test.ts +528 -528
  134. package/src/services/appointment/README.md +17 -17
  135. package/src/services/appointment/appointment.service.ts +2082 -2082
  136. package/src/services/appointment/index.ts +1 -1
  137. package/src/services/appointment/utils/appointment.utils.ts +552 -552
  138. package/src/services/appointment/utils/extended-procedure.utils.ts +314 -314
  139. package/src/services/appointment/utils/form-initialization.utils.ts +225 -225
  140. package/src/services/appointment/utils/recommended-procedure.utils.ts +195 -195
  141. package/src/services/appointment/utils/zone-management.utils.ts +353 -353
  142. package/src/services/appointment/utils/zone-photo.utils.ts +152 -152
  143. package/src/services/auth/auth.service.ts +989 -989
  144. package/src/services/auth/auth.v2.service.ts +961 -961
  145. package/src/services/auth/index.ts +7 -7
  146. package/src/services/auth/utils/error.utils.ts +90 -90
  147. package/src/services/auth/utils/firebase.utils.ts +49 -49
  148. package/src/services/auth/utils/index.ts +21 -21
  149. package/src/services/auth/utils/practitioner.utils.ts +125 -125
  150. package/src/services/base.service.ts +41 -41
  151. package/src/services/calendar/calendar.service.ts +1077 -1077
  152. package/src/services/calendar/calendar.v2.service.ts +1683 -1683
  153. package/src/services/calendar/calendar.v3.service.ts +313 -313
  154. package/src/services/calendar/externalCalendar.service.ts +178 -178
  155. package/src/services/calendar/index.ts +5 -5
  156. package/src/services/calendar/synced-calendars.service.ts +743 -743
  157. package/src/services/calendar/utils/appointment.utils.ts +265 -265
  158. package/src/services/calendar/utils/calendar-event.utils.ts +646 -646
  159. package/src/services/calendar/utils/clinic.utils.ts +237 -237
  160. package/src/services/calendar/utils/docs.utils.ts +157 -157
  161. package/src/services/calendar/utils/google-calendar.utils.ts +697 -697
  162. package/src/services/calendar/utils/index.ts +8 -8
  163. package/src/services/calendar/utils/patient.utils.ts +198 -198
  164. package/src/services/calendar/utils/practitioner.utils.ts +221 -221
  165. package/src/services/calendar/utils/synced-calendar.utils.ts +472 -472
  166. package/src/services/clinic/README.md +204 -204
  167. package/src/services/clinic/__tests__/clinic-admin.service.test.ts +287 -287
  168. package/src/services/clinic/__tests__/clinic-group.service.test.ts +352 -352
  169. package/src/services/clinic/__tests__/clinic.service.test.ts +354 -354
  170. package/src/services/clinic/billing-transactions.service.ts +217 -217
  171. package/src/services/clinic/clinic-admin.service.ts +202 -202
  172. package/src/services/clinic/clinic-group.service.ts +310 -310
  173. package/src/services/clinic/clinic.service.ts +708 -708
  174. package/src/services/clinic/index.ts +5 -5
  175. package/src/services/clinic/practitioner-invite.service.ts +519 -519
  176. package/src/services/clinic/utils/admin.utils.ts +551 -551
  177. package/src/services/clinic/utils/clinic-group.utils.ts +646 -646
  178. package/src/services/clinic/utils/clinic.utils.ts +949 -949
  179. package/src/services/clinic/utils/filter.utils.d.ts +23 -23
  180. package/src/services/clinic/utils/filter.utils.ts +446 -446
  181. package/src/services/clinic/utils/index.ts +11 -11
  182. package/src/services/clinic/utils/photos.utils.ts +188 -188
  183. package/src/services/clinic/utils/search.utils.ts +84 -84
  184. package/src/services/clinic/utils/tag.utils.ts +124 -124
  185. package/src/services/documentation-templates/documentation-template.service.ts +537 -537
  186. package/src/services/documentation-templates/filled-document.service.ts +587 -587
  187. package/src/services/documentation-templates/index.ts +2 -2
  188. package/src/services/index.ts +13 -13
  189. package/src/services/media/index.ts +1 -1
  190. package/src/services/media/media.service.ts +418 -418
  191. package/src/services/notifications/__tests__/notification.service.test.ts +242 -242
  192. package/src/services/notifications/index.ts +1 -1
  193. package/src/services/notifications/notification.service.ts +215 -215
  194. package/src/services/patient/README.md +48 -48
  195. package/src/services/patient/To-Do.md +43 -43
  196. package/src/services/patient/__tests__/patient.service.test.ts +294 -294
  197. package/src/services/patient/index.ts +2 -2
  198. package/src/services/patient/patient.service.ts +883 -883
  199. package/src/services/patient/patientRequirements.service.ts +285 -285
  200. package/src/services/patient/utils/aesthetic-analysis.utils.ts +176 -176
  201. package/src/services/patient/utils/clinic.utils.ts +80 -80
  202. package/src/services/patient/utils/docs.utils.ts +142 -142
  203. package/src/services/patient/utils/index.ts +9 -9
  204. package/src/services/patient/utils/location.utils.ts +126 -126
  205. package/src/services/patient/utils/medical-stuff.utils.ts +143 -143
  206. package/src/services/patient/utils/medical.utils.ts +458 -458
  207. package/src/services/patient/utils/practitioner.utils.ts +260 -260
  208. package/src/services/patient/utils/profile.utils.ts +510 -510
  209. package/src/services/patient/utils/sensitive.utils.ts +260 -260
  210. package/src/services/patient/utils/token.utils.ts +211 -211
  211. package/src/services/practitioner/README.md +145 -145
  212. package/src/services/practitioner/index.ts +1 -1
  213. package/src/services/practitioner/practitioner.service.ts +1742 -1742
  214. package/src/services/procedure/README.md +163 -163
  215. package/src/services/procedure/index.ts +1 -1
  216. package/src/services/procedure/procedure.service.ts +1682 -1682
  217. package/src/services/reviews/index.ts +1 -1
  218. package/src/services/reviews/reviews.service.ts +683 -636
  219. package/src/services/user/index.ts +1 -1
  220. package/src/services/user/user.service.ts +489 -489
  221. package/src/services/user/user.v2.service.ts +466 -466
  222. package/src/types/appointment/index.ts +453 -453
  223. package/src/types/calendar/index.ts +258 -258
  224. package/src/types/calendar/synced-calendar.types.ts +66 -66
  225. package/src/types/clinic/index.ts +489 -489
  226. package/src/types/clinic/practitioner-invite.types.ts +91 -91
  227. package/src/types/clinic/preferences.types.ts +159 -159
  228. package/src/types/clinic/to-do +3 -3
  229. package/src/types/documentation-templates/index.ts +308 -308
  230. package/src/types/index.ts +44 -44
  231. package/src/types/notifications/README.md +77 -77
  232. package/src/types/notifications/index.ts +265 -265
  233. package/src/types/patient/aesthetic-analysis.types.ts +66 -66
  234. package/src/types/patient/allergies.ts +58 -58
  235. package/src/types/patient/index.ts +273 -273
  236. package/src/types/patient/medical-info.types.ts +152 -152
  237. package/src/types/patient/patient-requirements.ts +92 -92
  238. package/src/types/patient/token.types.ts +61 -61
  239. package/src/types/practitioner/index.ts +206 -206
  240. package/src/types/procedure/index.ts +181 -181
  241. package/src/types/profile/index.ts +39 -39
  242. package/src/types/reviews/index.ts +132 -130
  243. package/src/types/tz-lookup.d.ts +4 -4
  244. package/src/types/user/index.ts +38 -38
  245. package/src/utils/TIMESTAMPS.md +176 -176
  246. package/src/utils/TimestampUtils.ts +241 -241
  247. package/src/utils/index.ts +1 -1
  248. package/src/validations/appointment.schema.ts +574 -574
  249. package/src/validations/calendar.schema.ts +225 -225
  250. package/src/validations/clinic.schema.ts +493 -493
  251. package/src/validations/common.schema.ts +25 -25
  252. package/src/validations/documentation-templates/index.ts +1 -1
  253. package/src/validations/documentation-templates/template.schema.ts +220 -220
  254. package/src/validations/documentation-templates.schema.ts +10 -10
  255. package/src/validations/index.ts +20 -20
  256. package/src/validations/media.schema.ts +10 -10
  257. package/src/validations/notification.schema.ts +90 -90
  258. package/src/validations/patient/aesthetic-analysis.schema.ts +55 -55
  259. package/src/validations/patient/medical-info.schema.ts +125 -125
  260. package/src/validations/patient/patient-requirements.schema.ts +84 -84
  261. package/src/validations/patient/token.schema.ts +29 -29
  262. package/src/validations/patient.schema.ts +216 -216
  263. package/src/validations/practitioner.schema.ts +222 -222
  264. package/src/validations/procedure-product.schema.ts +41 -41
  265. package/src/validations/procedure.schema.ts +124 -124
  266. package/src/validations/profile-info.schema.ts +41 -41
  267. package/src/validations/reviews.schema.ts +195 -189
  268. package/src/validations/schemas.ts +104 -104
  269. package/src/validations/shared.schema.ts +78 -78
@@ -1,1844 +1,1844 @@
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 { Practitioner, PRACTITIONERS_COLLECTION } from '../../../types/practitioner';
27
- import { Clinic, CLINICS_COLLECTION } from '../../../types/clinic';
28
- import { Procedure, PROCEDURES_COLLECTION } from '../../../types/procedure';
29
- import { RequirementSourceProcedure } from '../../../types/patient/patient-requirements';
30
- // import { UserRole } from "../../../types"; // Not directly used
31
-
32
- // Dependent Admin Services
33
- import { PatientRequirementsAdminService } from '../../requirements/patient-requirements.admin.service';
34
- import { NotificationsAdmin } from '../../notifications/notifications.admin';
35
- import { CalendarAdminService } from '../../calendar/calendar.admin.service';
36
- import { AppointmentMailingService } from '../../mailing/appointment/appointment.mailing.service';
37
- import { Logger } from '../../logger';
38
- import { UserRole } from '../../../types';
39
- import { CalendarEventStatus } from '../../../types/calendar';
40
-
41
- // Mailgun client will be injected via constructor
42
-
43
- /**
44
- * Type for requirement with source procedure tracking
45
- */
46
- type RequirementWithSource = {
47
- requirement: RequirementTemplate;
48
- sourceProcedures: RequirementSourceProcedure[];
49
- };
50
-
51
- /**
52
- * @class AppointmentAggregationService
53
- * @description Handles aggregation tasks and side effects related to appointment lifecycle events.
54
- * This service is intended to be used primarily by background functions (e.g., Cloud Functions)
55
- * triggered by changes in the appointments collection.
56
- */
57
- export class AppointmentAggregationService {
58
- private db: admin.firestore.Firestore;
59
- private appointmentMailingService: AppointmentMailingService;
60
- private notificationsAdmin: NotificationsAdmin;
61
- private calendarAdminService: CalendarAdminService;
62
- private patientRequirementsAdminService: PatientRequirementsAdminService;
63
-
64
- /**
65
- * Constructor for AppointmentAggregationService.
66
- * @param mailgunClient - An initialized Mailgun client instance.
67
- * @param firestore Optional Firestore instance. If not provided, it uses the default admin SDK instance.
68
- */
69
- constructor(
70
- mailgunClient: any, // Type as 'any' for now, to be provided by the calling Cloud Function
71
- firestore?: admin.firestore.Firestore,
72
- ) {
73
- this.db = firestore || admin.firestore();
74
- this.appointmentMailingService = new AppointmentMailingService(
75
- this.db,
76
- mailgunClient, // Pass the injected client
77
- );
78
- this.notificationsAdmin = new NotificationsAdmin(this.db);
79
- this.calendarAdminService = new CalendarAdminService(this.db);
80
- this.patientRequirementsAdminService = new PatientRequirementsAdminService(this.db);
81
- Logger.info('[AppointmentAggregationService] Initialized.');
82
- }
83
-
84
- /**
85
- * Handles side effects when an appointment is first created.
86
- * This function would typically be called by an Firestore onCreate trigger.
87
- * @param {Appointment} appointment - The newly created Appointment object.
88
- * @returns {Promise<void>}
89
- */
90
- async handleAppointmentCreate(appointment: Appointment): Promise<void> {
91
- Logger.info(
92
- `[AggService] Handling CREATE for appointment: ${appointment.id}, patient: ${appointment.patientId}, status: ${appointment.status}`,
93
- );
94
-
95
- try {
96
- // 1. Fetch necessary profiles for notifications and context
97
- // These can be fetched in parallel
98
- const [patientProfile, patientSensitiveInfo, practitionerProfile, clinicInfo] =
99
- await Promise.all([
100
- this.fetchPatientProfile(appointment.patientId),
101
- this.fetchPatientSensitiveInfo(appointment.patientId),
102
- this.fetchPractitionerProfile(appointment.practitionerId), // Needed for practitioner notifications
103
- this.fetchClinicInfo(appointment.clinicBranchId), // Needed for clinic admin notifications
104
- ]);
105
-
106
- // 2. Manage Patient-Clinic-Practitioner Links (moved from beginning to here)
107
- // Now we can pass the already fetched patient profile
108
- if (patientProfile) {
109
- await this.managePatientClinicPractitionerLinks(
110
- patientProfile,
111
- appointment.practitionerId,
112
- appointment.clinicBranchId,
113
- 'create',
114
- );
115
- }
116
-
117
- // 3. Initial State Handling based on appointment status
118
- if (appointment.status === AppointmentStatus.CONFIRMED) {
119
- Logger.info(`[AggService] Appt ${appointment.id} created as CONFIRMED.`);
120
- // Create pre-appointment requirements for confirmed appointments
121
- await this.createPreAppointmentRequirementInstances(appointment);
122
-
123
- // Send confirmation notifications
124
- if (patientSensitiveInfo?.email && patientProfile) {
125
- Logger.info(
126
- `[AggService] Sending appointment confirmed email to patient ${patientSensitiveInfo.email}`,
127
- );
128
- // Construct the data object for the mailing service
129
- const emailData = {
130
- appointment: appointment,
131
- recipientProfile: appointment.patientInfo,
132
- recipientRole: 'patient' as const, // Use 'as const' for literal type
133
- };
134
- // The type cast here might still be an issue if PatientProfileInfo is not imported.
135
- // However, the structure should be compatible enough for the call.
136
- await this.appointmentMailingService.sendAppointmentConfirmedEmail(
137
- emailData as any, // Using 'as any' temporarily to bypass strict type checking if PatientProfileInfo is not imported
138
- // TODO: Properly import PatientProfileInfo and ensure type compatibility
139
- );
140
- } else {
141
- Logger.warn(
142
- `[AggService] Cannot send confirmation email to patient ${appointment.patientId}: email missing.`,
143
- );
144
- }
145
-
146
- if (patientProfile?.expoTokens && patientProfile.expoTokens.length > 0) {
147
- Logger.info(
148
- `[AggService] TODO: Send appointment confirmed push to patient ${appointment.patientId}`,
149
- );
150
- await this.notificationsAdmin.sendAppointmentConfirmedPush(
151
- appointment,
152
- appointment.patientId,
153
- patientProfile.expoTokens,
154
- UserRole.PATIENT,
155
- );
156
- }
157
-
158
- if (practitionerProfile?.basicInfo?.email) {
159
- Logger.info(
160
- `[AggService] Sending appointment confirmation email to practitioner ${practitionerProfile.basicInfo.email}`,
161
- );
162
- const practitionerEmailData = {
163
- appointment: appointment,
164
- recipientProfile: appointment.practitionerInfo,
165
- recipientRole: 'practitioner' as const,
166
- };
167
- await this.appointmentMailingService.sendAppointmentConfirmedEmail(
168
- practitionerEmailData, // TODO: Properly import PractitionerProfileInfo and ensure type compatibility
169
- );
170
- }
171
- // TODO: Add push notification for practitioner if they have expoTokens
172
- } else if (appointment.status === AppointmentStatus.PENDING) {
173
- Logger.info(`[AggService] Appt ${appointment.id} created as PENDING.`);
174
- // Notify clinic admin about the pending appointment
175
- if (clinicInfo?.contactInfo?.email) {
176
- Logger.info(
177
- `[AggService] TODO: Send pending appointment notification email to clinic admin ${clinicInfo.contactInfo.email}`,
178
- );
179
- const clinicEmailData = {
180
- appointment: appointment,
181
- clinicProfile: appointment.clinicInfo, // clinicInfo should be compatible with ClinicInfo type
182
- };
183
- await this.appointmentMailingService.sendAppointmentRequestedEmailToClinic(
184
- clinicEmailData, // TODO: Properly import ClinicInfo if stricter typing is needed here and ensure compatibility
185
- );
186
- } else {
187
- Logger.warn(
188
- `[AggService] Cannot send pending appointment email to clinic ${appointment.clinicBranchId}: email missing.`,
189
- );
190
- }
191
- // TODO: Push notification for clinic admin if applicable (they usually don't have tokens)
192
- }
193
-
194
- // Calendar events are noted as being handled by BookingAdmin.orchestrateAppointmentCreation during the booking process itself.
195
- Logger.info(`[AggService] Successfully processed CREATE for appointment: ${appointment.id}`);
196
- } catch (error) {
197
- Logger.error(
198
- `[AggService] Critical error in handleAppointmentCreate for appointment ${appointment.id}:`,
199
- error,
200
- );
201
- // Depending on the error, you might want to re-throw or handle specific cases
202
- // (e.g., update appointment status to an error state if a critical part failed)
203
- }
204
- }
205
-
206
- /**
207
- * Handles side effects when an appointment is updated.
208
- * This function would typically be called by an Firestore onUpdate trigger.
209
- * @param {Appointment} before - The Appointment object before the update.
210
- * @param {Appointment} after - The Appointment object after the update.
211
- * @returns {Promise<void>}
212
- */
213
- async handleAppointmentUpdate(before: Appointment, after: Appointment): Promise<void> {
214
- Logger.info(
215
- `[AggService] Handling UPDATE for appointment: ${after.id}. Status ${before.status} -> ${after.status}`,
216
- );
217
-
218
- try {
219
- const statusChanged = before.status !== after.status;
220
- const timeChanged =
221
- before.appointmentStartTime.toMillis() !== after.appointmentStartTime.toMillis() ||
222
- before.appointmentEndTime.toMillis() !== after.appointmentEndTime.toMillis();
223
- const zonePhotosChanged = this.hasZonePhotosChanged(before, after);
224
- // const paymentStatusChanged = before.paymentStatus !== after.paymentStatus; // TODO: Handle later
225
- // const reviewAdded = !before.reviewInfo && after.reviewInfo; // TODO: Handle later
226
-
227
- // Fetch profiles for notifications - could be conditional based on changes
228
- // For simplicity, fetching upfront, but optimize if performance is an issue.
229
- const [patientProfile, patientSensitiveInfo, practitionerProfile, clinicInfo] =
230
- await Promise.all([
231
- this.fetchPatientProfile(after.patientId),
232
- this.fetchPatientSensitiveInfo(after.patientId),
233
- this.fetchPractitionerProfile(after.practitionerId),
234
- this.fetchClinicInfo(after.clinicBranchId),
235
- ]);
236
-
237
- if (statusChanged) {
238
- Logger.info(
239
- `[AggService] Status changed for ${after.id}: ${before.status} -> ${after.status}`,
240
- );
241
-
242
- // --- PENDING -> CONFIRMED ---
243
- if (
244
- before.status === AppointmentStatus.PENDING &&
245
- after.status === AppointmentStatus.CONFIRMED
246
- ) {
247
- Logger.info(`[AggService] Appt ${after.id} PENDING -> CONFIRMED.`);
248
- await this.createPreAppointmentRequirementInstances(after);
249
-
250
- // Update calendar events to CONFIRMED status
251
- await this.calendarAdminService.updateAppointmentCalendarEventsStatus(
252
- after,
253
- CalendarEventStatus.CONFIRMED,
254
- );
255
-
256
- // Send confirmation notifications
257
- if (patientSensitiveInfo?.email && patientProfile) {
258
- Logger.info(
259
- `[AggService] Sending appointment confirmed email to patient ${patientSensitiveInfo.email}`,
260
- );
261
- const emailData = {
262
- appointment: after,
263
- recipientProfile: after.patientInfo,
264
- recipientRole: 'patient' as const,
265
- };
266
- await this.appointmentMailingService.sendAppointmentConfirmedEmail(emailData as any);
267
- } else {
268
- Logger.warn(
269
- `[AggService] Cannot send confirmation email to patient ${after.patientId}: email missing.`,
270
- );
271
- }
272
-
273
- if (patientProfile?.expoTokens && patientProfile.expoTokens.length > 0) {
274
- Logger.info(
275
- `[AggService] TODO: Send appointment confirmed push to patient ${after.patientId}`,
276
- );
277
- await this.notificationsAdmin.sendAppointmentConfirmedPush(
278
- after,
279
- after.patientId,
280
- patientProfile.expoTokens,
281
- UserRole.PATIENT,
282
- );
283
- }
284
-
285
- if (practitionerProfile?.basicInfo?.email) {
286
- Logger.info(
287
- `[AggService] Sending appointment confirmation email to practitioner ${practitionerProfile.basicInfo.email}`,
288
- );
289
- const practitionerEmailData = {
290
- appointment: after,
291
- recipientProfile: after.practitionerInfo,
292
- recipientRole: 'practitioner' as const,
293
- };
294
- await this.appointmentMailingService.sendAppointmentConfirmedEmail(
295
- practitionerEmailData as any,
296
- );
297
- }
298
- }
299
- // --- RESCHEDULED_BY_CLINIC -> CONFIRMED (Reschedule Acceptance) ---
300
- else if (
301
- before.status === AppointmentStatus.RESCHEDULED_BY_CLINIC &&
302
- after.status === AppointmentStatus.CONFIRMED
303
- ) {
304
- Logger.info(`[AggService] Appt ${after.id} RESCHEDULED_BY_CLINIC -> CONFIRMED.`);
305
-
306
- // Update existing requirements as superseded and create new ones
307
- await this.updateRelatedPatientRequirementInstances(
308
- before,
309
- PatientRequirementOverallStatus.SUPERSEDED_RESCHEDULE,
310
- );
311
- await this.createPreAppointmentRequirementInstances(after);
312
-
313
- // Update calendar events to CONFIRMED status and update times
314
- await this.calendarAdminService.updateAppointmentCalendarEventsStatus(
315
- after,
316
- CalendarEventStatus.CONFIRMED,
317
- );
318
-
319
- // Send confirmation notifications (similar to PENDING -> CONFIRMED)
320
- if (patientSensitiveInfo?.email && patientProfile) {
321
- Logger.info(
322
- `[AggService] Sending appointment confirmed email to patient ${patientSensitiveInfo.email}`,
323
- );
324
- const emailData = {
325
- appointment: after,
326
- recipientProfile: after.patientInfo,
327
- recipientRole: 'patient' as const,
328
- };
329
- await this.appointmentMailingService.sendAppointmentConfirmedEmail(emailData as any);
330
- }
331
-
332
- if (patientProfile?.expoTokens && patientProfile.expoTokens.length > 0) {
333
- await this.notificationsAdmin.sendAppointmentConfirmedPush(
334
- after,
335
- after.patientId,
336
- patientProfile.expoTokens,
337
- UserRole.PATIENT,
338
- );
339
- }
340
-
341
- if (practitionerProfile?.basicInfo?.email) {
342
- Logger.info(
343
- `[AggService] Sending appointment confirmation email to practitioner ${practitionerProfile.basicInfo.email}`,
344
- );
345
- const practitionerEmailData = {
346
- appointment: after,
347
- recipientProfile: after.practitionerInfo,
348
- recipientRole: 'practitioner' as const,
349
- };
350
- await this.appointmentMailingService.sendAppointmentConfirmedEmail(
351
- practitionerEmailData as any,
352
- );
353
- }
354
- }
355
- // --- Any -> CANCELLED_* ---
356
- else if (
357
- after.status === AppointmentStatus.CANCELED_CLINIC ||
358
- after.status === AppointmentStatus.CANCELED_PATIENT ||
359
- after.status === AppointmentStatus.CANCELED_PATIENT_RESCHEDULED ||
360
- after.status === AppointmentStatus.NO_SHOW
361
- ) {
362
- Logger.info(
363
- `[AggService] Appt ${after.id} status -> ${after.status}. Processing as cancellation.`,
364
- );
365
- await this.updateRelatedPatientRequirementInstances(
366
- after,
367
- PatientRequirementOverallStatus.CANCELLED_APPOINTMENT,
368
- );
369
-
370
- // Update patient-clinic-practitioner links if patient profile exists
371
- if (patientProfile) {
372
- await this.managePatientClinicPractitionerLinks(
373
- patientProfile,
374
- after.practitionerId,
375
- after.clinicBranchId,
376
- 'cancel',
377
- after.status,
378
- );
379
- }
380
-
381
- const calendarStatus = (status: AppointmentStatus) => {
382
- switch (status) {
383
- case AppointmentStatus.NO_SHOW:
384
- return CalendarEventStatus.NO_SHOW;
385
- case AppointmentStatus.CANCELED_CLINIC:
386
- return CalendarEventStatus.REJECTED;
387
- case AppointmentStatus.CANCELED_PATIENT:
388
- return CalendarEventStatus.CANCELED;
389
- case AppointmentStatus.CANCELED_PATIENT_RESCHEDULED:
390
- return CalendarEventStatus.REJECTED;
391
- default:
392
- return CalendarEventStatus.CANCELED;
393
- }
394
- };
395
-
396
- await this.calendarAdminService.updateAppointmentCalendarEventsStatus(
397
- after,
398
- calendarStatus(after.status),
399
- );
400
-
401
- // Send cancellation email to Patient
402
- if (patientSensitiveInfo?.email && patientProfile) {
403
- Logger.info(
404
- `[AggService] Sending appointment cancellation email to patient ${patientSensitiveInfo.email}`,
405
- );
406
- const patientCancellationData = {
407
- appointment: after,
408
- recipientProfile: after.patientInfo,
409
- recipientRole: 'patient' as const,
410
- // cancellationReason: after.cancellationReason, // TODO: Add if cancellationReason is available on 'after' Appointment
411
- };
412
- await this.appointmentMailingService.sendAppointmentCancelledEmail(
413
- patientCancellationData as any, // TODO: Properly import types
414
- );
415
- }
416
-
417
- // Send cancellation email to Practitioner
418
- if (practitionerProfile?.basicInfo?.email) {
419
- Logger.info(
420
- `[AggService] Sending appointment cancellation email to practitioner ${practitionerProfile.basicInfo.email}`,
421
- );
422
- const practitionerCancellationData = {
423
- appointment: after,
424
- recipientProfile: after.practitionerInfo,
425
- recipientRole: 'practitioner' as const,
426
- // cancellationReason: after.cancellationReason, // TODO: Add if cancellationReason is available on 'after' Appointment
427
- };
428
- await this.appointmentMailingService.sendAppointmentCancelledEmail(
429
- practitionerCancellationData as any, // TODO: Properly import types
430
- );
431
- }
432
-
433
- // TODO: Send cancellation push notifications (patient, practitioner) via notificationsAdmin
434
- // TODO: Update/cancel calendar event via calendarAdminService.updateAppointmentCalendarEventStatus(after, CalendarEventStatus.CANCELED)
435
- }
436
- // --- Any -> COMPLETED ---
437
- else if (after.status === AppointmentStatus.COMPLETED) {
438
- Logger.info(`[AggService] Appt ${after.id} status -> COMPLETED.`);
439
- await this.createPostAppointmentRequirementInstances(after);
440
-
441
- // Update calendar events to COMPLETED status
442
- await this.calendarAdminService.updateAppointmentCalendarEventsStatus(
443
- after,
444
- CalendarEventStatus.COMPLETED,
445
- );
446
-
447
- // Send review request email to patient
448
- if (patientSensitiveInfo?.email && patientProfile) {
449
- Logger.info(
450
- `[AggService] Sending review request email to patient ${patientSensitiveInfo.email}`,
451
- );
452
- const reviewRequestData = {
453
- appointment: after,
454
- patientProfile: after.patientInfo,
455
- reviewLink: 'TODO: Generate actual review link', // Placeholder
456
- };
457
- await this.appointmentMailingService.sendReviewRequestEmail(
458
- reviewRequestData as any, // TODO: Properly import PatientProfileInfo and define reviewLink generation
459
- );
460
- }
461
- // TODO: Send review request push notification to patient
462
- }
463
- // --- RESCHEDULE Scenarios (e.g., PENDING/CONFIRMED -> RESCHEDULED_BY_CLINIC) ---
464
- else if (after.status === AppointmentStatus.RESCHEDULED_BY_CLINIC) {
465
- Logger.info(`[AggService] Appt ${after.id} status -> RESCHEDULED_BY_CLINIC.`);
466
- await this.updateRelatedPatientRequirementInstances(
467
- before, // Pass the 'before' state for old requirements
468
- PatientRequirementOverallStatus.SUPERSEDED_RESCHEDULE,
469
- );
470
-
471
- // First update the calendar event times with new proposed times
472
- await this.calendarAdminService.updateAppointmentCalendarEventsTime(after, {
473
- start: after.appointmentStartTime,
474
- end: after.appointmentEndTime,
475
- });
476
-
477
- // Then update calendar events to PENDING status (waiting for patient confirmation)
478
- await this.calendarAdminService.updateAppointmentCalendarEventsStatus(
479
- after,
480
- CalendarEventStatus.PENDING,
481
- );
482
-
483
- // Send reschedule proposal email to patient
484
- if (patientSensitiveInfo?.email && patientProfile) {
485
- Logger.info(
486
- `[AggService] Sending reschedule proposal email to patient ${patientSensitiveInfo.email}`,
487
- );
488
- const rescheduleEmailData = {
489
- appointment: after, // The new state of the appointment
490
- patientProfile: after.patientInfo,
491
- previousStartTime: before.appointmentStartTime,
492
- previousEndTime: before.appointmentEndTime,
493
- };
494
- await this.appointmentMailingService.sendAppointmentRescheduledProposalEmail(
495
- rescheduleEmailData as any, // TODO: Properly import PatientProfileInfo and types
496
- );
497
- }
498
-
499
- Logger.info(
500
- `[AggService] TODO: Send reschedule proposal notifications to practitioner as well.`,
501
- );
502
- // TODO: Update calendar event to reflect proposed new time via calendarAdminService.
503
- }
504
- // TODO: Add more specific status change handlers as needed
505
- }
506
-
507
- // --- Independent Time Change (if not tied to a status that already handled it) ---
508
- if (timeChanged && !statusChanged) {
509
- // Or if status change didn't fully cover reschedule implications
510
- Logger.info(`[AggService] Appointment ${after.id} time changed.`);
511
-
512
- // If confirmed appointment has time change, we need to update requirements
513
- if (after.status === AppointmentStatus.CONFIRMED) {
514
- // Update existing requirements as superseded and create new ones
515
- await this.updateRelatedPatientRequirementInstances(
516
- before,
517
- PatientRequirementOverallStatus.SUPERSEDED_RESCHEDULE,
518
- );
519
- await this.createPreAppointmentRequirementInstances(after);
520
-
521
- // Update calendar event times with new times
522
- await this.calendarAdminService.updateAppointmentCalendarEventsTime(after, {
523
- start: after.appointmentStartTime,
524
- end: after.appointmentEndTime,
525
- });
526
- } else {
527
- Logger.warn(
528
- `[AggService] Independent time change detected for ${after.id} with status ${after.status}. Review implications for requirements and calendar.`,
529
- );
530
- }
531
- }
532
-
533
- // TODO: Handle Payment Status Change
534
- // const paymentStatusChanged = before.paymentStatus !== after.paymentStatus;
535
- // if (paymentStatusChanged && after.paymentStatus === PaymentStatus.PAID) { ... }
536
-
537
- // Handle Zone Photos Changes
538
- if (zonePhotosChanged) {
539
- Logger.info(`[AggService] Zone photos changed for appointment ${after.id}`);
540
- await this.handleZonePhotosUpdate(before, after);
541
- }
542
-
543
- // TODO: Handle Review Added
544
- // const reviewAdded = !before.reviewInfo && after.reviewInfo;
545
- // if (reviewAdded) { ... }
546
-
547
- Logger.info(`[AggService] Successfully processed UPDATE for appointment: ${after.id}`);
548
- } catch (error) {
549
- Logger.error(
550
- `[AggService] Critical error in handleAppointmentUpdate for appointment ${after.id}:`,
551
- error,
552
- );
553
- }
554
- }
555
-
556
- /**
557
- * Handles side effects when an appointment is deleted.
558
- * @param deletedAppointment - The Appointment object that was deleted.
559
- * @returns {Promise<void>}
560
- */
561
- async handleAppointmentDelete(deletedAppointment: Appointment): Promise<void> {
562
- Logger.info(`[AggService] Handling DELETE for appointment: ${deletedAppointment.id}`);
563
- // Similar to cancellation
564
- await this.updateRelatedPatientRequirementInstances(
565
- deletedAppointment,
566
- PatientRequirementOverallStatus.CANCELLED_APPOINTMENT,
567
- );
568
-
569
- // Fetch patient profile first
570
- const patientProfile = await this.fetchPatientProfile(deletedAppointment.patientId);
571
-
572
- // Update relationship links if patient profile exists
573
- if (patientProfile) {
574
- await this.managePatientClinicPractitionerLinks(
575
- patientProfile,
576
- deletedAppointment.practitionerId,
577
- deletedAppointment.clinicBranchId,
578
- 'cancel',
579
- );
580
- }
581
-
582
- // Delete all associated calendar events
583
- await this.calendarAdminService.deleteAppointmentCalendarEvents(deletedAppointment);
584
-
585
- // TODO: Send cancellation/deletion notifications if appropriate (though data is gone)
586
- }
587
-
588
- // --- Helper Methods for Aggregation Logic ---
589
-
590
- /**
591
- * Creates PRE_APPOINTMENT PatientRequirementInstance documents for a given appointment.
592
- * Uses the `appointment.preProcedureRequirements` array, which should contain relevant Requirement templates.
593
- * For each active PRE requirement template, it constructs a new PatientRequirementInstance document
594
- * with derived instructions and batch writes them to Firestore under the patient's `patient_requirements` subcollection.
595
- *
596
- * @param {Appointment} appointment - The appointment for which to create pre-requirement instances.
597
- * @returns {Promise<void>} A promise that resolves when the operation is complete.
598
- */
599
- private async createPreAppointmentRequirementInstances(appointment: Appointment): Promise<void> {
600
- Logger.info(
601
- `[AggService] Creating PRE-appointment requirement instances for appt: ${appointment.id}, patient: ${appointment.patientId}`,
602
- );
603
-
604
- if (!appointment.procedureId) {
605
- Logger.warn(
606
- `[AggService] Appointment ${appointment.id} has no procedureId. Cannot create pre-requirement instances.`,
607
- );
608
- return;
609
- }
610
-
611
- if (
612
- !appointment.preProcedureRequirements ||
613
- appointment.preProcedureRequirements.length === 0
614
- ) {
615
- Logger.info(
616
- `[AggService] No preProcedureRequirements found on appointment ${appointment.id}. Nothing to create.`,
617
- );
618
- return;
619
- }
620
-
621
- try {
622
- const batch = this.db.batch();
623
- let instancesCreatedCount = 0;
624
- // Store created instances for fallback direct creation if needed
625
- let createdInstances = [];
626
-
627
- // Log more details about the pre-requirements
628
- Logger.info(
629
- `[AggService] Found ${
630
- appointment.preProcedureRequirements.length
631
- } pre-requirements to process: ${JSON.stringify(
632
- appointment.preProcedureRequirements.map(r => ({
633
- id: r.id,
634
- name: r.name,
635
- type: r.type,
636
- isActive: r.isActive,
637
- hasTimeframe: !!r.timeframe,
638
- notifyAtLength: r.timeframe?.notifyAt?.length || 0,
639
- })),
640
- )}`,
641
- );
642
-
643
- for (const template of appointment.preProcedureRequirements) {
644
- if (!template) {
645
- Logger.warn(
646
- `[AggService] Found null/undefined template in preProcedureRequirements array`,
647
- );
648
- continue;
649
- }
650
-
651
- // Ensure it's an active, PRE-type requirement
652
- if (template.type !== RequirementType.PRE || !template.isActive) {
653
- Logger.debug(
654
- `[AggService] Skipping template ${template.id} (${template.name}): not an active PRE requirement.`,
655
- );
656
- continue;
657
- }
658
-
659
- if (
660
- !template.timeframe ||
661
- !template.timeframe.notifyAt ||
662
- template.timeframe.notifyAt.length === 0
663
- ) {
664
- Logger.warn(
665
- `[AggService] Template ${template.id} (${template.name}) has no timeframe.notifyAt values. Creating with empty instructions.`,
666
- );
667
- }
668
-
669
- Logger.debug(
670
- `[AggService] Processing template ${template.id} (${template.name}) for appt ${appointment.id}`,
671
- );
672
-
673
- const newInstanceRef = this.db
674
- .collection(PATIENTS_COLLECTION)
675
- .doc(appointment.patientId)
676
- .collection(PATIENT_REQUIREMENTS_SUBCOLLECTION_NAME)
677
- .doc(); // Auto-generate ID for the new instance
678
-
679
- // Log the path for debugging
680
- Logger.debug(`[AggService] Created doc reference: ${newInstanceRef.path}`);
681
-
682
- const instructions: PatientRequirementInstruction[] = (
683
- template.timeframe?.notifyAt || []
684
- ).map(notifyAtValue => {
685
- let dueTime: any = appointment.appointmentStartTime;
686
- if (template.timeframe && typeof notifyAtValue === 'number') {
687
- const dueDateTime = new Date(appointment.appointmentStartTime.toMillis());
688
- if (template.timeframe.unit === TimeUnit.DAYS) {
689
- dueDateTime.setDate(dueDateTime.getDate() - notifyAtValue);
690
- } else if (template.timeframe.unit === TimeUnit.HOURS) {
691
- dueDateTime.setHours(dueDateTime.getHours() - notifyAtValue);
692
- }
693
- dueTime = admin.firestore.Timestamp.fromDate(dueDateTime);
694
- }
695
-
696
- // TODO: Determine source or default for 'actionableWindow' - consult requirements for PatientRequirementInstruction
697
- const actionableWindowHours =
698
- template.importance === 'high' ? 1 : template.importance === 'medium' ? 4 : 15; // Default to 15 hours for low importance; // Placeholder default, TODO: Define source
699
-
700
- const instructionObject: PatientRequirementInstruction = {
701
- instructionId: `${template.id}_${notifyAtValue}_${newInstanceRef.id}`.replace(
702
- /[^a-zA-Z0-9_]/g,
703
- '_',
704
- ),
705
- instructionText: template.description || template.name,
706
- dueTime: dueTime as any,
707
- actionableWindow: actionableWindowHours, // Directly assigning the placeholder default value
708
- status: PatientInstructionStatus.PENDING_NOTIFICATION,
709
- originalNotifyAtValue: notifyAtValue,
710
- originalTimeframeUnit: template.timeframe.unit,
711
- updatedAt: admin.firestore.Timestamp.now() as any, // Use current server timestamp
712
- };
713
- return instructionObject;
714
- });
715
-
716
- const newInstanceData: PatientRequirementInstance = {
717
- id: newInstanceRef.id, // Add the ID to the document data
718
- patientId: appointment.patientId,
719
- appointmentId: appointment.id,
720
- originalRequirementId: template.id,
721
- requirementName: template.name,
722
- requirementDescription: template.description,
723
- requirementType: template.type, // Should be RequirementType.PRE
724
- requirementImportance: template.importance,
725
- overallStatus: PatientRequirementOverallStatus.ACTIVE,
726
- instructions: instructions,
727
- // Timestamps - cast to any to satisfy client-side Timestamp type for now
728
- createdAt: admin.firestore.FieldValue.serverTimestamp() as any,
729
- updatedAt: admin.firestore.FieldValue.serverTimestamp() as any,
730
- };
731
-
732
- // Log the data being set
733
- Logger.debug(
734
- `[AggService] Setting data for requirement: ${JSON.stringify({
735
- id: newInstanceRef.id,
736
- patientId: newInstanceData.patientId,
737
- appointmentId: newInstanceData.appointmentId,
738
- requirementName: newInstanceData.requirementName,
739
- instructionsCount: newInstanceData.instructions.length,
740
- })}`,
741
- );
742
-
743
- batch.set(newInstanceRef, newInstanceData);
744
- // Store for potential fallback
745
- createdInstances.push({
746
- ref: newInstanceRef,
747
- data: newInstanceData,
748
- });
749
-
750
- instancesCreatedCount++;
751
- Logger.debug(
752
- `[AggService] Added PatientRequirementInstance ${newInstanceRef.id} to batch for template ${template.id}.`,
753
- );
754
- }
755
-
756
- if (instancesCreatedCount > 0) {
757
- try {
758
- await batch.commit();
759
- Logger.info(
760
- `[AggService] Successfully created ${instancesCreatedCount} PRE_APPOINTMENT requirement instances for appointment ${appointment.id}.`,
761
- );
762
-
763
- // Verify creation success
764
- try {
765
- const verifySnapshot = await this.db
766
- .collection(PATIENTS_COLLECTION)
767
- .doc(appointment.patientId)
768
- .collection(PATIENT_REQUIREMENTS_SUBCOLLECTION_NAME)
769
- .where('appointmentId', '==', appointment.id)
770
- .get();
771
-
772
- if (verifySnapshot.empty) {
773
- Logger.warn(
774
- `[AggService] Batch commit reported success but documents not found! Attempting direct creation as fallback...`,
775
- );
776
-
777
- // Fallback to direct creation if batch worked but docs aren't there
778
- const fallbackPromises = createdInstances.map(async ({ ref, data }) => {
779
- try {
780
- await ref.set(data);
781
- Logger.info(`[AggService] Fallback direct creation success for ${ref.id}`);
782
- return true;
783
- } catch (fallbackError) {
784
- Logger.error(
785
- `[AggService] Fallback direct creation failed for ${ref.id}:`,
786
- fallbackError,
787
- );
788
- return false;
789
- }
790
- });
791
-
792
- const fallbackResults = await Promise.allSettled(fallbackPromises);
793
- const successCount = fallbackResults.filter(
794
- r => r.status === 'fulfilled' && r.value === true,
795
- ).length;
796
-
797
- if (successCount > 0) {
798
- Logger.info(
799
- `[AggService] Fallback mechanism created ${successCount} out of ${createdInstances.length} requirements`,
800
- );
801
- } else {
802
- Logger.error(
803
- `[AggService] Both batch and fallback mechanisms failed to create requirements`,
804
- );
805
- throw new Error(
806
- 'Failed to create patient requirements through both batch and direct methods',
807
- );
808
- }
809
- } else {
810
- Logger.info(
811
- `[AggService] Verification confirmed ${verifySnapshot.size} requirement documents created`,
812
- );
813
- }
814
- } catch (verifyError) {
815
- Logger.error(
816
- `[AggService] Error during verification of created requirements:`,
817
- verifyError,
818
- );
819
- }
820
- } catch (commitError) {
821
- Logger.error(
822
- `[AggService] Error committing batch for PRE_APPOINTMENT requirement instances for appointment ${appointment.id}:`,
823
- commitError,
824
- );
825
-
826
- // Try direct creation as fallback
827
- Logger.info(`[AggService] Attempting direct creation as fallback...`);
828
- const fallbackPromises = createdInstances.map(async ({ ref, data }) => {
829
- try {
830
- await ref.set(data);
831
- Logger.info(`[AggService] Fallback direct creation success for ${ref.id}`);
832
- return true;
833
- } catch (fallbackError) {
834
- Logger.error(
835
- `[AggService] Fallback direct creation failed for ${ref.id}:`,
836
- fallbackError,
837
- );
838
- return false;
839
- }
840
- });
841
-
842
- const fallbackResults = await Promise.allSettled(fallbackPromises);
843
- const successCount = fallbackResults.filter(
844
- r => r.status === 'fulfilled' && r.value === true,
845
- ).length;
846
-
847
- if (successCount > 0) {
848
- Logger.info(
849
- `[AggService] Fallback mechanism created ${successCount} out of ${createdInstances.length} requirements`,
850
- );
851
- } else {
852
- Logger.error(
853
- `[AggService] Both batch and fallback mechanisms failed to create requirements`,
854
- );
855
- throw new Error(
856
- 'Failed to create patient requirements through both batch and direct methods',
857
- );
858
- }
859
- }
860
- } else {
861
- Logger.info(
862
- `[AggService] No new PRE_APPOINTMENT requirement instances were prepared for batch commit for appointment ${appointment.id}.`,
863
- );
864
- }
865
- } catch (error) {
866
- Logger.error(
867
- `[AggService] Error creating PRE_APPOINTMENT requirement instances for appointment ${appointment.id}:`,
868
- error,
869
- );
870
- throw error; // Re-throw to ensure the caller knows there was a problem
871
- }
872
- }
873
-
874
- /**
875
- * Fetches post-requirements from a procedure document
876
- * @param procedureId - The procedure ID to fetch requirements from
877
- * @returns Promise resolving to array of post-requirements with source procedure info
878
- */
879
- private async fetchPostRequirementsFromProcedure(
880
- procedureId: string,
881
- ): Promise<RequirementWithSource[]> {
882
- try {
883
- const procedureDoc = await this.db.collection(PROCEDURES_COLLECTION).doc(procedureId).get();
884
- if (!procedureDoc.exists) {
885
- Logger.warn(`[AggService] Procedure ${procedureId} not found when fetching requirements`);
886
- return [];
887
- }
888
-
889
- const procedure = procedureDoc.data() as Procedure;
890
- const postRequirements = procedure.postRequirements || [];
891
-
892
- if (postRequirements.length === 0) {
893
- return [];
894
- }
895
-
896
- return postRequirements.map(req => ({
897
- requirement: req,
898
- sourceProcedures: [
899
- {
900
- procedureId: procedure.id,
901
- procedureName: procedure.name,
902
- },
903
- ],
904
- }));
905
- } catch (error) {
906
- Logger.error(
907
- `[AggService] Error fetching post-requirements from procedure ${procedureId}:`,
908
- error,
909
- );
910
- return [];
911
- }
912
- }
913
-
914
- /**
915
- * Collects all post-requirements from primary and extended procedures
916
- * @param appointment - The appointment to collect requirements for
917
- * @returns Promise resolving to array of requirements with source procedures
918
- */
919
- private async collectAllPostRequirements(
920
- appointment: Appointment,
921
- ): Promise<RequirementWithSource[]> {
922
- const allRequirements: RequirementWithSource[] = [];
923
-
924
- // Fetch from primary procedure
925
- if (appointment.procedureId) {
926
- const primaryRequirements = await this.fetchPostRequirementsFromProcedure(
927
- appointment.procedureId,
928
- );
929
- allRequirements.push(...primaryRequirements);
930
- }
931
-
932
- // Fetch from extended procedures
933
- const extendedProcedures = appointment.metadata?.extendedProcedures || [];
934
- if (extendedProcedures.length > 0) {
935
- Logger.info(
936
- `[AggService] Fetching post-requirements from ${extendedProcedures.length} extended procedures`,
937
- );
938
-
939
- const extendedRequirementsPromises = extendedProcedures.map(extProc =>
940
- this.fetchPostRequirementsFromProcedure(extProc.procedureId),
941
- );
942
-
943
- const extendedRequirementsArrays = await Promise.all(extendedRequirementsPromises);
944
- extendedRequirementsArrays.forEach(reqs => {
945
- allRequirements.push(...reqs);
946
- });
947
- }
948
-
949
- return allRequirements;
950
- }
951
-
952
- /**
953
- * Generates a unique key for a requirement based on ID and timeframe
954
- * @param requirement - The requirement to generate a key for
955
- * @returns Unique key string
956
- */
957
- private getRequirementKey(requirement: RequirementTemplate): string {
958
- const timeframeSig = JSON.stringify({
959
- duration: requirement.timeframe?.duration || 0,
960
- unit: requirement.timeframe?.unit || '',
961
- notifyAt: (requirement.timeframe?.notifyAt || []).slice().sort((a, b) => a - b),
962
- });
963
- return `${requirement.id}:${timeframeSig}`;
964
- }
965
-
966
- /**
967
- * Deduplicates requirements based on requirement ID and timeframe
968
- * Merges source procedures when requirements match
969
- * @param requirements - Array of requirements with sources
970
- * @returns Deduplicated array of requirements
971
- */
972
- private deduplicateRequirements(
973
- requirements: RequirementWithSource[],
974
- ): RequirementWithSource[] {
975
- const requirementMap = new Map<string, RequirementWithSource>();
976
-
977
- for (const reqWithSource of requirements) {
978
- const key = this.getRequirementKey(reqWithSource.requirement);
979
-
980
- if (requirementMap.has(key)) {
981
- // Merge source procedures
982
- const existing = requirementMap.get(key)!;
983
- const existingProcedureIds = new Set(
984
- existing.sourceProcedures.map(sp => sp.procedureId),
985
- );
986
-
987
- // Add new source procedures that don't already exist
988
- reqWithSource.sourceProcedures.forEach(sp => {
989
- if (!existingProcedureIds.has(sp.procedureId)) {
990
- existing.sourceProcedures.push(sp);
991
- }
992
- });
993
- } else {
994
- // New requirement, add it
995
- requirementMap.set(key, {
996
- requirement: reqWithSource.requirement,
997
- sourceProcedures: [...reqWithSource.sourceProcedures],
998
- });
999
- }
1000
- }
1001
-
1002
- return Array.from(requirementMap.values());
1003
- }
1004
-
1005
- /**
1006
- * Creates POST_APPOINTMENT PatientRequirementInstance documents for a given appointment.
1007
- * Fetches requirements from primary and extended procedures, deduplicates them,
1008
- * and creates requirement instances with source procedure tracking.
1009
- *
1010
- * @param {Appointment} appointment - The appointment for which to create post-requirement instances.
1011
- * @returns {Promise<void>} A promise that resolves when the operation is complete.
1012
- */
1013
- private async createPostAppointmentRequirementInstances(appointment: Appointment): Promise<void> {
1014
- Logger.info(
1015
- `[AggService] Creating POST-appointment requirement instances for appt: ${appointment.id}, patient: ${appointment.patientId}`,
1016
- );
1017
-
1018
- if (!appointment.procedureId) {
1019
- Logger.warn(
1020
- `[AggService] Appointment ${appointment.id} has no procedureId. Cannot create post-requirement instances.`,
1021
- );
1022
- return;
1023
- }
1024
-
1025
- try {
1026
- // Collect all post-requirements from primary and extended procedures
1027
- const allRequirements = await this.collectAllPostRequirements(appointment);
1028
-
1029
- if (allRequirements.length === 0) {
1030
- Logger.info(
1031
- `[AggService] No post-requirements found from any procedures for appointment ${appointment.id}. Nothing to create.`,
1032
- );
1033
- return;
1034
- }
1035
-
1036
- // Deduplicate requirements based on ID + timeframe
1037
- const deduplicatedRequirements = this.deduplicateRequirements(allRequirements);
1038
-
1039
- Logger.info(
1040
- `[AggService] Found ${allRequirements.length} total post-requirements, ${deduplicatedRequirements.length} after deduplication`,
1041
- );
1042
-
1043
- // Log details about the deduplicated requirements
1044
- Logger.info(
1045
- `[AggService] Processing deduplicated post-requirements: ${JSON.stringify(
1046
- deduplicatedRequirements.map(r => ({
1047
- id: r.requirement.id,
1048
- name: r.requirement.name,
1049
- type: r.requirement.type,
1050
- isActive: r.requirement.isActive,
1051
- hasTimeframe: !!r.requirement.timeframe,
1052
- notifyAtLength: r.requirement.timeframe?.notifyAt?.length || 0,
1053
- sourceProcedures: r.sourceProcedures.map(sp => ({
1054
- procedureId: sp.procedureId,
1055
- procedureName: sp.procedureName,
1056
- })),
1057
- })),
1058
- )}`,
1059
- );
1060
-
1061
- const batch = this.db.batch();
1062
- let instancesCreatedCount = 0;
1063
- // Store created instances for fallback direct creation if needed
1064
- let createdInstances = [];
1065
-
1066
- for (const reqWithSource of deduplicatedRequirements) {
1067
- const template = reqWithSource.requirement;
1068
- if (!template) {
1069
- Logger.warn(
1070
- `[AggService] Found null/undefined template in postProcedureRequirements array`,
1071
- );
1072
- continue;
1073
- }
1074
-
1075
- // Ensure it's an active, POST-type requirement
1076
- if (template.type !== RequirementType.POST || !template.isActive) {
1077
- Logger.debug(
1078
- `[AggService] Skipping template ${template.id} (${template.name}): not an active POST requirement.`,
1079
- );
1080
- continue;
1081
- }
1082
-
1083
- if (
1084
- !template.timeframe ||
1085
- !template.timeframe.notifyAt ||
1086
- template.timeframe.notifyAt.length === 0
1087
- ) {
1088
- Logger.warn(
1089
- `[AggService] Template ${template.id} (${template.name}) has no timeframe.notifyAt values. Creating with empty instructions.`,
1090
- );
1091
- }
1092
-
1093
- Logger.debug(
1094
- `[AggService] Processing template ${template.id} (${template.name}) for appt ${appointment.id}`,
1095
- );
1096
-
1097
- const newInstanceRef = this.db
1098
- .collection(PATIENTS_COLLECTION)
1099
- .doc(appointment.patientId)
1100
- .collection(PATIENT_REQUIREMENTS_SUBCOLLECTION_NAME)
1101
- .doc(); // Auto-generate ID for the new instance
1102
-
1103
- // Log the path for debugging
1104
- Logger.debug(`[AggService] Created doc reference: ${newInstanceRef.path}`);
1105
-
1106
- const instructions: PatientRequirementInstruction[] = (
1107
- template.timeframe?.notifyAt || []
1108
- ).map(notifyAtValue => {
1109
- let dueTime: any = appointment.appointmentEndTime;
1110
- if (template.timeframe && typeof notifyAtValue === 'number') {
1111
- const dueDateTime = new Date(appointment.appointmentEndTime.toMillis());
1112
- // For POST requirements, notifyAtValue means AFTER the event
1113
- if (template.timeframe.unit === TimeUnit.DAYS) {
1114
- dueDateTime.setDate(dueDateTime.getDate() + notifyAtValue);
1115
- } else if (template.timeframe.unit === TimeUnit.HOURS) {
1116
- dueDateTime.setHours(dueDateTime.getHours() + notifyAtValue);
1117
- }
1118
- dueTime = admin.firestore.Timestamp.fromDate(dueDateTime);
1119
- }
1120
-
1121
- const actionableWindowHours =
1122
- template.importance === 'high' ? 1 : template.importance === 'medium' ? 4 : 15; // Default to 15 hours for low importance
1123
-
1124
- const instructionObject: PatientRequirementInstruction = {
1125
- instructionId: `${template.id}_${notifyAtValue}_${newInstanceRef.id}`.replace(
1126
- /[^a-zA-Z0-9_]/g,
1127
- '_',
1128
- ),
1129
- instructionText: template.description || template.name,
1130
- dueTime: dueTime as any,
1131
- actionableWindow: actionableWindowHours,
1132
- status: PatientInstructionStatus.PENDING_NOTIFICATION,
1133
- originalNotifyAtValue: notifyAtValue,
1134
- originalTimeframeUnit: template.timeframe.unit,
1135
- updatedAt: admin.firestore.Timestamp.now() as any,
1136
- notificationId: undefined,
1137
- actionTakenAt: undefined,
1138
- };
1139
- return instructionObject;
1140
- });
1141
-
1142
- const newInstanceData: PatientRequirementInstance = {
1143
- id: newInstanceRef.id,
1144
- patientId: appointment.patientId,
1145
- appointmentId: appointment.id,
1146
- originalRequirementId: template.id,
1147
- requirementName: template.name,
1148
- requirementDescription: template.description,
1149
- requirementType: template.type,
1150
- requirementImportance: template.importance,
1151
- overallStatus: PatientRequirementOverallStatus.ACTIVE,
1152
- instructions: instructions,
1153
- sourceProcedures: reqWithSource.sourceProcedures, // Track which procedures this requirement comes from
1154
- createdAt: admin.firestore.FieldValue.serverTimestamp() as any,
1155
- updatedAt: admin.firestore.FieldValue.serverTimestamp() as any,
1156
- };
1157
-
1158
- // Log the data being set
1159
- Logger.debug(
1160
- `[AggService] Setting data for requirement: ${JSON.stringify({
1161
- id: newInstanceRef.id,
1162
- patientId: newInstanceData.patientId,
1163
- appointmentId: newInstanceData.appointmentId,
1164
- requirementName: newInstanceData.requirementName,
1165
- instructionsCount: newInstanceData.instructions.length,
1166
- sourceProcedures: newInstanceData.sourceProcedures?.map(sp => ({
1167
- procedureId: sp.procedureId,
1168
- procedureName: sp.procedureName,
1169
- })) || [],
1170
- })}`,
1171
- );
1172
-
1173
- batch.set(newInstanceRef, newInstanceData);
1174
- // Store for potential fallback
1175
- createdInstances.push({
1176
- ref: newInstanceRef,
1177
- data: newInstanceData,
1178
- });
1179
-
1180
- instancesCreatedCount++;
1181
- Logger.debug(
1182
- `[AggService] Added PatientRequirementInstance ${newInstanceRef.id} to batch for template ${template.id}.`,
1183
- );
1184
- }
1185
-
1186
- if (instancesCreatedCount > 0) {
1187
- try {
1188
- await batch.commit();
1189
- Logger.info(
1190
- `[AggService] Successfully created ${instancesCreatedCount} POST_APPOINTMENT requirement instances for appointment ${appointment.id}.`,
1191
- );
1192
-
1193
- // Verify creation success
1194
- try {
1195
- const verifySnapshot = await this.db
1196
- .collection(PATIENTS_COLLECTION)
1197
- .doc(appointment.patientId)
1198
- .collection(PATIENT_REQUIREMENTS_SUBCOLLECTION_NAME)
1199
- .where('appointmentId', '==', appointment.id)
1200
- .get();
1201
-
1202
- if (verifySnapshot.empty) {
1203
- Logger.warn(
1204
- `[AggService] Batch commit reported success but documents not found! Attempting direct creation as fallback...`,
1205
- );
1206
-
1207
- // Fallback to direct creation if batch worked but docs aren't there
1208
- const fallbackPromises = createdInstances.map(async ({ ref, data }) => {
1209
- try {
1210
- await ref.set(data);
1211
- Logger.info(`[AggService] Fallback direct creation success for ${ref.id}`);
1212
- return true;
1213
- } catch (fallbackError) {
1214
- Logger.error(
1215
- `[AggService] Fallback direct creation failed for ${ref.id}:`,
1216
- fallbackError,
1217
- );
1218
- return false;
1219
- }
1220
- });
1221
-
1222
- const fallbackResults = await Promise.allSettled(fallbackPromises);
1223
- const successCount = fallbackResults.filter(
1224
- r => r.status === 'fulfilled' && r.value === true,
1225
- ).length;
1226
-
1227
- if (successCount > 0) {
1228
- Logger.info(
1229
- `[AggService] Fallback mechanism created ${successCount} out of ${createdInstances.length} requirements`,
1230
- );
1231
- } else {
1232
- Logger.error(
1233
- `[AggService] Both batch and fallback mechanisms failed to create requirements`,
1234
- );
1235
- throw new Error(
1236
- 'Failed to create patient requirements through both batch and direct methods',
1237
- );
1238
- }
1239
- } else {
1240
- Logger.info(
1241
- `[AggService] Verification confirmed ${verifySnapshot.size} requirement documents created`,
1242
- );
1243
- }
1244
- } catch (verifyError) {
1245
- Logger.error(
1246
- `[AggService] Error during verification of created requirements:`,
1247
- verifyError,
1248
- );
1249
- }
1250
- } catch (commitError) {
1251
- Logger.error(
1252
- `[AggService] Error committing batch for POST_APPOINTMENT requirement instances for appointment ${appointment.id}:`,
1253
- commitError,
1254
- );
1255
-
1256
- // Try direct creation as fallback
1257
- Logger.info(`[AggService] Attempting direct creation as fallback...`);
1258
- const fallbackPromises = createdInstances.map(async ({ ref, data }) => {
1259
- try {
1260
- await ref.set(data);
1261
- Logger.info(`[AggService] Fallback direct creation success for ${ref.id}`);
1262
- return true;
1263
- } catch (fallbackError) {
1264
- Logger.error(
1265
- `[AggService] Fallback direct creation failed for ${ref.id}:`,
1266
- fallbackError,
1267
- );
1268
- return false;
1269
- }
1270
- });
1271
-
1272
- const fallbackResults = await Promise.allSettled(fallbackPromises);
1273
- const successCount = fallbackResults.filter(
1274
- r => r.status === 'fulfilled' && r.value === true,
1275
- ).length;
1276
-
1277
- if (successCount > 0) {
1278
- Logger.info(
1279
- `[AggService] Fallback mechanism created ${successCount} out of ${createdInstances.length} requirements`,
1280
- );
1281
- } else {
1282
- Logger.error(
1283
- `[AggService] Both batch and fallback mechanisms failed to create requirements`,
1284
- );
1285
- throw new Error(
1286
- 'Failed to create patient requirements through both batch and direct methods',
1287
- );
1288
- }
1289
- }
1290
- } else {
1291
- Logger.info(
1292
- `[AggService] No new POST_APPOINTMENT requirement instances were prepared for batch commit for appointment ${appointment.id}.`,
1293
- );
1294
- }
1295
- } catch (error) {
1296
- Logger.error(
1297
- `[AggService] Error creating POST_APPOINTMENT requirement instances for appointment ${appointment.id}:`,
1298
- error,
1299
- );
1300
- throw error; // Re-throw to ensure the caller knows there was a problem
1301
- }
1302
- }
1303
-
1304
- /**
1305
- * Updates the overallStatus of all PatientRequirementInstance documents associated with a given appointment.
1306
- * This is typically used when an appointment is cancelled or rescheduled, making existing requirements void.
1307
- *
1308
- * @param {Appointment} appointment - The appointment whose requirement instances need updating.
1309
- * @param {PatientRequirementOverallStatus} newOverallStatus - The new status to set (e.g., CANCELLED_APPOINTMENT).
1310
- * @returns {Promise<void>} A promise that resolves when the operation is complete.
1311
- */
1312
- private async updateRelatedPatientRequirementInstances(
1313
- appointment: Appointment,
1314
- newOverallStatus: PatientRequirementOverallStatus,
1315
- _previousAppointmentData?: Appointment, // Not used in this basic implementation, but kept for signature consistency
1316
- ): Promise<void> {
1317
- Logger.info(
1318
- `[AggService] Updating related patient req instances for appt ${appointment.id} (patient: ${appointment.patientId}) to ${newOverallStatus}`,
1319
- );
1320
-
1321
- if (!appointment.id || !appointment.patientId) {
1322
- Logger.error(
1323
- '[AggService] updateRelatedPatientRequirementInstances called with missing appointmentId or patientId.',
1324
- { appointmentId: appointment.id, patientId: appointment.patientId },
1325
- );
1326
- return;
1327
- }
1328
-
1329
- try {
1330
- const instancesSnapshot = await this.db
1331
- .collection(PATIENTS_COLLECTION)
1332
- .doc(appointment.patientId)
1333
- .collection(PATIENT_REQUIREMENTS_SUBCOLLECTION_NAME)
1334
- .where('appointmentId', '==', appointment.id)
1335
- .get();
1336
-
1337
- if (instancesSnapshot.empty) {
1338
- Logger.info(
1339
- `[AggService] No patient requirement instances found for appointment ${appointment.id} to update.`,
1340
- );
1341
- return;
1342
- }
1343
-
1344
- const batch = this.db.batch();
1345
- let instancesUpdatedCount = 0;
1346
-
1347
- instancesSnapshot.docs.forEach(doc => {
1348
- const instance = doc.data() as PatientRequirementInstance;
1349
- // Update only if the status is actually different and not already in a terminal state like FAILED_TO_PROCESS
1350
- if (
1351
- instance.overallStatus !== newOverallStatus &&
1352
- instance.overallStatus !== PatientRequirementOverallStatus.FAILED_TO_PROCESS
1353
- ) {
1354
- batch.update(doc.ref, {
1355
- overallStatus: newOverallStatus,
1356
- updatedAt: admin.firestore.FieldValue.serverTimestamp() as any, // Cast for now
1357
- // Potentially also cancel individual instructions if not handled by another trigger
1358
- // instructions: instance.instructions.map(instr => ({ ...instr, status: PatientInstructionStatus.CANCELLED, updatedAt: admin.firestore.FieldValue.serverTimestamp() as any }))
1359
- });
1360
- instancesUpdatedCount++;
1361
- Logger.debug(
1362
- `[AggService] Added update for PatientRequirementInstance ${doc.id} to batch. New status: ${newOverallStatus}`,
1363
- );
1364
- }
1365
- });
1366
-
1367
- if (instancesUpdatedCount > 0) {
1368
- await batch.commit();
1369
- Logger.info(
1370
- `[AggService] Successfully updated ${instancesUpdatedCount} patient requirement instances for appointment ${appointment.id} to status ${newOverallStatus}.`,
1371
- );
1372
- } else {
1373
- Logger.info(
1374
- `[AggService] No patient requirement instances needed an update for appointment ${appointment.id}.`,
1375
- );
1376
- }
1377
- } catch (error) {
1378
- Logger.error(
1379
- `[AggService] Error updating patient requirement instances for appointment ${appointment.id}:`,
1380
- error,
1381
- );
1382
- }
1383
- }
1384
-
1385
- /**
1386
- * Manages relationships between a patient and clinics/practitioners.
1387
- * Only updates the patient profile with doctorIds and clinicIds.
1388
- *
1389
- * @param {PatientProfile} patientProfile - The patient profile to update
1390
- * @param {string} practitionerId - The practitioner ID
1391
- * @param {string} clinicId - The clinic ID
1392
- * @param {"create" | "cancel"} action - 'create' to add IDs, 'cancel' to potentially remove them
1393
- * @param {AppointmentStatus} [cancelStatus] - The appointment status if action is 'cancel'
1394
- * @returns {Promise<void>} A promise that resolves when the operation is complete.
1395
- */
1396
- private async managePatientClinicPractitionerLinks(
1397
- patientProfile: PatientProfile,
1398
- practitionerId: string,
1399
- clinicId: string,
1400
- action: 'create' | 'cancel',
1401
- cancelStatus?: AppointmentStatus,
1402
- ): Promise<void> {
1403
- Logger.info(
1404
- `[AggService] Managing patient-clinic-practitioner links for patient ${patientProfile.id}, action: ${action}`,
1405
- );
1406
-
1407
- try {
1408
- if (action === 'create') {
1409
- await this.addPatientLinks(patientProfile, practitionerId, clinicId);
1410
- } else if (action === 'cancel') {
1411
- await this.removePatientLinksIfNoActiveAppointments(
1412
- patientProfile,
1413
- practitionerId,
1414
- clinicId,
1415
- );
1416
- }
1417
- } catch (error) {
1418
- Logger.error(
1419
- `[AggService] Error managing patient-clinic-practitioner links for patient ${patientProfile.id}:`,
1420
- error,
1421
- );
1422
- }
1423
- }
1424
-
1425
- /**
1426
- * Adds practitioner and clinic IDs to the patient profile.
1427
- *
1428
- * @param {PatientProfile} patientProfile - The patient profile to update
1429
- * @param {string} practitionerId - The practitioner ID to add
1430
- * @param {string} clinicId - The clinic ID to add
1431
- * @returns {Promise<void>} A promise that resolves when the operation is complete.
1432
- */
1433
- private async addPatientLinks(
1434
- patientProfile: PatientProfile,
1435
- practitionerId: string,
1436
- clinicId: string,
1437
- ): Promise<void> {
1438
- try {
1439
- // Check if the IDs already exist in the arrays
1440
- const hasDoctor = patientProfile.doctorIds?.includes(practitionerId) || false;
1441
- const hasClinic = patientProfile.clinicIds?.includes(clinicId) || false;
1442
-
1443
- // Only update if necessary
1444
- if (!hasDoctor || !hasClinic) {
1445
- const patientRef = this.db.collection(PATIENTS_COLLECTION).doc(patientProfile.id);
1446
- const updateData: Record<string, any> = {
1447
- updatedAt: admin.firestore.FieldValue.serverTimestamp(),
1448
- };
1449
-
1450
- if (!hasDoctor) {
1451
- Logger.debug(
1452
- `[AggService] Adding practitioner ${practitionerId} to patient ${patientProfile.id}`,
1453
- );
1454
- updateData.doctorIds = admin.firestore.FieldValue.arrayUnion(practitionerId);
1455
- }
1456
-
1457
- if (!hasClinic) {
1458
- Logger.debug(`[AggService] Adding clinic ${clinicId} to patient ${patientProfile.id}`);
1459
- updateData.clinicIds = admin.firestore.FieldValue.arrayUnion(clinicId);
1460
- }
1461
-
1462
- await patientRef.update(updateData);
1463
- Logger.info(
1464
- `[AggService] Successfully updated patient ${patientProfile.id} with new links.`,
1465
- );
1466
- } else {
1467
- Logger.info(
1468
- `[AggService] Patient ${patientProfile.id} already has links to both practitioner ${practitionerId} and clinic ${clinicId}.`,
1469
- );
1470
- }
1471
- } catch (error) {
1472
- Logger.error(
1473
- `[AggService] Error updating patient ${patientProfile.id} with new links:`,
1474
- error,
1475
- );
1476
- throw error;
1477
- }
1478
- }
1479
-
1480
- /**
1481
- * Removes practitioner and clinic IDs from the patient profile if there are no more active appointments.
1482
- *
1483
- * @param {PatientProfile} patientProfile - The patient profile to update
1484
- * @param {string} practitionerId - The practitioner ID to remove
1485
- * @param {string} clinicId - The clinic ID to remove
1486
- * @returns {Promise<void>} A promise that resolves when the operation is complete.
1487
- */
1488
- private async removePatientLinksIfNoActiveAppointments(
1489
- patientProfile: PatientProfile,
1490
- practitionerId: string,
1491
- clinicId: string,
1492
- ): Promise<void> {
1493
- try {
1494
- // Check for active appointments with this practitioner and clinic
1495
- const activePractitionerAppointments = await this.checkActiveAppointments(
1496
- patientProfile.id,
1497
- 'practitionerId',
1498
- practitionerId,
1499
- );
1500
-
1501
- const activeClinicAppointments = await this.checkActiveAppointments(
1502
- patientProfile.id,
1503
- 'clinicBranchId',
1504
- clinicId,
1505
- );
1506
-
1507
- Logger.info(
1508
- `[AggService] Active appointment count for patient ${patientProfile.id}: With practitioner ${practitionerId}: ${activePractitionerAppointments}, With clinic ${clinicId}: ${activeClinicAppointments}`,
1509
- );
1510
-
1511
- // Only update if there are no active appointments
1512
- const patientRef = this.db.collection(PATIENTS_COLLECTION).doc(patientProfile.id);
1513
- const updateData: Record<string, any> = {};
1514
- let updateNeeded = false;
1515
-
1516
- if (
1517
- activePractitionerAppointments === 0 &&
1518
- patientProfile.doctorIds?.includes(practitionerId)
1519
- ) {
1520
- Logger.debug(
1521
- `[AggService] Removing practitioner ${practitionerId} from patient ${patientProfile.id}`,
1522
- );
1523
- updateData.doctorIds = admin.firestore.FieldValue.arrayRemove(practitionerId);
1524
- updateNeeded = true;
1525
- }
1526
-
1527
- if (activeClinicAppointments === 0 && patientProfile.clinicIds?.includes(clinicId)) {
1528
- Logger.debug(`[AggService] Removing clinic ${clinicId} from patient ${patientProfile.id}`);
1529
- updateData.clinicIds = admin.firestore.FieldValue.arrayRemove(clinicId);
1530
- updateNeeded = true;
1531
- }
1532
-
1533
- if (updateNeeded) {
1534
- updateData.updatedAt = admin.firestore.FieldValue.serverTimestamp();
1535
- await patientRef.update(updateData);
1536
- Logger.info(`[AggService] Successfully removed links from patient ${patientProfile.id}`);
1537
- } else {
1538
- Logger.info(`[AggService] No links need to be removed from patient ${patientProfile.id}`);
1539
- }
1540
- } catch (error) {
1541
- Logger.error(`[AggService] Error removing links from patient profile:`, error);
1542
- throw error;
1543
- }
1544
- }
1545
-
1546
- /**
1547
- * Checks if there are active appointments between a patient and another entity (practitioner or clinic).
1548
- *
1549
- * @param {string} patientId - The patient ID.
1550
- * @param {"practitionerId" | "clinicBranchId"} entityField - The field to check for the entity ID.
1551
- * @param {string} entityId - The entity ID (practitioner or clinic).
1552
- * @returns {Promise<number>} The number of active appointments found.
1553
- */
1554
- private async checkActiveAppointments(
1555
- patientId: string,
1556
- entityField: 'practitionerId' | 'clinicBranchId',
1557
- entityId: string,
1558
- ): Promise<number> {
1559
- try {
1560
- // Define all cancelled/inactive appointment statuses
1561
- const inactiveStatuses = [
1562
- AppointmentStatus.CANCELED_CLINIC,
1563
- AppointmentStatus.CANCELED_PATIENT,
1564
- AppointmentStatus.CANCELED_PATIENT_RESCHEDULED,
1565
- AppointmentStatus.NO_SHOW,
1566
- ];
1567
-
1568
- const snapshot = await this.db
1569
- .collection('appointments')
1570
- .where('patientId', '==', patientId)
1571
- .where(entityField, '==', entityId)
1572
- .where('status', 'not-in', inactiveStatuses)
1573
- .get();
1574
-
1575
- return snapshot.size;
1576
- } catch (error) {
1577
- Logger.error(`[AggService] Error checking active appointments:`, error);
1578
- throw error;
1579
- }
1580
- }
1581
-
1582
- // --- Data Fetching Helpers (Consider moving to a data access layer or using existing services if available) ---
1583
- private async fetchPatientProfile(patientId: string): Promise<PatientProfile | null> {
1584
- try {
1585
- const doc = await this.db.collection(PATIENTS_COLLECTION).doc(patientId).get();
1586
- return doc.exists ? (doc.data() as PatientProfile) : null;
1587
- } catch (error) {
1588
- Logger.error(`[AggService] Error fetching patient profile ${patientId}:`, error);
1589
- return null;
1590
- }
1591
- }
1592
-
1593
- /**
1594
- * Fetches the sensitive information for a given patient ID.
1595
- * @param patientId The ID of the patient to fetch sensitive information for.
1596
- * @returns {Promise<PatientSensitiveInfo | null>} The patient sensitive information or null if not found or an error occurs.
1597
- */
1598
- private async fetchPatientSensitiveInfo(patientId: string): Promise<PatientSensitiveInfo | null> {
1599
- try {
1600
- // Assuming sensitive info is in a subcollection PATIENT_SENSITIVE_INFO_COLLECTION
1601
- // under the patient's document, and the sensitive info document ID is the patientId itself.
1602
- // If the document ID is fixed (e.g., 'details'), this path should be adjusted.
1603
- const doc = await this.db
1604
- .collection(PATIENTS_COLLECTION)
1605
- .doc(patientId)
1606
- .collection(PATIENT_SENSITIVE_INFO_COLLECTION)
1607
- .doc(patientId) // CONFIRM THIS DOCUMENT ID PATTERN
1608
- .get();
1609
- if (!doc.exists) {
1610
- Logger.warn(`[AggService] No sensitive info found for patient ${patientId}`);
1611
- return null;
1612
- }
1613
- return doc.data() as PatientSensitiveInfo;
1614
- } catch (error) {
1615
- Logger.error(`[AggService] Error fetching patient sensitive info ${patientId}:`, error);
1616
- return null;
1617
- }
1618
- }
1619
-
1620
- /**
1621
- * Fetches the profile for a given practitioner ID.
1622
- * @param practitionerId The ID of the practitioner to fetch.
1623
- * @returns {Promise<Practitioner | null>} The practitioner profile or null if not found or an error occurs.
1624
- */
1625
- private async fetchPractitionerProfile(practitionerId: string): Promise<Practitioner | null> {
1626
- if (!practitionerId) {
1627
- Logger.warn('[AggService] fetchPractitionerProfile called with no practitionerId.');
1628
- return null;
1629
- }
1630
- try {
1631
- const doc = await this.db.collection(PRACTITIONERS_COLLECTION).doc(practitionerId).get();
1632
- if (!doc.exists) {
1633
- Logger.warn(`[AggService] No practitioner profile found for ID ${practitionerId}`);
1634
- return null;
1635
- }
1636
- return doc.data() as Practitioner;
1637
- } catch (error) {
1638
- Logger.error(`[AggService] Error fetching practitioner profile ${practitionerId}:`, error);
1639
- return null;
1640
- }
1641
- }
1642
-
1643
- /**
1644
- * Fetches the information for a given clinic ID.
1645
- * @param clinicId The ID of the clinic to fetch.
1646
- * @returns {Promise<Clinic | null>} The clinic information or null if not found or an error occurs.
1647
- */
1648
- private async fetchClinicInfo(clinicId: string): Promise<Clinic | null> {
1649
- if (!clinicId) {
1650
- Logger.warn('[AggService] fetchClinicInfo called with no clinicId.');
1651
- return null;
1652
- }
1653
- try {
1654
- const doc = await this.db.collection(CLINICS_COLLECTION).doc(clinicId).get();
1655
- if (!doc.exists) {
1656
- Logger.warn(`[AggService] No clinic info found for ID ${clinicId}`);
1657
- return null;
1658
- }
1659
- return doc.data() as Clinic;
1660
- } catch (error) {
1661
- Logger.error(`[AggService] Error fetching clinic info ${clinicId}:`, error);
1662
- return null;
1663
- }
1664
- }
1665
-
1666
- /**
1667
- * Checks if zone photos have changed between two appointment states
1668
- * @param before - The appointment state before update
1669
- * @param after - The appointment state after update
1670
- * @returns True if zone photos have changed, false otherwise
1671
- */
1672
- private hasZonePhotosChanged(before: Appointment, after: Appointment): boolean {
1673
- const beforePhotos = before.metadata?.zonePhotos;
1674
- const afterPhotos = after.metadata?.zonePhotos;
1675
-
1676
- // If both are null/undefined, no change
1677
- if (!beforePhotos && !afterPhotos) {
1678
- return false;
1679
- }
1680
-
1681
- // If one is null and the other isn't, there's a change
1682
- if (!beforePhotos || !afterPhotos) {
1683
- return true;
1684
- }
1685
-
1686
- // Compare the number of zones
1687
- const beforeZones = Object.keys(beforePhotos);
1688
- const afterZones = Object.keys(afterPhotos);
1689
-
1690
- if (beforeZones.length !== afterZones.length) {
1691
- return true;
1692
- }
1693
-
1694
- // Compare each zone's photos
1695
- for (const zoneId of afterZones) {
1696
- const beforeZonePhotos = beforePhotos[zoneId];
1697
- const afterZonePhotos = afterPhotos[zoneId];
1698
-
1699
- if (!beforeZonePhotos && !afterZonePhotos) {
1700
- continue;
1701
- }
1702
-
1703
- if (!beforeZonePhotos || !afterZonePhotos) {
1704
- return true;
1705
- }
1706
-
1707
- // Compare before and after photos arrays
1708
- // If array lengths differ or any entry differs, consider it changed
1709
- if (beforeZonePhotos.length !== afterZonePhotos.length) {
1710
- return true;
1711
- }
1712
-
1713
- // Compare each entry in the arrays
1714
- for (let i = 0; i < beforeZonePhotos.length; i++) {
1715
- const beforeEntry = beforeZonePhotos[i];
1716
- const afterEntry = afterZonePhotos[i];
1717
- if (
1718
- beforeEntry.before !== afterEntry.before ||
1719
- beforeEntry.after !== afterEntry.after ||
1720
- beforeEntry.beforeNote !== afterEntry.beforeNote ||
1721
- beforeEntry.afterNote !== afterEntry.afterNote
1722
- ) {
1723
- return true;
1724
- }
1725
- }
1726
- }
1727
-
1728
- return false;
1729
- }
1730
-
1731
- /**
1732
- * Handles zone photos update notifications and logging
1733
- * @param before - The appointment state before update
1734
- * @param after - The appointment state after update
1735
- */
1736
- private async handleZonePhotosUpdate(before: Appointment, after: Appointment): Promise<void> {
1737
- try {
1738
- Logger.info(`[AggService] Processing zone photos update for appointment ${after.id}`);
1739
-
1740
- const beforePhotos = before.metadata?.zonePhotos || {};
1741
- const afterPhotos = after.metadata?.zonePhotos || {};
1742
-
1743
- // Find zones with new or updated photos
1744
- const updatedZones: string[] = [];
1745
- const newPhotoTypes: { zoneId: string; photoType: 'before' | 'after' }[] = [];
1746
-
1747
- for (const zoneId of Object.keys(afterPhotos)) {
1748
- const beforeZonePhotos = beforePhotos[zoneId] || [];
1749
- const afterZonePhotos = afterPhotos[zoneId] || [];
1750
-
1751
- if (beforeZonePhotos.length === 0 && afterZonePhotos.length > 0) {
1752
- // New zone with photos
1753
- updatedZones.push(zoneId);
1754
- afterZonePhotos.forEach(entry => {
1755
- if (entry.before) {
1756
- newPhotoTypes.push({ zoneId, photoType: 'before' });
1757
- }
1758
- if (entry.after) {
1759
- newPhotoTypes.push({ zoneId, photoType: 'after' });
1760
- }
1761
- });
1762
- } else if (afterZonePhotos.length > beforeZonePhotos.length) {
1763
- // New photos added to existing zone
1764
- updatedZones.push(zoneId);
1765
- const newEntries = afterZonePhotos.slice(beforeZonePhotos.length);
1766
- newEntries.forEach(entry => {
1767
- if (entry.before) {
1768
- newPhotoTypes.push({ zoneId, photoType: 'before' });
1769
- }
1770
- if (entry.after) {
1771
- newPhotoTypes.push({ zoneId, photoType: 'after' });
1772
- }
1773
- });
1774
- } else {
1775
- // Check for updated photos in existing entries
1776
- for (let i = 0; i < afterZonePhotos.length; i++) {
1777
- const beforeEntry = beforeZonePhotos[i];
1778
- const afterEntry = afterZonePhotos[i];
1779
-
1780
- if (beforeEntry && afterEntry) {
1781
- if (beforeEntry.before !== afterEntry.before && afterEntry.before) {
1782
- updatedZones.push(zoneId);
1783
- newPhotoTypes.push({ zoneId, photoType: 'before' });
1784
- }
1785
- if (beforeEntry.after !== afterEntry.after && afterEntry.after) {
1786
- updatedZones.push(zoneId);
1787
- newPhotoTypes.push({ zoneId, photoType: 'after' });
1788
- }
1789
- }
1790
- }
1791
- }
1792
- }
1793
-
1794
- if (updatedZones.length > 0) {
1795
- Logger.info(
1796
- `[AggService] Zone photos updated for appointment ${after.id}: ${updatedZones.join(
1797
- ', ',
1798
- )}`,
1799
- );
1800
-
1801
- // Log specific photo types that were added
1802
- for (const { zoneId, photoType } of newPhotoTypes) {
1803
- Logger.info(
1804
- `[AggService] New ${photoType} photo added for zone ${zoneId} in appointment ${after.id}`,
1805
- );
1806
- }
1807
-
1808
- // TODO: Add notifications to practitioners/clinic admins about photo updates
1809
- // TODO: Add audit logging for photo uploads
1810
- // TODO: Trigger any business logic related to photo completion (e.g., appointment progress tracking)
1811
- }
1812
-
1813
- // Check if all required photos are now complete
1814
- const selectedZones = after.metadata?.selectedZones || [];
1815
- if (selectedZones.length > 0) {
1816
- const completedZones = selectedZones.filter(zoneId => {
1817
- const zonePhotos = afterPhotos[zoneId];
1818
- return zonePhotos && zonePhotos.length > 0 && zonePhotos.some(entry => entry.before || entry.after);
1819
- });
1820
-
1821
- const completionPercentage = (completedZones.length / selectedZones.length) * 100;
1822
- Logger.info(
1823
- `[AggService] Photo completion for appointment ${
1824
- after.id
1825
- }: ${completionPercentage.toFixed(1)}% (${completedZones.length}/${
1826
- selectedZones.length
1827
- } zones)`,
1828
- );
1829
-
1830
- // TODO: Trigger notifications when all photos are complete
1831
- if (completionPercentage === 100) {
1832
- Logger.info(`[AggService] All zone photos completed for appointment ${after.id}`);
1833
- // TODO: Send notification to relevant parties
1834
- }
1835
- }
1836
- } catch (error) {
1837
- Logger.error(
1838
- `[AggService] Error handling zone photos update for appointment ${after.id}:`,
1839
- error,
1840
- );
1841
- // Don't throw - this is a side effect and shouldn't break the main update flow
1842
- }
1843
- }
1844
- }
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 { Practitioner, PRACTITIONERS_COLLECTION } from '../../../types/practitioner';
27
+ import { Clinic, CLINICS_COLLECTION } from '../../../types/clinic';
28
+ import { Procedure, PROCEDURES_COLLECTION } from '../../../types/procedure';
29
+ import { RequirementSourceProcedure } from '../../../types/patient/patient-requirements';
30
+ // import { UserRole } from "../../../types"; // Not directly used
31
+
32
+ // Dependent Admin Services
33
+ import { PatientRequirementsAdminService } from '../../requirements/patient-requirements.admin.service';
34
+ import { NotificationsAdmin } from '../../notifications/notifications.admin';
35
+ import { CalendarAdminService } from '../../calendar/calendar.admin.service';
36
+ import { AppointmentMailingService } from '../../mailing/appointment/appointment.mailing.service';
37
+ import { Logger } from '../../logger';
38
+ import { UserRole } from '../../../types';
39
+ import { CalendarEventStatus } from '../../../types/calendar';
40
+
41
+ // Mailgun client will be injected via constructor
42
+
43
+ /**
44
+ * Type for requirement with source procedure tracking
45
+ */
46
+ type RequirementWithSource = {
47
+ requirement: RequirementTemplate;
48
+ sourceProcedures: RequirementSourceProcedure[];
49
+ };
50
+
51
+ /**
52
+ * @class AppointmentAggregationService
53
+ * @description Handles aggregation tasks and side effects related to appointment lifecycle events.
54
+ * This service is intended to be used primarily by background functions (e.g., Cloud Functions)
55
+ * triggered by changes in the appointments collection.
56
+ */
57
+ export class AppointmentAggregationService {
58
+ private db: admin.firestore.Firestore;
59
+ private appointmentMailingService: AppointmentMailingService;
60
+ private notificationsAdmin: NotificationsAdmin;
61
+ private calendarAdminService: CalendarAdminService;
62
+ private patientRequirementsAdminService: PatientRequirementsAdminService;
63
+
64
+ /**
65
+ * Constructor for AppointmentAggregationService.
66
+ * @param mailgunClient - An initialized Mailgun client instance.
67
+ * @param firestore Optional Firestore instance. If not provided, it uses the default admin SDK instance.
68
+ */
69
+ constructor(
70
+ mailgunClient: any, // Type as 'any' for now, to be provided by the calling Cloud Function
71
+ firestore?: admin.firestore.Firestore,
72
+ ) {
73
+ this.db = firestore || admin.firestore();
74
+ this.appointmentMailingService = new AppointmentMailingService(
75
+ this.db,
76
+ mailgunClient, // Pass the injected client
77
+ );
78
+ this.notificationsAdmin = new NotificationsAdmin(this.db);
79
+ this.calendarAdminService = new CalendarAdminService(this.db);
80
+ this.patientRequirementsAdminService = new PatientRequirementsAdminService(this.db);
81
+ Logger.info('[AppointmentAggregationService] Initialized.');
82
+ }
83
+
84
+ /**
85
+ * Handles side effects when an appointment is first created.
86
+ * This function would typically be called by an Firestore onCreate trigger.
87
+ * @param {Appointment} appointment - The newly created Appointment object.
88
+ * @returns {Promise<void>}
89
+ */
90
+ async handleAppointmentCreate(appointment: Appointment): Promise<void> {
91
+ Logger.info(
92
+ `[AggService] Handling CREATE for appointment: ${appointment.id}, patient: ${appointment.patientId}, status: ${appointment.status}`,
93
+ );
94
+
95
+ try {
96
+ // 1. Fetch necessary profiles for notifications and context
97
+ // These can be fetched in parallel
98
+ const [patientProfile, patientSensitiveInfo, practitionerProfile, clinicInfo] =
99
+ await Promise.all([
100
+ this.fetchPatientProfile(appointment.patientId),
101
+ this.fetchPatientSensitiveInfo(appointment.patientId),
102
+ this.fetchPractitionerProfile(appointment.practitionerId), // Needed for practitioner notifications
103
+ this.fetchClinicInfo(appointment.clinicBranchId), // Needed for clinic admin notifications
104
+ ]);
105
+
106
+ // 2. Manage Patient-Clinic-Practitioner Links (moved from beginning to here)
107
+ // Now we can pass the already fetched patient profile
108
+ if (patientProfile) {
109
+ await this.managePatientClinicPractitionerLinks(
110
+ patientProfile,
111
+ appointment.practitionerId,
112
+ appointment.clinicBranchId,
113
+ 'create',
114
+ );
115
+ }
116
+
117
+ // 3. Initial State Handling based on appointment status
118
+ if (appointment.status === AppointmentStatus.CONFIRMED) {
119
+ Logger.info(`[AggService] Appt ${appointment.id} created as CONFIRMED.`);
120
+ // Create pre-appointment requirements for confirmed appointments
121
+ await this.createPreAppointmentRequirementInstances(appointment);
122
+
123
+ // Send confirmation notifications
124
+ if (patientSensitiveInfo?.email && patientProfile) {
125
+ Logger.info(
126
+ `[AggService] Sending appointment confirmed email to patient ${patientSensitiveInfo.email}`,
127
+ );
128
+ // Construct the data object for the mailing service
129
+ const emailData = {
130
+ appointment: appointment,
131
+ recipientProfile: appointment.patientInfo,
132
+ recipientRole: 'patient' as const, // Use 'as const' for literal type
133
+ };
134
+ // The type cast here might still be an issue if PatientProfileInfo is not imported.
135
+ // However, the structure should be compatible enough for the call.
136
+ await this.appointmentMailingService.sendAppointmentConfirmedEmail(
137
+ emailData as any, // Using 'as any' temporarily to bypass strict type checking if PatientProfileInfo is not imported
138
+ // TODO: Properly import PatientProfileInfo and ensure type compatibility
139
+ );
140
+ } else {
141
+ Logger.warn(
142
+ `[AggService] Cannot send confirmation email to patient ${appointment.patientId}: email missing.`,
143
+ );
144
+ }
145
+
146
+ if (patientProfile?.expoTokens && patientProfile.expoTokens.length > 0) {
147
+ Logger.info(
148
+ `[AggService] TODO: Send appointment confirmed push to patient ${appointment.patientId}`,
149
+ );
150
+ await this.notificationsAdmin.sendAppointmentConfirmedPush(
151
+ appointment,
152
+ appointment.patientId,
153
+ patientProfile.expoTokens,
154
+ UserRole.PATIENT,
155
+ );
156
+ }
157
+
158
+ if (practitionerProfile?.basicInfo?.email) {
159
+ Logger.info(
160
+ `[AggService] Sending appointment confirmation email to practitioner ${practitionerProfile.basicInfo.email}`,
161
+ );
162
+ const practitionerEmailData = {
163
+ appointment: appointment,
164
+ recipientProfile: appointment.practitionerInfo,
165
+ recipientRole: 'practitioner' as const,
166
+ };
167
+ await this.appointmentMailingService.sendAppointmentConfirmedEmail(
168
+ practitionerEmailData, // TODO: Properly import PractitionerProfileInfo and ensure type compatibility
169
+ );
170
+ }
171
+ // TODO: Add push notification for practitioner if they have expoTokens
172
+ } else if (appointment.status === AppointmentStatus.PENDING) {
173
+ Logger.info(`[AggService] Appt ${appointment.id} created as PENDING.`);
174
+ // Notify clinic admin about the pending appointment
175
+ if (clinicInfo?.contactInfo?.email) {
176
+ Logger.info(
177
+ `[AggService] TODO: Send pending appointment notification email to clinic admin ${clinicInfo.contactInfo.email}`,
178
+ );
179
+ const clinicEmailData = {
180
+ appointment: appointment,
181
+ clinicProfile: appointment.clinicInfo, // clinicInfo should be compatible with ClinicInfo type
182
+ };
183
+ await this.appointmentMailingService.sendAppointmentRequestedEmailToClinic(
184
+ clinicEmailData, // TODO: Properly import ClinicInfo if stricter typing is needed here and ensure compatibility
185
+ );
186
+ } else {
187
+ Logger.warn(
188
+ `[AggService] Cannot send pending appointment email to clinic ${appointment.clinicBranchId}: email missing.`,
189
+ );
190
+ }
191
+ // TODO: Push notification for clinic admin if applicable (they usually don't have tokens)
192
+ }
193
+
194
+ // Calendar events are noted as being handled by BookingAdmin.orchestrateAppointmentCreation during the booking process itself.
195
+ Logger.info(`[AggService] Successfully processed CREATE for appointment: ${appointment.id}`);
196
+ } catch (error) {
197
+ Logger.error(
198
+ `[AggService] Critical error in handleAppointmentCreate for appointment ${appointment.id}:`,
199
+ error,
200
+ );
201
+ // Depending on the error, you might want to re-throw or handle specific cases
202
+ // (e.g., update appointment status to an error state if a critical part failed)
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Handles side effects when an appointment is updated.
208
+ * This function would typically be called by an Firestore onUpdate trigger.
209
+ * @param {Appointment} before - The Appointment object before the update.
210
+ * @param {Appointment} after - The Appointment object after the update.
211
+ * @returns {Promise<void>}
212
+ */
213
+ async handleAppointmentUpdate(before: Appointment, after: Appointment): Promise<void> {
214
+ Logger.info(
215
+ `[AggService] Handling UPDATE for appointment: ${after.id}. Status ${before.status} -> ${after.status}`,
216
+ );
217
+
218
+ try {
219
+ const statusChanged = before.status !== after.status;
220
+ const timeChanged =
221
+ before.appointmentStartTime.toMillis() !== after.appointmentStartTime.toMillis() ||
222
+ before.appointmentEndTime.toMillis() !== after.appointmentEndTime.toMillis();
223
+ const zonePhotosChanged = this.hasZonePhotosChanged(before, after);
224
+ // const paymentStatusChanged = before.paymentStatus !== after.paymentStatus; // TODO: Handle later
225
+ // const reviewAdded = !before.reviewInfo && after.reviewInfo; // TODO: Handle later
226
+
227
+ // Fetch profiles for notifications - could be conditional based on changes
228
+ // For simplicity, fetching upfront, but optimize if performance is an issue.
229
+ const [patientProfile, patientSensitiveInfo, practitionerProfile, clinicInfo] =
230
+ await Promise.all([
231
+ this.fetchPatientProfile(after.patientId),
232
+ this.fetchPatientSensitiveInfo(after.patientId),
233
+ this.fetchPractitionerProfile(after.practitionerId),
234
+ this.fetchClinicInfo(after.clinicBranchId),
235
+ ]);
236
+
237
+ if (statusChanged) {
238
+ Logger.info(
239
+ `[AggService] Status changed for ${after.id}: ${before.status} -> ${after.status}`,
240
+ );
241
+
242
+ // --- PENDING -> CONFIRMED ---
243
+ if (
244
+ before.status === AppointmentStatus.PENDING &&
245
+ after.status === AppointmentStatus.CONFIRMED
246
+ ) {
247
+ Logger.info(`[AggService] Appt ${after.id} PENDING -> CONFIRMED.`);
248
+ await this.createPreAppointmentRequirementInstances(after);
249
+
250
+ // Update calendar events to CONFIRMED status
251
+ await this.calendarAdminService.updateAppointmentCalendarEventsStatus(
252
+ after,
253
+ CalendarEventStatus.CONFIRMED,
254
+ );
255
+
256
+ // Send confirmation notifications
257
+ if (patientSensitiveInfo?.email && patientProfile) {
258
+ Logger.info(
259
+ `[AggService] Sending appointment confirmed email to patient ${patientSensitiveInfo.email}`,
260
+ );
261
+ const emailData = {
262
+ appointment: after,
263
+ recipientProfile: after.patientInfo,
264
+ recipientRole: 'patient' as const,
265
+ };
266
+ await this.appointmentMailingService.sendAppointmentConfirmedEmail(emailData as any);
267
+ } else {
268
+ Logger.warn(
269
+ `[AggService] Cannot send confirmation email to patient ${after.patientId}: email missing.`,
270
+ );
271
+ }
272
+
273
+ if (patientProfile?.expoTokens && patientProfile.expoTokens.length > 0) {
274
+ Logger.info(
275
+ `[AggService] TODO: Send appointment confirmed push to patient ${after.patientId}`,
276
+ );
277
+ await this.notificationsAdmin.sendAppointmentConfirmedPush(
278
+ after,
279
+ after.patientId,
280
+ patientProfile.expoTokens,
281
+ UserRole.PATIENT,
282
+ );
283
+ }
284
+
285
+ if (practitionerProfile?.basicInfo?.email) {
286
+ Logger.info(
287
+ `[AggService] Sending appointment confirmation email to practitioner ${practitionerProfile.basicInfo.email}`,
288
+ );
289
+ const practitionerEmailData = {
290
+ appointment: after,
291
+ recipientProfile: after.practitionerInfo,
292
+ recipientRole: 'practitioner' as const,
293
+ };
294
+ await this.appointmentMailingService.sendAppointmentConfirmedEmail(
295
+ practitionerEmailData as any,
296
+ );
297
+ }
298
+ }
299
+ // --- RESCHEDULED_BY_CLINIC -> CONFIRMED (Reschedule Acceptance) ---
300
+ else if (
301
+ before.status === AppointmentStatus.RESCHEDULED_BY_CLINIC &&
302
+ after.status === AppointmentStatus.CONFIRMED
303
+ ) {
304
+ Logger.info(`[AggService] Appt ${after.id} RESCHEDULED_BY_CLINIC -> CONFIRMED.`);
305
+
306
+ // Update existing requirements as superseded and create new ones
307
+ await this.updateRelatedPatientRequirementInstances(
308
+ before,
309
+ PatientRequirementOverallStatus.SUPERSEDED_RESCHEDULE,
310
+ );
311
+ await this.createPreAppointmentRequirementInstances(after);
312
+
313
+ // Update calendar events to CONFIRMED status and update times
314
+ await this.calendarAdminService.updateAppointmentCalendarEventsStatus(
315
+ after,
316
+ CalendarEventStatus.CONFIRMED,
317
+ );
318
+
319
+ // Send confirmation notifications (similar to PENDING -> CONFIRMED)
320
+ if (patientSensitiveInfo?.email && patientProfile) {
321
+ Logger.info(
322
+ `[AggService] Sending appointment confirmed email to patient ${patientSensitiveInfo.email}`,
323
+ );
324
+ const emailData = {
325
+ appointment: after,
326
+ recipientProfile: after.patientInfo,
327
+ recipientRole: 'patient' as const,
328
+ };
329
+ await this.appointmentMailingService.sendAppointmentConfirmedEmail(emailData as any);
330
+ }
331
+
332
+ if (patientProfile?.expoTokens && patientProfile.expoTokens.length > 0) {
333
+ await this.notificationsAdmin.sendAppointmentConfirmedPush(
334
+ after,
335
+ after.patientId,
336
+ patientProfile.expoTokens,
337
+ UserRole.PATIENT,
338
+ );
339
+ }
340
+
341
+ if (practitionerProfile?.basicInfo?.email) {
342
+ Logger.info(
343
+ `[AggService] Sending appointment confirmation email to practitioner ${practitionerProfile.basicInfo.email}`,
344
+ );
345
+ const practitionerEmailData = {
346
+ appointment: after,
347
+ recipientProfile: after.practitionerInfo,
348
+ recipientRole: 'practitioner' as const,
349
+ };
350
+ await this.appointmentMailingService.sendAppointmentConfirmedEmail(
351
+ practitionerEmailData as any,
352
+ );
353
+ }
354
+ }
355
+ // --- Any -> CANCELLED_* ---
356
+ else if (
357
+ after.status === AppointmentStatus.CANCELED_CLINIC ||
358
+ after.status === AppointmentStatus.CANCELED_PATIENT ||
359
+ after.status === AppointmentStatus.CANCELED_PATIENT_RESCHEDULED ||
360
+ after.status === AppointmentStatus.NO_SHOW
361
+ ) {
362
+ Logger.info(
363
+ `[AggService] Appt ${after.id} status -> ${after.status}. Processing as cancellation.`,
364
+ );
365
+ await this.updateRelatedPatientRequirementInstances(
366
+ after,
367
+ PatientRequirementOverallStatus.CANCELLED_APPOINTMENT,
368
+ );
369
+
370
+ // Update patient-clinic-practitioner links if patient profile exists
371
+ if (patientProfile) {
372
+ await this.managePatientClinicPractitionerLinks(
373
+ patientProfile,
374
+ after.practitionerId,
375
+ after.clinicBranchId,
376
+ 'cancel',
377
+ after.status,
378
+ );
379
+ }
380
+
381
+ const calendarStatus = (status: AppointmentStatus) => {
382
+ switch (status) {
383
+ case AppointmentStatus.NO_SHOW:
384
+ return CalendarEventStatus.NO_SHOW;
385
+ case AppointmentStatus.CANCELED_CLINIC:
386
+ return CalendarEventStatus.REJECTED;
387
+ case AppointmentStatus.CANCELED_PATIENT:
388
+ return CalendarEventStatus.CANCELED;
389
+ case AppointmentStatus.CANCELED_PATIENT_RESCHEDULED:
390
+ return CalendarEventStatus.REJECTED;
391
+ default:
392
+ return CalendarEventStatus.CANCELED;
393
+ }
394
+ };
395
+
396
+ await this.calendarAdminService.updateAppointmentCalendarEventsStatus(
397
+ after,
398
+ calendarStatus(after.status),
399
+ );
400
+
401
+ // Send cancellation email to Patient
402
+ if (patientSensitiveInfo?.email && patientProfile) {
403
+ Logger.info(
404
+ `[AggService] Sending appointment cancellation email to patient ${patientSensitiveInfo.email}`,
405
+ );
406
+ const patientCancellationData = {
407
+ appointment: after,
408
+ recipientProfile: after.patientInfo,
409
+ recipientRole: 'patient' as const,
410
+ // cancellationReason: after.cancellationReason, // TODO: Add if cancellationReason is available on 'after' Appointment
411
+ };
412
+ await this.appointmentMailingService.sendAppointmentCancelledEmail(
413
+ patientCancellationData as any, // TODO: Properly import types
414
+ );
415
+ }
416
+
417
+ // Send cancellation email to Practitioner
418
+ if (practitionerProfile?.basicInfo?.email) {
419
+ Logger.info(
420
+ `[AggService] Sending appointment cancellation email to practitioner ${practitionerProfile.basicInfo.email}`,
421
+ );
422
+ const practitionerCancellationData = {
423
+ appointment: after,
424
+ recipientProfile: after.practitionerInfo,
425
+ recipientRole: 'practitioner' as const,
426
+ // cancellationReason: after.cancellationReason, // TODO: Add if cancellationReason is available on 'after' Appointment
427
+ };
428
+ await this.appointmentMailingService.sendAppointmentCancelledEmail(
429
+ practitionerCancellationData as any, // TODO: Properly import types
430
+ );
431
+ }
432
+
433
+ // TODO: Send cancellation push notifications (patient, practitioner) via notificationsAdmin
434
+ // TODO: Update/cancel calendar event via calendarAdminService.updateAppointmentCalendarEventStatus(after, CalendarEventStatus.CANCELED)
435
+ }
436
+ // --- Any -> COMPLETED ---
437
+ else if (after.status === AppointmentStatus.COMPLETED) {
438
+ Logger.info(`[AggService] Appt ${after.id} status -> COMPLETED.`);
439
+ await this.createPostAppointmentRequirementInstances(after);
440
+
441
+ // Update calendar events to COMPLETED status
442
+ await this.calendarAdminService.updateAppointmentCalendarEventsStatus(
443
+ after,
444
+ CalendarEventStatus.COMPLETED,
445
+ );
446
+
447
+ // Send review request email to patient
448
+ if (patientSensitiveInfo?.email && patientProfile) {
449
+ Logger.info(
450
+ `[AggService] Sending review request email to patient ${patientSensitiveInfo.email}`,
451
+ );
452
+ const reviewRequestData = {
453
+ appointment: after,
454
+ patientProfile: after.patientInfo,
455
+ reviewLink: 'TODO: Generate actual review link', // Placeholder
456
+ };
457
+ await this.appointmentMailingService.sendReviewRequestEmail(
458
+ reviewRequestData as any, // TODO: Properly import PatientProfileInfo and define reviewLink generation
459
+ );
460
+ }
461
+ // TODO: Send review request push notification to patient
462
+ }
463
+ // --- RESCHEDULE Scenarios (e.g., PENDING/CONFIRMED -> RESCHEDULED_BY_CLINIC) ---
464
+ else if (after.status === AppointmentStatus.RESCHEDULED_BY_CLINIC) {
465
+ Logger.info(`[AggService] Appt ${after.id} status -> RESCHEDULED_BY_CLINIC.`);
466
+ await this.updateRelatedPatientRequirementInstances(
467
+ before, // Pass the 'before' state for old requirements
468
+ PatientRequirementOverallStatus.SUPERSEDED_RESCHEDULE,
469
+ );
470
+
471
+ // First update the calendar event times with new proposed times
472
+ await this.calendarAdminService.updateAppointmentCalendarEventsTime(after, {
473
+ start: after.appointmentStartTime,
474
+ end: after.appointmentEndTime,
475
+ });
476
+
477
+ // Then update calendar events to PENDING status (waiting for patient confirmation)
478
+ await this.calendarAdminService.updateAppointmentCalendarEventsStatus(
479
+ after,
480
+ CalendarEventStatus.PENDING,
481
+ );
482
+
483
+ // Send reschedule proposal email to patient
484
+ if (patientSensitiveInfo?.email && patientProfile) {
485
+ Logger.info(
486
+ `[AggService] Sending reschedule proposal email to patient ${patientSensitiveInfo.email}`,
487
+ );
488
+ const rescheduleEmailData = {
489
+ appointment: after, // The new state of the appointment
490
+ patientProfile: after.patientInfo,
491
+ previousStartTime: before.appointmentStartTime,
492
+ previousEndTime: before.appointmentEndTime,
493
+ };
494
+ await this.appointmentMailingService.sendAppointmentRescheduledProposalEmail(
495
+ rescheduleEmailData as any, // TODO: Properly import PatientProfileInfo and types
496
+ );
497
+ }
498
+
499
+ Logger.info(
500
+ `[AggService] TODO: Send reschedule proposal notifications to practitioner as well.`,
501
+ );
502
+ // TODO: Update calendar event to reflect proposed new time via calendarAdminService.
503
+ }
504
+ // TODO: Add more specific status change handlers as needed
505
+ }
506
+
507
+ // --- Independent Time Change (if not tied to a status that already handled it) ---
508
+ if (timeChanged && !statusChanged) {
509
+ // Or if status change didn't fully cover reschedule implications
510
+ Logger.info(`[AggService] Appointment ${after.id} time changed.`);
511
+
512
+ // If confirmed appointment has time change, we need to update requirements
513
+ if (after.status === AppointmentStatus.CONFIRMED) {
514
+ // Update existing requirements as superseded and create new ones
515
+ await this.updateRelatedPatientRequirementInstances(
516
+ before,
517
+ PatientRequirementOverallStatus.SUPERSEDED_RESCHEDULE,
518
+ );
519
+ await this.createPreAppointmentRequirementInstances(after);
520
+
521
+ // Update calendar event times with new times
522
+ await this.calendarAdminService.updateAppointmentCalendarEventsTime(after, {
523
+ start: after.appointmentStartTime,
524
+ end: after.appointmentEndTime,
525
+ });
526
+ } else {
527
+ Logger.warn(
528
+ `[AggService] Independent time change detected for ${after.id} with status ${after.status}. Review implications for requirements and calendar.`,
529
+ );
530
+ }
531
+ }
532
+
533
+ // TODO: Handle Payment Status Change
534
+ // const paymentStatusChanged = before.paymentStatus !== after.paymentStatus;
535
+ // if (paymentStatusChanged && after.paymentStatus === PaymentStatus.PAID) { ... }
536
+
537
+ // Handle Zone Photos Changes
538
+ if (zonePhotosChanged) {
539
+ Logger.info(`[AggService] Zone photos changed for appointment ${after.id}`);
540
+ await this.handleZonePhotosUpdate(before, after);
541
+ }
542
+
543
+ // TODO: Handle Review Added
544
+ // const reviewAdded = !before.reviewInfo && after.reviewInfo;
545
+ // if (reviewAdded) { ... }
546
+
547
+ Logger.info(`[AggService] Successfully processed UPDATE for appointment: ${after.id}`);
548
+ } catch (error) {
549
+ Logger.error(
550
+ `[AggService] Critical error in handleAppointmentUpdate for appointment ${after.id}:`,
551
+ error,
552
+ );
553
+ }
554
+ }
555
+
556
+ /**
557
+ * Handles side effects when an appointment is deleted.
558
+ * @param deletedAppointment - The Appointment object that was deleted.
559
+ * @returns {Promise<void>}
560
+ */
561
+ async handleAppointmentDelete(deletedAppointment: Appointment): Promise<void> {
562
+ Logger.info(`[AggService] Handling DELETE for appointment: ${deletedAppointment.id}`);
563
+ // Similar to cancellation
564
+ await this.updateRelatedPatientRequirementInstances(
565
+ deletedAppointment,
566
+ PatientRequirementOverallStatus.CANCELLED_APPOINTMENT,
567
+ );
568
+
569
+ // Fetch patient profile first
570
+ const patientProfile = await this.fetchPatientProfile(deletedAppointment.patientId);
571
+
572
+ // Update relationship links if patient profile exists
573
+ if (patientProfile) {
574
+ await this.managePatientClinicPractitionerLinks(
575
+ patientProfile,
576
+ deletedAppointment.practitionerId,
577
+ deletedAppointment.clinicBranchId,
578
+ 'cancel',
579
+ );
580
+ }
581
+
582
+ // Delete all associated calendar events
583
+ await this.calendarAdminService.deleteAppointmentCalendarEvents(deletedAppointment);
584
+
585
+ // TODO: Send cancellation/deletion notifications if appropriate (though data is gone)
586
+ }
587
+
588
+ // --- Helper Methods for Aggregation Logic ---
589
+
590
+ /**
591
+ * Creates PRE_APPOINTMENT PatientRequirementInstance documents for a given appointment.
592
+ * Uses the `appointment.preProcedureRequirements` array, which should contain relevant Requirement templates.
593
+ * For each active PRE requirement template, it constructs a new PatientRequirementInstance document
594
+ * with derived instructions and batch writes them to Firestore under the patient's `patient_requirements` subcollection.
595
+ *
596
+ * @param {Appointment} appointment - The appointment for which to create pre-requirement instances.
597
+ * @returns {Promise<void>} A promise that resolves when the operation is complete.
598
+ */
599
+ private async createPreAppointmentRequirementInstances(appointment: Appointment): Promise<void> {
600
+ Logger.info(
601
+ `[AggService] Creating PRE-appointment requirement instances for appt: ${appointment.id}, patient: ${appointment.patientId}`,
602
+ );
603
+
604
+ if (!appointment.procedureId) {
605
+ Logger.warn(
606
+ `[AggService] Appointment ${appointment.id} has no procedureId. Cannot create pre-requirement instances.`,
607
+ );
608
+ return;
609
+ }
610
+
611
+ if (
612
+ !appointment.preProcedureRequirements ||
613
+ appointment.preProcedureRequirements.length === 0
614
+ ) {
615
+ Logger.info(
616
+ `[AggService] No preProcedureRequirements found on appointment ${appointment.id}. Nothing to create.`,
617
+ );
618
+ return;
619
+ }
620
+
621
+ try {
622
+ const batch = this.db.batch();
623
+ let instancesCreatedCount = 0;
624
+ // Store created instances for fallback direct creation if needed
625
+ let createdInstances = [];
626
+
627
+ // Log more details about the pre-requirements
628
+ Logger.info(
629
+ `[AggService] Found ${
630
+ appointment.preProcedureRequirements.length
631
+ } pre-requirements to process: ${JSON.stringify(
632
+ appointment.preProcedureRequirements.map(r => ({
633
+ id: r.id,
634
+ name: r.name,
635
+ type: r.type,
636
+ isActive: r.isActive,
637
+ hasTimeframe: !!r.timeframe,
638
+ notifyAtLength: r.timeframe?.notifyAt?.length || 0,
639
+ })),
640
+ )}`,
641
+ );
642
+
643
+ for (const template of appointment.preProcedureRequirements) {
644
+ if (!template) {
645
+ Logger.warn(
646
+ `[AggService] Found null/undefined template in preProcedureRequirements array`,
647
+ );
648
+ continue;
649
+ }
650
+
651
+ // Ensure it's an active, PRE-type requirement
652
+ if (template.type !== RequirementType.PRE || !template.isActive) {
653
+ Logger.debug(
654
+ `[AggService] Skipping template ${template.id} (${template.name}): not an active PRE requirement.`,
655
+ );
656
+ continue;
657
+ }
658
+
659
+ if (
660
+ !template.timeframe ||
661
+ !template.timeframe.notifyAt ||
662
+ template.timeframe.notifyAt.length === 0
663
+ ) {
664
+ Logger.warn(
665
+ `[AggService] Template ${template.id} (${template.name}) has no timeframe.notifyAt values. Creating with empty instructions.`,
666
+ );
667
+ }
668
+
669
+ Logger.debug(
670
+ `[AggService] Processing template ${template.id} (${template.name}) for appt ${appointment.id}`,
671
+ );
672
+
673
+ const newInstanceRef = this.db
674
+ .collection(PATIENTS_COLLECTION)
675
+ .doc(appointment.patientId)
676
+ .collection(PATIENT_REQUIREMENTS_SUBCOLLECTION_NAME)
677
+ .doc(); // Auto-generate ID for the new instance
678
+
679
+ // Log the path for debugging
680
+ Logger.debug(`[AggService] Created doc reference: ${newInstanceRef.path}`);
681
+
682
+ const instructions: PatientRequirementInstruction[] = (
683
+ template.timeframe?.notifyAt || []
684
+ ).map(notifyAtValue => {
685
+ let dueTime: any = appointment.appointmentStartTime;
686
+ if (template.timeframe && typeof notifyAtValue === 'number') {
687
+ const dueDateTime = new Date(appointment.appointmentStartTime.toMillis());
688
+ if (template.timeframe.unit === TimeUnit.DAYS) {
689
+ dueDateTime.setDate(dueDateTime.getDate() - notifyAtValue);
690
+ } else if (template.timeframe.unit === TimeUnit.HOURS) {
691
+ dueDateTime.setHours(dueDateTime.getHours() - notifyAtValue);
692
+ }
693
+ dueTime = admin.firestore.Timestamp.fromDate(dueDateTime);
694
+ }
695
+
696
+ // TODO: Determine source or default for 'actionableWindow' - consult requirements for PatientRequirementInstruction
697
+ const actionableWindowHours =
698
+ template.importance === 'high' ? 1 : template.importance === 'medium' ? 4 : 15; // Default to 15 hours for low importance; // Placeholder default, TODO: Define source
699
+
700
+ const instructionObject: PatientRequirementInstruction = {
701
+ instructionId: `${template.id}_${notifyAtValue}_${newInstanceRef.id}`.replace(
702
+ /[^a-zA-Z0-9_]/g,
703
+ '_',
704
+ ),
705
+ instructionText: template.description || template.name,
706
+ dueTime: dueTime as any,
707
+ actionableWindow: actionableWindowHours, // Directly assigning the placeholder default value
708
+ status: PatientInstructionStatus.PENDING_NOTIFICATION,
709
+ originalNotifyAtValue: notifyAtValue,
710
+ originalTimeframeUnit: template.timeframe.unit,
711
+ updatedAt: admin.firestore.Timestamp.now() as any, // Use current server timestamp
712
+ };
713
+ return instructionObject;
714
+ });
715
+
716
+ const newInstanceData: PatientRequirementInstance = {
717
+ id: newInstanceRef.id, // Add the ID to the document data
718
+ patientId: appointment.patientId,
719
+ appointmentId: appointment.id,
720
+ originalRequirementId: template.id,
721
+ requirementName: template.name,
722
+ requirementDescription: template.description,
723
+ requirementType: template.type, // Should be RequirementType.PRE
724
+ requirementImportance: template.importance,
725
+ overallStatus: PatientRequirementOverallStatus.ACTIVE,
726
+ instructions: instructions,
727
+ // Timestamps - cast to any to satisfy client-side Timestamp type for now
728
+ createdAt: admin.firestore.FieldValue.serverTimestamp() as any,
729
+ updatedAt: admin.firestore.FieldValue.serverTimestamp() as any,
730
+ };
731
+
732
+ // Log the data being set
733
+ Logger.debug(
734
+ `[AggService] Setting data for requirement: ${JSON.stringify({
735
+ id: newInstanceRef.id,
736
+ patientId: newInstanceData.patientId,
737
+ appointmentId: newInstanceData.appointmentId,
738
+ requirementName: newInstanceData.requirementName,
739
+ instructionsCount: newInstanceData.instructions.length,
740
+ })}`,
741
+ );
742
+
743
+ batch.set(newInstanceRef, newInstanceData);
744
+ // Store for potential fallback
745
+ createdInstances.push({
746
+ ref: newInstanceRef,
747
+ data: newInstanceData,
748
+ });
749
+
750
+ instancesCreatedCount++;
751
+ Logger.debug(
752
+ `[AggService] Added PatientRequirementInstance ${newInstanceRef.id} to batch for template ${template.id}.`,
753
+ );
754
+ }
755
+
756
+ if (instancesCreatedCount > 0) {
757
+ try {
758
+ await batch.commit();
759
+ Logger.info(
760
+ `[AggService] Successfully created ${instancesCreatedCount} PRE_APPOINTMENT requirement instances for appointment ${appointment.id}.`,
761
+ );
762
+
763
+ // Verify creation success
764
+ try {
765
+ const verifySnapshot = await this.db
766
+ .collection(PATIENTS_COLLECTION)
767
+ .doc(appointment.patientId)
768
+ .collection(PATIENT_REQUIREMENTS_SUBCOLLECTION_NAME)
769
+ .where('appointmentId', '==', appointment.id)
770
+ .get();
771
+
772
+ if (verifySnapshot.empty) {
773
+ Logger.warn(
774
+ `[AggService] Batch commit reported success but documents not found! Attempting direct creation as fallback...`,
775
+ );
776
+
777
+ // Fallback to direct creation if batch worked but docs aren't there
778
+ const fallbackPromises = createdInstances.map(async ({ ref, data }) => {
779
+ try {
780
+ await ref.set(data);
781
+ Logger.info(`[AggService] Fallback direct creation success for ${ref.id}`);
782
+ return true;
783
+ } catch (fallbackError) {
784
+ Logger.error(
785
+ `[AggService] Fallback direct creation failed for ${ref.id}:`,
786
+ fallbackError,
787
+ );
788
+ return false;
789
+ }
790
+ });
791
+
792
+ const fallbackResults = await Promise.allSettled(fallbackPromises);
793
+ const successCount = fallbackResults.filter(
794
+ r => r.status === 'fulfilled' && r.value === true,
795
+ ).length;
796
+
797
+ if (successCount > 0) {
798
+ Logger.info(
799
+ `[AggService] Fallback mechanism created ${successCount} out of ${createdInstances.length} requirements`,
800
+ );
801
+ } else {
802
+ Logger.error(
803
+ `[AggService] Both batch and fallback mechanisms failed to create requirements`,
804
+ );
805
+ throw new Error(
806
+ 'Failed to create patient requirements through both batch and direct methods',
807
+ );
808
+ }
809
+ } else {
810
+ Logger.info(
811
+ `[AggService] Verification confirmed ${verifySnapshot.size} requirement documents created`,
812
+ );
813
+ }
814
+ } catch (verifyError) {
815
+ Logger.error(
816
+ `[AggService] Error during verification of created requirements:`,
817
+ verifyError,
818
+ );
819
+ }
820
+ } catch (commitError) {
821
+ Logger.error(
822
+ `[AggService] Error committing batch for PRE_APPOINTMENT requirement instances for appointment ${appointment.id}:`,
823
+ commitError,
824
+ );
825
+
826
+ // Try direct creation as fallback
827
+ Logger.info(`[AggService] Attempting direct creation as fallback...`);
828
+ const fallbackPromises = createdInstances.map(async ({ ref, data }) => {
829
+ try {
830
+ await ref.set(data);
831
+ Logger.info(`[AggService] Fallback direct creation success for ${ref.id}`);
832
+ return true;
833
+ } catch (fallbackError) {
834
+ Logger.error(
835
+ `[AggService] Fallback direct creation failed for ${ref.id}:`,
836
+ fallbackError,
837
+ );
838
+ return false;
839
+ }
840
+ });
841
+
842
+ const fallbackResults = await Promise.allSettled(fallbackPromises);
843
+ const successCount = fallbackResults.filter(
844
+ r => r.status === 'fulfilled' && r.value === true,
845
+ ).length;
846
+
847
+ if (successCount > 0) {
848
+ Logger.info(
849
+ `[AggService] Fallback mechanism created ${successCount} out of ${createdInstances.length} requirements`,
850
+ );
851
+ } else {
852
+ Logger.error(
853
+ `[AggService] Both batch and fallback mechanisms failed to create requirements`,
854
+ );
855
+ throw new Error(
856
+ 'Failed to create patient requirements through both batch and direct methods',
857
+ );
858
+ }
859
+ }
860
+ } else {
861
+ Logger.info(
862
+ `[AggService] No new PRE_APPOINTMENT requirement instances were prepared for batch commit for appointment ${appointment.id}.`,
863
+ );
864
+ }
865
+ } catch (error) {
866
+ Logger.error(
867
+ `[AggService] Error creating PRE_APPOINTMENT requirement instances for appointment ${appointment.id}:`,
868
+ error,
869
+ );
870
+ throw error; // Re-throw to ensure the caller knows there was a problem
871
+ }
872
+ }
873
+
874
+ /**
875
+ * Fetches post-requirements from a procedure document
876
+ * @param procedureId - The procedure ID to fetch requirements from
877
+ * @returns Promise resolving to array of post-requirements with source procedure info
878
+ */
879
+ private async fetchPostRequirementsFromProcedure(
880
+ procedureId: string,
881
+ ): Promise<RequirementWithSource[]> {
882
+ try {
883
+ const procedureDoc = await this.db.collection(PROCEDURES_COLLECTION).doc(procedureId).get();
884
+ if (!procedureDoc.exists) {
885
+ Logger.warn(`[AggService] Procedure ${procedureId} not found when fetching requirements`);
886
+ return [];
887
+ }
888
+
889
+ const procedure = procedureDoc.data() as Procedure;
890
+ const postRequirements = procedure.postRequirements || [];
891
+
892
+ if (postRequirements.length === 0) {
893
+ return [];
894
+ }
895
+
896
+ return postRequirements.map(req => ({
897
+ requirement: req,
898
+ sourceProcedures: [
899
+ {
900
+ procedureId: procedure.id,
901
+ procedureName: procedure.name,
902
+ },
903
+ ],
904
+ }));
905
+ } catch (error) {
906
+ Logger.error(
907
+ `[AggService] Error fetching post-requirements from procedure ${procedureId}:`,
908
+ error,
909
+ );
910
+ return [];
911
+ }
912
+ }
913
+
914
+ /**
915
+ * Collects all post-requirements from primary and extended procedures
916
+ * @param appointment - The appointment to collect requirements for
917
+ * @returns Promise resolving to array of requirements with source procedures
918
+ */
919
+ private async collectAllPostRequirements(
920
+ appointment: Appointment,
921
+ ): Promise<RequirementWithSource[]> {
922
+ const allRequirements: RequirementWithSource[] = [];
923
+
924
+ // Fetch from primary procedure
925
+ if (appointment.procedureId) {
926
+ const primaryRequirements = await this.fetchPostRequirementsFromProcedure(
927
+ appointment.procedureId,
928
+ );
929
+ allRequirements.push(...primaryRequirements);
930
+ }
931
+
932
+ // Fetch from extended procedures
933
+ const extendedProcedures = appointment.metadata?.extendedProcedures || [];
934
+ if (extendedProcedures.length > 0) {
935
+ Logger.info(
936
+ `[AggService] Fetching post-requirements from ${extendedProcedures.length} extended procedures`,
937
+ );
938
+
939
+ const extendedRequirementsPromises = extendedProcedures.map(extProc =>
940
+ this.fetchPostRequirementsFromProcedure(extProc.procedureId),
941
+ );
942
+
943
+ const extendedRequirementsArrays = await Promise.all(extendedRequirementsPromises);
944
+ extendedRequirementsArrays.forEach(reqs => {
945
+ allRequirements.push(...reqs);
946
+ });
947
+ }
948
+
949
+ return allRequirements;
950
+ }
951
+
952
+ /**
953
+ * Generates a unique key for a requirement based on ID and timeframe
954
+ * @param requirement - The requirement to generate a key for
955
+ * @returns Unique key string
956
+ */
957
+ private getRequirementKey(requirement: RequirementTemplate): string {
958
+ const timeframeSig = JSON.stringify({
959
+ duration: requirement.timeframe?.duration || 0,
960
+ unit: requirement.timeframe?.unit || '',
961
+ notifyAt: (requirement.timeframe?.notifyAt || []).slice().sort((a, b) => a - b),
962
+ });
963
+ return `${requirement.id}:${timeframeSig}`;
964
+ }
965
+
966
+ /**
967
+ * Deduplicates requirements based on requirement ID and timeframe
968
+ * Merges source procedures when requirements match
969
+ * @param requirements - Array of requirements with sources
970
+ * @returns Deduplicated array of requirements
971
+ */
972
+ private deduplicateRequirements(
973
+ requirements: RequirementWithSource[],
974
+ ): RequirementWithSource[] {
975
+ const requirementMap = new Map<string, RequirementWithSource>();
976
+
977
+ for (const reqWithSource of requirements) {
978
+ const key = this.getRequirementKey(reqWithSource.requirement);
979
+
980
+ if (requirementMap.has(key)) {
981
+ // Merge source procedures
982
+ const existing = requirementMap.get(key)!;
983
+ const existingProcedureIds = new Set(
984
+ existing.sourceProcedures.map(sp => sp.procedureId),
985
+ );
986
+
987
+ // Add new source procedures that don't already exist
988
+ reqWithSource.sourceProcedures.forEach(sp => {
989
+ if (!existingProcedureIds.has(sp.procedureId)) {
990
+ existing.sourceProcedures.push(sp);
991
+ }
992
+ });
993
+ } else {
994
+ // New requirement, add it
995
+ requirementMap.set(key, {
996
+ requirement: reqWithSource.requirement,
997
+ sourceProcedures: [...reqWithSource.sourceProcedures],
998
+ });
999
+ }
1000
+ }
1001
+
1002
+ return Array.from(requirementMap.values());
1003
+ }
1004
+
1005
+ /**
1006
+ * Creates POST_APPOINTMENT PatientRequirementInstance documents for a given appointment.
1007
+ * Fetches requirements from primary and extended procedures, deduplicates them,
1008
+ * and creates requirement instances with source procedure tracking.
1009
+ *
1010
+ * @param {Appointment} appointment - The appointment for which to create post-requirement instances.
1011
+ * @returns {Promise<void>} A promise that resolves when the operation is complete.
1012
+ */
1013
+ private async createPostAppointmentRequirementInstances(appointment: Appointment): Promise<void> {
1014
+ Logger.info(
1015
+ `[AggService] Creating POST-appointment requirement instances for appt: ${appointment.id}, patient: ${appointment.patientId}`,
1016
+ );
1017
+
1018
+ if (!appointment.procedureId) {
1019
+ Logger.warn(
1020
+ `[AggService] Appointment ${appointment.id} has no procedureId. Cannot create post-requirement instances.`,
1021
+ );
1022
+ return;
1023
+ }
1024
+
1025
+ try {
1026
+ // Collect all post-requirements from primary and extended procedures
1027
+ const allRequirements = await this.collectAllPostRequirements(appointment);
1028
+
1029
+ if (allRequirements.length === 0) {
1030
+ Logger.info(
1031
+ `[AggService] No post-requirements found from any procedures for appointment ${appointment.id}. Nothing to create.`,
1032
+ );
1033
+ return;
1034
+ }
1035
+
1036
+ // Deduplicate requirements based on ID + timeframe
1037
+ const deduplicatedRequirements = this.deduplicateRequirements(allRequirements);
1038
+
1039
+ Logger.info(
1040
+ `[AggService] Found ${allRequirements.length} total post-requirements, ${deduplicatedRequirements.length} after deduplication`,
1041
+ );
1042
+
1043
+ // Log details about the deduplicated requirements
1044
+ Logger.info(
1045
+ `[AggService] Processing deduplicated post-requirements: ${JSON.stringify(
1046
+ deduplicatedRequirements.map(r => ({
1047
+ id: r.requirement.id,
1048
+ name: r.requirement.name,
1049
+ type: r.requirement.type,
1050
+ isActive: r.requirement.isActive,
1051
+ hasTimeframe: !!r.requirement.timeframe,
1052
+ notifyAtLength: r.requirement.timeframe?.notifyAt?.length || 0,
1053
+ sourceProcedures: r.sourceProcedures.map(sp => ({
1054
+ procedureId: sp.procedureId,
1055
+ procedureName: sp.procedureName,
1056
+ })),
1057
+ })),
1058
+ )}`,
1059
+ );
1060
+
1061
+ const batch = this.db.batch();
1062
+ let instancesCreatedCount = 0;
1063
+ // Store created instances for fallback direct creation if needed
1064
+ let createdInstances = [];
1065
+
1066
+ for (const reqWithSource of deduplicatedRequirements) {
1067
+ const template = reqWithSource.requirement;
1068
+ if (!template) {
1069
+ Logger.warn(
1070
+ `[AggService] Found null/undefined template in postProcedureRequirements array`,
1071
+ );
1072
+ continue;
1073
+ }
1074
+
1075
+ // Ensure it's an active, POST-type requirement
1076
+ if (template.type !== RequirementType.POST || !template.isActive) {
1077
+ Logger.debug(
1078
+ `[AggService] Skipping template ${template.id} (${template.name}): not an active POST requirement.`,
1079
+ );
1080
+ continue;
1081
+ }
1082
+
1083
+ if (
1084
+ !template.timeframe ||
1085
+ !template.timeframe.notifyAt ||
1086
+ template.timeframe.notifyAt.length === 0
1087
+ ) {
1088
+ Logger.warn(
1089
+ `[AggService] Template ${template.id} (${template.name}) has no timeframe.notifyAt values. Creating with empty instructions.`,
1090
+ );
1091
+ }
1092
+
1093
+ Logger.debug(
1094
+ `[AggService] Processing template ${template.id} (${template.name}) for appt ${appointment.id}`,
1095
+ );
1096
+
1097
+ const newInstanceRef = this.db
1098
+ .collection(PATIENTS_COLLECTION)
1099
+ .doc(appointment.patientId)
1100
+ .collection(PATIENT_REQUIREMENTS_SUBCOLLECTION_NAME)
1101
+ .doc(); // Auto-generate ID for the new instance
1102
+
1103
+ // Log the path for debugging
1104
+ Logger.debug(`[AggService] Created doc reference: ${newInstanceRef.path}`);
1105
+
1106
+ const instructions: PatientRequirementInstruction[] = (
1107
+ template.timeframe?.notifyAt || []
1108
+ ).map(notifyAtValue => {
1109
+ let dueTime: any = appointment.appointmentEndTime;
1110
+ if (template.timeframe && typeof notifyAtValue === 'number') {
1111
+ const dueDateTime = new Date(appointment.appointmentEndTime.toMillis());
1112
+ // For POST requirements, notifyAtValue means AFTER the event
1113
+ if (template.timeframe.unit === TimeUnit.DAYS) {
1114
+ dueDateTime.setDate(dueDateTime.getDate() + notifyAtValue);
1115
+ } else if (template.timeframe.unit === TimeUnit.HOURS) {
1116
+ dueDateTime.setHours(dueDateTime.getHours() + notifyAtValue);
1117
+ }
1118
+ dueTime = admin.firestore.Timestamp.fromDate(dueDateTime);
1119
+ }
1120
+
1121
+ const actionableWindowHours =
1122
+ template.importance === 'high' ? 1 : template.importance === 'medium' ? 4 : 15; // Default to 15 hours for low importance
1123
+
1124
+ const instructionObject: PatientRequirementInstruction = {
1125
+ instructionId: `${template.id}_${notifyAtValue}_${newInstanceRef.id}`.replace(
1126
+ /[^a-zA-Z0-9_]/g,
1127
+ '_',
1128
+ ),
1129
+ instructionText: template.description || template.name,
1130
+ dueTime: dueTime as any,
1131
+ actionableWindow: actionableWindowHours,
1132
+ status: PatientInstructionStatus.PENDING_NOTIFICATION,
1133
+ originalNotifyAtValue: notifyAtValue,
1134
+ originalTimeframeUnit: template.timeframe.unit,
1135
+ updatedAt: admin.firestore.Timestamp.now() as any,
1136
+ notificationId: undefined,
1137
+ actionTakenAt: undefined,
1138
+ };
1139
+ return instructionObject;
1140
+ });
1141
+
1142
+ const newInstanceData: PatientRequirementInstance = {
1143
+ id: newInstanceRef.id,
1144
+ patientId: appointment.patientId,
1145
+ appointmentId: appointment.id,
1146
+ originalRequirementId: template.id,
1147
+ requirementName: template.name,
1148
+ requirementDescription: template.description,
1149
+ requirementType: template.type,
1150
+ requirementImportance: template.importance,
1151
+ overallStatus: PatientRequirementOverallStatus.ACTIVE,
1152
+ instructions: instructions,
1153
+ sourceProcedures: reqWithSource.sourceProcedures, // Track which procedures this requirement comes from
1154
+ createdAt: admin.firestore.FieldValue.serverTimestamp() as any,
1155
+ updatedAt: admin.firestore.FieldValue.serverTimestamp() as any,
1156
+ };
1157
+
1158
+ // Log the data being set
1159
+ Logger.debug(
1160
+ `[AggService] Setting data for requirement: ${JSON.stringify({
1161
+ id: newInstanceRef.id,
1162
+ patientId: newInstanceData.patientId,
1163
+ appointmentId: newInstanceData.appointmentId,
1164
+ requirementName: newInstanceData.requirementName,
1165
+ instructionsCount: newInstanceData.instructions.length,
1166
+ sourceProcedures: newInstanceData.sourceProcedures?.map(sp => ({
1167
+ procedureId: sp.procedureId,
1168
+ procedureName: sp.procedureName,
1169
+ })) || [],
1170
+ })}`,
1171
+ );
1172
+
1173
+ batch.set(newInstanceRef, newInstanceData);
1174
+ // Store for potential fallback
1175
+ createdInstances.push({
1176
+ ref: newInstanceRef,
1177
+ data: newInstanceData,
1178
+ });
1179
+
1180
+ instancesCreatedCount++;
1181
+ Logger.debug(
1182
+ `[AggService] Added PatientRequirementInstance ${newInstanceRef.id} to batch for template ${template.id}.`,
1183
+ );
1184
+ }
1185
+
1186
+ if (instancesCreatedCount > 0) {
1187
+ try {
1188
+ await batch.commit();
1189
+ Logger.info(
1190
+ `[AggService] Successfully created ${instancesCreatedCount} POST_APPOINTMENT requirement instances for appointment ${appointment.id}.`,
1191
+ );
1192
+
1193
+ // Verify creation success
1194
+ try {
1195
+ const verifySnapshot = await this.db
1196
+ .collection(PATIENTS_COLLECTION)
1197
+ .doc(appointment.patientId)
1198
+ .collection(PATIENT_REQUIREMENTS_SUBCOLLECTION_NAME)
1199
+ .where('appointmentId', '==', appointment.id)
1200
+ .get();
1201
+
1202
+ if (verifySnapshot.empty) {
1203
+ Logger.warn(
1204
+ `[AggService] Batch commit reported success but documents not found! Attempting direct creation as fallback...`,
1205
+ );
1206
+
1207
+ // Fallback to direct creation if batch worked but docs aren't there
1208
+ const fallbackPromises = createdInstances.map(async ({ ref, data }) => {
1209
+ try {
1210
+ await ref.set(data);
1211
+ Logger.info(`[AggService] Fallback direct creation success for ${ref.id}`);
1212
+ return true;
1213
+ } catch (fallbackError) {
1214
+ Logger.error(
1215
+ `[AggService] Fallback direct creation failed for ${ref.id}:`,
1216
+ fallbackError,
1217
+ );
1218
+ return false;
1219
+ }
1220
+ });
1221
+
1222
+ const fallbackResults = await Promise.allSettled(fallbackPromises);
1223
+ const successCount = fallbackResults.filter(
1224
+ r => r.status === 'fulfilled' && r.value === true,
1225
+ ).length;
1226
+
1227
+ if (successCount > 0) {
1228
+ Logger.info(
1229
+ `[AggService] Fallback mechanism created ${successCount} out of ${createdInstances.length} requirements`,
1230
+ );
1231
+ } else {
1232
+ Logger.error(
1233
+ `[AggService] Both batch and fallback mechanisms failed to create requirements`,
1234
+ );
1235
+ throw new Error(
1236
+ 'Failed to create patient requirements through both batch and direct methods',
1237
+ );
1238
+ }
1239
+ } else {
1240
+ Logger.info(
1241
+ `[AggService] Verification confirmed ${verifySnapshot.size} requirement documents created`,
1242
+ );
1243
+ }
1244
+ } catch (verifyError) {
1245
+ Logger.error(
1246
+ `[AggService] Error during verification of created requirements:`,
1247
+ verifyError,
1248
+ );
1249
+ }
1250
+ } catch (commitError) {
1251
+ Logger.error(
1252
+ `[AggService] Error committing batch for POST_APPOINTMENT requirement instances for appointment ${appointment.id}:`,
1253
+ commitError,
1254
+ );
1255
+
1256
+ // Try direct creation as fallback
1257
+ Logger.info(`[AggService] Attempting direct creation as fallback...`);
1258
+ const fallbackPromises = createdInstances.map(async ({ ref, data }) => {
1259
+ try {
1260
+ await ref.set(data);
1261
+ Logger.info(`[AggService] Fallback direct creation success for ${ref.id}`);
1262
+ return true;
1263
+ } catch (fallbackError) {
1264
+ Logger.error(
1265
+ `[AggService] Fallback direct creation failed for ${ref.id}:`,
1266
+ fallbackError,
1267
+ );
1268
+ return false;
1269
+ }
1270
+ });
1271
+
1272
+ const fallbackResults = await Promise.allSettled(fallbackPromises);
1273
+ const successCount = fallbackResults.filter(
1274
+ r => r.status === 'fulfilled' && r.value === true,
1275
+ ).length;
1276
+
1277
+ if (successCount > 0) {
1278
+ Logger.info(
1279
+ `[AggService] Fallback mechanism created ${successCount} out of ${createdInstances.length} requirements`,
1280
+ );
1281
+ } else {
1282
+ Logger.error(
1283
+ `[AggService] Both batch and fallback mechanisms failed to create requirements`,
1284
+ );
1285
+ throw new Error(
1286
+ 'Failed to create patient requirements through both batch and direct methods',
1287
+ );
1288
+ }
1289
+ }
1290
+ } else {
1291
+ Logger.info(
1292
+ `[AggService] No new POST_APPOINTMENT requirement instances were prepared for batch commit for appointment ${appointment.id}.`,
1293
+ );
1294
+ }
1295
+ } catch (error) {
1296
+ Logger.error(
1297
+ `[AggService] Error creating POST_APPOINTMENT requirement instances for appointment ${appointment.id}:`,
1298
+ error,
1299
+ );
1300
+ throw error; // Re-throw to ensure the caller knows there was a problem
1301
+ }
1302
+ }
1303
+
1304
+ /**
1305
+ * Updates the overallStatus of all PatientRequirementInstance documents associated with a given appointment.
1306
+ * This is typically used when an appointment is cancelled or rescheduled, making existing requirements void.
1307
+ *
1308
+ * @param {Appointment} appointment - The appointment whose requirement instances need updating.
1309
+ * @param {PatientRequirementOverallStatus} newOverallStatus - The new status to set (e.g., CANCELLED_APPOINTMENT).
1310
+ * @returns {Promise<void>} A promise that resolves when the operation is complete.
1311
+ */
1312
+ private async updateRelatedPatientRequirementInstances(
1313
+ appointment: Appointment,
1314
+ newOverallStatus: PatientRequirementOverallStatus,
1315
+ _previousAppointmentData?: Appointment, // Not used in this basic implementation, but kept for signature consistency
1316
+ ): Promise<void> {
1317
+ Logger.info(
1318
+ `[AggService] Updating related patient req instances for appt ${appointment.id} (patient: ${appointment.patientId}) to ${newOverallStatus}`,
1319
+ );
1320
+
1321
+ if (!appointment.id || !appointment.patientId) {
1322
+ Logger.error(
1323
+ '[AggService] updateRelatedPatientRequirementInstances called with missing appointmentId or patientId.',
1324
+ { appointmentId: appointment.id, patientId: appointment.patientId },
1325
+ );
1326
+ return;
1327
+ }
1328
+
1329
+ try {
1330
+ const instancesSnapshot = await this.db
1331
+ .collection(PATIENTS_COLLECTION)
1332
+ .doc(appointment.patientId)
1333
+ .collection(PATIENT_REQUIREMENTS_SUBCOLLECTION_NAME)
1334
+ .where('appointmentId', '==', appointment.id)
1335
+ .get();
1336
+
1337
+ if (instancesSnapshot.empty) {
1338
+ Logger.info(
1339
+ `[AggService] No patient requirement instances found for appointment ${appointment.id} to update.`,
1340
+ );
1341
+ return;
1342
+ }
1343
+
1344
+ const batch = this.db.batch();
1345
+ let instancesUpdatedCount = 0;
1346
+
1347
+ instancesSnapshot.docs.forEach(doc => {
1348
+ const instance = doc.data() as PatientRequirementInstance;
1349
+ // Update only if the status is actually different and not already in a terminal state like FAILED_TO_PROCESS
1350
+ if (
1351
+ instance.overallStatus !== newOverallStatus &&
1352
+ instance.overallStatus !== PatientRequirementOverallStatus.FAILED_TO_PROCESS
1353
+ ) {
1354
+ batch.update(doc.ref, {
1355
+ overallStatus: newOverallStatus,
1356
+ updatedAt: admin.firestore.FieldValue.serverTimestamp() as any, // Cast for now
1357
+ // Potentially also cancel individual instructions if not handled by another trigger
1358
+ // instructions: instance.instructions.map(instr => ({ ...instr, status: PatientInstructionStatus.CANCELLED, updatedAt: admin.firestore.FieldValue.serverTimestamp() as any }))
1359
+ });
1360
+ instancesUpdatedCount++;
1361
+ Logger.debug(
1362
+ `[AggService] Added update for PatientRequirementInstance ${doc.id} to batch. New status: ${newOverallStatus}`,
1363
+ );
1364
+ }
1365
+ });
1366
+
1367
+ if (instancesUpdatedCount > 0) {
1368
+ await batch.commit();
1369
+ Logger.info(
1370
+ `[AggService] Successfully updated ${instancesUpdatedCount} patient requirement instances for appointment ${appointment.id} to status ${newOverallStatus}.`,
1371
+ );
1372
+ } else {
1373
+ Logger.info(
1374
+ `[AggService] No patient requirement instances needed an update for appointment ${appointment.id}.`,
1375
+ );
1376
+ }
1377
+ } catch (error) {
1378
+ Logger.error(
1379
+ `[AggService] Error updating patient requirement instances for appointment ${appointment.id}:`,
1380
+ error,
1381
+ );
1382
+ }
1383
+ }
1384
+
1385
+ /**
1386
+ * Manages relationships between a patient and clinics/practitioners.
1387
+ * Only updates the patient profile with doctorIds and clinicIds.
1388
+ *
1389
+ * @param {PatientProfile} patientProfile - The patient profile to update
1390
+ * @param {string} practitionerId - The practitioner ID
1391
+ * @param {string} clinicId - The clinic ID
1392
+ * @param {"create" | "cancel"} action - 'create' to add IDs, 'cancel' to potentially remove them
1393
+ * @param {AppointmentStatus} [cancelStatus] - The appointment status if action is 'cancel'
1394
+ * @returns {Promise<void>} A promise that resolves when the operation is complete.
1395
+ */
1396
+ private async managePatientClinicPractitionerLinks(
1397
+ patientProfile: PatientProfile,
1398
+ practitionerId: string,
1399
+ clinicId: string,
1400
+ action: 'create' | 'cancel',
1401
+ cancelStatus?: AppointmentStatus,
1402
+ ): Promise<void> {
1403
+ Logger.info(
1404
+ `[AggService] Managing patient-clinic-practitioner links for patient ${patientProfile.id}, action: ${action}`,
1405
+ );
1406
+
1407
+ try {
1408
+ if (action === 'create') {
1409
+ await this.addPatientLinks(patientProfile, practitionerId, clinicId);
1410
+ } else if (action === 'cancel') {
1411
+ await this.removePatientLinksIfNoActiveAppointments(
1412
+ patientProfile,
1413
+ practitionerId,
1414
+ clinicId,
1415
+ );
1416
+ }
1417
+ } catch (error) {
1418
+ Logger.error(
1419
+ `[AggService] Error managing patient-clinic-practitioner links for patient ${patientProfile.id}:`,
1420
+ error,
1421
+ );
1422
+ }
1423
+ }
1424
+
1425
+ /**
1426
+ * Adds practitioner and clinic IDs to the patient profile.
1427
+ *
1428
+ * @param {PatientProfile} patientProfile - The patient profile to update
1429
+ * @param {string} practitionerId - The practitioner ID to add
1430
+ * @param {string} clinicId - The clinic ID to add
1431
+ * @returns {Promise<void>} A promise that resolves when the operation is complete.
1432
+ */
1433
+ private async addPatientLinks(
1434
+ patientProfile: PatientProfile,
1435
+ practitionerId: string,
1436
+ clinicId: string,
1437
+ ): Promise<void> {
1438
+ try {
1439
+ // Check if the IDs already exist in the arrays
1440
+ const hasDoctor = patientProfile.doctorIds?.includes(practitionerId) || false;
1441
+ const hasClinic = patientProfile.clinicIds?.includes(clinicId) || false;
1442
+
1443
+ // Only update if necessary
1444
+ if (!hasDoctor || !hasClinic) {
1445
+ const patientRef = this.db.collection(PATIENTS_COLLECTION).doc(patientProfile.id);
1446
+ const updateData: Record<string, any> = {
1447
+ updatedAt: admin.firestore.FieldValue.serverTimestamp(),
1448
+ };
1449
+
1450
+ if (!hasDoctor) {
1451
+ Logger.debug(
1452
+ `[AggService] Adding practitioner ${practitionerId} to patient ${patientProfile.id}`,
1453
+ );
1454
+ updateData.doctorIds = admin.firestore.FieldValue.arrayUnion(practitionerId);
1455
+ }
1456
+
1457
+ if (!hasClinic) {
1458
+ Logger.debug(`[AggService] Adding clinic ${clinicId} to patient ${patientProfile.id}`);
1459
+ updateData.clinicIds = admin.firestore.FieldValue.arrayUnion(clinicId);
1460
+ }
1461
+
1462
+ await patientRef.update(updateData);
1463
+ Logger.info(
1464
+ `[AggService] Successfully updated patient ${patientProfile.id} with new links.`,
1465
+ );
1466
+ } else {
1467
+ Logger.info(
1468
+ `[AggService] Patient ${patientProfile.id} already has links to both practitioner ${practitionerId} and clinic ${clinicId}.`,
1469
+ );
1470
+ }
1471
+ } catch (error) {
1472
+ Logger.error(
1473
+ `[AggService] Error updating patient ${patientProfile.id} with new links:`,
1474
+ error,
1475
+ );
1476
+ throw error;
1477
+ }
1478
+ }
1479
+
1480
+ /**
1481
+ * Removes practitioner and clinic IDs from the patient profile if there are no more active appointments.
1482
+ *
1483
+ * @param {PatientProfile} patientProfile - The patient profile to update
1484
+ * @param {string} practitionerId - The practitioner ID to remove
1485
+ * @param {string} clinicId - The clinic ID to remove
1486
+ * @returns {Promise<void>} A promise that resolves when the operation is complete.
1487
+ */
1488
+ private async removePatientLinksIfNoActiveAppointments(
1489
+ patientProfile: PatientProfile,
1490
+ practitionerId: string,
1491
+ clinicId: string,
1492
+ ): Promise<void> {
1493
+ try {
1494
+ // Check for active appointments with this practitioner and clinic
1495
+ const activePractitionerAppointments = await this.checkActiveAppointments(
1496
+ patientProfile.id,
1497
+ 'practitionerId',
1498
+ practitionerId,
1499
+ );
1500
+
1501
+ const activeClinicAppointments = await this.checkActiveAppointments(
1502
+ patientProfile.id,
1503
+ 'clinicBranchId',
1504
+ clinicId,
1505
+ );
1506
+
1507
+ Logger.info(
1508
+ `[AggService] Active appointment count for patient ${patientProfile.id}: With practitioner ${practitionerId}: ${activePractitionerAppointments}, With clinic ${clinicId}: ${activeClinicAppointments}`,
1509
+ );
1510
+
1511
+ // Only update if there are no active appointments
1512
+ const patientRef = this.db.collection(PATIENTS_COLLECTION).doc(patientProfile.id);
1513
+ const updateData: Record<string, any> = {};
1514
+ let updateNeeded = false;
1515
+
1516
+ if (
1517
+ activePractitionerAppointments === 0 &&
1518
+ patientProfile.doctorIds?.includes(practitionerId)
1519
+ ) {
1520
+ Logger.debug(
1521
+ `[AggService] Removing practitioner ${practitionerId} from patient ${patientProfile.id}`,
1522
+ );
1523
+ updateData.doctorIds = admin.firestore.FieldValue.arrayRemove(practitionerId);
1524
+ updateNeeded = true;
1525
+ }
1526
+
1527
+ if (activeClinicAppointments === 0 && patientProfile.clinicIds?.includes(clinicId)) {
1528
+ Logger.debug(`[AggService] Removing clinic ${clinicId} from patient ${patientProfile.id}`);
1529
+ updateData.clinicIds = admin.firestore.FieldValue.arrayRemove(clinicId);
1530
+ updateNeeded = true;
1531
+ }
1532
+
1533
+ if (updateNeeded) {
1534
+ updateData.updatedAt = admin.firestore.FieldValue.serverTimestamp();
1535
+ await patientRef.update(updateData);
1536
+ Logger.info(`[AggService] Successfully removed links from patient ${patientProfile.id}`);
1537
+ } else {
1538
+ Logger.info(`[AggService] No links need to be removed from patient ${patientProfile.id}`);
1539
+ }
1540
+ } catch (error) {
1541
+ Logger.error(`[AggService] Error removing links from patient profile:`, error);
1542
+ throw error;
1543
+ }
1544
+ }
1545
+
1546
+ /**
1547
+ * Checks if there are active appointments between a patient and another entity (practitioner or clinic).
1548
+ *
1549
+ * @param {string} patientId - The patient ID.
1550
+ * @param {"practitionerId" | "clinicBranchId"} entityField - The field to check for the entity ID.
1551
+ * @param {string} entityId - The entity ID (practitioner or clinic).
1552
+ * @returns {Promise<number>} The number of active appointments found.
1553
+ */
1554
+ private async checkActiveAppointments(
1555
+ patientId: string,
1556
+ entityField: 'practitionerId' | 'clinicBranchId',
1557
+ entityId: string,
1558
+ ): Promise<number> {
1559
+ try {
1560
+ // Define all cancelled/inactive appointment statuses
1561
+ const inactiveStatuses = [
1562
+ AppointmentStatus.CANCELED_CLINIC,
1563
+ AppointmentStatus.CANCELED_PATIENT,
1564
+ AppointmentStatus.CANCELED_PATIENT_RESCHEDULED,
1565
+ AppointmentStatus.NO_SHOW,
1566
+ ];
1567
+
1568
+ const snapshot = await this.db
1569
+ .collection('appointments')
1570
+ .where('patientId', '==', patientId)
1571
+ .where(entityField, '==', entityId)
1572
+ .where('status', 'not-in', inactiveStatuses)
1573
+ .get();
1574
+
1575
+ return snapshot.size;
1576
+ } catch (error) {
1577
+ Logger.error(`[AggService] Error checking active appointments:`, error);
1578
+ throw error;
1579
+ }
1580
+ }
1581
+
1582
+ // --- Data Fetching Helpers (Consider moving to a data access layer or using existing services if available) ---
1583
+ private async fetchPatientProfile(patientId: string): Promise<PatientProfile | null> {
1584
+ try {
1585
+ const doc = await this.db.collection(PATIENTS_COLLECTION).doc(patientId).get();
1586
+ return doc.exists ? (doc.data() as PatientProfile) : null;
1587
+ } catch (error) {
1588
+ Logger.error(`[AggService] Error fetching patient profile ${patientId}:`, error);
1589
+ return null;
1590
+ }
1591
+ }
1592
+
1593
+ /**
1594
+ * Fetches the sensitive information for a given patient ID.
1595
+ * @param patientId The ID of the patient to fetch sensitive information for.
1596
+ * @returns {Promise<PatientSensitiveInfo | null>} The patient sensitive information or null if not found or an error occurs.
1597
+ */
1598
+ private async fetchPatientSensitiveInfo(patientId: string): Promise<PatientSensitiveInfo | null> {
1599
+ try {
1600
+ // Assuming sensitive info is in a subcollection PATIENT_SENSITIVE_INFO_COLLECTION
1601
+ // under the patient's document, and the sensitive info document ID is the patientId itself.
1602
+ // If the document ID is fixed (e.g., 'details'), this path should be adjusted.
1603
+ const doc = await this.db
1604
+ .collection(PATIENTS_COLLECTION)
1605
+ .doc(patientId)
1606
+ .collection(PATIENT_SENSITIVE_INFO_COLLECTION)
1607
+ .doc(patientId) // CONFIRM THIS DOCUMENT ID PATTERN
1608
+ .get();
1609
+ if (!doc.exists) {
1610
+ Logger.warn(`[AggService] No sensitive info found for patient ${patientId}`);
1611
+ return null;
1612
+ }
1613
+ return doc.data() as PatientSensitiveInfo;
1614
+ } catch (error) {
1615
+ Logger.error(`[AggService] Error fetching patient sensitive info ${patientId}:`, error);
1616
+ return null;
1617
+ }
1618
+ }
1619
+
1620
+ /**
1621
+ * Fetches the profile for a given practitioner ID.
1622
+ * @param practitionerId The ID of the practitioner to fetch.
1623
+ * @returns {Promise<Practitioner | null>} The practitioner profile or null if not found or an error occurs.
1624
+ */
1625
+ private async fetchPractitionerProfile(practitionerId: string): Promise<Practitioner | null> {
1626
+ if (!practitionerId) {
1627
+ Logger.warn('[AggService] fetchPractitionerProfile called with no practitionerId.');
1628
+ return null;
1629
+ }
1630
+ try {
1631
+ const doc = await this.db.collection(PRACTITIONERS_COLLECTION).doc(practitionerId).get();
1632
+ if (!doc.exists) {
1633
+ Logger.warn(`[AggService] No practitioner profile found for ID ${practitionerId}`);
1634
+ return null;
1635
+ }
1636
+ return doc.data() as Practitioner;
1637
+ } catch (error) {
1638
+ Logger.error(`[AggService] Error fetching practitioner profile ${practitionerId}:`, error);
1639
+ return null;
1640
+ }
1641
+ }
1642
+
1643
+ /**
1644
+ * Fetches the information for a given clinic ID.
1645
+ * @param clinicId The ID of the clinic to fetch.
1646
+ * @returns {Promise<Clinic | null>} The clinic information or null if not found or an error occurs.
1647
+ */
1648
+ private async fetchClinicInfo(clinicId: string): Promise<Clinic | null> {
1649
+ if (!clinicId) {
1650
+ Logger.warn('[AggService] fetchClinicInfo called with no clinicId.');
1651
+ return null;
1652
+ }
1653
+ try {
1654
+ const doc = await this.db.collection(CLINICS_COLLECTION).doc(clinicId).get();
1655
+ if (!doc.exists) {
1656
+ Logger.warn(`[AggService] No clinic info found for ID ${clinicId}`);
1657
+ return null;
1658
+ }
1659
+ return doc.data() as Clinic;
1660
+ } catch (error) {
1661
+ Logger.error(`[AggService] Error fetching clinic info ${clinicId}:`, error);
1662
+ return null;
1663
+ }
1664
+ }
1665
+
1666
+ /**
1667
+ * Checks if zone photos have changed between two appointment states
1668
+ * @param before - The appointment state before update
1669
+ * @param after - The appointment state after update
1670
+ * @returns True if zone photos have changed, false otherwise
1671
+ */
1672
+ private hasZonePhotosChanged(before: Appointment, after: Appointment): boolean {
1673
+ const beforePhotos = before.metadata?.zonePhotos;
1674
+ const afterPhotos = after.metadata?.zonePhotos;
1675
+
1676
+ // If both are null/undefined, no change
1677
+ if (!beforePhotos && !afterPhotos) {
1678
+ return false;
1679
+ }
1680
+
1681
+ // If one is null and the other isn't, there's a change
1682
+ if (!beforePhotos || !afterPhotos) {
1683
+ return true;
1684
+ }
1685
+
1686
+ // Compare the number of zones
1687
+ const beforeZones = Object.keys(beforePhotos);
1688
+ const afterZones = Object.keys(afterPhotos);
1689
+
1690
+ if (beforeZones.length !== afterZones.length) {
1691
+ return true;
1692
+ }
1693
+
1694
+ // Compare each zone's photos
1695
+ for (const zoneId of afterZones) {
1696
+ const beforeZonePhotos = beforePhotos[zoneId];
1697
+ const afterZonePhotos = afterPhotos[zoneId];
1698
+
1699
+ if (!beforeZonePhotos && !afterZonePhotos) {
1700
+ continue;
1701
+ }
1702
+
1703
+ if (!beforeZonePhotos || !afterZonePhotos) {
1704
+ return true;
1705
+ }
1706
+
1707
+ // Compare before and after photos arrays
1708
+ // If array lengths differ or any entry differs, consider it changed
1709
+ if (beforeZonePhotos.length !== afterZonePhotos.length) {
1710
+ return true;
1711
+ }
1712
+
1713
+ // Compare each entry in the arrays
1714
+ for (let i = 0; i < beforeZonePhotos.length; i++) {
1715
+ const beforeEntry = beforeZonePhotos[i];
1716
+ const afterEntry = afterZonePhotos[i];
1717
+ if (
1718
+ beforeEntry.before !== afterEntry.before ||
1719
+ beforeEntry.after !== afterEntry.after ||
1720
+ beforeEntry.beforeNote !== afterEntry.beforeNote ||
1721
+ beforeEntry.afterNote !== afterEntry.afterNote
1722
+ ) {
1723
+ return true;
1724
+ }
1725
+ }
1726
+ }
1727
+
1728
+ return false;
1729
+ }
1730
+
1731
+ /**
1732
+ * Handles zone photos update notifications and logging
1733
+ * @param before - The appointment state before update
1734
+ * @param after - The appointment state after update
1735
+ */
1736
+ private async handleZonePhotosUpdate(before: Appointment, after: Appointment): Promise<void> {
1737
+ try {
1738
+ Logger.info(`[AggService] Processing zone photos update for appointment ${after.id}`);
1739
+
1740
+ const beforePhotos = before.metadata?.zonePhotos || {};
1741
+ const afterPhotos = after.metadata?.zonePhotos || {};
1742
+
1743
+ // Find zones with new or updated photos
1744
+ const updatedZones: string[] = [];
1745
+ const newPhotoTypes: { zoneId: string; photoType: 'before' | 'after' }[] = [];
1746
+
1747
+ for (const zoneId of Object.keys(afterPhotos)) {
1748
+ const beforeZonePhotos = beforePhotos[zoneId] || [];
1749
+ const afterZonePhotos = afterPhotos[zoneId] || [];
1750
+
1751
+ if (beforeZonePhotos.length === 0 && afterZonePhotos.length > 0) {
1752
+ // New zone with photos
1753
+ updatedZones.push(zoneId);
1754
+ afterZonePhotos.forEach(entry => {
1755
+ if (entry.before) {
1756
+ newPhotoTypes.push({ zoneId, photoType: 'before' });
1757
+ }
1758
+ if (entry.after) {
1759
+ newPhotoTypes.push({ zoneId, photoType: 'after' });
1760
+ }
1761
+ });
1762
+ } else if (afterZonePhotos.length > beforeZonePhotos.length) {
1763
+ // New photos added to existing zone
1764
+ updatedZones.push(zoneId);
1765
+ const newEntries = afterZonePhotos.slice(beforeZonePhotos.length);
1766
+ newEntries.forEach(entry => {
1767
+ if (entry.before) {
1768
+ newPhotoTypes.push({ zoneId, photoType: 'before' });
1769
+ }
1770
+ if (entry.after) {
1771
+ newPhotoTypes.push({ zoneId, photoType: 'after' });
1772
+ }
1773
+ });
1774
+ } else {
1775
+ // Check for updated photos in existing entries
1776
+ for (let i = 0; i < afterZonePhotos.length; i++) {
1777
+ const beforeEntry = beforeZonePhotos[i];
1778
+ const afterEntry = afterZonePhotos[i];
1779
+
1780
+ if (beforeEntry && afterEntry) {
1781
+ if (beforeEntry.before !== afterEntry.before && afterEntry.before) {
1782
+ updatedZones.push(zoneId);
1783
+ newPhotoTypes.push({ zoneId, photoType: 'before' });
1784
+ }
1785
+ if (beforeEntry.after !== afterEntry.after && afterEntry.after) {
1786
+ updatedZones.push(zoneId);
1787
+ newPhotoTypes.push({ zoneId, photoType: 'after' });
1788
+ }
1789
+ }
1790
+ }
1791
+ }
1792
+ }
1793
+
1794
+ if (updatedZones.length > 0) {
1795
+ Logger.info(
1796
+ `[AggService] Zone photos updated for appointment ${after.id}: ${updatedZones.join(
1797
+ ', ',
1798
+ )}`,
1799
+ );
1800
+
1801
+ // Log specific photo types that were added
1802
+ for (const { zoneId, photoType } of newPhotoTypes) {
1803
+ Logger.info(
1804
+ `[AggService] New ${photoType} photo added for zone ${zoneId} in appointment ${after.id}`,
1805
+ );
1806
+ }
1807
+
1808
+ // TODO: Add notifications to practitioners/clinic admins about photo updates
1809
+ // TODO: Add audit logging for photo uploads
1810
+ // TODO: Trigger any business logic related to photo completion (e.g., appointment progress tracking)
1811
+ }
1812
+
1813
+ // Check if all required photos are now complete
1814
+ const selectedZones = after.metadata?.selectedZones || [];
1815
+ if (selectedZones.length > 0) {
1816
+ const completedZones = selectedZones.filter(zoneId => {
1817
+ const zonePhotos = afterPhotos[zoneId];
1818
+ return zonePhotos && zonePhotos.length > 0 && zonePhotos.some(entry => entry.before || entry.after);
1819
+ });
1820
+
1821
+ const completionPercentage = (completedZones.length / selectedZones.length) * 100;
1822
+ Logger.info(
1823
+ `[AggService] Photo completion for appointment ${
1824
+ after.id
1825
+ }: ${completionPercentage.toFixed(1)}% (${completedZones.length}/${
1826
+ selectedZones.length
1827
+ } zones)`,
1828
+ );
1829
+
1830
+ // TODO: Trigger notifications when all photos are complete
1831
+ if (completionPercentage === 100) {
1832
+ Logger.info(`[AggService] All zone photos completed for appointment ${after.id}`);
1833
+ // TODO: Send notification to relevant parties
1834
+ }
1835
+ }
1836
+ } catch (error) {
1837
+ Logger.error(
1838
+ `[AggService] Error handling zone photos update for appointment ${after.id}:`,
1839
+ error,
1840
+ );
1841
+ // Don't throw - this is a side effect and shouldn't break the main update flow
1842
+ }
1843
+ }
1844
+ }