@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
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
} from "../../types/calendar";
|
|
14
14
|
import { PractitionerClinicWorkingHours } from "../../types/practitioner";
|
|
15
15
|
import { Clinic } from "../../types/clinic";
|
|
16
|
+
import { ResourceCalendarEvent } from "../../types/resource";
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* Calculator for determining available booking slots
|
|
@@ -88,11 +89,20 @@ export class BookingAvailabilityCalculator {
|
|
|
88
89
|
practitionerCalendarEvents
|
|
89
90
|
);
|
|
90
91
|
|
|
92
|
+
// Step 5: Apply resource availability constraints (if procedure requires resources)
|
|
93
|
+
if (request.resourceCalendarEventsMap) {
|
|
94
|
+
availableIntervals = this.applyResourceAvailability(
|
|
95
|
+
availableIntervals,
|
|
96
|
+
request.resourceCalendarEventsMap,
|
|
97
|
+
timeframe
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
91
101
|
console.log(
|
|
92
102
|
`After all filters, have ${availableIntervals.length} available intervals`
|
|
93
103
|
);
|
|
94
104
|
|
|
95
|
-
// Step
|
|
105
|
+
// Step 6: Generate available slots based on scheduling interval and procedure duration
|
|
96
106
|
const availableSlots = this.generateAvailableSlots(
|
|
97
107
|
availableIntervals,
|
|
98
108
|
schedulingIntervalMinutes,
|
|
@@ -709,4 +719,132 @@ export class BookingAvailabilityCalculator {
|
|
|
709
719
|
|
|
710
720
|
return result;
|
|
711
721
|
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* Apply resource availability constraints to the available intervals.
|
|
725
|
+
* For each required resource:
|
|
726
|
+
* - Compute each instance's availability (full timeframe minus its busy events)
|
|
727
|
+
* - Union all instance availabilities (at least one must be free)
|
|
728
|
+
* Then intersect all resource availabilities with the current doctor availability.
|
|
729
|
+
*
|
|
730
|
+
* @param intervals - Current available intervals (doctor availability)
|
|
731
|
+
* @param resourceEventsMap - Map of resource data with per-instance calendar events
|
|
732
|
+
* @param timeframe - Overall timeframe being considered
|
|
733
|
+
* @returns Intervals where doctor AND all required resources are available
|
|
734
|
+
*/
|
|
735
|
+
private static applyResourceAvailability(
|
|
736
|
+
intervals: TimeInterval[],
|
|
737
|
+
resourceEventsMap: Record<
|
|
738
|
+
string,
|
|
739
|
+
{
|
|
740
|
+
resourceId: string;
|
|
741
|
+
resourceName: string;
|
|
742
|
+
quantity: number;
|
|
743
|
+
instanceEvents: Record<string, ResourceCalendarEvent[]>;
|
|
744
|
+
}
|
|
745
|
+
>,
|
|
746
|
+
timeframe: { start: Timestamp; end: Timestamp }
|
|
747
|
+
): TimeInterval[] {
|
|
748
|
+
if (!intervals.length) return [];
|
|
749
|
+
|
|
750
|
+
const resourceIds = Object.keys(resourceEventsMap);
|
|
751
|
+
if (resourceIds.length === 0) return intervals;
|
|
752
|
+
|
|
753
|
+
console.log(
|
|
754
|
+
`Applying resource availability for ${resourceIds.length} resource(s)`
|
|
755
|
+
);
|
|
756
|
+
|
|
757
|
+
let result = intervals;
|
|
758
|
+
|
|
759
|
+
// For each required resource, compute its combined availability and intersect
|
|
760
|
+
for (const resourceId of resourceIds) {
|
|
761
|
+
const resourceData = resourceEventsMap[resourceId];
|
|
762
|
+
const resourceAvailability = this.computeResourceAvailability(
|
|
763
|
+
resourceData.instanceEvents,
|
|
764
|
+
timeframe
|
|
765
|
+
);
|
|
766
|
+
|
|
767
|
+
console.log(
|
|
768
|
+
`Resource "${resourceData.resourceName}" (qty=${resourceData.quantity}): ${resourceAvailability.length} available intervals`
|
|
769
|
+
);
|
|
770
|
+
|
|
771
|
+
// Intersect doctor availability with this resource's availability
|
|
772
|
+
result = this.intersectIntervals(result, resourceAvailability);
|
|
773
|
+
|
|
774
|
+
if (result.length === 0) {
|
|
775
|
+
console.log(
|
|
776
|
+
`No availability remaining after applying resource "${resourceData.resourceName}"`
|
|
777
|
+
);
|
|
778
|
+
return [];
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
return result;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* Compute combined availability for a single resource across all its instances.
|
|
787
|
+
* For each instance, subtract its busy events from the full timeframe.
|
|
788
|
+
* Then union all instance availabilities — if at least one instance is free, the resource is available.
|
|
789
|
+
*
|
|
790
|
+
* @param instanceEvents - Calendar events keyed by instanceId
|
|
791
|
+
* @param timeframe - Overall timeframe
|
|
792
|
+
* @returns Combined availability intervals for the resource
|
|
793
|
+
*/
|
|
794
|
+
private static computeResourceAvailability(
|
|
795
|
+
instanceEvents: Record<string, ResourceCalendarEvent[]>,
|
|
796
|
+
timeframe: { start: Timestamp; end: Timestamp }
|
|
797
|
+
): TimeInterval[] {
|
|
798
|
+
const instanceIds = Object.keys(instanceEvents);
|
|
799
|
+
if (instanceIds.length === 0) {
|
|
800
|
+
// No instances means the full timeframe is available (no constraints)
|
|
801
|
+
return [{ start: timeframe.start, end: timeframe.end }];
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// Collect availability intervals from all instances
|
|
805
|
+
const allInstanceAvailabilities: TimeInterval[] = [];
|
|
806
|
+
|
|
807
|
+
for (const instanceId of instanceIds) {
|
|
808
|
+
const events = instanceEvents[instanceId];
|
|
809
|
+
// Start with full timeframe
|
|
810
|
+
let instanceAvailability: TimeInterval[] = [
|
|
811
|
+
{ start: timeframe.start, end: timeframe.end },
|
|
812
|
+
];
|
|
813
|
+
|
|
814
|
+
// Subtract each busy event from this instance's availability
|
|
815
|
+
for (const event of events) {
|
|
816
|
+
// Blocking events always subtract from availability regardless of status.
|
|
817
|
+
// Booking events only subtract when actively held (pending or confirmed).
|
|
818
|
+
const isBlockingEvent =
|
|
819
|
+
(event as any).eventType === CalendarEventType.BLOCKING ||
|
|
820
|
+
(event as any).eventType === CalendarEventType.BREAK ||
|
|
821
|
+
(event as any).eventType === CalendarEventType.FREE_DAY;
|
|
822
|
+
|
|
823
|
+
const isActiveBooking =
|
|
824
|
+
event.status === CalendarEventStatus.PENDING ||
|
|
825
|
+
event.status === CalendarEventStatus.CONFIRMED;
|
|
826
|
+
|
|
827
|
+
if (isBlockingEvent || isActiveBooking) {
|
|
828
|
+
const busyInterval: TimeInterval = {
|
|
829
|
+
start: event.eventTime.start,
|
|
830
|
+
end: event.eventTime.end,
|
|
831
|
+
};
|
|
832
|
+
|
|
833
|
+
const newAvailability: TimeInterval[] = [];
|
|
834
|
+
for (const interval of instanceAvailability) {
|
|
835
|
+
newAvailability.push(
|
|
836
|
+
...this.subtractInterval(interval, busyInterval)
|
|
837
|
+
);
|
|
838
|
+
}
|
|
839
|
+
instanceAvailability = newAvailability;
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// Add this instance's available intervals to the combined pool
|
|
844
|
+
allInstanceAvailabilities.push(...instanceAvailability);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// Union (merge) all instance availabilities — at least one instance being free is sufficient
|
|
848
|
+
return this.mergeOverlappingIntervals(allInstanceAvailabilities);
|
|
849
|
+
}
|
|
712
850
|
}
|
|
@@ -3,6 +3,7 @@ import { Clinic } from "../../types/clinic";
|
|
|
3
3
|
import { Practitioner } from "../../types/practitioner";
|
|
4
4
|
import { Procedure } from "../../types/procedure";
|
|
5
5
|
import { CalendarEvent } from "../../types/calendar";
|
|
6
|
+
import { ResourceCalendarEvent } from "../../types/resource";
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Request parameters for calculating available booking slots
|
|
@@ -31,6 +32,22 @@ export interface BookingAvailabilityRequest {
|
|
|
31
32
|
|
|
32
33
|
/** IANA timezone of the clinic */
|
|
33
34
|
tz: string;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Resource calendar events per resource, keyed by resourceId.
|
|
38
|
+
* Each entry contains all instances' events for that resource.
|
|
39
|
+
* Only present when the procedure has resourceRequirements.
|
|
40
|
+
*/
|
|
41
|
+
resourceCalendarEventsMap?: Record<
|
|
42
|
+
string,
|
|
43
|
+
{
|
|
44
|
+
resourceId: string;
|
|
45
|
+
resourceName: string;
|
|
46
|
+
quantity: number;
|
|
47
|
+
/** Calendar events keyed by instanceId */
|
|
48
|
+
instanceEvents: Record<string, ResourceCalendarEvent[]>;
|
|
49
|
+
}
|
|
50
|
+
>;
|
|
34
51
|
}
|
|
35
52
|
|
|
36
53
|
/**
|
|
@@ -4,4 +4,59 @@ This directory contains services related to calendar functionality that are inte
|
|
|
4
4
|
|
|
5
5
|
## Services
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
### `calendar.admin.service.ts` — CalendarAdminService
|
|
8
|
+
|
|
9
|
+
Manages calendar events for practitioners, patients, and clinics. Used by the `AppointmentAggregationService` to update calendar event statuses and times during the appointment lifecycle.
|
|
10
|
+
|
|
11
|
+
**Key methods:**
|
|
12
|
+
- `updateAppointmentCalendarEventStatus(appointment, newStatus)` — Updates the status of all calendar events for an appointment
|
|
13
|
+
- `updateAppointmentCalendarEventTime(appointment, newEventTime)` — Updates the time range of calendar events (on reschedule)
|
|
14
|
+
- `deleteAppointmentCalendarEvents(appointment)` — Deletes all calendar events for a deleted appointment
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
### `resource-calendar.admin.ts` — ResourceCalendarAdminService
|
|
19
|
+
|
|
20
|
+
Manages calendar events on **resource instances** throughout the appointment lifecycle. This service is part of the [Clinic Resources System](../../../../docs/RESOURCE_SYSTEM.md).
|
|
21
|
+
|
|
22
|
+
**Constructor:**
|
|
23
|
+
```typescript
|
|
24
|
+
constructor(firestore?: admin.firestore.Firestore)
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
**Methods:**
|
|
28
|
+
|
|
29
|
+
#### `updateResourceBookingEventsStatus(appointment, newStatus)`
|
|
30
|
+
Updates the status of all resource calendar events for an appointment. Iterates through `appointment.resourceBookings` and updates each event's `status` and `updatedAt`.
|
|
31
|
+
|
|
32
|
+
**Firestore path:** `clinics/{clinicBranchId}/resources/{resourceId}/instances/{instanceId}/calendar/{calendarEventId}`
|
|
33
|
+
|
|
34
|
+
#### `updateResourceBookingEventsTime(appointment, newEventTime)`
|
|
35
|
+
Updates the time range of all resource calendar events (used during reschedule). Updates `eventTime.start`, `eventTime.end`, and `updatedAt`.
|
|
36
|
+
|
|
37
|
+
#### `deleteResourceBookingEvents(appointment)`
|
|
38
|
+
Deletes all resource calendar events for a deleted appointment. Uses a Firestore batch to delete all events atomically.
|
|
39
|
+
|
|
40
|
+
**Lifecycle integration:** Called by `AppointmentAggregationService` during appointment transitions:
|
|
41
|
+
|
|
42
|
+
| Transition | Action |
|
|
43
|
+
|---|---|
|
|
44
|
+
| PENDING → CONFIRMED | `updateResourceBookingEventsStatus(after, CONFIRMED)` |
|
|
45
|
+
| RESCHEDULED → CONFIRMED | `updateResourceBookingEventsStatus(after, CONFIRMED)` |
|
|
46
|
+
| Any → CANCELLED / NO_SHOW | `updateResourceBookingEventsStatus(after, CANCELLED)` |
|
|
47
|
+
| Any → COMPLETED | `updateResourceBookingEventsStatus(after, COMPLETED)` |
|
|
48
|
+
| RESCHEDULED_BY_CLINIC | `updateResourceBookingEventsTime()` + `updateResourceBookingEventsStatus(after, PENDING)` |
|
|
49
|
+
| Time change (same status) | `updateResourceBookingEventsTime()` |
|
|
50
|
+
| Appointment deleted | `deleteResourceBookingEvents()` |
|
|
51
|
+
|
|
52
|
+
**Safety:** All methods check for `appointment.resourceBookings` before proceeding. If no resource bookings exist (backward-compatible appointments), the methods return early.
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
### `synced-calendars.service.ts`
|
|
57
|
+
|
|
58
|
+
Manages the synchronization of calendars with external providers. It should not be used on the client side.
|
|
59
|
+
|
|
60
|
+
## Export
|
|
61
|
+
|
|
62
|
+
`index.ts` exports `CalendarAdminService` and `ResourceCalendarAdminService`.
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import * as admin from "firebase-admin";
|
|
2
|
+
import { Appointment } from "../../types/appointment";
|
|
3
|
+
import { CalendarEventStatus } from "../../types/calendar";
|
|
4
|
+
import { Logger } from "../logger";
|
|
5
|
+
import { CLINICS_COLLECTION } from "../../types/clinic";
|
|
6
|
+
import {
|
|
7
|
+
RESOURCES_COLLECTION,
|
|
8
|
+
RESOURCE_INSTANCES_SUBCOLLECTION,
|
|
9
|
+
RESOURCE_CALENDAR_SUBCOLLECTION,
|
|
10
|
+
ResourceBookingInfo,
|
|
11
|
+
} from "../../types/resource";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @class ResourceCalendarAdminService
|
|
15
|
+
* @description Handles administrative tasks for resource calendar events linked to appointments,
|
|
16
|
+
* such as status updates, time changes, or deletions during appointment lifecycle events.
|
|
17
|
+
*/
|
|
18
|
+
export class ResourceCalendarAdminService {
|
|
19
|
+
private db: admin.firestore.Firestore;
|
|
20
|
+
|
|
21
|
+
constructor(firestore?: admin.firestore.Firestore) {
|
|
22
|
+
this.db = firestore || admin.firestore();
|
|
23
|
+
Logger.info("[ResourceCalendarAdminService] Initialized.");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Builds the Firestore document path for a resource calendar event.
|
|
28
|
+
*/
|
|
29
|
+
private getResourceCalendarEventPath(
|
|
30
|
+
clinicBranchId: string,
|
|
31
|
+
booking: ResourceBookingInfo
|
|
32
|
+
): string {
|
|
33
|
+
return `${CLINICS_COLLECTION}/${clinicBranchId}/${RESOURCES_COLLECTION}/${booking.resourceId}/${RESOURCE_INSTANCES_SUBCOLLECTION}/${booking.resourceInstanceId}/${RESOURCE_CALENDAR_SUBCOLLECTION}/${booking.calendarEventId}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Updates the status of all resource calendar events associated with a given appointment.
|
|
38
|
+
*
|
|
39
|
+
* @param appointment - The appointment object containing resourceBookings.
|
|
40
|
+
* @param newStatus - The new CalendarEventStatus to set.
|
|
41
|
+
*/
|
|
42
|
+
async updateResourceBookingEventsStatus(
|
|
43
|
+
appointment: Appointment,
|
|
44
|
+
newStatus: CalendarEventStatus
|
|
45
|
+
): Promise<void> {
|
|
46
|
+
const resourceBookings = appointment.resourceBookings;
|
|
47
|
+
if (!resourceBookings || resourceBookings.length === 0) {
|
|
48
|
+
Logger.debug(
|
|
49
|
+
`[ResourceCalendarAdminService] No resource bookings on appointment ${appointment.id}, skipping status update.`
|
|
50
|
+
);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
Logger.info(
|
|
55
|
+
`[ResourceCalendarAdminService] Updating ${resourceBookings.length} resource calendar event(s) to ${newStatus} for appointment ${appointment.id}`
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const batch = this.db.batch();
|
|
59
|
+
const serverTimestamp = admin.firestore.FieldValue.serverTimestamp();
|
|
60
|
+
|
|
61
|
+
for (const booking of resourceBookings) {
|
|
62
|
+
const eventPath = this.getResourceCalendarEventPath(
|
|
63
|
+
appointment.clinicBranchId,
|
|
64
|
+
booking
|
|
65
|
+
);
|
|
66
|
+
const eventRef = this.db.doc(eventPath);
|
|
67
|
+
batch.update(eventRef, {
|
|
68
|
+
status: newStatus,
|
|
69
|
+
updatedAt: serverTimestamp,
|
|
70
|
+
});
|
|
71
|
+
Logger.debug(
|
|
72
|
+
`[ResourceCalendarAdminService] Added status update for resource event ${booking.calendarEventId} (${booking.resourceName} / ${booking.resourceInstanceLabel})`
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
await batch.commit();
|
|
78
|
+
Logger.info(
|
|
79
|
+
`[ResourceCalendarAdminService] Successfully updated ${resourceBookings.length} resource calendar event statuses for appointment ${appointment.id}.`
|
|
80
|
+
);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
Logger.error(
|
|
83
|
+
`[ResourceCalendarAdminService] Error updating resource calendar event statuses for appointment ${appointment.id}:`,
|
|
84
|
+
error
|
|
85
|
+
);
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Updates the eventTime (start and end) of all resource calendar events
|
|
92
|
+
* associated with a given appointment.
|
|
93
|
+
*
|
|
94
|
+
* @param appointment - The appointment object containing resourceBookings.
|
|
95
|
+
* @param newEventTime - The new event time with admin Timestamps.
|
|
96
|
+
*/
|
|
97
|
+
async updateResourceBookingEventsTime(
|
|
98
|
+
appointment: Appointment,
|
|
99
|
+
newEventTime: {
|
|
100
|
+
start: admin.firestore.Timestamp;
|
|
101
|
+
end: admin.firestore.Timestamp;
|
|
102
|
+
}
|
|
103
|
+
): Promise<void> {
|
|
104
|
+
const resourceBookings = appointment.resourceBookings;
|
|
105
|
+
if (!resourceBookings || resourceBookings.length === 0) {
|
|
106
|
+
Logger.debug(
|
|
107
|
+
`[ResourceCalendarAdminService] No resource bookings on appointment ${appointment.id}, skipping time update.`
|
|
108
|
+
);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
Logger.info(
|
|
113
|
+
`[ResourceCalendarAdminService] Updating ${resourceBookings.length} resource calendar event time(s) for appointment ${appointment.id}`
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const batch = this.db.batch();
|
|
117
|
+
const serverTimestamp = admin.firestore.FieldValue.serverTimestamp();
|
|
118
|
+
|
|
119
|
+
for (const booking of resourceBookings) {
|
|
120
|
+
const eventPath = this.getResourceCalendarEventPath(
|
|
121
|
+
appointment.clinicBranchId,
|
|
122
|
+
booking
|
|
123
|
+
);
|
|
124
|
+
const eventRef = this.db.doc(eventPath);
|
|
125
|
+
batch.update(eventRef, {
|
|
126
|
+
eventTime: {
|
|
127
|
+
start: newEventTime.start,
|
|
128
|
+
end: newEventTime.end,
|
|
129
|
+
},
|
|
130
|
+
updatedAt: serverTimestamp,
|
|
131
|
+
});
|
|
132
|
+
Logger.debug(
|
|
133
|
+
`[ResourceCalendarAdminService] Added time update for resource event ${booking.calendarEventId} (${booking.resourceName} / ${booking.resourceInstanceLabel})`
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
await batch.commit();
|
|
139
|
+
Logger.info(
|
|
140
|
+
`[ResourceCalendarAdminService] Successfully updated ${resourceBookings.length} resource calendar event times for appointment ${appointment.id}.`
|
|
141
|
+
);
|
|
142
|
+
} catch (error) {
|
|
143
|
+
Logger.error(
|
|
144
|
+
`[ResourceCalendarAdminService] Error updating resource calendar event times for appointment ${appointment.id}:`,
|
|
145
|
+
error
|
|
146
|
+
);
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Deletes all resource calendar events associated with a given appointment.
|
|
153
|
+
*
|
|
154
|
+
* @param appointment - The appointment object containing resourceBookings.
|
|
155
|
+
*/
|
|
156
|
+
async deleteResourceBookingEvents(
|
|
157
|
+
appointment: Appointment
|
|
158
|
+
): Promise<void> {
|
|
159
|
+
const resourceBookings = appointment.resourceBookings;
|
|
160
|
+
if (!resourceBookings || resourceBookings.length === 0) {
|
|
161
|
+
Logger.debug(
|
|
162
|
+
`[ResourceCalendarAdminService] No resource bookings on appointment ${appointment.id}, skipping deletion.`
|
|
163
|
+
);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
Logger.info(
|
|
168
|
+
`[ResourceCalendarAdminService] Deleting ${resourceBookings.length} resource calendar event(s) for appointment ${appointment.id}`
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
const batch = this.db.batch();
|
|
172
|
+
|
|
173
|
+
for (const booking of resourceBookings) {
|
|
174
|
+
const eventPath = this.getResourceCalendarEventPath(
|
|
175
|
+
appointment.clinicBranchId,
|
|
176
|
+
booking
|
|
177
|
+
);
|
|
178
|
+
const eventRef = this.db.doc(eventPath);
|
|
179
|
+
batch.delete(eventRef);
|
|
180
|
+
Logger.debug(
|
|
181
|
+
`[ResourceCalendarAdminService] Added deletion for resource event ${booking.calendarEventId} (${booking.resourceName} / ${booking.resourceInstanceLabel})`
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
await batch.commit();
|
|
187
|
+
Logger.info(
|
|
188
|
+
`[ResourceCalendarAdminService] Successfully deleted ${resourceBookings.length} resource calendar events for appointment ${appointment.id}.`
|
|
189
|
+
);
|
|
190
|
+
} catch (error) {
|
|
191
|
+
Logger.error(
|
|
192
|
+
`[ResourceCalendarAdminService] Error deleting resource calendar events for appointment ${appointment.id}:`,
|
|
193
|
+
error
|
|
194
|
+
);
|
|
195
|
+
throw error;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
package/src/config/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ClinicRole } from '../types/clinic/rbac.types';
|
|
2
2
|
import type { TierConfig } from '../types/clinic/rbac.types';
|
|
3
|
+
import type { PlanConfigDocument } from '../types/system/planConfig.types';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Permission keys used for role-based access control.
|
|
@@ -27,6 +28,12 @@ export const PERMISSION_KEYS = {
|
|
|
27
28
|
'procedures.edit': true,
|
|
28
29
|
'procedures.delete': true,
|
|
29
30
|
|
|
31
|
+
// Resources
|
|
32
|
+
'resources.view': true,
|
|
33
|
+
'resources.create': true,
|
|
34
|
+
'resources.edit': true,
|
|
35
|
+
'resources.delete': true,
|
|
36
|
+
|
|
30
37
|
// Patients
|
|
31
38
|
'patients.view': true,
|
|
32
39
|
'patients.edit': true,
|
|
@@ -57,11 +64,11 @@ export type PermissionKey = keyof typeof PERMISSION_KEYS;
|
|
|
57
64
|
* - Appointments and messages are NEVER limited (bad patient UX)
|
|
58
65
|
* - Providers are per branch, not per clinic group
|
|
59
66
|
* - Procedures are per provider (each provider gets this allowance)
|
|
60
|
-
* - Add-ons available on Connect/Pro: +1 branch, +1 provider, +
|
|
67
|
+
* - Add-ons available on Connect/Pro: +1 branch, +1 provider, +5 treatments
|
|
61
68
|
*
|
|
62
|
-
* Free: 1 branch, 1 provider/branch, 3
|
|
63
|
-
* Connect (CHF
|
|
64
|
-
* Pro (CHF
|
|
69
|
+
* Free: 1 branch, 1 provider/branch, 3 treatments/provider
|
|
70
|
+
* Connect (CHF 199/mo): 1 branch, 3 providers/branch, 10 treatments/provider
|
|
71
|
+
* Pro (CHF 449/mo): 3 branches, 5 providers/branch, 20 treatments/provider
|
|
65
72
|
*/
|
|
66
73
|
export const TIER_CONFIG: Record<string, TierConfig> = {
|
|
67
74
|
free: {
|
|
@@ -86,7 +93,7 @@ export const TIER_CONFIG: Record<string, TierConfig> = {
|
|
|
86
93
|
tier: 'pro',
|
|
87
94
|
name: 'Pro',
|
|
88
95
|
limits: {
|
|
89
|
-
maxProvidersPerBranch:
|
|
96
|
+
maxProvidersPerBranch: 5,
|
|
90
97
|
maxProceduresPerProvider: 20,
|
|
91
98
|
maxBranches: 3,
|
|
92
99
|
},
|
|
@@ -111,6 +118,10 @@ export const DEFAULT_ROLE_PERMISSIONS: Record<ClinicRole, Record<string, boolean
|
|
|
111
118
|
'procedures.create': true,
|
|
112
119
|
'procedures.edit': true,
|
|
113
120
|
'procedures.delete': true,
|
|
121
|
+
'resources.view': true,
|
|
122
|
+
'resources.create': true,
|
|
123
|
+
'resources.edit': true,
|
|
124
|
+
'resources.delete': true,
|
|
114
125
|
'patients.view': true,
|
|
115
126
|
'patients.edit': true,
|
|
116
127
|
'providers.view': true,
|
|
@@ -133,6 +144,10 @@ export const DEFAULT_ROLE_PERMISSIONS: Record<ClinicRole, Record<string, boolean
|
|
|
133
144
|
'procedures.create': true,
|
|
134
145
|
'procedures.edit': true,
|
|
135
146
|
'procedures.delete': true,
|
|
147
|
+
'resources.view': true,
|
|
148
|
+
'resources.create': true,
|
|
149
|
+
'resources.edit': true,
|
|
150
|
+
'resources.delete': true,
|
|
136
151
|
'patients.view': true,
|
|
137
152
|
'patients.edit': true,
|
|
138
153
|
'providers.view': true,
|
|
@@ -155,6 +170,10 @@ export const DEFAULT_ROLE_PERMISSIONS: Record<ClinicRole, Record<string, boolean
|
|
|
155
170
|
'procedures.create': false,
|
|
156
171
|
'procedures.edit': false,
|
|
157
172
|
'procedures.delete': false,
|
|
173
|
+
'resources.view': true,
|
|
174
|
+
'resources.create': false,
|
|
175
|
+
'resources.edit': false,
|
|
176
|
+
'resources.delete': false,
|
|
158
177
|
'patients.view': true,
|
|
159
178
|
'patients.edit': true,
|
|
160
179
|
'providers.view': true,
|
|
@@ -177,6 +196,10 @@ export const DEFAULT_ROLE_PERMISSIONS: Record<ClinicRole, Record<string, boolean
|
|
|
177
196
|
'procedures.create': false,
|
|
178
197
|
'procedures.edit': false,
|
|
179
198
|
'procedures.delete': false,
|
|
199
|
+
'resources.view': true,
|
|
200
|
+
'resources.create': false,
|
|
201
|
+
'resources.edit': false,
|
|
202
|
+
'resources.delete': false,
|
|
180
203
|
'patients.view': true,
|
|
181
204
|
'patients.edit': false,
|
|
182
205
|
'providers.view': true,
|
|
@@ -199,6 +222,10 @@ export const DEFAULT_ROLE_PERMISSIONS: Record<ClinicRole, Record<string, boolean
|
|
|
199
222
|
'procedures.create': false,
|
|
200
223
|
'procedures.edit': false,
|
|
201
224
|
'procedures.delete': false,
|
|
225
|
+
'resources.view': true,
|
|
226
|
+
'resources.create': false,
|
|
227
|
+
'resources.edit': false,
|
|
228
|
+
'resources.delete': false,
|
|
202
229
|
'patients.view': true,
|
|
203
230
|
'patients.edit': false,
|
|
204
231
|
'providers.view': true,
|
|
@@ -210,6 +237,95 @@ export const DEFAULT_ROLE_PERMISSIONS: Record<ClinicRole, Record<string, boolean
|
|
|
210
237
|
},
|
|
211
238
|
};
|
|
212
239
|
|
|
240
|
+
/**
|
|
241
|
+
* Default PlanConfigDocument — used as fallback when Firestore `system/planConfig`
|
|
242
|
+
* doesn't exist or is unavailable. Also used as the seed value.
|
|
243
|
+
*
|
|
244
|
+
* Stripe price IDs reference the SKS Innovation SA sandbox account.
|
|
245
|
+
*/
|
|
246
|
+
export const DEFAULT_PLAN_CONFIG: PlanConfigDocument = {
|
|
247
|
+
version: 1,
|
|
248
|
+
updatedAt: null,
|
|
249
|
+
updatedBy: 'system',
|
|
250
|
+
|
|
251
|
+
tiers: {
|
|
252
|
+
free: {
|
|
253
|
+
name: 'Free',
|
|
254
|
+
limits: { maxProvidersPerBranch: 1, maxProceduresPerProvider: 3, maxBranches: 1 },
|
|
255
|
+
},
|
|
256
|
+
connect: {
|
|
257
|
+
name: 'Connect',
|
|
258
|
+
limits: { maxProvidersPerBranch: 3, maxProceduresPerProvider: 10, maxBranches: 1 },
|
|
259
|
+
},
|
|
260
|
+
pro: {
|
|
261
|
+
name: 'Pro',
|
|
262
|
+
limits: { maxProvidersPerBranch: 5, maxProceduresPerProvider: 20, maxBranches: 3 },
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
|
|
266
|
+
plans: {
|
|
267
|
+
FREE: {
|
|
268
|
+
priceId: null,
|
|
269
|
+
priceMonthly: 0,
|
|
270
|
+
currency: 'CHF',
|
|
271
|
+
description: 'Get started with basic access',
|
|
272
|
+
stripeProductId: null,
|
|
273
|
+
},
|
|
274
|
+
CONNECT: {
|
|
275
|
+
priceId: 'price_1TD0l7AaFI0fqFWNC8Lg3QJG',
|
|
276
|
+
priceMonthly: 199,
|
|
277
|
+
currency: 'CHF',
|
|
278
|
+
description: 'Consultation tools and integrated booking',
|
|
279
|
+
stripeProductId: null,
|
|
280
|
+
},
|
|
281
|
+
PRO: {
|
|
282
|
+
priceId: 'price_1TD0lEAaFI0fqFWN3tzCrfUb',
|
|
283
|
+
priceMonthly: 449,
|
|
284
|
+
currency: 'CHF',
|
|
285
|
+
description: 'Complete clinic management with CRM and analytics',
|
|
286
|
+
stripeProductId: null,
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
|
|
290
|
+
addons: {
|
|
291
|
+
seat: {
|
|
292
|
+
priceId: 'price_1TD0lKAaFI0fqFWNH9pQgTzI',
|
|
293
|
+
pricePerUnit: 39,
|
|
294
|
+
currency: 'CHF',
|
|
295
|
+
allowedTiers: ['connect', 'pro'],
|
|
296
|
+
},
|
|
297
|
+
branch: {
|
|
298
|
+
connect: { priceId: 'price_1TD0lSAaFI0fqFWNjsPvzW1T', pricePerUnit: 119, currency: 'CHF' },
|
|
299
|
+
pro: { priceId: 'price_1TD0lTAaFI0fqFWNYxUnvUxH', pricePerUnit: 279, currency: 'CHF' },
|
|
300
|
+
allowedTiers: ['connect', 'pro'],
|
|
301
|
+
},
|
|
302
|
+
procedure: {
|
|
303
|
+
priceId: 'price_1TCL3eAaFI0fqFWNJVuMYjii',
|
|
304
|
+
pricePerBlock: 19,
|
|
305
|
+
proceduresPerBlock: 5,
|
|
306
|
+
currency: 'CHF',
|
|
307
|
+
allowedTiers: ['connect', 'pro'],
|
|
308
|
+
},
|
|
309
|
+
},
|
|
310
|
+
|
|
311
|
+
priceToModel: {
|
|
312
|
+
'price_1TD0l7AaFI0fqFWNC8Lg3QJG': 'connect',
|
|
313
|
+
'price_1TD0lEAaFI0fqFWN3tzCrfUb': 'pro',
|
|
314
|
+
},
|
|
315
|
+
|
|
316
|
+
priceToType: {
|
|
317
|
+
'price_1TD0l7AaFI0fqFWNC8Lg3QJG': 'CONNECT',
|
|
318
|
+
'price_1TD0lEAaFI0fqFWN3tzCrfUb': 'PRO',
|
|
319
|
+
},
|
|
320
|
+
|
|
321
|
+
addonPriceIds: [
|
|
322
|
+
'price_1TD0lKAaFI0fqFWNH9pQgTzI', // seat
|
|
323
|
+
'price_1TD0lSAaFI0fqFWNjsPvzW1T', // branch connect
|
|
324
|
+
'price_1TD0lTAaFI0fqFWNYxUnvUxH', // branch pro
|
|
325
|
+
'price_1TCL3eAaFI0fqFWNJVuMYjii', // procedure
|
|
326
|
+
],
|
|
327
|
+
};
|
|
328
|
+
|
|
213
329
|
/**
|
|
214
330
|
* Maps subscription model strings to tier keys.
|
|
215
331
|
*/
|
package/src/services/index.ts
CHANGED