@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.
@@ -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; // Admin is already in the group
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; // Default 7 days
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: generateId(),
489
- token: generateId(),
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
- // Dodajemo token u grupu
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 uses an admin token
512
- * @param db - Firestore database instance
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
- token: string,
536
+ tokenValue: string,
523
537
  userRef: string,
524
538
  app: FirebaseApp
525
539
  ): Promise<boolean> {
526
- const group = await getClinicGroup(db, groupId);
527
- if (!group) {
528
- throw new Error("Clinic group not found");
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
- const adminToken = group.adminTokens.find((t) => t.token === token);
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
- // Token je istekao, ažuriramo status
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
- // Token je validan, ažuriramo status
560
- const updatedTokens = group.adminTokens.map((t) =>
561
- t.id === adminToken.id
562
- ? {
563
- ...t,
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
- await updateClinicGroup(
571
- db,
572
- groupId,
573
- {
574
- adminTokens: updatedTokens,
575
- },
576
- app
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 true;
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
- // Uklanjamo token
608
- const updatedTokens = group.adminTokens.filter((t) => t.id !== tokenId);
618
+ await deleteDoc(doc(adminTokensRef(db, groupId), tokenId));
619
+ }
609
620
 
610
- await updateClinicGroup(
611
- db,
612
- groupId,
613
- {
614
- adminTokens: updatedTokens,
615
- },
616
- app
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 active admin tokens for a clinic group
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 getActiveAdminTokens(
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
- // Vraćamo samo aktivne tokene
645
- return group.adminTokens.filter((t) => t.status === AdminTokenStatus.ACTIVE);
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 || undefined,
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
- // Add new instances
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
- for (let i = existing.quantity + 1; i <= data.quantity; i++) {
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
- 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,
251
+ batch.update(instanceRef, {
249
252
  status: ResourceStatus.ACTIVE,
250
- createdAt: now,
251
253
  updatedAt: now,
252
- };
253
- batch.set(instanceRef, instanceData);
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 config = TIER_CONFIG[tier];
175
- if (!config) return;
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 = config.limits.maxProvidersPerBranch;
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 config = TIER_CONFIG[tier];
203
- if (!config) return;
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 = config.limits.maxProceduresPerProvider;
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 effectiveMax = baseMax + (procedureBlocks * 5);
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 config = TIER_CONFIG[tier];
228
- if (!config) return;
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 = config.limits.maxBranches;
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: AdminToken[];
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.