@blackcode_sa/metaestetics-api 1.6.5 → 1.6.6
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 +195 -172
- package/dist/admin/index.d.ts +195 -172
- package/dist/admin/index.js +8928 -8364
- package/dist/admin/index.mjs +8920 -8356
- package/dist/index.d.mts +8 -2
- package/dist/index.d.ts +8 -2
- package/dist/index.js +3 -0
- package/dist/index.mjs +3 -0
- package/package.json +1 -1
- package/src/admin/aggregation/appointment/README.md +128 -0
- package/src/admin/aggregation/appointment/appointment.aggregation.service.ts +950 -218
- package/src/admin/booking/README.md +125 -0
- package/src/admin/booking/booking.admin.ts +288 -26
- package/src/admin/calendar/calendar.admin.service.ts +183 -0
- package/src/admin/documentation-templates/document-manager.admin.ts +131 -0
- package/src/admin/mailing/appointment/appointment.mailing.service.ts +264 -0
- package/src/admin/mailing/appointment/templates/patient/appointment-confirmed.html +40 -0
- package/src/admin/mailing/base.mailing.service.ts +1 -1
- package/src/admin/mailing/index.ts +2 -0
- package/src/admin/notifications/notifications.admin.ts +397 -1
- package/src/types/appointment/index.ts +1 -0
- package/src/types/notifications/index.ts +4 -2
- package/src/validations/appointment.schema.ts +1 -0
|
@@ -2,29 +2,43 @@ import * as admin from "firebase-admin";
|
|
|
2
2
|
import {
|
|
3
3
|
Appointment,
|
|
4
4
|
AppointmentStatus,
|
|
5
|
-
APPOINTMENTS_COLLECTION,
|
|
6
|
-
} from "../../../types/appointment";
|
|
5
|
+
// APPOINTMENTS_COLLECTION, // Not directly used in this file after refactor
|
|
6
|
+
} from "../../../types/appointment";
|
|
7
7
|
import {
|
|
8
8
|
PatientRequirementInstance,
|
|
9
9
|
PatientRequirementOverallStatus,
|
|
10
10
|
PatientInstructionStatus,
|
|
11
11
|
PATIENT_REQUIREMENTS_SUBCOLLECTION_NAME,
|
|
12
|
+
PatientRequirementInstruction, // Added import
|
|
12
13
|
} from "../../../types/patient/patient-requirements";
|
|
13
14
|
import {
|
|
14
15
|
Requirement as RequirementTemplate,
|
|
15
|
-
REQUIREMENTS_COLLECTION as REQUIREMENTS_TEMPLATES_COLLECTION,
|
|
16
|
-
RequirementType
|
|
16
|
+
// REQUIREMENTS_COLLECTION as REQUIREMENTS_TEMPLATES_COLLECTION, // Not used directly after refactor
|
|
17
|
+
RequirementType,
|
|
18
|
+
TimeUnit, // Added import
|
|
17
19
|
} from "../../../backoffice/types/requirement.types";
|
|
18
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
PATIENTS_COLLECTION,
|
|
22
|
+
PatientProfile,
|
|
23
|
+
PatientSensitiveInfo,
|
|
24
|
+
PATIENT_SENSITIVE_INFO_COLLECTION,
|
|
25
|
+
} from "../../../types/patient";
|
|
26
|
+
import {
|
|
27
|
+
Practitioner,
|
|
28
|
+
PRACTITIONERS_COLLECTION,
|
|
29
|
+
} from "../../../types/practitioner";
|
|
30
|
+
import { Clinic, CLINICS_COLLECTION } from "../../../types/clinic";
|
|
31
|
+
// import { UserRole } from "../../../types"; // Not directly used
|
|
19
32
|
|
|
20
33
|
// Dependent Admin Services
|
|
21
34
|
import { PatientRequirementsAdminService } from "../../requirements/patient-requirements.admin.service";
|
|
22
35
|
import { NotificationsAdmin } from "../../notifications/notifications.admin";
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
36
|
+
import { CalendarAdminService } from "../../calendar/calendar.admin.service";
|
|
37
|
+
import { AppointmentMailingService } from "../../mailing/appointment/appointment.mailing.service";
|
|
38
|
+
import { Logger } from "../../logger";
|
|
39
|
+
import { UserRole } from "../../../types";
|
|
40
|
+
|
|
41
|
+
// Mailgun client will be injected via constructor
|
|
28
42
|
|
|
29
43
|
/**
|
|
30
44
|
* @class AppointmentAggregationService
|
|
@@ -34,288 +48,1006 @@ import { NotificationsAdmin } from "../../notifications/notifications.admin";
|
|
|
34
48
|
*/
|
|
35
49
|
export class AppointmentAggregationService {
|
|
36
50
|
private db: admin.firestore.Firestore;
|
|
37
|
-
private
|
|
51
|
+
private appointmentMailingService: AppointmentMailingService;
|
|
38
52
|
private notificationsAdmin: NotificationsAdmin;
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
// private patientAdminService: PatientAdminService; // Placeholder
|
|
42
|
-
// private practitionerAdminService: PractitionerAdminService; // Placeholder
|
|
43
|
-
// private clinicAdminService: ClinicAdminService; // Placeholder
|
|
53
|
+
private calendarAdminService: CalendarAdminService;
|
|
54
|
+
private patientRequirementsAdminService: PatientRequirementsAdminService;
|
|
44
55
|
|
|
45
56
|
/**
|
|
46
57
|
* Constructor for AppointmentAggregationService.
|
|
58
|
+
* @param mailgunClient - An initialized Mailgun client instance.
|
|
47
59
|
* @param firestore Optional Firestore instance. If not provided, it uses the default admin SDK instance.
|
|
48
60
|
*/
|
|
49
|
-
constructor(
|
|
61
|
+
constructor(
|
|
62
|
+
mailgunClient: any, // Type as 'any' for now, to be provided by the calling Cloud Function
|
|
63
|
+
firestore?: admin.firestore.Firestore
|
|
64
|
+
) {
|
|
50
65
|
this.db = firestore || admin.firestore();
|
|
66
|
+
this.appointmentMailingService = new AppointmentMailingService(
|
|
67
|
+
this.db,
|
|
68
|
+
mailgunClient // Pass the injected client
|
|
69
|
+
);
|
|
70
|
+
this.notificationsAdmin = new NotificationsAdmin(this.db);
|
|
71
|
+
this.calendarAdminService = new CalendarAdminService(this.db);
|
|
51
72
|
this.patientRequirementsAdminService = new PatientRequirementsAdminService(
|
|
52
73
|
this.db
|
|
53
74
|
);
|
|
54
|
-
|
|
55
|
-
// this.calendarAdminService = new CalendarAdminService(this.db); // Placeholder
|
|
56
|
-
// this.appointmentMailingService = new AppointmentMailingService(this.db /*, mailgunClient */); // Placeholder
|
|
57
|
-
// this.patientAdminService = new PatientAdminService(this.db); // Placeholder
|
|
58
|
-
// this.practitionerAdminService = new PractitionerAdminService(this.db); // Placeholder
|
|
59
|
-
// this.clinicAdminService = new ClinicAdminService(this.db); // Placeholder
|
|
75
|
+
Logger.info("[AppointmentAggregationService] Initialized.");
|
|
60
76
|
}
|
|
61
77
|
|
|
62
78
|
/**
|
|
63
|
-
* Handles side effects when an appointment is
|
|
64
|
-
*
|
|
65
|
-
* -
|
|
66
|
-
* - Triggers email/push notifications.
|
|
67
|
-
* - Manages patient-clinic-practitioner links.
|
|
68
|
-
* @param appointment - The confirmed Appointment object.
|
|
79
|
+
* Handles side effects when an appointment is first created.
|
|
80
|
+
* This function would typically be called by an Firestore onCreate trigger.
|
|
81
|
+
* @param {Appointment} appointment - The newly created Appointment object.
|
|
69
82
|
* @returns {Promise<void>}
|
|
70
83
|
*/
|
|
71
|
-
async
|
|
72
|
-
|
|
73
|
-
`[
|
|
84
|
+
async handleAppointmentCreate(appointment: Appointment): Promise<void> {
|
|
85
|
+
Logger.info(
|
|
86
|
+
`[AggService] Handling CREATE for appointment: ${appointment.id}, patient: ${appointment.patientId}, status: ${appointment.status}`
|
|
74
87
|
);
|
|
75
|
-
// 1. Create PRE-appointment PatientRequirementInstances
|
|
76
|
-
// - Fetch RequirementTemplates for procedureId
|
|
77
|
-
// - Construct and save PatientRequirementInstance documents
|
|
78
|
-
// - Rely on PatientRequirementInstance triggers for notification scheduling
|
|
79
88
|
|
|
80
|
-
|
|
89
|
+
try {
|
|
90
|
+
// 1. Manage Patient-Clinic-Practitioner Links (non-critical for core flow, can run in parallel or first)
|
|
91
|
+
// No need to await if other operations don't depend on its immediate completion, but awaiting for simplicity here.
|
|
92
|
+
await this.managePatientClinicPractitionerLinks(appointment, "create");
|
|
81
93
|
|
|
82
|
-
|
|
94
|
+
// 2. Fetch necessary profiles for notifications and context
|
|
95
|
+
// These can be fetched in parallel
|
|
96
|
+
const [
|
|
97
|
+
patientProfile,
|
|
98
|
+
patientSensitiveInfo,
|
|
99
|
+
practitionerProfile,
|
|
100
|
+
clinicInfo,
|
|
101
|
+
] = await Promise.all([
|
|
102
|
+
this.fetchPatientProfile(appointment.patientId),
|
|
103
|
+
this.fetchPatientSensitiveInfo(appointment.patientId),
|
|
104
|
+
this.fetchPractitionerProfile(appointment.practitionerId), // Needed for practitioner notifications
|
|
105
|
+
this.fetchClinicInfo(appointment.clinicBranchId), // Needed for clinic admin notifications
|
|
106
|
+
]);
|
|
83
107
|
|
|
84
|
-
|
|
108
|
+
// 3. Initial State Handling based on appointment status
|
|
109
|
+
if (appointment.status === AppointmentStatus.CONFIRMED) {
|
|
110
|
+
Logger.info(
|
|
111
|
+
`[AggService] Appt ${appointment.id} created as CONFIRMED.`
|
|
112
|
+
);
|
|
113
|
+
// Create pre-appointment requirements for confirmed appointments
|
|
114
|
+
await this.createPreAppointmentRequirementInstances(appointment);
|
|
85
115
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
116
|
+
// Send confirmation notifications
|
|
117
|
+
if (patientSensitiveInfo?.email && patientProfile) {
|
|
118
|
+
Logger.info(
|
|
119
|
+
`[AggService] Sending appointment confirmed email to patient ${patientSensitiveInfo.email}`
|
|
120
|
+
);
|
|
121
|
+
// Construct the data object for the mailing service
|
|
122
|
+
const emailData = {
|
|
123
|
+
appointment: appointment,
|
|
124
|
+
recipientProfile: appointment.patientInfo,
|
|
125
|
+
recipientRole: "patient" as const, // Use 'as const' for literal type
|
|
126
|
+
};
|
|
127
|
+
// The type cast here might still be an issue if PatientProfileInfo is not imported.
|
|
128
|
+
// However, the structure should be compatible enough for the call.
|
|
129
|
+
await this.appointmentMailingService.sendAppointmentConfirmedEmail(
|
|
130
|
+
emailData as any // Using 'as any' temporarily to bypass strict type checking if PatientProfileInfo is not imported
|
|
131
|
+
// TODO: Properly import PatientProfileInfo and ensure type compatibility
|
|
132
|
+
);
|
|
133
|
+
} else {
|
|
134
|
+
Logger.warn(
|
|
135
|
+
`[AggService] Cannot send confirmation email to patient ${appointment.patientId}: email missing.`
|
|
136
|
+
);
|
|
137
|
+
}
|
|
92
138
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
139
|
+
if (
|
|
140
|
+
patientProfile?.expoTokens &&
|
|
141
|
+
patientProfile.expoTokens.length > 0
|
|
142
|
+
) {
|
|
143
|
+
Logger.info(
|
|
144
|
+
`[AggService] TODO: Send appointment confirmed push to patient ${appointment.patientId}`
|
|
145
|
+
);
|
|
146
|
+
await this.notificationsAdmin.sendAppointmentConfirmedPush(
|
|
147
|
+
appointment,
|
|
148
|
+
appointment.patientId,
|
|
149
|
+
patientProfile.expoTokens,
|
|
150
|
+
UserRole.PATIENT
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (practitionerProfile?.basicInfo?.email) {
|
|
155
|
+
Logger.info(
|
|
156
|
+
`[AggService] Sending appointment confirmation email to practitioner ${practitionerProfile.basicInfo.email}`
|
|
157
|
+
);
|
|
158
|
+
const practitionerEmailData = {
|
|
159
|
+
appointment: appointment,
|
|
160
|
+
recipientProfile: appointment.practitionerInfo,
|
|
161
|
+
recipientRole: "practitioner" as const,
|
|
162
|
+
};
|
|
163
|
+
await this.appointmentMailingService.sendAppointmentConfirmedEmail(
|
|
164
|
+
practitionerEmailData // TODO: Properly import PractitionerProfileInfo and ensure type compatibility
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
// TODO: Add push notification for practitioner if they have expoTokens
|
|
168
|
+
} else if (appointment.status === AppointmentStatus.PENDING) {
|
|
169
|
+
Logger.info(`[AggService] Appt ${appointment.id} created as PENDING.`);
|
|
170
|
+
// Notify clinic admin about the pending appointment
|
|
171
|
+
if (clinicInfo?.contactInfo?.email) {
|
|
172
|
+
Logger.info(
|
|
173
|
+
`[AggService] TODO: Send pending appointment notification email to clinic admin ${clinicInfo.contactInfo.email}`
|
|
174
|
+
);
|
|
175
|
+
const clinicEmailData = {
|
|
176
|
+
appointment: appointment,
|
|
177
|
+
clinicProfile: appointment.clinicInfo, // clinicInfo should be compatible with ClinicInfo type
|
|
178
|
+
};
|
|
179
|
+
await this.appointmentMailingService.sendAppointmentRequestedEmailToClinic(
|
|
180
|
+
clinicEmailData // TODO: Properly import ClinicInfo if stricter typing is needed here and ensure compatibility
|
|
181
|
+
);
|
|
182
|
+
} else {
|
|
183
|
+
Logger.warn(
|
|
184
|
+
`[AggService] Cannot send pending appointment email to clinic ${appointment.clinicBranchId}: email missing.`
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
// TODO: Push notification for clinic admin if applicable (they usually don't have tokens)
|
|
188
|
+
}
|
|
107
189
|
|
|
108
|
-
|
|
190
|
+
// Calendar events are noted as being handled by BookingAdmin.orchestrateAppointmentCreation during the booking process itself.
|
|
191
|
+
Logger.info(
|
|
192
|
+
`[AggService] Successfully processed CREATE for appointment: ${appointment.id}`
|
|
193
|
+
);
|
|
194
|
+
} catch (error) {
|
|
195
|
+
Logger.error(
|
|
196
|
+
`[AggService] Critical error in handleAppointmentCreate for appointment ${appointment.id}:`,
|
|
197
|
+
error
|
|
198
|
+
);
|
|
199
|
+
// Depending on the error, you might want to re-throw or handle specific cases
|
|
200
|
+
// (e.g., update appointment status to an error state if a critical part failed)
|
|
201
|
+
}
|
|
109
202
|
}
|
|
110
203
|
|
|
111
204
|
/**
|
|
112
|
-
* Handles side effects when an appointment is
|
|
113
|
-
*
|
|
114
|
-
* -
|
|
115
|
-
* -
|
|
116
|
-
* - Evaluates patient-clinic-practitioner links.
|
|
117
|
-
* @param appointment - The cancelled Appointment object.
|
|
118
|
-
* @param previousStatus - The status of the appointment before cancellation.
|
|
205
|
+
* Handles side effects when an appointment is updated.
|
|
206
|
+
* This function would typically be called by an Firestore onUpdate trigger.
|
|
207
|
+
* @param {Appointment} before - The Appointment object before the update.
|
|
208
|
+
* @param {Appointment} after - The Appointment object after the update.
|
|
119
209
|
* @returns {Promise<void>}
|
|
120
210
|
*/
|
|
121
|
-
async
|
|
122
|
-
|
|
123
|
-
|
|
211
|
+
async handleAppointmentUpdate(
|
|
212
|
+
before: Appointment,
|
|
213
|
+
after: Appointment
|
|
124
214
|
): Promise<void> {
|
|
125
|
-
|
|
126
|
-
`[
|
|
215
|
+
Logger.info(
|
|
216
|
+
`[AggService] Handling UPDATE for appointment: ${after.id}. Status ${before.status} -> ${after.status}`
|
|
127
217
|
);
|
|
128
|
-
// 1. Update related PatientRequirementInstances' overallStatus
|
|
129
|
-
// - Query instances by appointment.id
|
|
130
|
-
// - Update overallStatus to PatientRequirementOverallStatus.CANCELLED_APPOINTMENT
|
|
131
|
-
// - PatientRequirementsAdminService (via trigger) should handle notification cancellations.
|
|
132
218
|
|
|
133
|
-
|
|
219
|
+
try {
|
|
220
|
+
const statusChanged = before.status !== after.status;
|
|
221
|
+
const timeChanged =
|
|
222
|
+
before.appointmentStartTime.toMillis() !==
|
|
223
|
+
after.appointmentStartTime.toMillis() ||
|
|
224
|
+
before.appointmentEndTime.toMillis() !==
|
|
225
|
+
after.appointmentEndTime.toMillis();
|
|
226
|
+
// const paymentStatusChanged = before.paymentStatus !== after.paymentStatus; // TODO: Handle later
|
|
227
|
+
// const reviewAdded = !before.reviewInfo && after.reviewInfo; // TODO: Handle later
|
|
228
|
+
|
|
229
|
+
// Fetch profiles for notifications - could be conditional based on changes
|
|
230
|
+
// For simplicity, fetching upfront, but optimize if performance is an issue.
|
|
231
|
+
const [
|
|
232
|
+
patientProfile,
|
|
233
|
+
patientSensitiveInfo,
|
|
234
|
+
practitionerProfile,
|
|
235
|
+
clinicInfo,
|
|
236
|
+
] = await Promise.all([
|
|
237
|
+
this.fetchPatientProfile(after.patientId),
|
|
238
|
+
this.fetchPatientSensitiveInfo(after.patientId),
|
|
239
|
+
this.fetchPractitionerProfile(after.practitionerId),
|
|
240
|
+
this.fetchClinicInfo(after.clinicBranchId),
|
|
241
|
+
]);
|
|
242
|
+
|
|
243
|
+
if (statusChanged) {
|
|
244
|
+
Logger.info(
|
|
245
|
+
`[AggService] Status changed for ${after.id}: ${before.status} -> ${after.status}`
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
// --- PENDING -> CONFIRMED ---
|
|
249
|
+
if (
|
|
250
|
+
before.status === AppointmentStatus.PENDING &&
|
|
251
|
+
after.status === AppointmentStatus.CONFIRMED
|
|
252
|
+
) {
|
|
253
|
+
Logger.info(`[AggService] Appt ${after.id} PENDING -> CONFIRMED.`);
|
|
254
|
+
await this.createPreAppointmentRequirementInstances(after);
|
|
255
|
+
// Send confirmation notifications
|
|
256
|
+
if (patientSensitiveInfo?.email && patientProfile) {
|
|
257
|
+
Logger.info(
|
|
258
|
+
`[AggService] Sending appointment confirmed email to patient ${patientSensitiveInfo.email}`
|
|
259
|
+
);
|
|
260
|
+
const emailData = {
|
|
261
|
+
appointment: after,
|
|
262
|
+
recipientProfile: after.patientInfo,
|
|
263
|
+
recipientRole: "patient" as const,
|
|
264
|
+
};
|
|
265
|
+
await this.appointmentMailingService.sendAppointmentConfirmedEmail(
|
|
266
|
+
emailData as any
|
|
267
|
+
);
|
|
268
|
+
} else {
|
|
269
|
+
Logger.warn(
|
|
270
|
+
`[AggService] Cannot send confirmation email to patient ${after.patientId}: email missing.`
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (
|
|
275
|
+
patientProfile?.expoTokens &&
|
|
276
|
+
patientProfile.expoTokens.length > 0
|
|
277
|
+
) {
|
|
278
|
+
Logger.info(
|
|
279
|
+
`[AggService] TODO: Send appointment confirmed push to patient ${after.patientId}`
|
|
280
|
+
);
|
|
281
|
+
await this.notificationsAdmin.sendAppointmentConfirmedPush(
|
|
282
|
+
after,
|
|
283
|
+
after.patientId,
|
|
284
|
+
patientProfile.expoTokens,
|
|
285
|
+
UserRole.PATIENT
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (practitionerProfile?.basicInfo?.email) {
|
|
290
|
+
Logger.info(
|
|
291
|
+
`[AggService] Sending appointment confirmation email to practitioner ${practitionerProfile.basicInfo.email}`
|
|
292
|
+
);
|
|
293
|
+
const practitionerEmailData = {
|
|
294
|
+
appointment: after,
|
|
295
|
+
recipientProfile: after.practitionerInfo,
|
|
296
|
+
recipientRole: "practitioner" as const,
|
|
297
|
+
};
|
|
298
|
+
await this.appointmentMailingService.sendAppointmentConfirmedEmail(
|
|
299
|
+
practitionerEmailData as any // TODO: Properly import PractitionerProfileInfo and ensure type compatibility
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
// TODO: Add push notification for practitioner if they have expoTokens
|
|
303
|
+
}
|
|
304
|
+
// --- Any -> CANCELLED_* ---
|
|
305
|
+
else if (
|
|
306
|
+
after.status === AppointmentStatus.CANCELED_CLINIC ||
|
|
307
|
+
after.status === AppointmentStatus.CANCELED_PATIENT ||
|
|
308
|
+
after.status === AppointmentStatus.CANCELED_PATIENT_RESCHEDULED ||
|
|
309
|
+
after.status === AppointmentStatus.NO_SHOW
|
|
310
|
+
) {
|
|
311
|
+
Logger.info(
|
|
312
|
+
`[AggService] Appt ${after.id} status -> ${after.status}. Processing as cancellation.`
|
|
313
|
+
);
|
|
314
|
+
await this.updateRelatedPatientRequirementInstances(
|
|
315
|
+
after,
|
|
316
|
+
PatientRequirementOverallStatus.CANCELLED_APPOINTMENT
|
|
317
|
+
);
|
|
318
|
+
await this.managePatientClinicPractitionerLinks(after, "cancel");
|
|
319
|
+
|
|
320
|
+
// Send cancellation email to Patient
|
|
321
|
+
if (patientSensitiveInfo?.email && patientProfile) {
|
|
322
|
+
Logger.info(
|
|
323
|
+
`[AggService] Sending appointment cancellation email to patient ${patientSensitiveInfo.email}`
|
|
324
|
+
);
|
|
325
|
+
const patientCancellationData = {
|
|
326
|
+
appointment: after,
|
|
327
|
+
recipientProfile: after.patientInfo,
|
|
328
|
+
recipientRole: "patient" as const,
|
|
329
|
+
// cancellationReason: after.cancellationReason, // TODO: Add if cancellationReason is available on 'after' Appointment
|
|
330
|
+
};
|
|
331
|
+
await this.appointmentMailingService.sendAppointmentCancelledEmail(
|
|
332
|
+
patientCancellationData as any // TODO: Properly import types
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Send cancellation email to Practitioner
|
|
337
|
+
if (practitionerProfile?.basicInfo?.email) {
|
|
338
|
+
Logger.info(
|
|
339
|
+
`[AggService] Sending appointment cancellation email to practitioner ${practitionerProfile.basicInfo.email}`
|
|
340
|
+
);
|
|
341
|
+
const practitionerCancellationData = {
|
|
342
|
+
appointment: after,
|
|
343
|
+
recipientProfile: after.practitionerInfo,
|
|
344
|
+
recipientRole: "practitioner" as const,
|
|
345
|
+
// cancellationReason: after.cancellationReason, // TODO: Add if cancellationReason is available on 'after' Appointment
|
|
346
|
+
};
|
|
347
|
+
await this.appointmentMailingService.sendAppointmentCancelledEmail(
|
|
348
|
+
practitionerCancellationData as any // TODO: Properly import types
|
|
349
|
+
);
|
|
350
|
+
}
|
|
134
351
|
|
|
135
|
-
|
|
352
|
+
// TODO: Send cancellation push notifications (patient, practitioner) via notificationsAdmin
|
|
353
|
+
// TODO: Update/cancel calendar event via calendarAdminService.updateAppointmentCalendarEventStatus(after, CalendarEventStatus.CANCELED)
|
|
354
|
+
}
|
|
355
|
+
// --- Any -> COMPLETED ---
|
|
356
|
+
else if (after.status === AppointmentStatus.COMPLETED) {
|
|
357
|
+
Logger.info(`[AggService] Appt ${after.id} status -> COMPLETED.`);
|
|
358
|
+
await this.createPostAppointmentRequirementInstances(after);
|
|
136
359
|
|
|
137
|
-
|
|
360
|
+
// Send review request email to patient
|
|
361
|
+
if (patientSensitiveInfo?.email && patientProfile) {
|
|
362
|
+
Logger.info(
|
|
363
|
+
`[AggService] Sending review request email to patient ${patientSensitiveInfo.email}`
|
|
364
|
+
);
|
|
365
|
+
const reviewRequestData = {
|
|
366
|
+
appointment: after,
|
|
367
|
+
patientProfile: after.patientInfo,
|
|
368
|
+
reviewLink: "TODO: Generate actual review link", // Placeholder
|
|
369
|
+
};
|
|
370
|
+
await this.appointmentMailingService.sendReviewRequestEmail(
|
|
371
|
+
reviewRequestData as any // TODO: Properly import PatientProfileInfo and define reviewLink generation
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
// TODO: Send review request push notification to patient
|
|
375
|
+
}
|
|
376
|
+
// --- RESCHEDULE Scenarios (e.g., PENDING/CONFIRMED -> RESCHEDULED_BY_CLINIC) ---
|
|
377
|
+
else if (after.status === AppointmentStatus.RESCHEDULED_BY_CLINIC) {
|
|
378
|
+
Logger.info(
|
|
379
|
+
`[AggService] Appt ${after.id} status -> RESCHEDULED_BY_CLINIC.`
|
|
380
|
+
);
|
|
381
|
+
await this.updateRelatedPatientRequirementInstances(
|
|
382
|
+
before, // Pass the 'before' state for old requirements
|
|
383
|
+
PatientRequirementOverallStatus.SUPERSEDED_RESCHEDULE
|
|
384
|
+
);
|
|
385
|
+
Logger.info(
|
|
386
|
+
`[AggService] TODO: Handle RESCHEDULE logic for requirements carefully based on confirmation flow.`
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
// Send reschedule proposal email to patient
|
|
390
|
+
if (patientSensitiveInfo?.email && patientProfile) {
|
|
391
|
+
Logger.info(
|
|
392
|
+
`[AggService] Sending reschedule proposal email to patient ${patientSensitiveInfo.email}`
|
|
393
|
+
);
|
|
394
|
+
const rescheduleEmailData = {
|
|
395
|
+
appointment: after, // The new state of the appointment
|
|
396
|
+
patientProfile: after.patientInfo,
|
|
397
|
+
previousStartTime: before.appointmentStartTime,
|
|
398
|
+
previousEndTime: before.appointmentEndTime,
|
|
399
|
+
};
|
|
400
|
+
await this.appointmentMailingService.sendAppointmentRescheduledProposalEmail(
|
|
401
|
+
rescheduleEmailData as any // TODO: Properly import PatientProfileInfo and types
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
Logger.info(
|
|
406
|
+
`[AggService] TODO: Send reschedule proposal notifications to practitioner as well.`
|
|
407
|
+
);
|
|
408
|
+
// TODO: Update calendar event to reflect proposed new time via calendarAdminService.
|
|
409
|
+
}
|
|
410
|
+
// TODO: Add more specific status change handlers as needed
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// --- Independent Time Change (if not tied to a status that already handled it) ---
|
|
414
|
+
if (timeChanged && !statusChanged) {
|
|
415
|
+
// Or if status change didn't fully cover reschedule implications
|
|
416
|
+
Logger.info(`[AggService] Appointment ${after.id} time changed.`);
|
|
417
|
+
// This scenario is tricky. If time changes, pre-requirements might need to be re-evaluated.
|
|
418
|
+
// Typically, a time change would also involve a status change to some form of "rescheduled".
|
|
419
|
+
// If it can happen independently while, e.g., staying CONFIRMED, then:
|
|
420
|
+
// await this.updateRelatedPatientRequirementInstances(before, PatientRequirementOverallStatus.SUPERSEDED_RESCHEDULE);
|
|
421
|
+
// await this.createPreAppointmentRequirementInstances(after);
|
|
422
|
+
// TODO: Send reschedule notifications
|
|
423
|
+
// TODO: Update calendar event via calendarAdminService
|
|
424
|
+
Logger.warn(
|
|
425
|
+
`[AggService] Independent time change detected for ${after.id}. Review implications for requirements and calendar.`
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// TODO: Handle Payment Status Change
|
|
430
|
+
// const paymentStatusChanged = before.paymentStatus !== after.paymentStatus;
|
|
431
|
+
// if (paymentStatusChanged && after.paymentStatus === PaymentStatus.PAID) { ... }
|
|
432
|
+
|
|
433
|
+
// TODO: Handle Review Added
|
|
434
|
+
// const reviewAdded = !before.reviewInfo && after.reviewInfo;
|
|
435
|
+
// if (reviewAdded) { ... }
|
|
436
|
+
|
|
437
|
+
Logger.info(
|
|
438
|
+
`[AggService] Successfully processed UPDATE for appointment: ${after.id}`
|
|
439
|
+
);
|
|
440
|
+
} catch (error) {
|
|
441
|
+
Logger.error(
|
|
442
|
+
`[AggService] Critical error in handleAppointmentUpdate for appointment ${after.id}:`,
|
|
443
|
+
error
|
|
444
|
+
);
|
|
445
|
+
}
|
|
138
446
|
}
|
|
139
447
|
|
|
140
448
|
/**
|
|
141
|
-
* Handles side effects when an appointment is
|
|
142
|
-
*
|
|
143
|
-
* - Triggers calendar event updates.
|
|
144
|
-
* - Triggers email/push notifications for rescheduling.
|
|
145
|
-
* @param appointment - The rescheduled Appointment object with new times.
|
|
146
|
-
* @param previousAppointmentData - The appointment data before rescheduling.
|
|
449
|
+
* Handles side effects when an appointment is deleted.
|
|
450
|
+
* @param deletedAppointment - The Appointment object that was deleted.
|
|
147
451
|
* @returns {Promise<void>}
|
|
148
452
|
*/
|
|
149
|
-
async
|
|
150
|
-
|
|
151
|
-
previousAppointmentData: Appointment
|
|
453
|
+
async handleAppointmentDelete(
|
|
454
|
+
deletedAppointment: Appointment
|
|
152
455
|
): Promise<void> {
|
|
153
|
-
|
|
154
|
-
`[
|
|
456
|
+
Logger.info(
|
|
457
|
+
`[AggService] Handling DELETE for appointment: ${deletedAppointment.id}`
|
|
155
458
|
);
|
|
156
|
-
//
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
//
|
|
162
|
-
|
|
163
|
-
|
|
459
|
+
// Similar to cancellation
|
|
460
|
+
await this.updateRelatedPatientRequirementInstances(
|
|
461
|
+
deletedAppointment,
|
|
462
|
+
PatientRequirementOverallStatus.CANCELLED_APPOINTMENT
|
|
463
|
+
);
|
|
464
|
+
// await this.calendarAdminService.deleteAppointmentCalendarEvents(deletedAppointment);
|
|
465
|
+
await this.managePatientClinicPractitionerLinks(
|
|
466
|
+
deletedAppointment,
|
|
467
|
+
"cancel"
|
|
468
|
+
);
|
|
469
|
+
// TODO: Send cancellation/deletion notifications if appropriate (though data is gone)
|
|
164
470
|
}
|
|
165
471
|
|
|
472
|
+
// --- Helper Methods for Aggregation Logic ---
|
|
473
|
+
|
|
166
474
|
/**
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
475
|
+
* Creates PRE_APPOINTMENT PatientRequirementInstance documents for a given appointment.
|
|
476
|
+
* Uses the `appointment.preProcedureRequirements` array, which should contain relevant Requirement templates.
|
|
477
|
+
* For each active PRE requirement template, it constructs a new PatientRequirementInstance document
|
|
478
|
+
* with derived instructions and batch writes them to Firestore under the patient's `patient_requirements` subcollection.
|
|
479
|
+
*
|
|
480
|
+
* @param {Appointment} appointment - The appointment for which to create pre-requirement instances.
|
|
481
|
+
* @returns {Promise<void>} A promise that resolves when the operation is complete.
|
|
172
482
|
*/
|
|
173
|
-
async
|
|
174
|
-
appointment: Appointment
|
|
175
|
-
action: "create" | "cancel"
|
|
483
|
+
private async createPreAppointmentRequirementInstances(
|
|
484
|
+
appointment: Appointment
|
|
176
485
|
): Promise<void> {
|
|
177
|
-
|
|
178
|
-
`[
|
|
486
|
+
Logger.info(
|
|
487
|
+
`[AggService] Creating PRE-appointment requirement instances for appt: ${appointment.id}, patient: ${appointment.patientId}`
|
|
179
488
|
);
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
489
|
+
|
|
490
|
+
if (!appointment.procedureId) {
|
|
491
|
+
Logger.warn(
|
|
492
|
+
`[AggService] Appointment ${appointment.id} has no procedureId. Cannot create pre-requirement instances.`
|
|
493
|
+
);
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
if (
|
|
498
|
+
!appointment.preProcedureRequirements ||
|
|
499
|
+
appointment.preProcedureRequirements.length === 0
|
|
500
|
+
) {
|
|
501
|
+
Logger.info(
|
|
502
|
+
`[AggService] No preProcedureRequirements found on appointment ${appointment.id}. Nothing to create.`
|
|
503
|
+
);
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
try {
|
|
508
|
+
const batch = this.db.batch();
|
|
509
|
+
let instancesCreatedCount = 0;
|
|
510
|
+
|
|
511
|
+
for (const template of appointment.preProcedureRequirements) {
|
|
512
|
+
if (!template) continue; // Skip if template is null or undefined in the array
|
|
513
|
+
|
|
514
|
+
// Ensure it's an active, PRE-type requirement
|
|
515
|
+
if (template.type !== RequirementType.PRE || !template.isActive) {
|
|
516
|
+
Logger.debug(
|
|
517
|
+
`[AggService] Skipping template ${template.id} (${template.name}): not an active PRE requirement.`
|
|
518
|
+
);
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
Logger.debug(
|
|
523
|
+
`[AggService] Processing template ${template.id} (${template.name}) for appt ${appointment.id}`
|
|
524
|
+
);
|
|
525
|
+
|
|
526
|
+
const newInstanceRef = this.db
|
|
527
|
+
.collection(PATIENTS_COLLECTION)
|
|
528
|
+
.doc(appointment.patientId)
|
|
529
|
+
.collection(PATIENT_REQUIREMENTS_SUBCOLLECTION_NAME)
|
|
530
|
+
.doc(); // Auto-generate ID for the new instance
|
|
531
|
+
|
|
532
|
+
const instructions: PatientRequirementInstruction[] = (
|
|
533
|
+
template.timeframe?.notifyAt || []
|
|
534
|
+
).map((notifyAtValue) => {
|
|
535
|
+
let dueTime: any = appointment.appointmentStartTime;
|
|
536
|
+
if (template.timeframe && typeof notifyAtValue === "number") {
|
|
537
|
+
const dueDateTime = new Date(
|
|
538
|
+
appointment.appointmentStartTime.toMillis()
|
|
539
|
+
);
|
|
540
|
+
if (template.timeframe.unit === TimeUnit.DAYS) {
|
|
541
|
+
dueDateTime.setDate(dueDateTime.getDate() - notifyAtValue);
|
|
542
|
+
} else if (template.timeframe.unit === TimeUnit.HOURS) {
|
|
543
|
+
dueDateTime.setHours(dueDateTime.getHours() - notifyAtValue);
|
|
544
|
+
}
|
|
545
|
+
dueTime = admin.firestore.Timestamp.fromDate(dueDateTime);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// TODO: Determine source or default for 'actionableWindow' - consult requirements for PatientRequirementInstruction
|
|
549
|
+
const actionableWindowHours =
|
|
550
|
+
template.importance === "high"
|
|
551
|
+
? 1
|
|
552
|
+
: template.importance === "medium"
|
|
553
|
+
? 4
|
|
554
|
+
: 15; // Default to 15 hours for low importance; // Placeholder default, TODO: Define source
|
|
555
|
+
|
|
556
|
+
const instructionObject: PatientRequirementInstruction = {
|
|
557
|
+
instructionId:
|
|
558
|
+
`${template.id}_${notifyAtValue}_${newInstanceRef.id}`.replace(
|
|
559
|
+
/[^a-zA-Z0-9_]/g,
|
|
560
|
+
"_"
|
|
561
|
+
),
|
|
562
|
+
instructionText: template.description || template.name,
|
|
563
|
+
dueTime: dueTime as any,
|
|
564
|
+
actionableWindow: actionableWindowHours, // Directly assigning the placeholder default value
|
|
565
|
+
status: PatientInstructionStatus.PENDING_NOTIFICATION,
|
|
566
|
+
originalNotifyAtValue: notifyAtValue,
|
|
567
|
+
originalTimeframeUnit: template.timeframe.unit,
|
|
568
|
+
updatedAt: admin.firestore.FieldValue.serverTimestamp() as any,
|
|
569
|
+
notificationId: undefined,
|
|
570
|
+
actionTakenAt: undefined,
|
|
571
|
+
};
|
|
572
|
+
return instructionObject;
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
const newInstanceData: Omit<PatientRequirementInstance, "id"> = {
|
|
576
|
+
patientId: appointment.patientId,
|
|
577
|
+
appointmentId: appointment.id,
|
|
578
|
+
originalRequirementId: template.id,
|
|
579
|
+
requirementName: template.name,
|
|
580
|
+
requirementDescription: template.description,
|
|
581
|
+
requirementType: template.type, // Should be RequirementType.PRE
|
|
582
|
+
requirementImportance: template.importance,
|
|
583
|
+
overallStatus: PatientRequirementOverallStatus.ACTIVE,
|
|
584
|
+
instructions: instructions,
|
|
585
|
+
// Timestamps - cast to any to satisfy client-side Timestamp type for now
|
|
586
|
+
createdAt: admin.firestore.FieldValue.serverTimestamp() as any,
|
|
587
|
+
updatedAt: admin.firestore.FieldValue.serverTimestamp() as any,
|
|
588
|
+
};
|
|
589
|
+
|
|
590
|
+
batch.set(newInstanceRef, newInstanceData);
|
|
591
|
+
instancesCreatedCount++;
|
|
592
|
+
Logger.debug(
|
|
593
|
+
`[AggService] Added PatientRequirementInstance ${newInstanceRef.id} to batch for template ${template.id}.`
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
if (instancesCreatedCount > 0) {
|
|
598
|
+
await batch.commit();
|
|
599
|
+
Logger.info(
|
|
600
|
+
`[AggService] Successfully created ${instancesCreatedCount} PRE_APPOINTMENT requirement instances for appointment ${appointment.id}.`
|
|
601
|
+
);
|
|
602
|
+
} else {
|
|
603
|
+
Logger.info(
|
|
604
|
+
`[AggService] No new PRE_APPOINTMENT requirement instances were prepared for batch commit for appointment ${appointment.id}.`
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
} catch (error) {
|
|
608
|
+
Logger.error(
|
|
609
|
+
`[AggService] Error creating PRE_APPOINTMENT requirement instances for appointment ${appointment.id}:`,
|
|
610
|
+
error
|
|
611
|
+
);
|
|
612
|
+
// Rethrow or handle as per error policy, e.g., if this failure should halt further processing.
|
|
613
|
+
// For now, just logging.
|
|
614
|
+
}
|
|
183
615
|
}
|
|
184
616
|
|
|
185
617
|
/**
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
618
|
+
* Creates POST_APPOINTMENT PatientRequirementInstance documents for a given appointment.
|
|
619
|
+
* Uses the `appointment.postProcedureRequirements` array.
|
|
620
|
+
* For each active POST requirement template, it constructs a new PatientRequirementInstance document
|
|
621
|
+
* with derived instructions and batch writes them to Firestore under the patient's `patient_requirements` subcollection.
|
|
622
|
+
*
|
|
623
|
+
* @param {Appointment} appointment - The appointment for which to create post-requirement instances.
|
|
624
|
+
* @returns {Promise<void>} A promise that resolves when the operation is complete.
|
|
191
625
|
*/
|
|
192
|
-
async
|
|
193
|
-
appointment: Appointment
|
|
194
|
-
action: "create" | "update" | "delete",
|
|
195
|
-
previousAppointmentData?: Appointment
|
|
626
|
+
private async createPostAppointmentRequirementInstances(
|
|
627
|
+
appointment: Appointment
|
|
196
628
|
): Promise<void> {
|
|
197
|
-
|
|
198
|
-
`[
|
|
629
|
+
Logger.info(
|
|
630
|
+
`[AggService] Creating POST-appointment requirement instances for appt: ${appointment.id}, patient: ${appointment.patientId}`
|
|
199
631
|
);
|
|
200
|
-
|
|
632
|
+
|
|
633
|
+
if (!appointment.procedureId) {
|
|
634
|
+
Logger.warn(
|
|
635
|
+
`[AggService] Appointment ${appointment.id} has no procedureId. Cannot create post-requirement instances.`
|
|
636
|
+
);
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
if (
|
|
641
|
+
!appointment.postProcedureRequirements ||
|
|
642
|
+
appointment.postProcedureRequirements.length === 0
|
|
643
|
+
) {
|
|
644
|
+
Logger.info(
|
|
645
|
+
`[AggService] No postProcedureRequirements found on appointment ${appointment.id}. Nothing to create.`
|
|
646
|
+
);
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
try {
|
|
651
|
+
const batch = this.db.batch();
|
|
652
|
+
let instancesCreatedCount = 0;
|
|
653
|
+
|
|
654
|
+
for (const template of appointment.postProcedureRequirements) {
|
|
655
|
+
if (!template) continue;
|
|
656
|
+
|
|
657
|
+
if (template.type !== RequirementType.POST || !template.isActive) {
|
|
658
|
+
Logger.debug(
|
|
659
|
+
`[AggService] Skipping template ${template.id} (${template.name}): not an active POST requirement.`
|
|
660
|
+
);
|
|
661
|
+
continue;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
Logger.debug(
|
|
665
|
+
`[AggService] Processing POST template ${template.id} (${template.name}) for appt ${appointment.id}`
|
|
666
|
+
);
|
|
667
|
+
|
|
668
|
+
const newInstanceRef = this.db
|
|
669
|
+
.collection(PATIENTS_COLLECTION)
|
|
670
|
+
.doc(appointment.patientId)
|
|
671
|
+
.collection(PATIENT_REQUIREMENTS_SUBCOLLECTION_NAME)
|
|
672
|
+
.doc();
|
|
673
|
+
|
|
674
|
+
const instructions: PatientRequirementInstruction[] = (
|
|
675
|
+
template.timeframe?.notifyAt || []
|
|
676
|
+
).map((notifyAtValue) => {
|
|
677
|
+
let dueTime: any = appointment.appointmentEndTime; // Default to appointment end time
|
|
678
|
+
if (template.timeframe && typeof notifyAtValue === "number") {
|
|
679
|
+
const dueDateTime = new Date(
|
|
680
|
+
appointment.appointmentEndTime.toMillis()
|
|
681
|
+
);
|
|
682
|
+
// For POST requirements, notifyAtValue usually means AFTER the event
|
|
683
|
+
if (template.timeframe.unit === TimeUnit.DAYS) {
|
|
684
|
+
dueDateTime.setDate(dueDateTime.getDate() + notifyAtValue);
|
|
685
|
+
} else if (template.timeframe.unit === TimeUnit.HOURS) {
|
|
686
|
+
dueDateTime.setHours(dueDateTime.getHours() + notifyAtValue);
|
|
687
|
+
}
|
|
688
|
+
dueTime = admin.firestore.Timestamp.fromDate(dueDateTime);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
const actionableWindowHours =
|
|
692
|
+
template.importance === "high"
|
|
693
|
+
? 1
|
|
694
|
+
: template.importance === "medium"
|
|
695
|
+
? 4
|
|
696
|
+
: 15; // Default to 15 hours for low importance; // Placeholder default, TODO: Define source
|
|
697
|
+
|
|
698
|
+
return {
|
|
699
|
+
instructionId:
|
|
700
|
+
`${template.id}_${notifyAtValue}_${newInstanceRef.id}`.replace(
|
|
701
|
+
/[^a-zA-Z0-9_]/g,
|
|
702
|
+
"_"
|
|
703
|
+
),
|
|
704
|
+
instructionText: template.description || template.name,
|
|
705
|
+
dueTime: dueTime as any,
|
|
706
|
+
actionableWindow: actionableWindowHours,
|
|
707
|
+
status: PatientInstructionStatus.PENDING_NOTIFICATION,
|
|
708
|
+
originalNotifyAtValue: notifyAtValue,
|
|
709
|
+
originalTimeframeUnit: template.timeframe.unit,
|
|
710
|
+
updatedAt: admin.firestore.FieldValue.serverTimestamp() as any,
|
|
711
|
+
notificationId: undefined,
|
|
712
|
+
actionTakenAt: undefined,
|
|
713
|
+
} as PatientRequirementInstruction;
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
const newInstanceData: Omit<PatientRequirementInstance, "id"> = {
|
|
717
|
+
patientId: appointment.patientId,
|
|
718
|
+
appointmentId: appointment.id,
|
|
719
|
+
originalRequirementId: template.id,
|
|
720
|
+
requirementName: template.name,
|
|
721
|
+
requirementDescription: template.description,
|
|
722
|
+
requirementType: template.type, // Should be RequirementType.POST
|
|
723
|
+
requirementImportance: template.importance,
|
|
724
|
+
overallStatus: PatientRequirementOverallStatus.ACTIVE,
|
|
725
|
+
instructions: instructions,
|
|
726
|
+
createdAt: admin.firestore.FieldValue.serverTimestamp() as any,
|
|
727
|
+
updatedAt: admin.firestore.FieldValue.serverTimestamp() as any,
|
|
728
|
+
};
|
|
729
|
+
|
|
730
|
+
batch.set(newInstanceRef, newInstanceData);
|
|
731
|
+
instancesCreatedCount++;
|
|
732
|
+
Logger.debug(
|
|
733
|
+
`[AggService] Added POST PatientRequirementInstance ${newInstanceRef.id} to batch for template ${template.id}.`
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
if (instancesCreatedCount > 0) {
|
|
738
|
+
await batch.commit();
|
|
739
|
+
Logger.info(
|
|
740
|
+
`[AggService] Successfully created ${instancesCreatedCount} POST_APPOINTMENT requirement instances for appointment ${appointment.id}.`
|
|
741
|
+
);
|
|
742
|
+
} else {
|
|
743
|
+
Logger.info(
|
|
744
|
+
`[AggService] No new POST_APPOINTMENT requirement instances were prepared for batch commit for appointment ${appointment.id}.`
|
|
745
|
+
);
|
|
746
|
+
}
|
|
747
|
+
} catch (error) {
|
|
748
|
+
Logger.error(
|
|
749
|
+
`[AggService] Error creating POST_APPOINTMENT requirement instances for appointment ${appointment.id}:`,
|
|
750
|
+
error
|
|
751
|
+
);
|
|
752
|
+
}
|
|
201
753
|
}
|
|
202
754
|
|
|
203
755
|
/**
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
* @param
|
|
208
|
-
* @
|
|
756
|
+
* Updates the overallStatus of all PatientRequirementInstance documents associated with a given appointment.
|
|
757
|
+
* This is typically used when an appointment is cancelled or rescheduled, making existing requirements void.
|
|
758
|
+
*
|
|
759
|
+
* @param {Appointment} appointment - The appointment whose requirement instances need updating.
|
|
760
|
+
* @param {PatientRequirementOverallStatus} newOverallStatus - The new status to set (e.g., CANCELLED_APPOINTMENT).
|
|
761
|
+
* @returns {Promise<void>} A promise that resolves when the operation is complete.
|
|
209
762
|
*/
|
|
210
|
-
async
|
|
763
|
+
private async updateRelatedPatientRequirementInstances(
|
|
211
764
|
appointment: Appointment,
|
|
212
|
-
|
|
213
|
-
|
|
765
|
+
newOverallStatus: PatientRequirementOverallStatus,
|
|
766
|
+
_previousAppointmentData?: Appointment // Not used in this basic implementation, but kept for signature consistency
|
|
214
767
|
): Promise<void> {
|
|
215
|
-
|
|
216
|
-
`[
|
|
768
|
+
Logger.info(
|
|
769
|
+
`[AggService] Updating related patient req instances for appt ${appointment.id} (patient: ${appointment.patientId}) to ${newOverallStatus}`
|
|
217
770
|
);
|
|
218
|
-
|
|
771
|
+
|
|
772
|
+
if (!appointment.id || !appointment.patientId) {
|
|
773
|
+
Logger.error(
|
|
774
|
+
"[AggService] updateRelatedPatientRequirementInstances called with missing appointmentId or patientId.",
|
|
775
|
+
{ appointmentId: appointment.id, patientId: appointment.patientId }
|
|
776
|
+
);
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
try {
|
|
781
|
+
const instancesSnapshot = await this.db
|
|
782
|
+
.collection(PATIENTS_COLLECTION)
|
|
783
|
+
.doc(appointment.patientId)
|
|
784
|
+
.collection(PATIENT_REQUIREMENTS_SUBCOLLECTION_NAME)
|
|
785
|
+
.where("appointmentId", "==", appointment.id)
|
|
786
|
+
.get();
|
|
787
|
+
|
|
788
|
+
if (instancesSnapshot.empty) {
|
|
789
|
+
Logger.info(
|
|
790
|
+
`[AggService] No patient requirement instances found for appointment ${appointment.id} to update.`
|
|
791
|
+
);
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
const batch = this.db.batch();
|
|
796
|
+
let instancesUpdatedCount = 0;
|
|
797
|
+
|
|
798
|
+
instancesSnapshot.docs.forEach((doc) => {
|
|
799
|
+
const instance = doc.data() as PatientRequirementInstance;
|
|
800
|
+
// Update only if the status is actually different and not already in a terminal state like FAILED_TO_PROCESS
|
|
801
|
+
if (
|
|
802
|
+
instance.overallStatus !== newOverallStatus &&
|
|
803
|
+
instance.overallStatus !==
|
|
804
|
+
PatientRequirementOverallStatus.FAILED_TO_PROCESS
|
|
805
|
+
) {
|
|
806
|
+
batch.update(doc.ref, {
|
|
807
|
+
overallStatus: newOverallStatus,
|
|
808
|
+
updatedAt: admin.firestore.FieldValue.serverTimestamp() as any, // Cast for now
|
|
809
|
+
// Potentially also cancel individual instructions if not handled by another trigger
|
|
810
|
+
// instructions: instance.instructions.map(instr => ({ ...instr, status: PatientInstructionStatus.CANCELLED, updatedAt: admin.firestore.FieldValue.serverTimestamp() as any }))
|
|
811
|
+
});
|
|
812
|
+
instancesUpdatedCount++;
|
|
813
|
+
Logger.debug(
|
|
814
|
+
`[AggService] Added update for PatientRequirementInstance ${doc.id} to batch. New status: ${newOverallStatus}`
|
|
815
|
+
);
|
|
816
|
+
}
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
if (instancesUpdatedCount > 0) {
|
|
820
|
+
await batch.commit();
|
|
821
|
+
Logger.info(
|
|
822
|
+
`[AggService] Successfully updated ${instancesUpdatedCount} patient requirement instances for appointment ${appointment.id} to status ${newOverallStatus}.`
|
|
823
|
+
);
|
|
824
|
+
} else {
|
|
825
|
+
Logger.info(
|
|
826
|
+
`[AggService] No patient requirement instances needed an update for appointment ${appointment.id}.`
|
|
827
|
+
);
|
|
828
|
+
}
|
|
829
|
+
} catch (error) {
|
|
830
|
+
Logger.error(
|
|
831
|
+
`[AggService] Error updating patient requirement instances for appointment ${appointment.id}:`,
|
|
832
|
+
error
|
|
833
|
+
);
|
|
834
|
+
}
|
|
219
835
|
}
|
|
220
836
|
|
|
221
837
|
/**
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
838
|
+
* Manages denormalized links between a patient and the clinic/practitioner associated with an appointment.
|
|
839
|
+
* Adds patientId to arrays on clinic and practitioner documents on appointment creation.
|
|
840
|
+
* Removes patientId on appointment cancellation (basic removal, can be enhanced).
|
|
841
|
+
*
|
|
842
|
+
* @param {Appointment} appointment - The appointment object.
|
|
843
|
+
* @param {"create" | "cancel"} action - 'create' to add links, 'cancel' to remove them.
|
|
844
|
+
* @returns {Promise<void>} A promise that resolves when the operation is complete.
|
|
225
845
|
*/
|
|
226
|
-
async
|
|
227
|
-
|
|
228
|
-
|
|
846
|
+
private async managePatientClinicPractitionerLinks(
|
|
847
|
+
appointment: Appointment,
|
|
848
|
+
action: "create" | "cancel"
|
|
849
|
+
): Promise<void> {
|
|
850
|
+
Logger.info(
|
|
851
|
+
`[AggService] Managing patient-clinic-practitioner links for appt ${appointment.id} (patient: ${appointment.patientId}), action: ${action}`
|
|
229
852
|
);
|
|
230
|
-
|
|
231
|
-
|
|
853
|
+
|
|
854
|
+
if (
|
|
855
|
+
!appointment.patientId ||
|
|
856
|
+
!appointment.practitionerId ||
|
|
857
|
+
!appointment.clinicBranchId
|
|
858
|
+
) {
|
|
859
|
+
Logger.error(
|
|
860
|
+
"[AggService] managePatientClinicPractitionerLinks called with missing IDs.",
|
|
861
|
+
{
|
|
862
|
+
patientId: appointment.patientId,
|
|
863
|
+
practitionerId: appointment.practitionerId,
|
|
864
|
+
clinicBranchId: appointment.clinicBranchId,
|
|
865
|
+
}
|
|
866
|
+
);
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
const batch = this.db.batch();
|
|
871
|
+
const patientId = appointment.patientId;
|
|
872
|
+
|
|
873
|
+
// Practitioner link
|
|
874
|
+
const practitionerRef = this.db
|
|
875
|
+
.collection(PRACTITIONERS_COLLECTION)
|
|
876
|
+
.doc(appointment.practitionerId);
|
|
877
|
+
// Clinic link
|
|
878
|
+
const clinicRef = this.db
|
|
879
|
+
.collection(CLINICS_COLLECTION)
|
|
880
|
+
.doc(appointment.clinicBranchId);
|
|
881
|
+
|
|
882
|
+
if (action === "create") {
|
|
883
|
+
// Add patient to practitioner's list of patients (if field exists, e.g., 'patientIds')
|
|
884
|
+
// Assuming a field named 'patientIds' on the practitioner document
|
|
885
|
+
batch.update(practitionerRef, {
|
|
886
|
+
patientIds: admin.firestore.FieldValue.arrayUnion(patientId),
|
|
887
|
+
// Optionally, update a count or last interaction timestamp
|
|
888
|
+
updatedAt: admin.firestore.FieldValue.serverTimestamp() as any,
|
|
889
|
+
});
|
|
890
|
+
Logger.debug(
|
|
891
|
+
`[AggService] Adding patient ${patientId} to practitioner ${appointment.practitionerId} patientIds.`
|
|
892
|
+
);
|
|
893
|
+
|
|
894
|
+
// Add patient to clinic's list of patients (if field exists, e.g., 'patientIds')
|
|
895
|
+
// Assuming a field named 'patientIds' on the clinic document
|
|
896
|
+
batch.update(clinicRef, {
|
|
897
|
+
patientIds: admin.firestore.FieldValue.arrayUnion(patientId),
|
|
898
|
+
updatedAt: admin.firestore.FieldValue.serverTimestamp() as any,
|
|
899
|
+
});
|
|
900
|
+
Logger.debug(
|
|
901
|
+
`[AggService] Adding patient ${patientId} to clinic ${appointment.clinicBranchId} patientIds.`
|
|
902
|
+
);
|
|
903
|
+
} else if (action === "cancel") {
|
|
904
|
+
// Basic removal.
|
|
905
|
+
// TODO: Implement more robust removal logic: only remove if this was the last active/confirmed appointment
|
|
906
|
+
// linking this patient to this practitioner/clinic.
|
|
907
|
+
batch.update(practitionerRef, {
|
|
908
|
+
patientIds: admin.firestore.FieldValue.arrayRemove(patientId),
|
|
909
|
+
updatedAt: admin.firestore.FieldValue.serverTimestamp() as any,
|
|
910
|
+
});
|
|
911
|
+
Logger.debug(
|
|
912
|
+
`[AggService] Removing patient ${patientId} from practitioner ${appointment.practitionerId} patientIds.`
|
|
913
|
+
);
|
|
914
|
+
|
|
915
|
+
batch.update(clinicRef, {
|
|
916
|
+
patientIds: admin.firestore.FieldValue.arrayRemove(patientId),
|
|
917
|
+
updatedAt: admin.firestore.FieldValue.serverTimestamp() as any,
|
|
918
|
+
});
|
|
919
|
+
Logger.debug(
|
|
920
|
+
`[AggService] Removing patient ${patientId} from clinic ${appointment.clinicBranchId} patientIds.`
|
|
921
|
+
);
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
try {
|
|
925
|
+
await batch.commit();
|
|
926
|
+
Logger.info(
|
|
927
|
+
`[AggService] Successfully updated patient-clinic-practitioner links for appointment ${appointment.id}, action: ${action}.`
|
|
928
|
+
);
|
|
929
|
+
} catch (error) {
|
|
930
|
+
Logger.error(
|
|
931
|
+
`[AggService] Error managing patient-clinic-practitioner links for appointment ${appointment.id}:`,
|
|
932
|
+
error
|
|
933
|
+
);
|
|
934
|
+
}
|
|
232
935
|
}
|
|
233
936
|
|
|
234
|
-
// ---
|
|
937
|
+
// --- Data Fetching Helpers (Consider moving to a data access layer or using existing services if available) ---
|
|
938
|
+
private async fetchPatientProfile(
|
|
939
|
+
patientId: string
|
|
940
|
+
): Promise<PatientProfile | null> {
|
|
941
|
+
try {
|
|
942
|
+
const doc = await this.db
|
|
943
|
+
.collection(PATIENTS_COLLECTION)
|
|
944
|
+
.doc(patientId)
|
|
945
|
+
.get();
|
|
946
|
+
return doc.exists ? (doc.data() as PatientProfile) : null;
|
|
947
|
+
} catch (error) {
|
|
948
|
+
Logger.error(
|
|
949
|
+
`[AggService] Error fetching patient profile ${patientId}:`,
|
|
950
|
+
error
|
|
951
|
+
);
|
|
952
|
+
return null;
|
|
953
|
+
}
|
|
954
|
+
}
|
|
235
955
|
|
|
236
956
|
/**
|
|
237
|
-
* Fetches
|
|
238
|
-
* @param
|
|
239
|
-
* @
|
|
240
|
-
* @returns {Promise<RequirementTemplate[]>}
|
|
957
|
+
* Fetches the sensitive information for a given patient ID.
|
|
958
|
+
* @param patientId The ID of the patient to fetch sensitive information for.
|
|
959
|
+
* @returns {Promise<PatientSensitiveInfo | null>} The patient sensitive information or null if not found or an error occurs.
|
|
241
960
|
*/
|
|
242
|
-
private async
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
961
|
+
private async fetchPatientSensitiveInfo(
|
|
962
|
+
patientId: string
|
|
963
|
+
): Promise<PatientSensitiveInfo | null> {
|
|
964
|
+
try {
|
|
965
|
+
// Assuming sensitive info is in a subcollection PATIENT_SENSITIVE_INFO_COLLECTION
|
|
966
|
+
// under the patient's document, and the sensitive info document ID is the patientId itself.
|
|
967
|
+
// If the document ID is fixed (e.g., 'details'), this path should be adjusted.
|
|
968
|
+
const doc = await this.db
|
|
969
|
+
.collection(PATIENTS_COLLECTION)
|
|
970
|
+
.doc(patientId)
|
|
971
|
+
.collection(PATIENT_SENSITIVE_INFO_COLLECTION)
|
|
972
|
+
.doc(patientId) // CONFIRM THIS DOCUMENT ID PATTERN
|
|
973
|
+
.get();
|
|
974
|
+
if (!doc.exists) {
|
|
975
|
+
Logger.warn(
|
|
976
|
+
`[AggService] No sensitive info found for patient ${patientId}`
|
|
977
|
+
);
|
|
978
|
+
return null;
|
|
979
|
+
}
|
|
980
|
+
return doc.data() as PatientSensitiveInfo;
|
|
981
|
+
} catch (error) {
|
|
982
|
+
Logger.error(
|
|
983
|
+
`[AggService] Error fetching patient sensitive info ${patientId}:`,
|
|
984
|
+
error
|
|
985
|
+
);
|
|
986
|
+
return null;
|
|
987
|
+
}
|
|
257
988
|
}
|
|
258
989
|
|
|
259
990
|
/**
|
|
260
|
-
*
|
|
261
|
-
* @param
|
|
262
|
-
* @
|
|
263
|
-
* @param type 'PRE' or 'POST'.
|
|
264
|
-
* @returns {Promise<string>} ID of the created instance.
|
|
991
|
+
* Fetches the profile for a given practitioner ID.
|
|
992
|
+
* @param practitionerId The ID of the practitioner to fetch.
|
|
993
|
+
* @returns {Promise<Practitioner | null>} The practitioner profile or null if not found or an error occurs.
|
|
265
994
|
*/
|
|
266
|
-
private async
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
// type: type, // 'PRE' or 'POST'
|
|
295
|
-
// overallStatus: PatientRequirementOverallStatus.ACTIVE,
|
|
296
|
-
// instructions: instructions,
|
|
297
|
-
// // ... other fields like clinicId, practitionerId if needed from appointment
|
|
298
|
-
// };
|
|
299
|
-
|
|
300
|
-
// await newInstanceRef.set({
|
|
301
|
-
// ...instanceData,
|
|
302
|
-
// createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
|
303
|
-
// updatedAt: admin.firestore.FieldValue.serverTimestamp(),
|
|
304
|
-
// });
|
|
305
|
-
// return newInstanceRef.id;
|
|
306
|
-
return newInstanceRef.id; // Placeholder
|
|
995
|
+
private async fetchPractitionerProfile(
|
|
996
|
+
practitionerId: string
|
|
997
|
+
): Promise<Practitioner | null> {
|
|
998
|
+
if (!practitionerId) {
|
|
999
|
+
Logger.warn(
|
|
1000
|
+
"[AggService] fetchPractitionerProfile called with no practitionerId."
|
|
1001
|
+
);
|
|
1002
|
+
return null;
|
|
1003
|
+
}
|
|
1004
|
+
try {
|
|
1005
|
+
const doc = await this.db
|
|
1006
|
+
.collection(PRACTITIONERS_COLLECTION)
|
|
1007
|
+
.doc(practitionerId)
|
|
1008
|
+
.get();
|
|
1009
|
+
if (!doc.exists) {
|
|
1010
|
+
Logger.warn(
|
|
1011
|
+
`[AggService] No practitioner profile found for ID ${practitionerId}`
|
|
1012
|
+
);
|
|
1013
|
+
return null;
|
|
1014
|
+
}
|
|
1015
|
+
return doc.data() as Practitioner;
|
|
1016
|
+
} catch (error) {
|
|
1017
|
+
Logger.error(
|
|
1018
|
+
`[AggService] Error fetching practitioner profile ${practitionerId}:`,
|
|
1019
|
+
error
|
|
1020
|
+
);
|
|
1021
|
+
return null;
|
|
1022
|
+
}
|
|
307
1023
|
}
|
|
308
1024
|
|
|
309
1025
|
/**
|
|
310
|
-
*
|
|
311
|
-
* @param
|
|
312
|
-
* @
|
|
313
|
-
* @param type 'PRE' or 'POST'.
|
|
314
|
-
* @returns {admin.firestore.Timestamp}
|
|
1026
|
+
* Fetches the information for a given clinic ID.
|
|
1027
|
+
* @param clinicId The ID of the clinic to fetch.
|
|
1028
|
+
* @returns {Promise<Clinic | null>} The clinic information or null if not found or an error occurs.
|
|
315
1029
|
*/
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
1030
|
+
private async fetchClinicInfo(clinicId: string): Promise<Clinic | null> {
|
|
1031
|
+
if (!clinicId) {
|
|
1032
|
+
Logger.warn("[AggService] fetchClinicInfo called with no clinicId.");
|
|
1033
|
+
return null;
|
|
1034
|
+
}
|
|
1035
|
+
try {
|
|
1036
|
+
const doc = await this.db
|
|
1037
|
+
.collection(CLINICS_COLLECTION)
|
|
1038
|
+
.doc(clinicId)
|
|
1039
|
+
.get();
|
|
1040
|
+
if (!doc.exists) {
|
|
1041
|
+
Logger.warn(`[AggService] No clinic info found for ID ${clinicId}`);
|
|
1042
|
+
return null;
|
|
1043
|
+
}
|
|
1044
|
+
return doc.data() as Clinic;
|
|
1045
|
+
} catch (error) {
|
|
1046
|
+
Logger.error(
|
|
1047
|
+
`[AggService] Error fetching clinic info ${clinicId}:`,
|
|
1048
|
+
error
|
|
1049
|
+
);
|
|
1050
|
+
return null;
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
321
1053
|
}
|