@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.
- 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 +371 -4
- package/dist/index.d.ts +371 -4
- package/dist/index.js +2227 -1580
- package/dist/index.mjs +1543 -891
- 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 +116 -0
- 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 +15 -10
- 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
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
import {
|
|
2
|
+
collection,
|
|
3
|
+
doc,
|
|
4
|
+
getDoc,
|
|
5
|
+
getDocs,
|
|
6
|
+
setDoc,
|
|
7
|
+
deleteDoc,
|
|
8
|
+
query,
|
|
9
|
+
where,
|
|
10
|
+
orderBy,
|
|
11
|
+
writeBatch,
|
|
12
|
+
updateDoc,
|
|
13
|
+
serverTimestamp,
|
|
14
|
+
Timestamp,
|
|
15
|
+
} from "firebase/firestore";
|
|
16
|
+
import { BaseService } from "../base.service";
|
|
17
|
+
import {
|
|
18
|
+
Resource,
|
|
19
|
+
ResourceInstance,
|
|
20
|
+
ResourceCalendarEvent,
|
|
21
|
+
ResourceStatus,
|
|
22
|
+
CreateResourceData,
|
|
23
|
+
UpdateResourceData,
|
|
24
|
+
CreateResourceBlockingEventParams,
|
|
25
|
+
UpdateResourceBlockingEventParams,
|
|
26
|
+
RESOURCES_COLLECTION,
|
|
27
|
+
RESOURCE_INSTANCES_SUBCOLLECTION,
|
|
28
|
+
RESOURCE_CALENDAR_SUBCOLLECTION,
|
|
29
|
+
} from "../../types/resource";
|
|
30
|
+
import { CalendarEventType, CalendarEventStatus } from "../../types/calendar";
|
|
31
|
+
import {
|
|
32
|
+
createResourceBlockingEventSchema,
|
|
33
|
+
updateResourceBlockingEventSchema,
|
|
34
|
+
} from "../../validations/resource.schema";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Service for managing clinic resources and their instances.
|
|
38
|
+
* Resources are stored as subcollections under clinics:
|
|
39
|
+
* clinics/{clinicId}/resources/{resourceId}
|
|
40
|
+
* clinics/{clinicId}/resources/{resourceId}/instances/{instanceId}
|
|
41
|
+
* clinics/{clinicId}/resources/{resourceId}/instances/{instanceId}/calendar/{eventId}
|
|
42
|
+
*/
|
|
43
|
+
export class ResourceService extends BaseService {
|
|
44
|
+
/**
|
|
45
|
+
* Gets reference to a clinic's resources collection
|
|
46
|
+
*/
|
|
47
|
+
private getResourcesRef(clinicBranchId: string) {
|
|
48
|
+
return collection(this.db, "clinics", clinicBranchId, RESOURCES_COLLECTION);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Gets reference to a specific resource document
|
|
53
|
+
*/
|
|
54
|
+
private getResourceDocRef(clinicBranchId: string, resourceId: string) {
|
|
55
|
+
return doc(
|
|
56
|
+
this.db,
|
|
57
|
+
"clinics",
|
|
58
|
+
clinicBranchId,
|
|
59
|
+
RESOURCES_COLLECTION,
|
|
60
|
+
resourceId
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Gets reference to a resource's instances subcollection
|
|
66
|
+
*/
|
|
67
|
+
private getInstancesRef(clinicBranchId: string, resourceId: string) {
|
|
68
|
+
return collection(
|
|
69
|
+
this.db,
|
|
70
|
+
"clinics",
|
|
71
|
+
clinicBranchId,
|
|
72
|
+
RESOURCES_COLLECTION,
|
|
73
|
+
resourceId,
|
|
74
|
+
RESOURCE_INSTANCES_SUBCOLLECTION
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Gets reference to an instance's calendar subcollection
|
|
80
|
+
*/
|
|
81
|
+
private getInstanceCalendarRef(
|
|
82
|
+
clinicBranchId: string,
|
|
83
|
+
resourceId: string,
|
|
84
|
+
instanceId: string
|
|
85
|
+
) {
|
|
86
|
+
return collection(
|
|
87
|
+
this.db,
|
|
88
|
+
"clinics",
|
|
89
|
+
clinicBranchId,
|
|
90
|
+
RESOURCES_COLLECTION,
|
|
91
|
+
resourceId,
|
|
92
|
+
RESOURCE_INSTANCES_SUBCOLLECTION,
|
|
93
|
+
instanceId,
|
|
94
|
+
RESOURCE_CALENDAR_SUBCOLLECTION
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Creates a new resource with its instances.
|
|
100
|
+
* Creates the resource document and N instance subdocuments in a single batch.
|
|
101
|
+
*/
|
|
102
|
+
async createResource(data: CreateResourceData): Promise<Resource> {
|
|
103
|
+
const batch = writeBatch(this.db);
|
|
104
|
+
|
|
105
|
+
const resourceRef = doc(this.getResourcesRef(data.clinicBranchId));
|
|
106
|
+
const resourceId = resourceRef.id;
|
|
107
|
+
|
|
108
|
+
const now = serverTimestamp();
|
|
109
|
+
|
|
110
|
+
const resourceData: Omit<Resource, "createdAt" | "updatedAt"> & {
|
|
111
|
+
createdAt: any;
|
|
112
|
+
updatedAt: any;
|
|
113
|
+
} = {
|
|
114
|
+
id: resourceId,
|
|
115
|
+
clinicBranchId: data.clinicBranchId,
|
|
116
|
+
name: data.name,
|
|
117
|
+
nameLower: data.name.toLowerCase(),
|
|
118
|
+
category: data.category,
|
|
119
|
+
description: data.description || undefined,
|
|
120
|
+
quantity: data.quantity,
|
|
121
|
+
status: ResourceStatus.ACTIVE,
|
|
122
|
+
linkedProcedureIds: [],
|
|
123
|
+
createdAt: now,
|
|
124
|
+
updatedAt: now,
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
batch.set(resourceRef, resourceData);
|
|
128
|
+
|
|
129
|
+
// Create instance subdocuments
|
|
130
|
+
for (let i = 1; i <= data.quantity; i++) {
|
|
131
|
+
const instanceRef = doc(
|
|
132
|
+
this.getInstancesRef(data.clinicBranchId, resourceId)
|
|
133
|
+
);
|
|
134
|
+
const instanceData: Omit<ResourceInstance, "createdAt" | "updatedAt"> & {
|
|
135
|
+
createdAt: any;
|
|
136
|
+
updatedAt: any;
|
|
137
|
+
} = {
|
|
138
|
+
id: instanceRef.id,
|
|
139
|
+
resourceId,
|
|
140
|
+
clinicBranchId: data.clinicBranchId,
|
|
141
|
+
label: `${data.name} #${i}`,
|
|
142
|
+
index: i,
|
|
143
|
+
status: ResourceStatus.ACTIVE,
|
|
144
|
+
createdAt: now,
|
|
145
|
+
updatedAt: now,
|
|
146
|
+
};
|
|
147
|
+
batch.set(instanceRef, instanceData);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
await batch.commit();
|
|
151
|
+
|
|
152
|
+
// Fetch and return the created resource
|
|
153
|
+
const created = await this.getResource(data.clinicBranchId, resourceId);
|
|
154
|
+
if (!created) throw new Error("Failed to read created resource");
|
|
155
|
+
return created;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Gets a single resource by ID
|
|
160
|
+
*/
|
|
161
|
+
async getResource(
|
|
162
|
+
clinicBranchId: string,
|
|
163
|
+
resourceId: string
|
|
164
|
+
): Promise<Resource | null> {
|
|
165
|
+
const docRef = this.getResourceDocRef(clinicBranchId, resourceId);
|
|
166
|
+
const docSnap = await getDoc(docRef);
|
|
167
|
+
if (!docSnap.exists()) return null;
|
|
168
|
+
return { id: docSnap.id, ...docSnap.data() } as Resource;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Gets all resources for a clinic branch
|
|
173
|
+
*/
|
|
174
|
+
async getResourcesByClinic(clinicBranchId: string): Promise<Resource[]> {
|
|
175
|
+
const q = query(
|
|
176
|
+
this.getResourcesRef(clinicBranchId),
|
|
177
|
+
orderBy("nameLower")
|
|
178
|
+
);
|
|
179
|
+
const snapshot = await getDocs(q);
|
|
180
|
+
return snapshot.docs.map(
|
|
181
|
+
(d) => ({ id: d.id, ...d.data() } as Resource)
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Gets all active resources for a clinic branch
|
|
187
|
+
*/
|
|
188
|
+
async getActiveResourcesByClinic(
|
|
189
|
+
clinicBranchId: string
|
|
190
|
+
): Promise<Resource[]> {
|
|
191
|
+
const q = query(
|
|
192
|
+
this.getResourcesRef(clinicBranchId),
|
|
193
|
+
where("status", "==", ResourceStatus.ACTIVE),
|
|
194
|
+
orderBy("nameLower")
|
|
195
|
+
);
|
|
196
|
+
const snapshot = await getDocs(q);
|
|
197
|
+
return snapshot.docs.map(
|
|
198
|
+
(d) => ({ id: d.id, ...d.data() } as Resource)
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Updates a resource. Handles quantity changes:
|
|
204
|
+
* - Increasing quantity: creates new instances
|
|
205
|
+
* - Decreasing quantity: blocked if affected instances have future bookings
|
|
206
|
+
*/
|
|
207
|
+
async updateResource(
|
|
208
|
+
clinicBranchId: string,
|
|
209
|
+
resourceId: string,
|
|
210
|
+
data: UpdateResourceData
|
|
211
|
+
): Promise<Resource> {
|
|
212
|
+
const existing = await this.getResource(clinicBranchId, resourceId);
|
|
213
|
+
if (!existing) throw new Error(`Resource ${resourceId} not found`);
|
|
214
|
+
|
|
215
|
+
const updateData: Record<string, any> = {
|
|
216
|
+
updatedAt: serverTimestamp(),
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
if (data.name !== undefined) {
|
|
220
|
+
updateData.name = data.name;
|
|
221
|
+
updateData.nameLower = data.name.toLowerCase();
|
|
222
|
+
}
|
|
223
|
+
if (data.category !== undefined) updateData.category = data.category;
|
|
224
|
+
if (data.description !== undefined)
|
|
225
|
+
updateData.description = data.description;
|
|
226
|
+
if (data.status !== undefined) updateData.status = data.status;
|
|
227
|
+
|
|
228
|
+
// Handle quantity change
|
|
229
|
+
if (data.quantity !== undefined && data.quantity !== existing.quantity) {
|
|
230
|
+
if (data.quantity > existing.quantity) {
|
|
231
|
+
// Add new instances
|
|
232
|
+
const batch = writeBatch(this.db);
|
|
233
|
+
const now = serverTimestamp();
|
|
234
|
+
const resourceName = data.name || existing.name;
|
|
235
|
+
|
|
236
|
+
for (let i = existing.quantity + 1; i <= data.quantity; i++) {
|
|
237
|
+
const instanceRef = doc(
|
|
238
|
+
this.getInstancesRef(clinicBranchId, resourceId)
|
|
239
|
+
);
|
|
240
|
+
const instanceData: Omit<
|
|
241
|
+
ResourceInstance,
|
|
242
|
+
"createdAt" | "updatedAt"
|
|
243
|
+
> & { createdAt: any; updatedAt: any } = {
|
|
244
|
+
id: instanceRef.id,
|
|
245
|
+
resourceId,
|
|
246
|
+
clinicBranchId,
|
|
247
|
+
label: `${resourceName} #${i}`,
|
|
248
|
+
index: i,
|
|
249
|
+
status: ResourceStatus.ACTIVE,
|
|
250
|
+
createdAt: now,
|
|
251
|
+
updatedAt: now,
|
|
252
|
+
};
|
|
253
|
+
batch.set(instanceRef, instanceData);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
updateData.quantity = data.quantity;
|
|
257
|
+
|
|
258
|
+
// Update resource doc in the batch too
|
|
259
|
+
const resourceDocRef = this.getResourceDocRef(
|
|
260
|
+
clinicBranchId,
|
|
261
|
+
resourceId
|
|
262
|
+
);
|
|
263
|
+
batch.update(resourceDocRef, updateData);
|
|
264
|
+
await batch.commit();
|
|
265
|
+
|
|
266
|
+
const updated = await this.getResource(clinicBranchId, resourceId);
|
|
267
|
+
if (!updated) throw new Error("Failed to read updated resource");
|
|
268
|
+
return updated;
|
|
269
|
+
} else {
|
|
270
|
+
// Decreasing quantity - check for future bookings on affected instances
|
|
271
|
+
const instances = await this.getResourceInstances(
|
|
272
|
+
clinicBranchId,
|
|
273
|
+
resourceId
|
|
274
|
+
);
|
|
275
|
+
const instancesToRemove = instances
|
|
276
|
+
.filter((inst) => inst.index > data.quantity!)
|
|
277
|
+
.filter((inst) => inst.status === ResourceStatus.ACTIVE);
|
|
278
|
+
|
|
279
|
+
for (const instance of instancesToRemove) {
|
|
280
|
+
const hasFutureBookings = await this.instanceHasFutureBookings(
|
|
281
|
+
clinicBranchId,
|
|
282
|
+
resourceId,
|
|
283
|
+
instance.id
|
|
284
|
+
);
|
|
285
|
+
if (hasFutureBookings) {
|
|
286
|
+
throw new Error(
|
|
287
|
+
`Cannot reduce quantity: instance "${instance.label}" has future bookings. Cancel those bookings first.`
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Safe to deactivate instances
|
|
293
|
+
const batch = writeBatch(this.db);
|
|
294
|
+
for (const instance of instancesToRemove) {
|
|
295
|
+
const instanceRef = doc(
|
|
296
|
+
this.getInstancesRef(clinicBranchId, resourceId),
|
|
297
|
+
instance.id
|
|
298
|
+
);
|
|
299
|
+
batch.update(instanceRef, {
|
|
300
|
+
status: ResourceStatus.INACTIVE,
|
|
301
|
+
updatedAt: serverTimestamp(),
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
updateData.quantity = data.quantity;
|
|
306
|
+
const resourceDocRef = this.getResourceDocRef(
|
|
307
|
+
clinicBranchId,
|
|
308
|
+
resourceId
|
|
309
|
+
);
|
|
310
|
+
batch.update(resourceDocRef, updateData);
|
|
311
|
+
await batch.commit();
|
|
312
|
+
|
|
313
|
+
const updated = await this.getResource(clinicBranchId, resourceId);
|
|
314
|
+
if (!updated) throw new Error("Failed to read updated resource");
|
|
315
|
+
return updated;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Simple update (no quantity change)
|
|
320
|
+
const resourceDocRef = this.getResourceDocRef(clinicBranchId, resourceId);
|
|
321
|
+
await updateDoc(resourceDocRef, updateData);
|
|
322
|
+
|
|
323
|
+
const updated = await this.getResource(clinicBranchId, resourceId);
|
|
324
|
+
if (!updated) throw new Error("Failed to read updated resource");
|
|
325
|
+
return updated;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Soft deletes a resource by setting status to INACTIVE
|
|
330
|
+
*/
|
|
331
|
+
async deleteResource(
|
|
332
|
+
clinicBranchId: string,
|
|
333
|
+
resourceId: string
|
|
334
|
+
): Promise<void> {
|
|
335
|
+
await this.updateResource(clinicBranchId, resourceId, {
|
|
336
|
+
status: ResourceStatus.INACTIVE,
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Gets all instances for a resource
|
|
342
|
+
*/
|
|
343
|
+
async getResourceInstances(
|
|
344
|
+
clinicBranchId: string,
|
|
345
|
+
resourceId: string
|
|
346
|
+
): Promise<ResourceInstance[]> {
|
|
347
|
+
const q = query(
|
|
348
|
+
this.getInstancesRef(clinicBranchId, resourceId),
|
|
349
|
+
orderBy("index")
|
|
350
|
+
);
|
|
351
|
+
const snapshot = await getDocs(q);
|
|
352
|
+
return snapshot.docs.map(
|
|
353
|
+
(d) => ({ id: d.id, ...d.data() } as ResourceInstance)
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Gets active instances for a resource
|
|
359
|
+
*/
|
|
360
|
+
async getActiveResourceInstances(
|
|
361
|
+
clinicBranchId: string,
|
|
362
|
+
resourceId: string
|
|
363
|
+
): Promise<ResourceInstance[]> {
|
|
364
|
+
const q = query(
|
|
365
|
+
this.getInstancesRef(clinicBranchId, resourceId),
|
|
366
|
+
where("status", "==", ResourceStatus.ACTIVE),
|
|
367
|
+
orderBy("index")
|
|
368
|
+
);
|
|
369
|
+
const snapshot = await getDocs(q);
|
|
370
|
+
return snapshot.docs.map(
|
|
371
|
+
(d) => ({ id: d.id, ...d.data() } as ResourceInstance)
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Gets calendar events for a specific resource instance within a time range
|
|
377
|
+
*/
|
|
378
|
+
async getResourceCalendarEvents(
|
|
379
|
+
clinicBranchId: string,
|
|
380
|
+
resourceId: string,
|
|
381
|
+
instanceId: string,
|
|
382
|
+
start: Timestamp,
|
|
383
|
+
end: Timestamp
|
|
384
|
+
): Promise<ResourceCalendarEvent[]> {
|
|
385
|
+
const calendarRef = this.getInstanceCalendarRef(
|
|
386
|
+
clinicBranchId,
|
|
387
|
+
resourceId,
|
|
388
|
+
instanceId
|
|
389
|
+
);
|
|
390
|
+
const q = query(
|
|
391
|
+
calendarRef,
|
|
392
|
+
where("eventTime.start", ">=", start),
|
|
393
|
+
where("eventTime.start", "<=", end),
|
|
394
|
+
orderBy("eventTime.start")
|
|
395
|
+
);
|
|
396
|
+
const snapshot = await getDocs(q);
|
|
397
|
+
return snapshot.docs.map(
|
|
398
|
+
(d) => ({ id: d.id, ...d.data() } as ResourceCalendarEvent)
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Checks if a resource instance has any future active bookings
|
|
404
|
+
*/
|
|
405
|
+
private async instanceHasFutureBookings(
|
|
406
|
+
clinicBranchId: string,
|
|
407
|
+
resourceId: string,
|
|
408
|
+
instanceId: string
|
|
409
|
+
): Promise<boolean> {
|
|
410
|
+
const now = Timestamp.now();
|
|
411
|
+
const calendarRef = this.getInstanceCalendarRef(
|
|
412
|
+
clinicBranchId,
|
|
413
|
+
resourceId,
|
|
414
|
+
instanceId
|
|
415
|
+
);
|
|
416
|
+
const q = query(
|
|
417
|
+
calendarRef,
|
|
418
|
+
where("eventTime.start", ">=", now),
|
|
419
|
+
where("status", "in", ["pending", "confirmed"])
|
|
420
|
+
);
|
|
421
|
+
const snapshot = await getDocs(q);
|
|
422
|
+
return !snapshot.empty;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// --- Resource Instance Blocking Events ---
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Creates a blocking event on a resource instance's calendar.
|
|
429
|
+
* Blocking events prevent the instance from being booked during the specified time.
|
|
430
|
+
*/
|
|
431
|
+
async createResourceBlockingEvent(
|
|
432
|
+
params: CreateResourceBlockingEventParams
|
|
433
|
+
): Promise<ResourceCalendarEvent> {
|
|
434
|
+
createResourceBlockingEventSchema.parse(params);
|
|
435
|
+
|
|
436
|
+
const calendarRef = this.getInstanceCalendarRef(
|
|
437
|
+
params.clinicBranchId,
|
|
438
|
+
params.resourceId,
|
|
439
|
+
params.resourceInstanceId
|
|
440
|
+
);
|
|
441
|
+
const eventRef = doc(calendarRef);
|
|
442
|
+
const now = serverTimestamp();
|
|
443
|
+
|
|
444
|
+
const eventData = {
|
|
445
|
+
id: eventRef.id,
|
|
446
|
+
resourceId: params.resourceId,
|
|
447
|
+
resourceInstanceId: params.resourceInstanceId,
|
|
448
|
+
clinicBranchId: params.clinicBranchId,
|
|
449
|
+
eventType: CalendarEventType.BLOCKING,
|
|
450
|
+
eventName: params.eventName,
|
|
451
|
+
eventTime: params.eventTime,
|
|
452
|
+
status: CalendarEventStatus.CONFIRMED,
|
|
453
|
+
description: params.description || "",
|
|
454
|
+
createdAt: now,
|
|
455
|
+
updatedAt: now,
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
await setDoc(eventRef, eventData);
|
|
459
|
+
|
|
460
|
+
return {
|
|
461
|
+
...eventData,
|
|
462
|
+
createdAt: Timestamp.now(),
|
|
463
|
+
updatedAt: Timestamp.now(),
|
|
464
|
+
} as ResourceCalendarEvent;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Updates an existing blocking event on a resource instance's calendar.
|
|
469
|
+
* Only provided fields are updated.
|
|
470
|
+
*/
|
|
471
|
+
async updateResourceBlockingEvent(
|
|
472
|
+
params: UpdateResourceBlockingEventParams
|
|
473
|
+
): Promise<ResourceCalendarEvent> {
|
|
474
|
+
updateResourceBlockingEventSchema.parse(params);
|
|
475
|
+
|
|
476
|
+
const calendarRef = this.getInstanceCalendarRef(
|
|
477
|
+
params.clinicBranchId,
|
|
478
|
+
params.resourceId,
|
|
479
|
+
params.resourceInstanceId
|
|
480
|
+
);
|
|
481
|
+
const eventRef = doc(calendarRef, params.eventId);
|
|
482
|
+
|
|
483
|
+
const eventSnap = await getDoc(eventRef);
|
|
484
|
+
if (!eventSnap.exists()) {
|
|
485
|
+
throw new Error(`Blocking event ${params.eventId} not found`);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const updateData: Record<string, any> = {
|
|
489
|
+
updatedAt: serverTimestamp(),
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
if (params.eventName !== undefined) {
|
|
493
|
+
updateData.eventName = params.eventName;
|
|
494
|
+
}
|
|
495
|
+
if (params.eventTime !== undefined) {
|
|
496
|
+
updateData.eventTime = params.eventTime;
|
|
497
|
+
}
|
|
498
|
+
if (params.description !== undefined) {
|
|
499
|
+
updateData.description = params.description;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
await updateDoc(eventRef, updateData);
|
|
503
|
+
|
|
504
|
+
const updatedSnap = await getDoc(eventRef);
|
|
505
|
+
return { id: updatedSnap.id, ...updatedSnap.data() } as ResourceCalendarEvent;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Deletes a blocking event from a resource instance's calendar (hard delete).
|
|
510
|
+
*/
|
|
511
|
+
async deleteResourceBlockingEvent(
|
|
512
|
+
clinicBranchId: string,
|
|
513
|
+
resourceId: string,
|
|
514
|
+
instanceId: string,
|
|
515
|
+
eventId: string
|
|
516
|
+
): Promise<void> {
|
|
517
|
+
const calendarRef = this.getInstanceCalendarRef(
|
|
518
|
+
clinicBranchId,
|
|
519
|
+
resourceId,
|
|
520
|
+
instanceId
|
|
521
|
+
);
|
|
522
|
+
const eventRef = doc(calendarRef, eventId);
|
|
523
|
+
|
|
524
|
+
const eventSnap = await getDoc(eventRef);
|
|
525
|
+
if (!eventSnap.exists()) {
|
|
526
|
+
throw new Error(`Blocking event ${eventId} not found`);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
await deleteDoc(eventRef);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Gets all blocking events for a resource instance, ordered by start time.
|
|
534
|
+
*/
|
|
535
|
+
async getResourceInstanceBlockingEvents(
|
|
536
|
+
clinicBranchId: string,
|
|
537
|
+
resourceId: string,
|
|
538
|
+
instanceId: string
|
|
539
|
+
): Promise<ResourceCalendarEvent[]> {
|
|
540
|
+
const calendarRef = this.getInstanceCalendarRef(
|
|
541
|
+
clinicBranchId,
|
|
542
|
+
resourceId,
|
|
543
|
+
instanceId
|
|
544
|
+
);
|
|
545
|
+
const q = query(
|
|
546
|
+
calendarRef,
|
|
547
|
+
where("eventType", "==", CalendarEventType.BLOCKING),
|
|
548
|
+
orderBy("eventTime.start")
|
|
549
|
+
);
|
|
550
|
+
const snapshot = await getDocs(q);
|
|
551
|
+
return snapshot.docs.map(
|
|
552
|
+
(d) => ({ id: d.id, ...d.data() } as ResourceCalendarEvent)
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
import { PRACTITIONERS_COLLECTION } from '../types/practitioner/index';
|
|
15
15
|
import { PROCEDURES_COLLECTION } from '../types/procedure/index';
|
|
16
16
|
import { TIER_CONFIG, resolveEffectiveTier } from '../config/tiers.config';
|
|
17
|
+
import { planConfigService } from './plan-config.service';
|
|
17
18
|
|
|
18
19
|
export class TierLimitError extends Error {
|
|
19
20
|
public readonly code = 'TIER_LIMIT_EXCEEDED';
|
|
@@ -171,10 +172,11 @@ export async function enforceProviderLimit(
|
|
|
171
172
|
branchId: string
|
|
172
173
|
): Promise<void> {
|
|
173
174
|
const { tier, billing } = await getClinicGroupTierData(db, clinicGroupId);
|
|
174
|
-
const
|
|
175
|
-
|
|
175
|
+
const dynamicConfig = await planConfigService.getConfig(db);
|
|
176
|
+
const tierDef = dynamicConfig.tiers[tier] ?? TIER_CONFIG[tier];
|
|
177
|
+
if (!tierDef) return;
|
|
176
178
|
|
|
177
|
-
const baseMax =
|
|
179
|
+
const baseMax = tierDef.limits.maxProvidersPerBranch;
|
|
178
180
|
if (baseMax === -1) return;
|
|
179
181
|
|
|
180
182
|
const addOns = billing?.addOns || {};
|
|
@@ -199,15 +201,17 @@ export async function enforceProcedureLimit(
|
|
|
199
201
|
count: number = 1
|
|
200
202
|
): Promise<void> {
|
|
201
203
|
const { tier, billing } = await getClinicGroupTierData(db, clinicGroupId);
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
+
const dynamicConfig = await planConfigService.getConfig(db);
|
|
205
|
+
const tierDef = dynamicConfig.tiers[tier] ?? TIER_CONFIG[tier];
|
|
206
|
+
if (!tierDef) return;
|
|
204
207
|
|
|
205
|
-
const baseMax =
|
|
208
|
+
const baseMax = tierDef.limits.maxProceduresPerProvider;
|
|
206
209
|
if (baseMax === -1) return;
|
|
207
210
|
|
|
208
211
|
const addOns = billing?.addOns || {};
|
|
209
212
|
const procedureBlocks = addOns[branchId]?.procedureBlocks || 0;
|
|
210
|
-
const
|
|
213
|
+
const proceduresPerBlock = dynamicConfig.addons?.procedure?.proceduresPerBlock ?? 5;
|
|
214
|
+
const effectiveMax = baseMax + (procedureBlocks * proceduresPerBlock);
|
|
211
215
|
|
|
212
216
|
const currentCount = await countProceduresForProvider(db, branchId, providerId);
|
|
213
217
|
if (currentCount + count > effectiveMax) {
|
|
@@ -224,10 +228,11 @@ export async function enforceBranchLimit(
|
|
|
224
228
|
clinicGroupId: string
|
|
225
229
|
): Promise<void> {
|
|
226
230
|
const { tier, billing } = await getClinicGroupTierData(db, clinicGroupId);
|
|
227
|
-
const
|
|
228
|
-
|
|
231
|
+
const dynamicConfig = await planConfigService.getConfig(db);
|
|
232
|
+
const tierDef = dynamicConfig.tiers[tier] ?? TIER_CONFIG[tier];
|
|
233
|
+
if (!tierDef) return;
|
|
229
234
|
|
|
230
|
-
const baseMax =
|
|
235
|
+
const baseMax = tierDef.limits.maxBranches;
|
|
231
236
|
if (baseMax === -1) return;
|
|
232
237
|
|
|
233
238
|
const branchAddonCount = billing?.branchAddonCount || 0;
|
|
@@ -7,6 +7,7 @@ import { Requirement } from '../../backoffice/types/requirement.types';
|
|
|
7
7
|
import { FilledDocumentStatus } from '../documentation-templates';
|
|
8
8
|
import type { ContraindicationDynamic, ProcedureFamily } from '../../backoffice';
|
|
9
9
|
import type { MediaResource } from '../../services/media/media.service';
|
|
10
|
+
import type { ResourceBookingInfo } from '../resource';
|
|
10
11
|
import { string } from 'zod/v4';
|
|
11
12
|
|
|
12
13
|
/**
|
|
@@ -390,6 +391,9 @@ export interface Appointment {
|
|
|
390
391
|
|
|
391
392
|
/** NEW: Metadata for the appointment - used for area selection and photos */
|
|
392
393
|
metadata?: AppointmentMetadata;
|
|
394
|
+
|
|
395
|
+
/** Resources booked for this appointment (e.g., surgery room instance, device instance) */
|
|
396
|
+
resourceBookings?: ResourceBookingInfo[];
|
|
393
397
|
}
|
|
394
398
|
|
|
395
399
|
/**
|
|
@@ -470,6 +474,9 @@ export interface UpdateAppointmentData {
|
|
|
470
474
|
|
|
471
475
|
/** NEW: For updating metadata */
|
|
472
476
|
metadata?: AppointmentMetadata;
|
|
477
|
+
|
|
478
|
+
/** For updating resource bookings */
|
|
479
|
+
resourceBookings?: ResourceBookingInfo[] | FieldValue;
|
|
473
480
|
}
|
|
474
481
|
|
|
475
482
|
/**
|
|
@@ -236,6 +236,7 @@ export enum BillingTransactionType {
|
|
|
236
236
|
SUBSCRIPTION_CANCELED = 'subscription_canceled',
|
|
237
237
|
SUBSCRIPTION_REACTIVATED = 'subscription_reactivated',
|
|
238
238
|
SUBSCRIPTION_DELETED = 'subscription_deleted',
|
|
239
|
+
ADDON_PURCHASED = 'addon_purchased',
|
|
239
240
|
}
|
|
240
241
|
|
|
241
242
|
/**
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* RBAC (Role-Based Access Control) types for clinic staff management
|
|
3
3
|
*/
|
|
4
|
+
import { Timestamp } from 'firebase/firestore';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Roles that can be assigned to clinic staff members
|
|
@@ -24,8 +25,8 @@ export interface ClinicStaffMember {
|
|
|
24
25
|
role: ClinicRole;
|
|
25
26
|
permissions: Record<string, boolean>;
|
|
26
27
|
isActive: boolean;
|
|
27
|
-
createdAt?:
|
|
28
|
-
updatedAt?:
|
|
28
|
+
createdAt?: Timestamp;
|
|
29
|
+
updatedAt?: Timestamp;
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
/**
|
package/src/types/index.ts
CHANGED
|
@@ -37,9 +37,15 @@ export * from "./procedure";
|
|
|
37
37
|
// Profile types
|
|
38
38
|
export * from "./profile";
|
|
39
39
|
|
|
40
|
+
// Resource types
|
|
41
|
+
export * from "./resource";
|
|
42
|
+
|
|
40
43
|
// Reviews types
|
|
41
44
|
export * from "./reviews";
|
|
42
45
|
|
|
46
|
+
// System config types
|
|
47
|
+
export * from "./system";
|
|
48
|
+
|
|
43
49
|
// Analytics types
|
|
44
50
|
export * from "./analytics";
|
|
45
51
|
|