@blackcode_sa/metaestetics-api 1.15.17-staging.1 → 1.15.17-staging.11
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 +3 -1
- package/dist/admin/index.d.ts +3 -1
- package/dist/admin/index.js +1 -1
- package/dist/admin/index.mjs +1 -1
- package/dist/index.d.mts +236 -6
- package/dist/index.d.ts +236 -6
- package/dist/index.js +2097 -1787
- package/dist/index.mjs +1201 -898
- package/package.json +1 -1
- package/src/admin/aggregation/clinic/clinic.aggregation.service.ts +1 -1
- package/src/config/index.ts +3 -0
- package/src/config/tiers.config.ts +258 -39
- package/src/services/auth/auth.service.ts +16 -49
- package/src/services/clinic/clinic-group.service.ts +13 -6
- package/src/services/clinic/utils/clinic-group.utils.ts +110 -89
- package/src/services/plan-config.service.ts +55 -0
- package/src/services/procedure/procedure.service.ts +2 -0
- package/src/services/resource/resource.service.ts +86 -17
- package/src/services/tier-enforcement.ts +15 -10
- package/src/types/clinic/index.ts +8 -1
- package/src/types/clinic/rbac.types.ts +36 -1
- package/src/types/concern/index.ts +66 -0
- package/src/types/index.ts +6 -0
- package/src/types/system/index.ts +1 -0
- package/src/types/system/planConfig.types.ts +86 -0
- package/src/validations/clinic.schema.ts +9 -3
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import {
|
|
2
2
|
collection,
|
|
3
|
+
collectionGroup,
|
|
3
4
|
doc,
|
|
4
5
|
getDoc,
|
|
5
6
|
getDocs,
|
|
6
7
|
query,
|
|
7
8
|
where,
|
|
9
|
+
orderBy,
|
|
10
|
+
limit,
|
|
8
11
|
updateDoc,
|
|
9
12
|
setDoc,
|
|
10
13
|
deleteDoc,
|
|
@@ -47,6 +50,18 @@ function generateId(): string {
|
|
|
47
50
|
return `${randomPart}-${timestamp}`;
|
|
48
51
|
}
|
|
49
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Generates a short 6-character uppercase alphanumeric token code.
|
|
55
|
+
* Used for admin registration tokens — easy to read, type, and share.
|
|
56
|
+
* 36^6 = ~2.1 billion combinations, collision risk negligible for active tokens.
|
|
57
|
+
*/
|
|
58
|
+
function generateTokenCode(): string {
|
|
59
|
+
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no I/1/O/0 to avoid confusion
|
|
60
|
+
return Array.from({ length: 6 }, () =>
|
|
61
|
+
chars.charAt(Math.floor(Math.random() * chars.length))
|
|
62
|
+
).join("");
|
|
63
|
+
}
|
|
64
|
+
|
|
50
65
|
/**
|
|
51
66
|
* Creates a new clinic group
|
|
52
67
|
* @param db - Firestore database instance
|
|
@@ -161,7 +176,7 @@ export async function createClinicGroup(
|
|
|
161
176
|
clinicsInfo: [],
|
|
162
177
|
admins: [ownerId],
|
|
163
178
|
adminsInfo: [],
|
|
164
|
-
adminTokens: [],
|
|
179
|
+
adminTokens: [], // @deprecated — tokens now in subcollection, kept for backward compat
|
|
165
180
|
ownerId,
|
|
166
181
|
createdAt: now,
|
|
167
182
|
updatedAt: now,
|
|
@@ -369,9 +384,10 @@ export async function addAdminToGroup(
|
|
|
369
384
|
adminId,
|
|
370
385
|
groupId,
|
|
371
386
|
});
|
|
372
|
-
return;
|
|
387
|
+
return;
|
|
373
388
|
}
|
|
374
389
|
|
|
390
|
+
// Only add the admin ID — adminsInfo is populated by onCreateClinicAdmin trigger
|
|
375
391
|
console.log("[CLINIC_GROUP] Updating group with new admin");
|
|
376
392
|
await updateClinicGroup(
|
|
377
393
|
db,
|
|
@@ -459,6 +475,18 @@ export async function deactivateClinicGroup(
|
|
|
459
475
|
* @param data - Token data
|
|
460
476
|
* @returns The created admin token
|
|
461
477
|
*/
|
|
478
|
+
// --- Admin Token Subcollection ---
|
|
479
|
+
// Tokens are stored in: clinic_groups/{groupId}/adminTokens/{tokenId}
|
|
480
|
+
|
|
481
|
+
const ADMIN_TOKENS_SUBCOLLECTION = "adminTokens";
|
|
482
|
+
|
|
483
|
+
function adminTokensRef(db: Firestore, groupId: string) {
|
|
484
|
+
return collection(db, CLINIC_GROUPS_COLLECTION, groupId, ADMIN_TOKENS_SUBCOLLECTION);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Creates an admin token in the subcollection.
|
|
489
|
+
*/
|
|
462
490
|
export async function createAdminToken(
|
|
463
491
|
db: Firestore,
|
|
464
492
|
groupId: string,
|
|
@@ -471,121 +499,105 @@ export async function createAdminToken(
|
|
|
471
499
|
throw new Error("Clinic group not found");
|
|
472
500
|
}
|
|
473
501
|
|
|
474
|
-
// Proveravamo da li admin pripada grupi
|
|
475
502
|
if (!group.admins.includes(creatorAdminId)) {
|
|
476
503
|
throw new Error("Admin does not belong to this clinic group");
|
|
477
504
|
}
|
|
478
505
|
|
|
479
506
|
const now = Timestamp.now();
|
|
480
|
-
const expiresInDays = data?.expiresInDays || 7;
|
|
507
|
+
const expiresInDays = data?.expiresInDays || 7;
|
|
481
508
|
const email = data?.email || null;
|
|
482
509
|
const expiresAt = new Timestamp(
|
|
483
510
|
now.seconds + expiresInDays * 24 * 60 * 60,
|
|
484
511
|
now.nanoseconds
|
|
485
512
|
);
|
|
486
513
|
|
|
514
|
+
const tokenRef = doc(adminTokensRef(db, groupId));
|
|
487
515
|
const token: AdminToken = {
|
|
488
|
-
id:
|
|
489
|
-
token:
|
|
516
|
+
id: tokenRef.id,
|
|
517
|
+
token: generateTokenCode(),
|
|
490
518
|
status: AdminTokenStatus.ACTIVE,
|
|
491
519
|
email,
|
|
520
|
+
clinicGroupId: groupId,
|
|
492
521
|
createdAt: now,
|
|
493
522
|
expiresAt,
|
|
494
523
|
};
|
|
495
524
|
|
|
496
|
-
|
|
497
|
-
// Ovo treba promeniti, staviti admin tokene u sub-kolekciju u klinickoj grupi
|
|
498
|
-
await updateClinicGroup(
|
|
499
|
-
db,
|
|
500
|
-
groupId,
|
|
501
|
-
{
|
|
502
|
-
adminTokens: [...group.adminTokens, token],
|
|
503
|
-
},
|
|
504
|
-
app
|
|
505
|
-
);
|
|
506
|
-
|
|
525
|
+
await setDoc(tokenRef, token);
|
|
507
526
|
return token;
|
|
508
527
|
}
|
|
509
528
|
|
|
510
529
|
/**
|
|
511
|
-
* Verifies and
|
|
512
|
-
*
|
|
513
|
-
* @param groupId - ID of the clinic group
|
|
514
|
-
* @param token - Token to verify
|
|
515
|
-
* @param userRef - User reference
|
|
516
|
-
* @param app - Firebase app instance
|
|
517
|
-
* @returns Whether the token was successfully used
|
|
530
|
+
* Verifies and marks an admin token as used.
|
|
531
|
+
* Uses a collection group query to find the token across all groups.
|
|
518
532
|
*/
|
|
519
533
|
export async function verifyAndUseAdminToken(
|
|
520
534
|
db: Firestore,
|
|
521
535
|
groupId: string,
|
|
522
|
-
|
|
536
|
+
tokenValue: string,
|
|
523
537
|
userRef: string,
|
|
524
538
|
app: FirebaseApp
|
|
525
539
|
): Promise<boolean> {
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
540
|
+
// Query the subcollection for the matching token
|
|
541
|
+
const tokensQuery = query(
|
|
542
|
+
adminTokensRef(db, groupId),
|
|
543
|
+
where("token", "==", tokenValue),
|
|
544
|
+
limit(1)
|
|
545
|
+
);
|
|
546
|
+
const snapshot = await getDocs(tokensQuery);
|
|
530
547
|
|
|
531
|
-
|
|
532
|
-
if (!adminToken) {
|
|
548
|
+
if (snapshot.empty) {
|
|
533
549
|
throw new Error("Admin token not found");
|
|
534
550
|
}
|
|
535
551
|
|
|
552
|
+
const tokenDoc = snapshot.docs[0];
|
|
553
|
+
const adminToken = tokenDoc.data() as AdminToken;
|
|
554
|
+
|
|
536
555
|
if (adminToken.status !== AdminTokenStatus.ACTIVE) {
|
|
537
556
|
throw new Error("Admin token is not active");
|
|
538
557
|
}
|
|
539
558
|
|
|
540
559
|
const now = Timestamp.now();
|
|
541
560
|
if (adminToken.expiresAt.seconds < now.seconds) {
|
|
542
|
-
|
|
543
|
-
const updatedTokens = group.adminTokens.map((t) =>
|
|
544
|
-
t.id === adminToken.id ? { ...t, status: AdminTokenStatus.EXPIRED } : t
|
|
545
|
-
);
|
|
546
|
-
|
|
547
|
-
await updateClinicGroup(
|
|
548
|
-
db,
|
|
549
|
-
groupId,
|
|
550
|
-
{
|
|
551
|
-
adminTokens: updatedTokens,
|
|
552
|
-
},
|
|
553
|
-
app
|
|
554
|
-
);
|
|
555
|
-
|
|
561
|
+
await updateDoc(tokenDoc.ref, { status: AdminTokenStatus.EXPIRED });
|
|
556
562
|
throw new Error("Admin token has expired");
|
|
557
563
|
}
|
|
558
564
|
|
|
559
|
-
//
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
status: AdminTokenStatus.USED,
|
|
565
|
-
usedByUserRef: userRef,
|
|
566
|
-
}
|
|
567
|
-
: t
|
|
568
|
-
);
|
|
565
|
+
// Mark as used
|
|
566
|
+
await updateDoc(tokenDoc.ref, {
|
|
567
|
+
status: AdminTokenStatus.USED,
|
|
568
|
+
usedByUserRef: userRef,
|
|
569
|
+
});
|
|
569
570
|
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
571
|
+
return true;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Finds an admin token by its token string across ALL clinic groups.
|
|
576
|
+
* Uses a collection group query — much more efficient than scanning all groups.
|
|
577
|
+
*/
|
|
578
|
+
export async function findAdminTokenByValue(
|
|
579
|
+
db: Firestore,
|
|
580
|
+
tokenValue: string
|
|
581
|
+
): Promise<{ token: AdminToken; clinicGroupId: string } | null> {
|
|
582
|
+
const tokensQuery = query(
|
|
583
|
+
collectionGroup(db, ADMIN_TOKENS_SUBCOLLECTION),
|
|
584
|
+
where("token", "==", tokenValue),
|
|
585
|
+
where("status", "==", AdminTokenStatus.ACTIVE),
|
|
586
|
+
limit(1)
|
|
577
587
|
);
|
|
588
|
+
const snapshot = await getDocs(tokensQuery);
|
|
578
589
|
|
|
579
|
-
return
|
|
590
|
+
if (snapshot.empty) return null;
|
|
591
|
+
|
|
592
|
+
const tokenData = snapshot.docs[0].data() as AdminToken;
|
|
593
|
+
return {
|
|
594
|
+
token: tokenData,
|
|
595
|
+
clinicGroupId: tokenData.clinicGroupId,
|
|
596
|
+
};
|
|
580
597
|
}
|
|
581
598
|
|
|
582
599
|
/**
|
|
583
|
-
* Deletes an admin token
|
|
584
|
-
* @param db - Firestore database instance
|
|
585
|
-
* @param groupId - ID of the clinic group
|
|
586
|
-
* @param tokenId - ID of the token to delete
|
|
587
|
-
* @param adminId - ID of the admin making the deletion
|
|
588
|
-
* @param app - Firebase app instance
|
|
600
|
+
* Deletes an admin token from the subcollection.
|
|
589
601
|
*/
|
|
590
602
|
export async function deleteAdminToken(
|
|
591
603
|
db: Firestore,
|
|
@@ -599,33 +611,43 @@ export async function deleteAdminToken(
|
|
|
599
611
|
throw new Error("Clinic group not found");
|
|
600
612
|
}
|
|
601
613
|
|
|
602
|
-
// Proveravamo da li admin pripada grupi
|
|
603
614
|
if (!group.admins.includes(adminId)) {
|
|
604
615
|
throw new Error("Admin does not belong to this clinic group");
|
|
605
616
|
}
|
|
606
617
|
|
|
607
|
-
|
|
608
|
-
|
|
618
|
+
await deleteDoc(doc(adminTokensRef(db, groupId), tokenId));
|
|
619
|
+
}
|
|
609
620
|
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
621
|
+
/**
|
|
622
|
+
* Gets active admin tokens for a clinic group from the subcollection.
|
|
623
|
+
*/
|
|
624
|
+
export async function getActiveAdminTokens(
|
|
625
|
+
db: Firestore,
|
|
626
|
+
groupId: string,
|
|
627
|
+
adminId: string,
|
|
628
|
+
app: FirebaseApp
|
|
629
|
+
): Promise<AdminToken[]> {
|
|
630
|
+
const group = await getClinicGroup(db, groupId);
|
|
631
|
+
if (!group) {
|
|
632
|
+
throw new Error("Clinic group not found");
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
if (!group.admins.includes(adminId)) {
|
|
636
|
+
throw new Error("Admin does not belong to this clinic group");
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const tokensQuery = query(
|
|
640
|
+
adminTokensRef(db, groupId),
|
|
641
|
+
where("status", "==", AdminTokenStatus.ACTIVE)
|
|
617
642
|
);
|
|
643
|
+
const snapshot = await getDocs(tokensQuery);
|
|
644
|
+
return snapshot.docs.map((d) => d.data() as AdminToken);
|
|
618
645
|
}
|
|
619
646
|
|
|
620
647
|
/**
|
|
621
|
-
* Gets
|
|
622
|
-
* @param db - Firestore database instance
|
|
623
|
-
* @param groupId - ID of the clinic group
|
|
624
|
-
* @param adminId - ID of the admin requesting the tokens
|
|
625
|
-
* @param app - Firebase app instance (not used but included for consistency)
|
|
626
|
-
* @returns Array of active admin tokens
|
|
648
|
+
* Gets ALL admin tokens for a clinic group (all statuses).
|
|
627
649
|
*/
|
|
628
|
-
export async function
|
|
650
|
+
export async function getAllAdminTokens(
|
|
629
651
|
db: Firestore,
|
|
630
652
|
groupId: string,
|
|
631
653
|
adminId: string,
|
|
@@ -636,11 +658,10 @@ export async function getActiveAdminTokens(
|
|
|
636
658
|
throw new Error("Clinic group not found");
|
|
637
659
|
}
|
|
638
660
|
|
|
639
|
-
// Proveravamo da li admin pripada grupi
|
|
640
661
|
if (!group.admins.includes(adminId)) {
|
|
641
662
|
throw new Error("Admin does not belong to this clinic group");
|
|
642
663
|
}
|
|
643
664
|
|
|
644
|
-
|
|
645
|
-
return
|
|
665
|
+
const snapshot = await getDocs(adminTokensRef(db, groupId));
|
|
666
|
+
return snapshot.docs.map((d) => d.data() as AdminToken);
|
|
646
667
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan Config Service — reads `system/planConfig` from Firestore with caching.
|
|
3
|
+
* Uses the Firebase client SDK (same as tier-enforcement.ts).
|
|
4
|
+
* Falls back to DEFAULT_PLAN_CONFIG if the document doesn't exist.
|
|
5
|
+
*/
|
|
6
|
+
import { Firestore, doc, getDoc } from 'firebase/firestore';
|
|
7
|
+
import { DEFAULT_PLAN_CONFIG } from '../config/tiers.config';
|
|
8
|
+
import type { PlanConfigDocument } from '../types/system/planConfig.types';
|
|
9
|
+
import { PLAN_CONFIG_PATH } from '../types/system/planConfig.types';
|
|
10
|
+
|
|
11
|
+
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
12
|
+
|
|
13
|
+
class PlanConfigService {
|
|
14
|
+
private cachedConfig: PlanConfigDocument | null = null;
|
|
15
|
+
private cacheExpiry = 0;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Returns the current plan config, reading from Firestore if the cache
|
|
19
|
+
* has expired. Falls back to DEFAULT_PLAN_CONFIG if the doc is missing.
|
|
20
|
+
*/
|
|
21
|
+
async getConfig(db: Firestore): Promise<PlanConfigDocument> {
|
|
22
|
+
const now = Date.now();
|
|
23
|
+
if (this.cachedConfig && now < this.cacheExpiry) {
|
|
24
|
+
return this.cachedConfig;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const [collectionPath, docId] = PLAN_CONFIG_PATH.split('/');
|
|
29
|
+
const configRef = doc(db, collectionPath, docId);
|
|
30
|
+
const snap = await getDoc(configRef);
|
|
31
|
+
|
|
32
|
+
if (snap.exists()) {
|
|
33
|
+
this.cachedConfig = snap.data() as PlanConfigDocument;
|
|
34
|
+
} else {
|
|
35
|
+
this.cachedConfig = DEFAULT_PLAN_CONFIG;
|
|
36
|
+
}
|
|
37
|
+
} catch (error) {
|
|
38
|
+
console.error('PlanConfigService: Firestore read failed, using fallback config', error);
|
|
39
|
+
if (!this.cachedConfig) {
|
|
40
|
+
this.cachedConfig = DEFAULT_PLAN_CONFIG;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
this.cacheExpiry = now + CACHE_TTL_MS;
|
|
45
|
+
return this.cachedConfig;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Clears the cache — useful for testing or after config updates. */
|
|
49
|
+
invalidateCache(): void {
|
|
50
|
+
this.cachedConfig = null;
|
|
51
|
+
this.cacheExpiry = 0;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const planConfigService = new PlanConfigService();
|
|
@@ -1232,6 +1232,8 @@ export class ProcedureService extends BaseService {
|
|
|
1232
1232
|
updatedProcedureData.duration = validatedData.duration;
|
|
1233
1233
|
if (validatedData.isActive !== undefined)
|
|
1234
1234
|
updatedProcedureData.isActive = validatedData.isActive;
|
|
1235
|
+
if (validatedData.resourceRequirements !== undefined)
|
|
1236
|
+
updatedProcedureData.resourceRequirements = validatedData.resourceRequirements;
|
|
1235
1237
|
|
|
1236
1238
|
let practitionerChanged = false;
|
|
1237
1239
|
let clinicChanged = false;
|
|
@@ -116,7 +116,7 @@ export class ResourceService extends BaseService {
|
|
|
116
116
|
name: data.name,
|
|
117
117
|
nameLower: data.name.toLowerCase(),
|
|
118
118
|
category: data.category,
|
|
119
|
-
description: data.description ||
|
|
119
|
+
description: data.description || "",
|
|
120
120
|
quantity: data.quantity,
|
|
121
121
|
status: ResourceStatus.ACTIVE,
|
|
122
122
|
linkedProcedureIds: [],
|
|
@@ -228,34 +228,61 @@ export class ResourceService extends BaseService {
|
|
|
228
228
|
// Handle quantity change
|
|
229
229
|
if (data.quantity !== undefined && data.quantity !== existing.quantity) {
|
|
230
230
|
if (data.quantity > existing.quantity) {
|
|
231
|
-
//
|
|
231
|
+
// Increasing quantity — first reactivate inactive instances, then create new ones if needed
|
|
232
|
+
const allInstances = await this.getResourceInstances(clinicBranchId, resourceId);
|
|
233
|
+
const activeInstances = allInstances.filter(i => i.status === ResourceStatus.ACTIVE);
|
|
234
|
+
const inactiveInstances = allInstances.filter(i => i.status === ResourceStatus.INACTIVE);
|
|
235
|
+
|
|
236
|
+
const needed = data.quantity - activeInstances.length;
|
|
232
237
|
const batch = writeBatch(this.db);
|
|
233
238
|
const now = serverTimestamp();
|
|
234
239
|
const resourceName = data.name || existing.name;
|
|
235
240
|
|
|
236
|
-
|
|
241
|
+
let reactivated = 0;
|
|
242
|
+
|
|
243
|
+
// Step 1: Reactivate inactive instances (sorted by index, lowest first)
|
|
244
|
+
const sortedInactive = [...inactiveInstances].sort((a, b) => a.index - b.index);
|
|
245
|
+
for (const instance of sortedInactive) {
|
|
246
|
+
if (reactivated >= needed) break;
|
|
237
247
|
const instanceRef = doc(
|
|
238
|
-
this.getInstancesRef(clinicBranchId, resourceId)
|
|
248
|
+
this.getInstancesRef(clinicBranchId, resourceId),
|
|
249
|
+
instance.id
|
|
239
250
|
);
|
|
240
|
-
|
|
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,
|
|
251
|
+
batch.update(instanceRef, {
|
|
249
252
|
status: ResourceStatus.ACTIVE,
|
|
250
|
-
createdAt: now,
|
|
251
253
|
updatedAt: now,
|
|
252
|
-
};
|
|
253
|
-
|
|
254
|
+
});
|
|
255
|
+
reactivated++;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Step 2: Create new instances for any remaining needed
|
|
259
|
+
const remaining = needed - reactivated;
|
|
260
|
+
if (remaining > 0) {
|
|
261
|
+
// Find the highest existing index to continue numbering
|
|
262
|
+
const maxIndex = allInstances.reduce((max, i) => Math.max(max, i.index), 0);
|
|
263
|
+
for (let i = 1; i <= remaining; i++) {
|
|
264
|
+
const instanceRef = doc(
|
|
265
|
+
this.getInstancesRef(clinicBranchId, resourceId)
|
|
266
|
+
);
|
|
267
|
+
const instanceData: Omit<
|
|
268
|
+
ResourceInstance,
|
|
269
|
+
"createdAt" | "updatedAt"
|
|
270
|
+
> & { createdAt: any; updatedAt: any } = {
|
|
271
|
+
id: instanceRef.id,
|
|
272
|
+
resourceId,
|
|
273
|
+
clinicBranchId,
|
|
274
|
+
label: `${resourceName} #${maxIndex + i}`,
|
|
275
|
+
index: maxIndex + i,
|
|
276
|
+
status: ResourceStatus.ACTIVE,
|
|
277
|
+
createdAt: now,
|
|
278
|
+
updatedAt: now,
|
|
279
|
+
};
|
|
280
|
+
batch.set(instanceRef, instanceData);
|
|
281
|
+
}
|
|
254
282
|
}
|
|
255
283
|
|
|
256
284
|
updateData.quantity = data.quantity;
|
|
257
285
|
|
|
258
|
-
// Update resource doc in the batch too
|
|
259
286
|
const resourceDocRef = this.getResourceDocRef(
|
|
260
287
|
clinicBranchId,
|
|
261
288
|
resourceId
|
|
@@ -337,6 +364,48 @@ export class ResourceService extends BaseService {
|
|
|
337
364
|
});
|
|
338
365
|
}
|
|
339
366
|
|
|
367
|
+
/**
|
|
368
|
+
* Hard deletes a specific instance and all its calendar events.
|
|
369
|
+
* Only works on INACTIVE instances with no future bookings.
|
|
370
|
+
*/
|
|
371
|
+
async hardDeleteInstance(
|
|
372
|
+
clinicBranchId: string,
|
|
373
|
+
resourceId: string,
|
|
374
|
+
instanceId: string
|
|
375
|
+
): Promise<void> {
|
|
376
|
+
// Verify instance exists and is inactive
|
|
377
|
+
const instanceRef = doc(
|
|
378
|
+
this.getInstancesRef(clinicBranchId, resourceId),
|
|
379
|
+
instanceId
|
|
380
|
+
);
|
|
381
|
+
const instanceSnap = await getDoc(instanceRef);
|
|
382
|
+
if (!instanceSnap.exists()) throw new Error("Instance not found");
|
|
383
|
+
|
|
384
|
+
const instance = instanceSnap.data() as ResourceInstance;
|
|
385
|
+
if (instance.status === ResourceStatus.ACTIVE) {
|
|
386
|
+
throw new Error("Cannot hard-delete an active instance. Deactivate it first.");
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Check for future bookings
|
|
390
|
+
const hasFutureBookings = await this.instanceHasFutureBookings(
|
|
391
|
+
clinicBranchId, resourceId, instanceId
|
|
392
|
+
);
|
|
393
|
+
if (hasFutureBookings) {
|
|
394
|
+
throw new Error("Cannot delete instance with future bookings.");
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Delete all calendar events for this instance
|
|
398
|
+
const calendarRef = this.getInstanceCalendarRef(
|
|
399
|
+
clinicBranchId, resourceId, instanceId
|
|
400
|
+
);
|
|
401
|
+
const calendarSnap = await getDocs(calendarRef);
|
|
402
|
+
|
|
403
|
+
const batch = writeBatch(this.db);
|
|
404
|
+
calendarSnap.docs.forEach(d => batch.delete(d.ref));
|
|
405
|
+
batch.delete(instanceRef);
|
|
406
|
+
await batch.commit();
|
|
407
|
+
}
|
|
408
|
+
|
|
340
409
|
/**
|
|
341
410
|
* Gets all instances for a resource
|
|
342
411
|
*/
|
|
@@ -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;
|
|
@@ -3,6 +3,7 @@ import { Timestamp, FieldValue } from 'firebase/firestore';
|
|
|
3
3
|
import type { ClinicInfo } from '../profile';
|
|
4
4
|
import { ClinicReviewInfo } from '../reviews';
|
|
5
5
|
import { ProcedureSummaryInfo } from '../procedure';
|
|
6
|
+
import { ClinicRole } from './rbac.types';
|
|
6
7
|
|
|
7
8
|
export const CLINIC_GROUPS_COLLECTION = 'clinic_groups';
|
|
8
9
|
export const CLINIC_ADMINS_COLLECTION = 'clinic_admins';
|
|
@@ -110,6 +111,8 @@ export interface ClinicAdmin {
|
|
|
110
111
|
clinicsManagedInfo: ClinicInfo[];
|
|
111
112
|
contactInfo: ContactPerson;
|
|
112
113
|
roleTitle: string;
|
|
114
|
+
role?: ClinicRole;
|
|
115
|
+
permissions?: Record<string, boolean>;
|
|
113
116
|
createdAt: Timestamp;
|
|
114
117
|
updatedAt: Timestamp;
|
|
115
118
|
isActive: boolean;
|
|
@@ -126,6 +129,8 @@ export interface CreateClinicAdminData {
|
|
|
126
129
|
clinicsManagedInfo?: ClinicInfo[];
|
|
127
130
|
contactInfo: ContactPerson;
|
|
128
131
|
roleTitle: string;
|
|
132
|
+
role?: ClinicRole;
|
|
133
|
+
permissions?: Record<string, boolean>;
|
|
129
134
|
isActive: boolean;
|
|
130
135
|
}
|
|
131
136
|
|
|
@@ -155,6 +160,7 @@ export interface AdminToken {
|
|
|
155
160
|
email?: string | null;
|
|
156
161
|
status: AdminTokenStatus;
|
|
157
162
|
usedByUserRef?: string;
|
|
163
|
+
clinicGroupId: string;
|
|
158
164
|
createdAt: Timestamp;
|
|
159
165
|
expiresAt: Timestamp;
|
|
160
166
|
}
|
|
@@ -303,7 +309,8 @@ export interface ClinicGroup {
|
|
|
303
309
|
clinicsInfo: ClinicInfo[];
|
|
304
310
|
admins: string[];
|
|
305
311
|
adminsInfo: AdminInfo[];
|
|
306
|
-
adminTokens
|
|
312
|
+
/** @deprecated Tokens now stored in subcollection clinic_groups/{id}/adminTokens */
|
|
313
|
+
adminTokens?: AdminToken[];
|
|
307
314
|
ownerId: string | null;
|
|
308
315
|
createdAt: Timestamp;
|
|
309
316
|
updatedAt: Timestamp;
|
|
@@ -9,11 +9,19 @@ import { Timestamp } from 'firebase/firestore';
|
|
|
9
9
|
export enum ClinicRole {
|
|
10
10
|
OWNER = 'owner',
|
|
11
11
|
ADMIN = 'admin',
|
|
12
|
-
DOCTOR = 'doctor',
|
|
13
12
|
RECEPTIONIST = 'receptionist',
|
|
14
13
|
ASSISTANT = 'assistant',
|
|
15
14
|
}
|
|
16
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Display info denormalized onto a staff record for fast list rendering.
|
|
18
|
+
*/
|
|
19
|
+
export interface StaffDisplayInfo {
|
|
20
|
+
firstName: string;
|
|
21
|
+
lastName: string;
|
|
22
|
+
email: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
17
25
|
/**
|
|
18
26
|
* Represents a staff member within a clinic group
|
|
19
27
|
*/
|
|
@@ -25,6 +33,8 @@ export interface ClinicStaffMember {
|
|
|
25
33
|
role: ClinicRole;
|
|
26
34
|
permissions: Record<string, boolean>;
|
|
27
35
|
isActive: boolean;
|
|
36
|
+
displayInfo?: StaffDisplayInfo;
|
|
37
|
+
invitedBy?: string;
|
|
28
38
|
createdAt?: Timestamp;
|
|
29
39
|
updatedAt?: Timestamp;
|
|
30
40
|
}
|
|
@@ -38,6 +48,31 @@ export interface RolePermissionConfig {
|
|
|
38
48
|
permissions: Record<string, boolean>;
|
|
39
49
|
}
|
|
40
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Status of a staff invitation
|
|
53
|
+
*/
|
|
54
|
+
export enum StaffInviteStatus {
|
|
55
|
+
PENDING = 'pending',
|
|
56
|
+
ACCEPTED = 'accepted',
|
|
57
|
+
EXPIRED = 'expired',
|
|
58
|
+
CANCELLED = 'cancelled',
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* An invitation for a new staff member to join a clinic group
|
|
63
|
+
*/
|
|
64
|
+
export interface ClinicStaffInvite {
|
|
65
|
+
id: string;
|
|
66
|
+
email: string;
|
|
67
|
+
clinicGroupId: string;
|
|
68
|
+
role: ClinicRole;
|
|
69
|
+
token: string;
|
|
70
|
+
status: StaffInviteStatus;
|
|
71
|
+
invitedBy: string;
|
|
72
|
+
createdAt?: Timestamp;
|
|
73
|
+
expiresAt?: Timestamp;
|
|
74
|
+
}
|
|
75
|
+
|
|
41
76
|
/**
|
|
42
77
|
* Usage limits for a given subscription tier.
|
|
43
78
|
* All features are available on every tier — only usage is capped.
|