@blackcode_sa/metaestetics-api 1.13.4 → 1.13.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/admin/index.d.mts +15 -28
- package/dist/admin/index.d.ts +15 -28
- package/dist/index.d.mts +16 -29
- package/dist/index.d.ts +16 -29
- package/dist/index.js +1 -0
- package/dist/index.mjs +1 -0
- package/package.json +121 -119
- package/src/__mocks__/firstore.ts +10 -10
- package/src/admin/aggregation/README.md +79 -79
- package/src/admin/aggregation/appointment/README.md +128 -128
- package/src/admin/aggregation/appointment/appointment.aggregation.service.ts +1984 -1984
- package/src/admin/aggregation/appointment/index.ts +1 -1
- package/src/admin/aggregation/clinic/README.md +52 -52
- package/src/admin/aggregation/clinic/clinic.aggregation.service.ts +703 -703
- package/src/admin/aggregation/clinic/index.ts +1 -1
- package/src/admin/aggregation/forms/README.md +13 -13
- package/src/admin/aggregation/forms/filled-forms.aggregation.service.ts +322 -322
- package/src/admin/aggregation/forms/index.ts +1 -1
- package/src/admin/aggregation/index.ts +8 -8
- package/src/admin/aggregation/patient/README.md +27 -27
- package/src/admin/aggregation/patient/index.ts +1 -1
- package/src/admin/aggregation/patient/patient.aggregation.service.ts +141 -141
- package/src/admin/aggregation/practitioner/README.md +42 -42
- package/src/admin/aggregation/practitioner/index.ts +1 -1
- package/src/admin/aggregation/practitioner/practitioner.aggregation.service.ts +433 -433
- package/src/admin/aggregation/practitioner-invite/index.ts +1 -1
- package/src/admin/aggregation/practitioner-invite/practitioner-invite.aggregation.service.ts +961 -961
- package/src/admin/aggregation/procedure/README.md +43 -43
- package/src/admin/aggregation/procedure/index.ts +1 -1
- package/src/admin/aggregation/procedure/procedure.aggregation.service.ts +702 -702
- package/src/admin/aggregation/reviews/index.ts +1 -1
- package/src/admin/aggregation/reviews/reviews.aggregation.service.ts +689 -689
- package/src/admin/analytics/analytics.admin.service.ts +278 -278
- package/src/admin/analytics/index.ts +2 -2
- package/src/admin/booking/README.md +125 -125
- package/src/admin/booking/booking.admin.ts +1037 -1037
- package/src/admin/booking/booking.calculator.ts +712 -712
- package/src/admin/booking/booking.types.ts +59 -59
- package/src/admin/booking/index.ts +3 -3
- package/src/admin/booking/timezones-problem.md +185 -185
- package/src/admin/calendar/README.md +7 -7
- package/src/admin/calendar/calendar.admin.service.ts +345 -345
- package/src/admin/calendar/index.ts +1 -1
- package/src/admin/documentation-templates/document-manager.admin.ts +260 -260
- package/src/admin/documentation-templates/index.ts +1 -1
- package/src/admin/free-consultation/free-consultation-utils.admin.ts +148 -148
- package/src/admin/free-consultation/index.ts +1 -1
- package/src/admin/index.ts +81 -81
- package/src/admin/logger/index.ts +78 -78
- package/src/admin/mailing/README.md +95 -95
- package/src/admin/mailing/appointment/appointment.mailing.service.ts +732 -732
- package/src/admin/mailing/appointment/index.ts +1 -1
- package/src/admin/mailing/appointment/templates/patient/appointment-confirmed.html +40 -40
- package/src/admin/mailing/base.mailing.service.ts +208 -208
- package/src/admin/mailing/index.ts +3 -3
- package/src/admin/mailing/practitionerInvite/existing-practitioner-invite.mailing.ts +611 -611
- package/src/admin/mailing/practitionerInvite/index.ts +2 -2
- package/src/admin/mailing/practitionerInvite/practitionerInvite.mailing.ts +395 -395
- package/src/admin/mailing/practitionerInvite/templates/existing-practitioner-invitation.template.ts +155 -155
- package/src/admin/mailing/practitionerInvite/templates/invitation.template.ts +101 -101
- package/src/admin/mailing/practitionerInvite/templates/invite-accepted-notification.template.ts +228 -228
- package/src/admin/mailing/practitionerInvite/templates/invite-rejected-notification.template.ts +242 -242
- package/src/admin/notifications/index.ts +1 -1
- package/src/admin/notifications/notifications.admin.ts +710 -710
- package/src/admin/requirements/README.md +128 -128
- package/src/admin/requirements/index.ts +1 -1
- package/src/admin/requirements/patient-requirements.admin.service.ts +475 -475
- package/src/admin/users/index.ts +1 -1
- package/src/admin/users/user-profile.admin.ts +405 -405
- package/src/backoffice/constants/certification.constants.ts +13 -13
- package/src/backoffice/constants/index.ts +1 -1
- package/src/backoffice/errors/backoffice.errors.ts +181 -181
- package/src/backoffice/errors/index.ts +1 -1
- package/src/backoffice/expo-safe/README.md +26 -26
- package/src/backoffice/expo-safe/index.ts +41 -41
- package/src/backoffice/index.ts +5 -5
- package/src/backoffice/services/FIXES_README.md +102 -102
- package/src/backoffice/services/README.md +57 -57
- package/src/backoffice/services/analytics.service.proposal.md +863 -863
- package/src/backoffice/services/analytics.service.summary.md +143 -143
- package/src/backoffice/services/brand.service.ts +256 -256
- package/src/backoffice/services/category.service.ts +384 -384
- package/src/backoffice/services/constants.service.ts +385 -385
- package/src/backoffice/services/documentation-template.service.ts +202 -202
- package/src/backoffice/services/index.ts +10 -10
- package/src/backoffice/services/migrate-products.ts +116 -116
- package/src/backoffice/services/product.service.ts +553 -553
- package/src/backoffice/services/requirement.service.ts +235 -235
- package/src/backoffice/services/subcategory.service.ts +461 -461
- package/src/backoffice/services/technology.service.ts +1151 -1151
- package/src/backoffice/types/README.md +12 -12
- package/src/backoffice/types/admin-constants.types.ts +69 -69
- package/src/backoffice/types/brand.types.ts +29 -29
- package/src/backoffice/types/category.types.ts +67 -67
- package/src/backoffice/types/documentation-templates.types.ts +28 -28
- package/src/backoffice/types/index.ts +10 -10
- package/src/backoffice/types/procedure-product.types.ts +38 -38
- package/src/backoffice/types/product.types.ts +240 -240
- package/src/backoffice/types/requirement.types.ts +63 -63
- package/src/backoffice/types/static/README.md +18 -18
- package/src/backoffice/types/static/blocking-condition.types.ts +21 -21
- package/src/backoffice/types/static/certification.types.ts +37 -37
- package/src/backoffice/types/static/contraindication.types.ts +19 -19
- package/src/backoffice/types/static/index.ts +6 -6
- package/src/backoffice/types/static/pricing.types.ts +16 -16
- package/src/backoffice/types/static/procedure-family.types.ts +14 -14
- package/src/backoffice/types/static/treatment-benefit.types.ts +22 -22
- package/src/backoffice/types/subcategory.types.ts +34 -34
- package/src/backoffice/types/technology.types.ts +168 -168
- package/src/backoffice/validations/index.ts +1 -1
- package/src/backoffice/validations/schemas.ts +164 -164
- package/src/config/__mocks__/firebase.ts +99 -99
- package/src/config/firebase.ts +78 -78
- package/src/config/index.ts +9 -9
- package/src/errors/auth.error.ts +6 -6
- package/src/errors/auth.errors.ts +200 -200
- package/src/errors/clinic.errors.ts +32 -32
- package/src/errors/firebase.errors.ts +47 -47
- package/src/errors/user.errors.ts +99 -99
- package/src/index.backup.ts +407 -407
- package/src/index.ts +6 -6
- package/src/locales/en.ts +31 -31
- package/src/recommender/admin/index.ts +1 -1
- package/src/recommender/admin/services/recommender.service.admin.ts +5 -5
- package/src/recommender/front/index.ts +1 -1
- package/src/recommender/front/services/onboarding.service.ts +5 -5
- package/src/recommender/front/services/recommender.service.ts +3 -3
- package/src/recommender/index.ts +1 -1
- package/src/services/PATIENTAUTH.MD +197 -197
- package/src/services/README.md +106 -106
- package/src/services/__tests__/auth/auth.mock.test.ts +17 -17
- package/src/services/__tests__/auth/auth.setup.ts +293 -293
- package/src/services/__tests__/auth.service.test.ts +346 -346
- package/src/services/__tests__/base.service.test.ts +77 -77
- package/src/services/__tests__/user.service.test.ts +528 -528
- package/src/services/analytics/ARCHITECTURE.md +199 -199
- package/src/services/analytics/CLOUD_FUNCTIONS.md +225 -225
- package/src/services/analytics/GROUPED_ANALYTICS.md +501 -501
- package/src/services/analytics/QUICK_START.md +393 -393
- package/src/services/analytics/README.md +304 -304
- package/src/services/analytics/SUMMARY.md +141 -141
- package/src/services/analytics/TRENDS.md +380 -380
- package/src/services/analytics/USAGE_GUIDE.md +518 -518
- package/src/services/analytics/analytics-cloud.service.ts +222 -222
- package/src/services/analytics/analytics.service.ts +2142 -2142
- package/src/services/analytics/index.ts +4 -4
- package/src/services/analytics/review-analytics.service.ts +941 -941
- package/src/services/analytics/utils/appointment-filtering.utils.ts +138 -138
- package/src/services/analytics/utils/cost-calculation.utils.ts +182 -182
- package/src/services/analytics/utils/grouping.utils.ts +434 -434
- package/src/services/analytics/utils/stored-analytics.utils.ts +347 -347
- package/src/services/analytics/utils/time-calculation.utils.ts +186 -186
- package/src/services/analytics/utils/trend-calculation.utils.ts +200 -200
- package/src/services/appointment/README.md +17 -17
- package/src/services/appointment/appointment.service.ts +2558 -2558
- package/src/services/appointment/index.ts +1 -1
- package/src/services/appointment/utils/appointment.utils.ts +552 -552
- package/src/services/appointment/utils/extended-procedure.utils.ts +314 -314
- package/src/services/appointment/utils/form-initialization.utils.ts +225 -225
- package/src/services/appointment/utils/recommended-procedure.utils.ts +195 -195
- package/src/services/appointment/utils/zone-management.utils.ts +353 -353
- package/src/services/appointment/utils/zone-photo.utils.ts +152 -152
- package/src/services/auth/auth.service.ts +989 -989
- package/src/services/auth/auth.v2.service.ts +961 -961
- package/src/services/auth/index.ts +7 -7
- package/src/services/auth/utils/error.utils.ts +90 -90
- package/src/services/auth/utils/firebase.utils.ts +49 -49
- package/src/services/auth/utils/index.ts +21 -21
- package/src/services/auth/utils/practitioner.utils.ts +125 -125
- package/src/services/base.service.ts +41 -41
- package/src/services/calendar/calendar.service.ts +1077 -1077
- package/src/services/calendar/calendar.v2.service.ts +1683 -1683
- package/src/services/calendar/calendar.v3.service.ts +313 -313
- package/src/services/calendar/externalCalendar.service.ts +178 -178
- package/src/services/calendar/index.ts +5 -5
- package/src/services/calendar/synced-calendars.service.ts +743 -743
- package/src/services/calendar/utils/appointment.utils.ts +265 -265
- package/src/services/calendar/utils/calendar-event.utils.ts +646 -646
- package/src/services/calendar/utils/clinic.utils.ts +237 -237
- package/src/services/calendar/utils/docs.utils.ts +157 -157
- package/src/services/calendar/utils/google-calendar.utils.ts +697 -697
- package/src/services/calendar/utils/index.ts +8 -8
- package/src/services/calendar/utils/patient.utils.ts +198 -198
- package/src/services/calendar/utils/practitioner.utils.ts +221 -221
- package/src/services/calendar/utils/synced-calendar.utils.ts +472 -472
- package/src/services/clinic/README.md +204 -204
- package/src/services/clinic/__tests__/clinic-admin.service.test.ts +287 -287
- package/src/services/clinic/__tests__/clinic-group.service.test.ts +352 -352
- package/src/services/clinic/__tests__/clinic.service.test.ts +354 -354
- package/src/services/clinic/billing-transactions.service.ts +217 -217
- package/src/services/clinic/clinic-admin.service.ts +202 -202
- package/src/services/clinic/clinic-group.service.ts +310 -310
- package/src/services/clinic/clinic.service.ts +708 -708
- package/src/services/clinic/index.ts +5 -5
- package/src/services/clinic/practitioner-invite.service.ts +519 -519
- package/src/services/clinic/utils/admin.utils.ts +551 -551
- package/src/services/clinic/utils/clinic-group.utils.ts +646 -646
- package/src/services/clinic/utils/clinic.utils.ts +949 -949
- package/src/services/clinic/utils/filter.utils.d.ts +23 -23
- package/src/services/clinic/utils/filter.utils.ts +446 -446
- package/src/services/clinic/utils/index.ts +11 -11
- package/src/services/clinic/utils/photos.utils.ts +188 -188
- package/src/services/clinic/utils/search.utils.ts +84 -84
- package/src/services/clinic/utils/tag.utils.ts +124 -124
- package/src/services/documentation-templates/documentation-template.service.ts +537 -537
- package/src/services/documentation-templates/filled-document.service.ts +587 -587
- package/src/services/documentation-templates/index.ts +2 -2
- package/src/services/index.ts +14 -14
- package/src/services/media/index.ts +1 -1
- package/src/services/media/media.service.ts +418 -418
- package/src/services/notifications/__tests__/notification.service.test.ts +242 -242
- package/src/services/notifications/index.ts +1 -1
- package/src/services/notifications/notification.service.ts +215 -215
- package/src/services/patient/README.md +48 -48
- package/src/services/patient/To-Do.md +43 -43
- package/src/services/patient/__tests__/patient.service.test.ts +294 -294
- package/src/services/patient/index.ts +2 -2
- package/src/services/patient/patient.service.ts +883 -883
- package/src/services/patient/patientRequirements.service.ts +285 -285
- package/src/services/patient/utils/aesthetic-analysis.utils.ts +176 -176
- package/src/services/patient/utils/clinic.utils.ts +80 -80
- package/src/services/patient/utils/docs.utils.ts +142 -142
- package/src/services/patient/utils/index.ts +9 -9
- package/src/services/patient/utils/location.utils.ts +126 -126
- package/src/services/patient/utils/medical-stuff.utils.ts +143 -143
- package/src/services/patient/utils/medical.utils.ts +458 -458
- package/src/services/patient/utils/practitioner.utils.ts +260 -260
- package/src/services/patient/utils/profile.utils.ts +510 -510
- package/src/services/patient/utils/sensitive.utils.ts +260 -260
- package/src/services/patient/utils/token.utils.ts +211 -211
- package/src/services/practitioner/README.md +145 -145
- package/src/services/practitioner/index.ts +1 -1
- package/src/services/practitioner/practitioner.service.ts +1742 -1742
- package/src/services/procedure/README.md +163 -163
- package/src/services/procedure/index.ts +1 -1
- package/src/services/procedure/procedure.service.ts +2200 -2200
- package/src/services/reviews/index.ts +1 -1
- package/src/services/reviews/reviews.service.ts +734 -734
- package/src/services/user/index.ts +1 -1
- package/src/services/user/user.service.ts +489 -489
- package/src/services/user/user.v2.service.ts +466 -466
- package/src/types/analytics/analytics.types.ts +597 -597
- package/src/types/analytics/grouped-analytics.types.ts +173 -173
- package/src/types/analytics/index.ts +4 -4
- package/src/types/analytics/stored-analytics.types.ts +137 -137
- package/src/types/appointment/index.ts +480 -480
- package/src/types/calendar/index.ts +258 -258
- package/src/types/calendar/synced-calendar.types.ts +66 -66
- package/src/types/clinic/index.ts +498 -489
- package/src/types/clinic/practitioner-invite.types.ts +91 -91
- package/src/types/clinic/preferences.types.ts +159 -159
- package/src/types/clinic/to-do +3 -3
- package/src/types/documentation-templates/index.ts +308 -308
- package/src/types/index.ts +47 -47
- package/src/types/notifications/README.md +77 -77
- package/src/types/notifications/index.ts +286 -286
- package/src/types/patient/aesthetic-analysis.types.ts +66 -66
- package/src/types/patient/allergies.ts +58 -58
- package/src/types/patient/index.ts +275 -275
- package/src/types/patient/medical-info.types.ts +152 -152
- package/src/types/patient/patient-requirements.ts +92 -92
- package/src/types/patient/token.types.ts +61 -61
- package/src/types/practitioner/index.ts +206 -206
- package/src/types/procedure/index.ts +181 -181
- package/src/types/profile/index.ts +39 -39
- package/src/types/reviews/index.ts +132 -132
- package/src/types/tz-lookup.d.ts +4 -4
- package/src/types/user/index.ts +38 -38
- package/src/utils/TIMESTAMPS.md +176 -176
- package/src/utils/TimestampUtils.ts +241 -241
- package/src/utils/index.ts +1 -1
- package/src/validations/appointment.schema.ts +574 -574
- package/src/validations/calendar.schema.ts +225 -225
- package/src/validations/clinic.schema.ts +494 -493
- package/src/validations/common.schema.ts +25 -25
- package/src/validations/documentation-templates/index.ts +1 -1
- package/src/validations/documentation-templates/template.schema.ts +220 -220
- package/src/validations/documentation-templates.schema.ts +10 -10
- package/src/validations/index.ts +20 -20
- package/src/validations/media.schema.ts +10 -10
- package/src/validations/notification.schema.ts +90 -90
- package/src/validations/patient/aesthetic-analysis.schema.ts +55 -55
- package/src/validations/patient/medical-info.schema.ts +125 -125
- package/src/validations/patient/patient-requirements.schema.ts +84 -84
- package/src/validations/patient/token.schema.ts +29 -29
- package/src/validations/patient.schema.ts +217 -217
- package/src/validations/practitioner.schema.ts +222 -222
- package/src/validations/procedure-product.schema.ts +41 -41
- package/src/validations/procedure.schema.ts +124 -124
- package/src/validations/profile-info.schema.ts +41 -41
- package/src/validations/reviews.schema.ts +195 -195
- package/src/validations/schemas.ts +104 -104
- 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
|
+
}
|