@blackcode_sa/metaestetics-api 1.15.14 → 1.15.17
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 +377 -222
- package/dist/admin/index.d.ts +377 -222
- package/dist/admin/index.js +625 -206
- package/dist/admin/index.mjs +624 -206
- package/dist/backoffice/index.d.mts +24 -0
- package/dist/backoffice/index.d.ts +24 -0
- package/dist/index.d.mts +376 -9
- package/dist/index.d.ts +376 -9
- package/dist/index.js +2228 -1581
- package/dist/index.mjs +1544 -892
- package/package.json +1 -1
- package/src/admin/aggregation/appointment/README.md +24 -2
- package/src/admin/aggregation/appointment/appointment.aggregation.service.ts +46 -0
- package/src/admin/booking/README.md +61 -2
- package/src/admin/booking/booking.admin.ts +257 -0
- package/src/admin/booking/booking.calculator.ts +139 -1
- package/src/admin/booking/booking.types.ts +17 -0
- package/src/admin/calendar/README.md +56 -1
- package/src/admin/calendar/index.ts +1 -0
- package/src/admin/calendar/resource-calendar.admin.ts +198 -0
- package/src/config/index.ts +1 -0
- package/src/config/tiers.config.ts +121 -5
- package/src/services/index.ts +1 -0
- package/src/services/plan-config.service.ts +55 -0
- package/src/services/resource/README.md +119 -0
- package/src/services/resource/index.ts +1 -0
- package/src/services/resource/resource.service.ts +555 -0
- package/src/services/tier-enforcement.ts +16 -11
- package/src/types/appointment/index.ts +7 -0
- package/src/types/calendar/index.ts +1 -0
- package/src/types/clinic/index.ts +1 -0
- package/src/types/clinic/rbac.types.ts +3 -2
- package/src/types/index.ts +6 -0
- package/src/types/procedure/index.ts +6 -0
- package/src/types/resource/README.md +153 -0
- package/src/types/resource/index.ts +199 -0
- package/src/types/system/index.ts +1 -0
- package/src/types/system/planConfig.types.ts +86 -0
- package/src/validations/README.md +94 -0
- package/src/validations/index.ts +1 -0
- package/src/validations/procedure.schema.ts +12 -0
- package/src/validations/resource.schema.ts +57 -0
package/package.json
CHANGED
|
@@ -7,14 +7,16 @@ This service is responsible for handling side effects and data aggregation tasks
|
|
|
7
7
|
- Managing denormalized data links (e.g., patient-clinic, patient-practitioner).
|
|
8
8
|
- Creating and updating `PatientRequirementInstance` documents based on appointment status and associated procedure requirements.
|
|
9
9
|
- Orchestrating notifications (email and push) to patients, practitioners, and clinic admins at various stages of the appointment lifecycle.
|
|
10
|
-
- Interfacing with other admin services like `AppointmentMailingService`, `NotificationsAdmin`,
|
|
10
|
+
- Interfacing with other admin services like `AppointmentMailingService`, `NotificationsAdmin`, `CalendarAdminService`, and `ResourceCalendarAdminService`.
|
|
11
|
+
- Managing resource calendar events for appointments that have booked clinic resources (see [Resource System](../../../../../docs/RESOURCE_SYSTEM.md)).
|
|
11
12
|
|
|
12
13
|
## Implemented Functionality
|
|
13
14
|
|
|
14
15
|
### Constructor
|
|
15
16
|
|
|
16
17
|
- Initializes with a Firestore instance and a Mailgun client.
|
|
17
|
-
- Sets up instances of dependent services: `AppointmentMailingService`, `NotificationsAdmin`, `CalendarAdminService`, and `PatientRequirementsAdminService`.
|
|
18
|
+
- Sets up instances of dependent services: `AppointmentMailingService`, `NotificationsAdmin`, `CalendarAdminService`, `ResourceCalendarAdminService`, and `PatientRequirementsAdminService`.
|
|
19
|
+
- **`ResourceCalendarAdminService`**: Manages resource calendar events (status updates, time updates, deletion) for appointments that have `resourceBookings`. See [Resource System README](../../../../../docs/RESOURCE_SYSTEM.md).
|
|
18
20
|
- **Note**: `PatientRequirementsAdminService` is initialized but its methods are not (and should not be) directly called by this aggregation service for creating/managing requirement instances. That logic is correctly handled by local private methods in this service.
|
|
19
21
|
|
|
20
22
|
### `handleAppointmentCreate(appointment: Appointment)`
|
|
@@ -75,6 +77,26 @@ This service is responsible for handling side effects and data aggregation tasks
|
|
|
75
77
|
- **Data Fetching Helpers (`fetchPatientProfile`, `fetchPatientSensitiveInfo`, `fetchPractitionerProfile`, `fetchClinicInfo`)**:
|
|
76
78
|
- Basic methods to retrieve documents from Firestore by ID.
|
|
77
79
|
|
|
80
|
+
## Resource Calendar Event Handling
|
|
81
|
+
|
|
82
|
+
When an appointment has `resourceBookings` (i.e., the procedure required clinic resources), the aggregation service also manages resource calendar events via `ResourceCalendarAdminService`:
|
|
83
|
+
|
|
84
|
+
| Appointment Transition | Resource Calendar Action |
|
|
85
|
+
|---|---|
|
|
86
|
+
| `PENDING → CONFIRMED` | `resourceCalendarAdmin.updateResourceBookingEventsStatus(after, CONFIRMED)` |
|
|
87
|
+
| `RESCHEDULED_BY_CLINIC → CONFIRMED` | `resourceCalendarAdmin.updateResourceBookingEventsStatus(after, CONFIRMED)` |
|
|
88
|
+
| `Any → CANCELLED / NO_SHOW` | `resourceCalendarAdmin.updateResourceBookingEventsStatus(after, calendarStatus)` |
|
|
89
|
+
| `Any → COMPLETED` | `resourceCalendarAdmin.updateResourceBookingEventsStatus(after, COMPLETED)` |
|
|
90
|
+
| `Any → RESCHEDULED_BY_CLINIC` | `resourceCalendarAdmin.updateResourceBookingEventsTime(after, newTime)` + `updateResourceBookingEventsStatus(after, PENDING)` |
|
|
91
|
+
| Time change (CONFIRMED, no status change) | `resourceCalendarAdmin.updateResourceBookingEventsTime(after, newTime)` |
|
|
92
|
+
| Appointment deleted | `resourceCalendarAdmin.deleteResourceBookingEvents(deletedAppointment)` |
|
|
93
|
+
|
|
94
|
+
All resource operations are guarded by checking `appointment.resourceBookings` existence, so backward-compatible appointments (without resources) are unaffected.
|
|
95
|
+
|
|
96
|
+
For full details on the resource system, see [Resource System README](../../../../../docs/RESOURCE_SYSTEM.md).
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
78
100
|
## Pending `TODO` Items & Areas for Future Work
|
|
79
101
|
|
|
80
102
|
### General / Cross-Cutting
|
|
@@ -33,6 +33,7 @@ import { RequirementSourceProcedure } from '../../../types/patient/patient-requi
|
|
|
33
33
|
import { PatientRequirementsAdminService } from '../../requirements/patient-requirements.admin.service';
|
|
34
34
|
import { NotificationsAdmin } from '../../notifications/notifications.admin';
|
|
35
35
|
import { CalendarAdminService } from '../../calendar/calendar.admin.service';
|
|
36
|
+
import { ResourceCalendarAdminService } from '../../calendar/resource-calendar.admin';
|
|
36
37
|
import { AppointmentMailingService } from '../../mailing/appointment/appointment.mailing.service';
|
|
37
38
|
import { Logger } from '../../logger';
|
|
38
39
|
import { UserRole } from '../../../types';
|
|
@@ -77,6 +78,7 @@ export class AppointmentAggregationService {
|
|
|
77
78
|
private appointmentMailingService: AppointmentMailingService;
|
|
78
79
|
private notificationsAdmin: NotificationsAdmin;
|
|
79
80
|
private calendarAdminService: CalendarAdminService;
|
|
81
|
+
private resourceCalendarAdminService: ResourceCalendarAdminService;
|
|
80
82
|
private patientRequirementsAdminService: PatientRequirementsAdminService;
|
|
81
83
|
|
|
82
84
|
/**
|
|
@@ -95,6 +97,7 @@ export class AppointmentAggregationService {
|
|
|
95
97
|
);
|
|
96
98
|
this.notificationsAdmin = new NotificationsAdmin(this.db);
|
|
97
99
|
this.calendarAdminService = new CalendarAdminService(this.db);
|
|
100
|
+
this.resourceCalendarAdminService = new ResourceCalendarAdminService(this.db);
|
|
98
101
|
this.patientRequirementsAdminService = new PatientRequirementsAdminService(this.db);
|
|
99
102
|
Logger.info('[AppointmentAggregationService] Initialized.');
|
|
100
103
|
}
|
|
@@ -271,6 +274,12 @@ export class AppointmentAggregationService {
|
|
|
271
274
|
CalendarEventStatus.CONFIRMED,
|
|
272
275
|
);
|
|
273
276
|
|
|
277
|
+
// Also confirm resource calendar events
|
|
278
|
+
await this.resourceCalendarAdminService.updateResourceBookingEventsStatus(
|
|
279
|
+
after,
|
|
280
|
+
CalendarEventStatus.CONFIRMED,
|
|
281
|
+
);
|
|
282
|
+
|
|
274
283
|
// Send confirmation notifications
|
|
275
284
|
if (patientSensitiveInfo?.email && patientProfile) {
|
|
276
285
|
Logger.info(
|
|
@@ -334,6 +343,12 @@ export class AppointmentAggregationService {
|
|
|
334
343
|
CalendarEventStatus.CONFIRMED,
|
|
335
344
|
);
|
|
336
345
|
|
|
346
|
+
// Also confirm resource calendar events
|
|
347
|
+
await this.resourceCalendarAdminService.updateResourceBookingEventsStatus(
|
|
348
|
+
after,
|
|
349
|
+
CalendarEventStatus.CONFIRMED,
|
|
350
|
+
);
|
|
351
|
+
|
|
337
352
|
// Send confirmation notifications (similar to PENDING -> CONFIRMED)
|
|
338
353
|
if (patientSensitiveInfo?.email && patientProfile) {
|
|
339
354
|
Logger.info(
|
|
@@ -416,6 +431,12 @@ export class AppointmentAggregationService {
|
|
|
416
431
|
calendarStatus(after.status),
|
|
417
432
|
);
|
|
418
433
|
|
|
434
|
+
// Also cancel resource calendar events
|
|
435
|
+
await this.resourceCalendarAdminService.updateResourceBookingEventsStatus(
|
|
436
|
+
after,
|
|
437
|
+
calendarStatus(after.status),
|
|
438
|
+
);
|
|
439
|
+
|
|
419
440
|
// Send cancellation email to Patient
|
|
420
441
|
if (patientSensitiveInfo?.email && patientProfile) {
|
|
421
442
|
Logger.info(
|
|
@@ -472,6 +493,12 @@ export class AppointmentAggregationService {
|
|
|
472
493
|
CalendarEventStatus.COMPLETED,
|
|
473
494
|
);
|
|
474
495
|
|
|
496
|
+
// Also complete resource calendar events
|
|
497
|
+
await this.resourceCalendarAdminService.updateResourceBookingEventsStatus(
|
|
498
|
+
after,
|
|
499
|
+
CalendarEventStatus.COMPLETED,
|
|
500
|
+
);
|
|
501
|
+
|
|
475
502
|
// Send review request email to patient
|
|
476
503
|
if (patientSensitiveInfo?.email && patientProfile) {
|
|
477
504
|
Logger.info(
|
|
@@ -508,6 +535,16 @@ export class AppointmentAggregationService {
|
|
|
508
535
|
CalendarEventStatus.PENDING,
|
|
509
536
|
);
|
|
510
537
|
|
|
538
|
+
// Also update resource calendar events with new time and PENDING status
|
|
539
|
+
await this.resourceCalendarAdminService.updateResourceBookingEventsTime(after, {
|
|
540
|
+
start: after.appointmentStartTime,
|
|
541
|
+
end: after.appointmentEndTime,
|
|
542
|
+
});
|
|
543
|
+
await this.resourceCalendarAdminService.updateResourceBookingEventsStatus(
|
|
544
|
+
after,
|
|
545
|
+
CalendarEventStatus.PENDING,
|
|
546
|
+
);
|
|
547
|
+
|
|
511
548
|
// Send reschedule proposal email to patient
|
|
512
549
|
if (patientSensitiveInfo?.email && patientProfile) {
|
|
513
550
|
Logger.info(
|
|
@@ -563,6 +600,12 @@ export class AppointmentAggregationService {
|
|
|
563
600
|
start: after.appointmentStartTime,
|
|
564
601
|
end: after.appointmentEndTime,
|
|
565
602
|
});
|
|
603
|
+
|
|
604
|
+
// Also update resource calendar event times
|
|
605
|
+
await this.resourceCalendarAdminService.updateResourceBookingEventsTime(after, {
|
|
606
|
+
start: after.appointmentStartTime,
|
|
607
|
+
end: after.appointmentEndTime,
|
|
608
|
+
});
|
|
566
609
|
} else {
|
|
567
610
|
Logger.warn(
|
|
568
611
|
`[AggService] Independent time change detected for ${after.id} with status ${after.status}. Review implications for requirements and calendar.`,
|
|
@@ -629,6 +672,9 @@ export class AppointmentAggregationService {
|
|
|
629
672
|
// Delete all associated calendar events
|
|
630
673
|
await this.calendarAdminService.deleteAppointmentCalendarEvents(deletedAppointment);
|
|
631
674
|
|
|
675
|
+
// Also delete resource calendar events
|
|
676
|
+
await this.resourceCalendarAdminService.deleteResourceBookingEvents(deletedAppointment);
|
|
677
|
+
|
|
632
678
|
// TODO: Send cancellation/deletion notifications if appropriate (though data is gone)
|
|
633
679
|
}
|
|
634
680
|
|
|
@@ -117,9 +117,68 @@ async orchestrateAppointmentCreation(
|
|
|
117
117
|
|
|
118
118
|
---
|
|
119
119
|
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
## Resource System Integration
|
|
123
|
+
|
|
124
|
+
### Resource Availability in `getAvailableBookingSlots`
|
|
125
|
+
|
|
126
|
+
When a procedure has `resourceRequirements`, the method performs additional steps:
|
|
127
|
+
|
|
128
|
+
1. **Resource Data Fetching**: For each resource requirement on the procedure:
|
|
129
|
+
- Fetches the resource document and validates it is `active`
|
|
130
|
+
- Fetches all active instances of the resource
|
|
131
|
+
- For each instance, fetches calendar events within the timeframe (with 30-day lookback)
|
|
132
|
+
- Converts admin timestamps to client-compatible format
|
|
133
|
+
|
|
134
|
+
2. **Builds `resourceCalendarEventsMap`**: A map keyed by `resourceId`, containing:
|
|
135
|
+
```typescript
|
|
136
|
+
{
|
|
137
|
+
resourceId: string;
|
|
138
|
+
resourceName: string;
|
|
139
|
+
quantity: number;
|
|
140
|
+
instanceEvents: Record<string, ResourceCalendarEvent[]>; // keyed by instanceId
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
3. **Passes to Calculator**: The map is included in the `BookingAvailabilityRequest` passed to `BookingAvailabilityCalculator.calculateSlots()`.
|
|
145
|
+
|
|
146
|
+
### Resource Allocation in `orchestrateAppointmentCreation`
|
|
147
|
+
|
|
148
|
+
After creating practitioner/patient/clinic calendar events, if the procedure has `resourceRequirements`:
|
|
149
|
+
|
|
150
|
+
1. For each requirement, iterates active instances sorted by index
|
|
151
|
+
2. For each instance, queries for overlapping events with active statuses (`PENDING`, `CONFIRMED`, `CHECKED_IN`, `IN_PROGRESS`)
|
|
152
|
+
3. Picks the **first free instance** (no overlapping active events)
|
|
153
|
+
4. Creates a `ResourceCalendarEvent` on the instance's calendar subcollection via the batch
|
|
154
|
+
5. Collects `ResourceBookingInfo` entries and adds them to the appointment document
|
|
155
|
+
6. If no free instance is found, returns `{ success: false, error: "Resource {name} not available..." }`
|
|
156
|
+
|
|
157
|
+
### BookingAvailabilityCalculator (`booking.calculator.ts`)
|
|
158
|
+
|
|
159
|
+
**Step 5 (new)**: After subtracting practitioner busy times:
|
|
160
|
+
|
|
161
|
+
- For each resource in `resourceCalendarEventsMap`:
|
|
162
|
+
- Compute each instance's availability:
|
|
163
|
+
- Subtract **blocking events** (any event with `eventType` BLOCKING/BREAK/FREE_DAY) regardless of status
|
|
164
|
+
- Subtract **active booking events** (PENDING or CONFIRMED status) — events without `eventType` fall through to this check for backward compatibility
|
|
165
|
+
- **Union** all instance availabilities (at least one must be free)
|
|
166
|
+
- **Intersect** all resource availabilities together
|
|
167
|
+
- Intersect with doctor availability → final availability
|
|
168
|
+
|
|
169
|
+
Key private methods:
|
|
170
|
+
- `applyResourceAvailability()`: Applies resource constraints to available intervals
|
|
171
|
+
- `computeResourceAvailability()`: Computes union of instance availabilities for a single resource
|
|
172
|
+
- `unionIntervals()`: Merges overlapping intervals from multiple instances
|
|
173
|
+
|
|
174
|
+
See [Resource System README](../../../../docs/RESOURCE_SYSTEM.md) for the full algorithm detail.
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
120
178
|
### Dependencies
|
|
121
179
|
|
|
122
180
|
- **`DocumentManagerAdminService`**: Used to handle the creation and management of `FilledDocument` instances associated with appointments.
|
|
181
|
+
- **`ResourceService`**: Used to fetch resources, instances, and calendar events for availability calculations and allocation.
|
|
123
182
|
- **Firebase Admin SDK**: For all Firestore interactions.
|
|
124
|
-
- **Various Type Definitions**: From `../../types/` for `Appointment`, `CalendarEvent`, `Procedure`, `Clinic`, `Practitioner`, `Patient`, `DocumentTemplate`, `LinkedFormInfo`, etc.
|
|
125
|
-
- **`BookingAvailabilityCalculator`**: For calculating available booking slots.
|
|
183
|
+
- **Various Type Definitions**: From `../../types/` for `Appointment`, `CalendarEvent`, `Procedure`, `Clinic`, `Practitioner`, `Patient`, `DocumentTemplate`, `LinkedFormInfo`, `Resource`, `ResourceInstance`, `ResourceCalendarEvent`, `ResourceBookingInfo`, etc.
|
|
184
|
+
- **`BookingAvailabilityCalculator`**: For calculating available booking slots (including resource constraints).
|
|
@@ -65,6 +65,16 @@ import { DocumentManagerAdminService } from "../documentation-templates/document
|
|
|
65
65
|
import { LinkedFormInfo } from "../../types/appointment";
|
|
66
66
|
import { TimestampUtils } from "../../utils/TimestampUtils";
|
|
67
67
|
import { Logger } from "../logger";
|
|
68
|
+
import {
|
|
69
|
+
Resource,
|
|
70
|
+
ResourceInstance,
|
|
71
|
+
ResourceCalendarEvent,
|
|
72
|
+
ResourceBookingInfo,
|
|
73
|
+
ResourceStatus,
|
|
74
|
+
RESOURCES_COLLECTION,
|
|
75
|
+
RESOURCE_INSTANCES_SUBCOLLECTION,
|
|
76
|
+
RESOURCE_CALENDAR_SUBCOLLECTION,
|
|
77
|
+
} from "../../types/resource";
|
|
68
78
|
|
|
69
79
|
/**
|
|
70
80
|
* Interface for the data required by orchestrateAppointmentCreation
|
|
@@ -214,6 +224,125 @@ export class BookingAdmin {
|
|
|
214
224
|
count: practitionerCalendarEvents.length,
|
|
215
225
|
});
|
|
216
226
|
|
|
227
|
+
// 6. Fetch resource calendar events (if procedure requires resources)
|
|
228
|
+
let resourceCalendarEventsMap:
|
|
229
|
+
| BookingAvailabilityRequest["resourceCalendarEventsMap"]
|
|
230
|
+
| undefined;
|
|
231
|
+
if (
|
|
232
|
+
procedure.resourceRequirements &&
|
|
233
|
+
procedure.resourceRequirements.length > 0
|
|
234
|
+
) {
|
|
235
|
+
Logger.debug(
|
|
236
|
+
"[BookingAdmin] Procedure has resource requirements, fetching resource data",
|
|
237
|
+
{
|
|
238
|
+
resourceRequirementCount: procedure.resourceRequirements.length,
|
|
239
|
+
resourceIds: procedure.resourceRequirements.map(
|
|
240
|
+
(r) => r.resourceId
|
|
241
|
+
),
|
|
242
|
+
}
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
resourceCalendarEventsMap = {};
|
|
246
|
+
|
|
247
|
+
for (const requirement of procedure.resourceRequirements) {
|
|
248
|
+
// Fetch resource document
|
|
249
|
+
const resourceDoc = await this.db
|
|
250
|
+
.collection(CLINICS_COLLECTION)
|
|
251
|
+
.doc(clinicId)
|
|
252
|
+
.collection(RESOURCES_COLLECTION)
|
|
253
|
+
.doc(requirement.resourceId)
|
|
254
|
+
.get();
|
|
255
|
+
|
|
256
|
+
if (!resourceDoc.exists) {
|
|
257
|
+
Logger.warn(
|
|
258
|
+
"[BookingAdmin] Required resource not found, skipping",
|
|
259
|
+
{
|
|
260
|
+
resourceId: requirement.resourceId,
|
|
261
|
+
resourceName: requirement.resourceName,
|
|
262
|
+
}
|
|
263
|
+
);
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const resource = resourceDoc.data() as Resource;
|
|
268
|
+
if (resource.status !== ResourceStatus.ACTIVE) {
|
|
269
|
+
Logger.warn(
|
|
270
|
+
"[BookingAdmin] Required resource is not active, skipping",
|
|
271
|
+
{
|
|
272
|
+
resourceId: requirement.resourceId,
|
|
273
|
+
resourceStatus: resource.status,
|
|
274
|
+
}
|
|
275
|
+
);
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Fetch all active instances
|
|
280
|
+
const instancesSnapshot = await this.db
|
|
281
|
+
.collection(CLINICS_COLLECTION)
|
|
282
|
+
.doc(clinicId)
|
|
283
|
+
.collection(RESOURCES_COLLECTION)
|
|
284
|
+
.doc(requirement.resourceId)
|
|
285
|
+
.collection(RESOURCE_INSTANCES_SUBCOLLECTION)
|
|
286
|
+
.where("status", "==", ResourceStatus.ACTIVE)
|
|
287
|
+
.get();
|
|
288
|
+
|
|
289
|
+
const instanceEvents: Record<string, ResourceCalendarEvent[]> = {};
|
|
290
|
+
|
|
291
|
+
for (const instanceDoc of instancesSnapshot.docs) {
|
|
292
|
+
const instanceId = instanceDoc.id;
|
|
293
|
+
|
|
294
|
+
// Fetch calendar events for this instance in the timeframe
|
|
295
|
+
const MAX_EVENT_DURATION_MS = 30 * 24 * 60 * 60 * 1000;
|
|
296
|
+
const queryStart = admin.firestore.Timestamp.fromMillis(
|
|
297
|
+
start.toMillis() - MAX_EVENT_DURATION_MS
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
const eventsSnapshot = await this.db
|
|
301
|
+
.collection(CLINICS_COLLECTION)
|
|
302
|
+
.doc(clinicId)
|
|
303
|
+
.collection(RESOURCES_COLLECTION)
|
|
304
|
+
.doc(requirement.resourceId)
|
|
305
|
+
.collection(RESOURCE_INSTANCES_SUBCOLLECTION)
|
|
306
|
+
.doc(instanceId)
|
|
307
|
+
.collection(RESOURCE_CALENDAR_SUBCOLLECTION)
|
|
308
|
+
.where("eventTime.start", ">=", queryStart)
|
|
309
|
+
.where("eventTime.start", "<=", end)
|
|
310
|
+
.orderBy("eventTime.start")
|
|
311
|
+
.get();
|
|
312
|
+
|
|
313
|
+
// Post-filter for overlapping events and convert timestamps
|
|
314
|
+
const events = eventsSnapshot.docs
|
|
315
|
+
.map((doc) => ({ ...doc.data(), id: doc.id } as any))
|
|
316
|
+
.filter(
|
|
317
|
+
(event: any) =>
|
|
318
|
+
event.eventTime.end.toMillis() > start.toMillis()
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
// Convert admin timestamps to client timestamps
|
|
322
|
+
instanceEvents[instanceId] = this.convertEventsTimestamps(
|
|
323
|
+
events
|
|
324
|
+
) as unknown as ResourceCalendarEvent[];
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
resourceCalendarEventsMap[requirement.resourceId] = {
|
|
328
|
+
resourceId: requirement.resourceId,
|
|
329
|
+
resourceName: requirement.resourceName,
|
|
330
|
+
quantity: resource.quantity,
|
|
331
|
+
instanceEvents,
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
Logger.debug("[BookingAdmin] Fetched resource calendar data", {
|
|
335
|
+
resourceId: requirement.resourceId,
|
|
336
|
+
resourceName: requirement.resourceName,
|
|
337
|
+
instanceCount: instancesSnapshot.size,
|
|
338
|
+
totalEvents: Object.values(instanceEvents).reduce(
|
|
339
|
+
(sum, events) => sum + events.length,
|
|
340
|
+
0
|
|
341
|
+
),
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
217
346
|
// Since we're working with two different Timestamp implementations (admin vs client),
|
|
218
347
|
// we need to convert our timestamps to the client-side format expected by the calculator
|
|
219
348
|
// Create client Timestamp objects from admin Timestamp objects
|
|
@@ -234,6 +363,7 @@ export class BookingAdmin {
|
|
|
234
363
|
practitionerCalendarEvents
|
|
235
364
|
),
|
|
236
365
|
tz: clinic.location.tz || "UTC",
|
|
366
|
+
...(resourceCalendarEventsMap && { resourceCalendarEventsMap }),
|
|
237
367
|
};
|
|
238
368
|
|
|
239
369
|
Logger.info("[BookingAdmin] Calling availability calculator", {
|
|
@@ -243,6 +373,9 @@ export class BookingAdmin {
|
|
|
243
373
|
),
|
|
244
374
|
clinicEventsCount: clinicCalendarEvents.length,
|
|
245
375
|
practitionerEventsCount: practitionerCalendarEvents.length,
|
|
376
|
+
resourceRequirementCount:
|
|
377
|
+
procedure.resourceRequirements?.length || 0,
|
|
378
|
+
hasResourceData: !!resourceCalendarEventsMap,
|
|
246
379
|
});
|
|
247
380
|
|
|
248
381
|
// Use the calculator to compute available slots
|
|
@@ -850,6 +983,129 @@ export class BookingAdmin {
|
|
|
850
983
|
clinicCalendarEventData
|
|
851
984
|
);
|
|
852
985
|
|
|
986
|
+
// --- Resource Allocation (if procedure requires resources) ---
|
|
987
|
+
let resourceBookings: ResourceBookingInfo[] = [];
|
|
988
|
+
if (
|
|
989
|
+
procedure.resourceRequirements &&
|
|
990
|
+
procedure.resourceRequirements.length > 0
|
|
991
|
+
) {
|
|
992
|
+
console.log(
|
|
993
|
+
`[BookingAdmin] Allocating resources for appointment ${newAppointmentId}, ` +
|
|
994
|
+
`${procedure.resourceRequirements.length} resource(s) required`
|
|
995
|
+
);
|
|
996
|
+
|
|
997
|
+
for (const requirement of procedure.resourceRequirements) {
|
|
998
|
+
// Fetch all active instances of this resource, sorted by index
|
|
999
|
+
const instancesSnapshot = await this.db
|
|
1000
|
+
.collection(CLINICS_COLLECTION)
|
|
1001
|
+
.doc(clinicData.id)
|
|
1002
|
+
.collection(RESOURCES_COLLECTION)
|
|
1003
|
+
.doc(requirement.resourceId)
|
|
1004
|
+
.collection(RESOURCE_INSTANCES_SUBCOLLECTION)
|
|
1005
|
+
.where("status", "==", ResourceStatus.ACTIVE)
|
|
1006
|
+
.orderBy("index")
|
|
1007
|
+
.get();
|
|
1008
|
+
|
|
1009
|
+
if (instancesSnapshot.empty) {
|
|
1010
|
+
return {
|
|
1011
|
+
success: false,
|
|
1012
|
+
error: `Resource "${requirement.resourceName}" has no active instances available.`,
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
let allocatedInstance: ResourceInstance | null = null;
|
|
1017
|
+
|
|
1018
|
+
for (const instanceDoc of instancesSnapshot.docs) {
|
|
1019
|
+
const instance = {
|
|
1020
|
+
...instanceDoc.data(),
|
|
1021
|
+
id: instanceDoc.id,
|
|
1022
|
+
} as ResourceInstance;
|
|
1023
|
+
|
|
1024
|
+
// Check if this instance has overlapping active events during appointment time
|
|
1025
|
+
const overlappingEventsSnapshot = await this.db
|
|
1026
|
+
.collection(CLINICS_COLLECTION)
|
|
1027
|
+
.doc(clinicData.id)
|
|
1028
|
+
.collection(RESOURCES_COLLECTION)
|
|
1029
|
+
.doc(requirement.resourceId)
|
|
1030
|
+
.collection(RESOURCE_INSTANCES_SUBCOLLECTION)
|
|
1031
|
+
.doc(instance.id)
|
|
1032
|
+
.collection(RESOURCE_CALENDAR_SUBCOLLECTION)
|
|
1033
|
+
.where("eventTime.start", "<", data.appointmentEndTime)
|
|
1034
|
+
.where("status", "in", [
|
|
1035
|
+
CalendarEventStatus.PENDING,
|
|
1036
|
+
CalendarEventStatus.CONFIRMED,
|
|
1037
|
+
CalendarEventStatus.CHECKED_IN,
|
|
1038
|
+
CalendarEventStatus.IN_PROGRESS,
|
|
1039
|
+
])
|
|
1040
|
+
.get();
|
|
1041
|
+
|
|
1042
|
+
// Post-filter: event must end after appointment start to truly overlap
|
|
1043
|
+
const hasOverlap = overlappingEventsSnapshot.docs.some((doc) => {
|
|
1044
|
+
const event = doc.data();
|
|
1045
|
+
return (
|
|
1046
|
+
event.eventTime.end.toMillis() >
|
|
1047
|
+
data.appointmentStartTime.toMillis()
|
|
1048
|
+
);
|
|
1049
|
+
});
|
|
1050
|
+
|
|
1051
|
+
if (!hasOverlap) {
|
|
1052
|
+
allocatedInstance = instance;
|
|
1053
|
+
break;
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
if (!allocatedInstance) {
|
|
1058
|
+
return {
|
|
1059
|
+
success: false,
|
|
1060
|
+
error: `Resource "${requirement.resourceName}" is not available at the selected time. All instances are booked.`,
|
|
1061
|
+
};
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
// Create ResourceCalendarEvent on the allocated instance's calendar
|
|
1065
|
+
const resourceCalendarEventRef = this.db
|
|
1066
|
+
.collection(CLINICS_COLLECTION)
|
|
1067
|
+
.doc(clinicData.id)
|
|
1068
|
+
.collection(RESOURCES_COLLECTION)
|
|
1069
|
+
.doc(requirement.resourceId)
|
|
1070
|
+
.collection(RESOURCE_INSTANCES_SUBCOLLECTION)
|
|
1071
|
+
.doc(allocatedInstance.id)
|
|
1072
|
+
.collection(RESOURCE_CALENDAR_SUBCOLLECTION)
|
|
1073
|
+
.doc();
|
|
1074
|
+
|
|
1075
|
+
const resourceCalendarEventData = {
|
|
1076
|
+
id: resourceCalendarEventRef.id,
|
|
1077
|
+
resourceId: requirement.resourceId,
|
|
1078
|
+
resourceInstanceId: allocatedInstance.id,
|
|
1079
|
+
clinicBranchId: clinicData.id,
|
|
1080
|
+
eventType: CalendarEventType.RESOURCE_BOOKING,
|
|
1081
|
+
appointmentId: newAppointmentId,
|
|
1082
|
+
procedureId: procedure.id,
|
|
1083
|
+
practitionerId: practitionerData.id,
|
|
1084
|
+
patientId: data.patientId,
|
|
1085
|
+
eventTime: eventTimeForCalendarEvents,
|
|
1086
|
+
status: initialCalendarEventStatus,
|
|
1087
|
+
eventName: `${procedure.name} - ${allocatedInstance.label}`,
|
|
1088
|
+
createdAt: serverTimestampValue,
|
|
1089
|
+
updatedAt: serverTimestampValue,
|
|
1090
|
+
};
|
|
1091
|
+
|
|
1092
|
+
batch.set(resourceCalendarEventRef, resourceCalendarEventData);
|
|
1093
|
+
|
|
1094
|
+
resourceBookings.push({
|
|
1095
|
+
resourceId: requirement.resourceId,
|
|
1096
|
+
resourceName: requirement.resourceName,
|
|
1097
|
+
resourceInstanceId: allocatedInstance.id,
|
|
1098
|
+
resourceInstanceLabel: allocatedInstance.label,
|
|
1099
|
+
calendarEventId: resourceCalendarEventRef.id,
|
|
1100
|
+
});
|
|
1101
|
+
|
|
1102
|
+
console.log(
|
|
1103
|
+
`[BookingAdmin] Allocated resource "${requirement.resourceName}" → ` +
|
|
1104
|
+
`instance "${allocatedInstance.label}" (${allocatedInstance.id})`
|
|
1105
|
+
);
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
|
|
853
1109
|
// --- Initialize Pending/Draft Filled Documents and get form IDs ---
|
|
854
1110
|
let initializedFormsInfo: LinkedFormInfo[] = [];
|
|
855
1111
|
let pendingUserFormTemplateIds: string[] = [];
|
|
@@ -948,6 +1204,7 @@ export class BookingAdmin {
|
|
|
948
1204
|
isRecurring: false,
|
|
949
1205
|
recurringAppointmentId: null,
|
|
950
1206
|
isArchived: false,
|
|
1207
|
+
...(resourceBookings.length > 0 && { resourceBookings }),
|
|
951
1208
|
createdAt: adminTsNow as any,
|
|
952
1209
|
updatedAt: adminTsNow as any,
|
|
953
1210
|
};
|