@blackcode_sa/metaestetics-api 1.15.16 → 1.15.17-staging.1

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