@blackcode_sa/metaestetics-api 1.13.5 → 1.13.8

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