@blackcode_sa/metaestetics-api 1.15.16 → 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.
Files changed (42) hide show
  1. package/dist/admin/index.d.mts +377 -222
  2. package/dist/admin/index.d.ts +377 -222
  3. package/dist/admin/index.js +625 -206
  4. package/dist/admin/index.mjs +624 -206
  5. package/dist/backoffice/index.d.mts +24 -0
  6. package/dist/backoffice/index.d.ts +24 -0
  7. package/dist/index.d.mts +371 -4
  8. package/dist/index.d.ts +371 -4
  9. package/dist/index.js +2227 -1580
  10. package/dist/index.mjs +1543 -891
  11. package/package.json +1 -1
  12. package/src/admin/aggregation/appointment/README.md +24 -2
  13. package/src/admin/aggregation/appointment/appointment.aggregation.service.ts +46 -0
  14. package/src/admin/booking/README.md +61 -2
  15. package/src/admin/booking/booking.admin.ts +257 -0
  16. package/src/admin/booking/booking.calculator.ts +139 -1
  17. package/src/admin/booking/booking.types.ts +17 -0
  18. package/src/admin/calendar/README.md +56 -1
  19. package/src/admin/calendar/index.ts +1 -0
  20. package/src/admin/calendar/resource-calendar.admin.ts +198 -0
  21. package/src/config/index.ts +1 -0
  22. package/src/config/tiers.config.ts +116 -0
  23. package/src/services/index.ts +1 -0
  24. package/src/services/plan-config.service.ts +55 -0
  25. package/src/services/resource/README.md +119 -0
  26. package/src/services/resource/index.ts +1 -0
  27. package/src/services/resource/resource.service.ts +555 -0
  28. package/src/services/tier-enforcement.ts +15 -10
  29. package/src/types/appointment/index.ts +7 -0
  30. package/src/types/calendar/index.ts +1 -0
  31. package/src/types/clinic/index.ts +1 -0
  32. package/src/types/clinic/rbac.types.ts +3 -2
  33. package/src/types/index.ts +6 -0
  34. package/src/types/procedure/index.ts +6 -0
  35. package/src/types/resource/README.md +153 -0
  36. package/src/types/resource/index.ts +199 -0
  37. package/src/types/system/index.ts +1 -0
  38. package/src/types/system/planConfig.types.ts +86 -0
  39. package/src/validations/README.md +94 -0
  40. package/src/validations/index.ts +1 -0
  41. package/src/validations/procedure.schema.ts +12 -0
  42. package/src/validations/resource.schema.ts +57 -0
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@blackcode_sa/metaestetics-api",
3
3
  "private": false,
4
- "version": "1.15.16",
4
+ "version": "1.15.17",
5
5
  "description": "Firebase authentication service with anonymous upgrade support",
6
6
  "main": "dist/index.js",
7
7
  "module": "dist/index.mjs",
@@ -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`, and `CalendarAdminService` (though calendar interactions are mostly TODOs).
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
  };