@blackcode_sa/metaestetics-api 1.6.4 → 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 +236 -2
- package/dist/admin/index.d.ts +236 -2
- package/dist/admin/index.js +11251 -10447
- package/dist/admin/index.mjs +11251 -10447
- package/dist/backoffice/index.d.mts +2 -0
- package/dist/backoffice/index.d.ts +2 -0
- package/dist/index.d.mts +50 -77
- package/dist/index.d.ts +50 -77
- package/dist/index.js +77 -305
- package/dist/index.mjs +78 -306
- package/package.json +1 -1
- package/src/admin/aggregation/appointment/README.md +128 -0
- package/src/admin/aggregation/appointment/appointment.aggregation.service.ts +1053 -0
- package/src/admin/booking/README.md +125 -0
- package/src/admin/booking/booking.admin.ts +638 -3
- 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/backoffice/types/product.types.ts +2 -0
- package/src/services/appointment/appointment.service.ts +89 -182
- package/src/services/procedure/procedure.service.ts +1 -0
- package/src/types/appointment/index.ts +3 -1
- package/src/types/notifications/index.ts +4 -2
- package/src/types/procedure/index.ts +7 -0
- package/src/validations/appointment.schema.ts +2 -3
- package/src/validations/procedure.schema.ts +3 -0
|
@@ -0,0 +1,1053 @@
|
|
|
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 {
|
|
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
|
|
32
|
+
|
|
33
|
+
// Dependent Admin Services
|
|
34
|
+
import { PatientRequirementsAdminService } from "../../requirements/patient-requirements.admin.service";
|
|
35
|
+
import { NotificationsAdmin } from "../../notifications/notifications.admin";
|
|
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
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @class AppointmentAggregationService
|
|
45
|
+
* @description Handles aggregation tasks and side effects related to appointment lifecycle events.
|
|
46
|
+
* This service is intended to be used primarily by background functions (e.g., Cloud Functions)
|
|
47
|
+
* triggered by changes in the appointments collection.
|
|
48
|
+
*/
|
|
49
|
+
export class AppointmentAggregationService {
|
|
50
|
+
private db: admin.firestore.Firestore;
|
|
51
|
+
private appointmentMailingService: AppointmentMailingService;
|
|
52
|
+
private notificationsAdmin: NotificationsAdmin;
|
|
53
|
+
private calendarAdminService: CalendarAdminService;
|
|
54
|
+
private patientRequirementsAdminService: PatientRequirementsAdminService;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Constructor for AppointmentAggregationService.
|
|
58
|
+
* @param mailgunClient - An initialized Mailgun client instance.
|
|
59
|
+
* @param firestore Optional Firestore instance. If not provided, it uses the default admin SDK instance.
|
|
60
|
+
*/
|
|
61
|
+
constructor(
|
|
62
|
+
mailgunClient: any, // Type as 'any' for now, to be provided by the calling Cloud Function
|
|
63
|
+
firestore?: admin.firestore.Firestore
|
|
64
|
+
) {
|
|
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);
|
|
72
|
+
this.patientRequirementsAdminService = new PatientRequirementsAdminService(
|
|
73
|
+
this.db
|
|
74
|
+
);
|
|
75
|
+
Logger.info("[AppointmentAggregationService] Initialized.");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
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.
|
|
82
|
+
* @returns {Promise<void>}
|
|
83
|
+
*/
|
|
84
|
+
async handleAppointmentCreate(appointment: Appointment): Promise<void> {
|
|
85
|
+
Logger.info(
|
|
86
|
+
`[AggService] Handling CREATE for appointment: ${appointment.id}, patient: ${appointment.patientId}, status: ${appointment.status}`
|
|
87
|
+
);
|
|
88
|
+
|
|
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");
|
|
93
|
+
|
|
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
|
+
]);
|
|
107
|
+
|
|
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);
|
|
115
|
+
|
|
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
|
+
}
|
|
138
|
+
|
|
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
|
+
}
|
|
189
|
+
|
|
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
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
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.
|
|
209
|
+
* @returns {Promise<void>}
|
|
210
|
+
*/
|
|
211
|
+
async handleAppointmentUpdate(
|
|
212
|
+
before: Appointment,
|
|
213
|
+
after: Appointment
|
|
214
|
+
): 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() !==
|
|
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
|
+
}
|
|
351
|
+
|
|
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);
|
|
359
|
+
|
|
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
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Handles side effects when an appointment is deleted.
|
|
450
|
+
* @param deletedAppointment - The Appointment object that was deleted.
|
|
451
|
+
* @returns {Promise<void>}
|
|
452
|
+
*/
|
|
453
|
+
async handleAppointmentDelete(
|
|
454
|
+
deletedAppointment: Appointment
|
|
455
|
+
): Promise<void> {
|
|
456
|
+
Logger.info(
|
|
457
|
+
`[AggService] Handling DELETE for appointment: ${deletedAppointment.id}`
|
|
458
|
+
);
|
|
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)
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// --- Helper Methods for Aggregation Logic ---
|
|
473
|
+
|
|
474
|
+
/**
|
|
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.
|
|
482
|
+
*/
|
|
483
|
+
private async createPreAppointmentRequirementInstances(
|
|
484
|
+
appointment: Appointment
|
|
485
|
+
): Promise<void> {
|
|
486
|
+
Logger.info(
|
|
487
|
+
`[AggService] Creating PRE-appointment requirement instances for appt: ${appointment.id}, patient: ${appointment.patientId}`
|
|
488
|
+
);
|
|
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
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
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.
|
|
625
|
+
*/
|
|
626
|
+
private async createPostAppointmentRequirementInstances(
|
|
627
|
+
appointment: Appointment
|
|
628
|
+
): Promise<void> {
|
|
629
|
+
Logger.info(
|
|
630
|
+
`[AggService] Creating POST-appointment requirement instances for appt: ${appointment.id}, patient: ${appointment.patientId}`
|
|
631
|
+
);
|
|
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
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/**
|
|
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.
|
|
762
|
+
*/
|
|
763
|
+
private async updateRelatedPatientRequirementInstances(
|
|
764
|
+
appointment: Appointment,
|
|
765
|
+
newOverallStatus: PatientRequirementOverallStatus,
|
|
766
|
+
_previousAppointmentData?: Appointment // Not used in this basic implementation, but kept for signature consistency
|
|
767
|
+
): Promise<void> {
|
|
768
|
+
Logger.info(
|
|
769
|
+
`[AggService] Updating related patient req instances for appt ${appointment.id} (patient: ${appointment.patientId}) to ${newOverallStatus}`
|
|
770
|
+
);
|
|
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
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/**
|
|
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.
|
|
845
|
+
*/
|
|
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}`
|
|
852
|
+
);
|
|
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
|
+
}
|
|
935
|
+
}
|
|
936
|
+
|
|
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
|
+
}
|
|
955
|
+
|
|
956
|
+
/**
|
|
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.
|
|
960
|
+
*/
|
|
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
|
+
}
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
/**
|
|
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.
|
|
994
|
+
*/
|
|
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
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/**
|
|
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.
|
|
1029
|
+
*/
|
|
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
|
+
}
|
|
1053
|
+
}
|