@blackcode_sa/metaestetics-api 1.15.8 → 1.15.9
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 +7 -1
- package/dist/admin/index.d.ts +7 -1
- package/dist/index.d.mts +163 -2
- package/dist/index.d.ts +163 -2
- package/dist/index.js +1941 -1505
- package/dist/index.mjs +1626 -1197
- package/package.json +1 -1
- package/src/config/index.ts +9 -0
- package/src/config/tiers.config.ts +237 -0
- package/src/services/clinic/clinic.service.ts +7 -0
- package/src/services/index.ts +1 -0
- package/src/services/patient/patientRequirements.service.ts +24 -0
- package/src/services/practitioner/practitioner.service.ts +19 -0
- package/src/services/procedure/procedure.service.ts +7 -0
- package/src/services/tier-enforcement.ts +290 -0
- package/src/types/clinic/index.ts +9 -0
- package/src/types/clinic/rbac.types.ts +62 -0
package/package.json
CHANGED
package/src/config/index.ts
CHANGED
|
@@ -7,3 +7,12 @@ export {
|
|
|
7
7
|
getFirebaseStorage,
|
|
8
8
|
getFirebaseFunctions,
|
|
9
9
|
} from "./firebase";
|
|
10
|
+
|
|
11
|
+
export {
|
|
12
|
+
PERMISSION_KEYS,
|
|
13
|
+
TIER_CONFIG,
|
|
14
|
+
DEFAULT_ROLE_PERMISSIONS,
|
|
15
|
+
LEGACY_TIER_MAP,
|
|
16
|
+
resolveEffectiveTier,
|
|
17
|
+
} from "./tiers.config";
|
|
18
|
+
export type { PermissionKey } from "./tiers.config";
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { ClinicRole } from '../types/clinic/rbac.types';
|
|
2
|
+
import type { TierConfig } from '../types/clinic/rbac.types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Permission keys used for role-based access control.
|
|
6
|
+
* Every feature is available on every tier — these only control
|
|
7
|
+
* what a given role (owner/admin/doctor/etc.) can do.
|
|
8
|
+
*/
|
|
9
|
+
export const PERMISSION_KEYS = {
|
|
10
|
+
// Listing & profile
|
|
11
|
+
'clinic.view': true,
|
|
12
|
+
'clinic.edit': true,
|
|
13
|
+
'reviews.view': true,
|
|
14
|
+
|
|
15
|
+
// Calendar & Appointments
|
|
16
|
+
'calendar.view': true,
|
|
17
|
+
'appointments.view': true,
|
|
18
|
+
'appointments.confirm': true,
|
|
19
|
+
'appointments.cancel': true,
|
|
20
|
+
|
|
21
|
+
// Messaging
|
|
22
|
+
'messaging': true,
|
|
23
|
+
|
|
24
|
+
// Procedures
|
|
25
|
+
'procedures.view': true,
|
|
26
|
+
'procedures.create': true,
|
|
27
|
+
'procedures.edit': true,
|
|
28
|
+
'procedures.delete': true,
|
|
29
|
+
|
|
30
|
+
// Patients
|
|
31
|
+
'patients.view': true,
|
|
32
|
+
'patients.edit': true,
|
|
33
|
+
|
|
34
|
+
// Providers (doctors, nurses, laser assistants, etc.)
|
|
35
|
+
'providers.view': true,
|
|
36
|
+
'providers.manage': true,
|
|
37
|
+
|
|
38
|
+
// Analytics
|
|
39
|
+
'analytics.view': true,
|
|
40
|
+
|
|
41
|
+
// Staff management
|
|
42
|
+
'staff.manage': true,
|
|
43
|
+
|
|
44
|
+
// Settings / Admin
|
|
45
|
+
'settings.manage': true,
|
|
46
|
+
'billing.manage': true,
|
|
47
|
+
} as const;
|
|
48
|
+
|
|
49
|
+
export type PermissionKey = keyof typeof PERMISSION_KEYS;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Usage limits per subscription tier.
|
|
53
|
+
* All features are available on every tier — tiers differ only in usage caps.
|
|
54
|
+
* -1 means unlimited.
|
|
55
|
+
*
|
|
56
|
+
* Confirmed by client on 2026-03-09:
|
|
57
|
+
* - Free: 1 provider, 3 procedures, 3 appts/mo, 10 msgs/mo, 1 staff, 1 branch
|
|
58
|
+
* - Connect: 5 providers, 15 procedures, 100 appts/mo, unlimited msgs, 5 staff, 1 branch
|
|
59
|
+
* - Pro: unlimited everything
|
|
60
|
+
*/
|
|
61
|
+
export const TIER_CONFIG: Record<string, TierConfig> = {
|
|
62
|
+
free: {
|
|
63
|
+
tier: 'free',
|
|
64
|
+
name: 'Free',
|
|
65
|
+
limits: {
|
|
66
|
+
maxProviders: 1,
|
|
67
|
+
maxProcedures: 3,
|
|
68
|
+
maxAppointmentsPerMonth: 3,
|
|
69
|
+
maxMessagesPerMonth: 10,
|
|
70
|
+
maxStaff: 1,
|
|
71
|
+
maxBranches: 1,
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
connect: {
|
|
75
|
+
tier: 'connect',
|
|
76
|
+
name: 'Connect',
|
|
77
|
+
limits: {
|
|
78
|
+
maxProviders: 5,
|
|
79
|
+
maxProcedures: 15,
|
|
80
|
+
maxAppointmentsPerMonth: 100,
|
|
81
|
+
maxMessagesPerMonth: -1,
|
|
82
|
+
maxStaff: 5,
|
|
83
|
+
maxBranches: 1,
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
pro: {
|
|
87
|
+
tier: 'pro',
|
|
88
|
+
name: 'Pro',
|
|
89
|
+
limits: {
|
|
90
|
+
maxProviders: -1,
|
|
91
|
+
maxProcedures: -1,
|
|
92
|
+
maxAppointmentsPerMonth: -1,
|
|
93
|
+
maxMessagesPerMonth: -1,
|
|
94
|
+
maxStaff: -1,
|
|
95
|
+
maxBranches: -1,
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Default permissions per ClinicRole.
|
|
102
|
+
* These are the baseline permissions for each role; owners can override per-user.
|
|
103
|
+
*/
|
|
104
|
+
export const DEFAULT_ROLE_PERMISSIONS: Record<ClinicRole, Record<string, boolean>> = {
|
|
105
|
+
[ClinicRole.OWNER]: {
|
|
106
|
+
'clinic.view': true,
|
|
107
|
+
'clinic.edit': true,
|
|
108
|
+
'reviews.view': true,
|
|
109
|
+
'calendar.view': true,
|
|
110
|
+
'appointments.view': true,
|
|
111
|
+
'appointments.confirm': true,
|
|
112
|
+
'appointments.cancel': true,
|
|
113
|
+
'messaging': true,
|
|
114
|
+
'procedures.view': true,
|
|
115
|
+
'procedures.create': true,
|
|
116
|
+
'procedures.edit': true,
|
|
117
|
+
'procedures.delete': true,
|
|
118
|
+
'patients.view': true,
|
|
119
|
+
'patients.edit': true,
|
|
120
|
+
'providers.view': true,
|
|
121
|
+
'providers.manage': true,
|
|
122
|
+
'analytics.view': true,
|
|
123
|
+
'staff.manage': true,
|
|
124
|
+
'settings.manage': true,
|
|
125
|
+
'billing.manage': true,
|
|
126
|
+
},
|
|
127
|
+
[ClinicRole.ADMIN]: {
|
|
128
|
+
'clinic.view': true,
|
|
129
|
+
'clinic.edit': true,
|
|
130
|
+
'reviews.view': true,
|
|
131
|
+
'calendar.view': true,
|
|
132
|
+
'appointments.view': true,
|
|
133
|
+
'appointments.confirm': true,
|
|
134
|
+
'appointments.cancel': true,
|
|
135
|
+
'messaging': true,
|
|
136
|
+
'procedures.view': true,
|
|
137
|
+
'procedures.create': true,
|
|
138
|
+
'procedures.edit': true,
|
|
139
|
+
'procedures.delete': true,
|
|
140
|
+
'patients.view': true,
|
|
141
|
+
'patients.edit': true,
|
|
142
|
+
'providers.view': true,
|
|
143
|
+
'providers.manage': true,
|
|
144
|
+
'analytics.view': true,
|
|
145
|
+
'staff.manage': false,
|
|
146
|
+
'settings.manage': true,
|
|
147
|
+
'billing.manage': false,
|
|
148
|
+
},
|
|
149
|
+
[ClinicRole.DOCTOR]: {
|
|
150
|
+
'clinic.view': true,
|
|
151
|
+
'clinic.edit': false,
|
|
152
|
+
'reviews.view': true,
|
|
153
|
+
'calendar.view': true,
|
|
154
|
+
'appointments.view': true,
|
|
155
|
+
'appointments.confirm': true,
|
|
156
|
+
'appointments.cancel': true,
|
|
157
|
+
'messaging': true,
|
|
158
|
+
'procedures.view': true,
|
|
159
|
+
'procedures.create': false,
|
|
160
|
+
'procedures.edit': false,
|
|
161
|
+
'procedures.delete': false,
|
|
162
|
+
'patients.view': true,
|
|
163
|
+
'patients.edit': true,
|
|
164
|
+
'providers.view': true,
|
|
165
|
+
'providers.manage': false,
|
|
166
|
+
'analytics.view': true,
|
|
167
|
+
'staff.manage': false,
|
|
168
|
+
'settings.manage': false,
|
|
169
|
+
'billing.manage': false,
|
|
170
|
+
},
|
|
171
|
+
[ClinicRole.RECEPTIONIST]: {
|
|
172
|
+
'clinic.view': true,
|
|
173
|
+
'clinic.edit': false,
|
|
174
|
+
'reviews.view': true,
|
|
175
|
+
'calendar.view': true,
|
|
176
|
+
'appointments.view': true,
|
|
177
|
+
'appointments.confirm': true,
|
|
178
|
+
'appointments.cancel': false,
|
|
179
|
+
'messaging': true,
|
|
180
|
+
'procedures.view': true,
|
|
181
|
+
'procedures.create': false,
|
|
182
|
+
'procedures.edit': false,
|
|
183
|
+
'procedures.delete': false,
|
|
184
|
+
'patients.view': true,
|
|
185
|
+
'patients.edit': false,
|
|
186
|
+
'providers.view': true,
|
|
187
|
+
'providers.manage': false,
|
|
188
|
+
'analytics.view': false,
|
|
189
|
+
'staff.manage': false,
|
|
190
|
+
'settings.manage': false,
|
|
191
|
+
'billing.manage': false,
|
|
192
|
+
},
|
|
193
|
+
[ClinicRole.ASSISTANT]: {
|
|
194
|
+
'clinic.view': true,
|
|
195
|
+
'clinic.edit': false,
|
|
196
|
+
'reviews.view': true,
|
|
197
|
+
'calendar.view': true,
|
|
198
|
+
'appointments.view': true,
|
|
199
|
+
'appointments.confirm': false,
|
|
200
|
+
'appointments.cancel': false,
|
|
201
|
+
'messaging': false,
|
|
202
|
+
'procedures.view': true,
|
|
203
|
+
'procedures.create': false,
|
|
204
|
+
'procedures.edit': false,
|
|
205
|
+
'procedures.delete': false,
|
|
206
|
+
'patients.view': true,
|
|
207
|
+
'patients.edit': false,
|
|
208
|
+
'providers.view': true,
|
|
209
|
+
'providers.manage': false,
|
|
210
|
+
'analytics.view': false,
|
|
211
|
+
'staff.manage': false,
|
|
212
|
+
'settings.manage': false,
|
|
213
|
+
'billing.manage': false,
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Maps legacy subscription models to new tier equivalents.
|
|
219
|
+
* All paid legacy tiers map to PRO.
|
|
220
|
+
*/
|
|
221
|
+
export const LEGACY_TIER_MAP: Record<string, string> = {
|
|
222
|
+
basic: 'pro',
|
|
223
|
+
premium: 'pro',
|
|
224
|
+
enterprise: 'pro',
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Resolves the effective tier for a subscription model string,
|
|
229
|
+
* handling legacy values.
|
|
230
|
+
*/
|
|
231
|
+
export function resolveEffectiveTier(subscriptionModel: string): string {
|
|
232
|
+
const lower = subscriptionModel.toLowerCase();
|
|
233
|
+
if (LEGACY_TIER_MAP[lower]) {
|
|
234
|
+
return LEGACY_TIER_MAP[lower];
|
|
235
|
+
}
|
|
236
|
+
return lower;
|
|
237
|
+
}
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
import { getFunctions, httpsCallable } from "firebase/functions";
|
|
21
21
|
import tzlookup from "tz-lookup";
|
|
22
22
|
import { BaseService } from "../base.service";
|
|
23
|
+
import { enforceBranchLimit } from "../tier-enforcement";
|
|
23
24
|
import {
|
|
24
25
|
Clinic,
|
|
25
26
|
CreateClinicData,
|
|
@@ -221,6 +222,9 @@ export class ClinicService extends BaseService {
|
|
|
221
222
|
);
|
|
222
223
|
}
|
|
223
224
|
|
|
225
|
+
// Enforce tier limit before creating clinic branch
|
|
226
|
+
await enforceBranchLimit(this.db, validatedData.clinicGroupId);
|
|
227
|
+
|
|
224
228
|
// Process media fields - convert File/Blob objects to URLs
|
|
225
229
|
const logoUrl = await this.processMedia(
|
|
226
230
|
validatedData.logo,
|
|
@@ -587,6 +591,9 @@ export class ClinicService extends BaseService {
|
|
|
587
591
|
}
|
|
588
592
|
console.log("[CLINIC_SERVICE] Clinic group verified");
|
|
589
593
|
|
|
594
|
+
// Enforce tier limit before creating clinic branch
|
|
595
|
+
await enforceBranchLimit(this.db, clinicGroupId);
|
|
596
|
+
|
|
590
597
|
// Validate branch setup data first
|
|
591
598
|
const validatedSetupData = clinicBranchSetupSchema.parse(setupData);
|
|
592
599
|
|
package/src/services/index.ts
CHANGED
|
@@ -212,6 +212,30 @@ export class PatientRequirementsService extends BaseService {
|
|
|
212
212
|
|
|
213
213
|
const instructionToUpdate = instance.instructions[instructionIndex];
|
|
214
214
|
|
|
215
|
+
// Date enforcement: reject premature completions outside actionable window
|
|
216
|
+
const currentTime = Timestamp.now();
|
|
217
|
+
const dueTime = instructionToUpdate.dueTime;
|
|
218
|
+
if (dueTime) {
|
|
219
|
+
const dueTimeMs =
|
|
220
|
+
typeof dueTime === "number"
|
|
221
|
+
? dueTime
|
|
222
|
+
: typeof (dueTime as any).toMillis === "function"
|
|
223
|
+
? (dueTime as any).toMillis()
|
|
224
|
+
: 0;
|
|
225
|
+
if (dueTimeMs > 0) {
|
|
226
|
+
const actionableWindowHours =
|
|
227
|
+
(instructionToUpdate as any).actionableWindow || 24;
|
|
228
|
+
const windowStartMs =
|
|
229
|
+
dueTimeMs - actionableWindowHours * 60 * 60 * 1000;
|
|
230
|
+
if (currentTime.toMillis() < windowStartMs) {
|
|
231
|
+
throw new Error(
|
|
232
|
+
`Instruction ${instructionId} cannot be completed yet. ` +
|
|
233
|
+
`Actionable window starts at ${new Date(windowStartMs).toISOString()}.`
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
215
239
|
// Allow completion if it's PENDING_NOTIFICATION, ACTION_DUE, or even MISSED (if policy allows late completion)
|
|
216
240
|
if (
|
|
217
241
|
instructionToUpdate.status !==
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
FieldValue,
|
|
20
20
|
} from "firebase/firestore";
|
|
21
21
|
import { BaseService } from "../base.service";
|
|
22
|
+
import { enforceProviderLimit } from "../tier-enforcement";
|
|
22
23
|
import {
|
|
23
24
|
Practitioner,
|
|
24
25
|
CreatePractitionerData,
|
|
@@ -183,6 +184,19 @@ export class PractitionerService extends BaseService {
|
|
|
183
184
|
): Promise<Practitioner> {
|
|
184
185
|
try {
|
|
185
186
|
const validData = createPractitionerSchema.parse(data);
|
|
187
|
+
|
|
188
|
+
// Enforce tier limit: resolve clinicGroupId from the first assigned clinic
|
|
189
|
+
if (validData.clinics && validData.clinics.length > 0) {
|
|
190
|
+
const clinicRef = doc(this.db, CLINICS_COLLECTION, validData.clinics[0]);
|
|
191
|
+
const clinicSnap = await getDoc(clinicRef);
|
|
192
|
+
if (clinicSnap.exists()) {
|
|
193
|
+
const clinicGroupId = (clinicSnap.data() as any).clinicGroupId;
|
|
194
|
+
if (clinicGroupId) {
|
|
195
|
+
await enforceProviderLimit(this.db, clinicGroupId);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
186
200
|
const practitionerId = this.generateId();
|
|
187
201
|
|
|
188
202
|
// Default review info
|
|
@@ -282,6 +296,11 @@ export class PractitionerService extends BaseService {
|
|
|
282
296
|
throw new Error(`Clinic ${clinicId} not found`);
|
|
283
297
|
}
|
|
284
298
|
|
|
299
|
+
// Enforce tier limit before creating draft practitioner
|
|
300
|
+
if (clinic.clinicGroupId) {
|
|
301
|
+
await enforceProviderLimit(this.db, clinic.clinicGroupId);
|
|
302
|
+
}
|
|
303
|
+
|
|
285
304
|
// Make sure the primary clinic (clinicId) is always included
|
|
286
305
|
// Merge the clinics array with the primary clinicId, avoiding duplicates
|
|
287
306
|
const clinicsToAdd = new Set<string>([clinicId]);
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
documentId,
|
|
23
23
|
} from 'firebase/firestore';
|
|
24
24
|
import { BaseService } from '../base.service';
|
|
25
|
+
import { enforceProcedureLimit } from '../tier-enforcement';
|
|
25
26
|
import {
|
|
26
27
|
Procedure,
|
|
27
28
|
CreateProcedureData,
|
|
@@ -362,6 +363,9 @@ export class ProcedureService extends BaseService {
|
|
|
362
363
|
}
|
|
363
364
|
const clinic = clinicSnapshot.data() as Clinic; // Assert type
|
|
364
365
|
|
|
366
|
+
// Enforce tier limit before creating procedure
|
|
367
|
+
await enforceProcedureLimit(this.db, clinic.clinicGroupId);
|
|
368
|
+
|
|
365
369
|
const practitionerRef = doc(this.db, PRACTITIONERS_COLLECTION, validatedData.practitionerId);
|
|
366
370
|
const practitionerSnapshot = await getDoc(practitionerRef);
|
|
367
371
|
if (!practitionerSnapshot.exists()) {
|
|
@@ -881,6 +885,9 @@ export class ProcedureService extends BaseService {
|
|
|
881
885
|
}
|
|
882
886
|
const clinic = clinicSnapshot.data() as Clinic;
|
|
883
887
|
|
|
888
|
+
// Enforce tier limit before bulk creating procedures
|
|
889
|
+
await enforceProcedureLimit(this.db, clinic.clinicGroupId, practitionerIds.length);
|
|
890
|
+
|
|
884
891
|
// 3. Handle media uploads once for efficiency
|
|
885
892
|
let processedPhotos: string[] = [];
|
|
886
893
|
if (validatedData.photos && validatedData.photos.length > 0) {
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Firestore,
|
|
3
|
+
collection,
|
|
4
|
+
doc,
|
|
5
|
+
getDoc,
|
|
6
|
+
getDocs,
|
|
7
|
+
query,
|
|
8
|
+
where,
|
|
9
|
+
} from 'firebase/firestore';
|
|
10
|
+
import {
|
|
11
|
+
CLINIC_GROUPS_COLLECTION,
|
|
12
|
+
CLINICS_COLLECTION,
|
|
13
|
+
} from '../types/clinic/index';
|
|
14
|
+
import { PRACTITIONERS_COLLECTION } from '../types/practitioner/index';
|
|
15
|
+
import { PROCEDURES_COLLECTION } from '../types/procedure/index';
|
|
16
|
+
import { TIER_CONFIG, resolveEffectiveTier } from '../config/tiers.config';
|
|
17
|
+
|
|
18
|
+
export class TierLimitError extends Error {
|
|
19
|
+
public readonly code = 'TIER_LIMIT_EXCEEDED';
|
|
20
|
+
public readonly currentTier: string;
|
|
21
|
+
public readonly resource: string;
|
|
22
|
+
public readonly currentCount: number;
|
|
23
|
+
public readonly maxAllowed: number;
|
|
24
|
+
|
|
25
|
+
constructor(
|
|
26
|
+
resource: string,
|
|
27
|
+
currentTier: string,
|
|
28
|
+
currentCount: number,
|
|
29
|
+
maxAllowed: number
|
|
30
|
+
) {
|
|
31
|
+
const tierLabel = currentTier.charAt(0).toUpperCase() + currentTier.slice(1);
|
|
32
|
+
super(
|
|
33
|
+
`Your ${tierLabel} plan allows a maximum of ${maxAllowed} ${resource}. ` +
|
|
34
|
+
`You currently have ${currentCount}. Please upgrade your plan to add more.`
|
|
35
|
+
);
|
|
36
|
+
this.name = 'TierLimitError';
|
|
37
|
+
this.currentTier = currentTier;
|
|
38
|
+
this.resource = resource;
|
|
39
|
+
this.currentCount = currentCount;
|
|
40
|
+
this.maxAllowed = maxAllowed;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Fetches the effective tier for a clinic group.
|
|
46
|
+
* Returns the resolved tier string (e.g., 'free', 'connect', 'pro').
|
|
47
|
+
*/
|
|
48
|
+
export async function getEffectiveTier(
|
|
49
|
+
db: Firestore,
|
|
50
|
+
clinicGroupId: string
|
|
51
|
+
): Promise<string> {
|
|
52
|
+
const groupRef = doc(db, CLINIC_GROUPS_COLLECTION, clinicGroupId);
|
|
53
|
+
const groupSnap = await getDoc(groupRef);
|
|
54
|
+
if (!groupSnap.exists()) {
|
|
55
|
+
throw new Error(`Clinic group ${clinicGroupId} not found`);
|
|
56
|
+
}
|
|
57
|
+
const subscriptionModel = groupSnap.data().subscriptionModel || 'no_subscription';
|
|
58
|
+
return resolveEffectiveTier(subscriptionModel);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Counts active providers (practitioners) across all clinics in a clinic group.
|
|
63
|
+
*/
|
|
64
|
+
async function countProvidersInGroup(
|
|
65
|
+
db: Firestore,
|
|
66
|
+
clinicGroupId: string
|
|
67
|
+
): Promise<number> {
|
|
68
|
+
const clinicsQuery = query(
|
|
69
|
+
collection(db, CLINICS_COLLECTION),
|
|
70
|
+
where('clinicGroupId', '==', clinicGroupId),
|
|
71
|
+
where('isActive', '==', true)
|
|
72
|
+
);
|
|
73
|
+
const clinicsSnap = await getDocs(clinicsQuery);
|
|
74
|
+
const clinicIds = clinicsSnap.docs.map(d => d.id);
|
|
75
|
+
|
|
76
|
+
if (clinicIds.length === 0) return 0;
|
|
77
|
+
|
|
78
|
+
const practitionersQuery = query(
|
|
79
|
+
collection(db, PRACTITIONERS_COLLECTION),
|
|
80
|
+
where('isActive', '==', true)
|
|
81
|
+
);
|
|
82
|
+
const practitionersSnap = await getDocs(practitionersQuery);
|
|
83
|
+
|
|
84
|
+
const clinicIdSet = new Set(clinicIds);
|
|
85
|
+
const uniqueProviders = practitionersSnap.docs.filter(d => {
|
|
86
|
+
const data = d.data();
|
|
87
|
+
const clinics: string[] = data.clinics || [];
|
|
88
|
+
return clinics.some(c => clinicIdSet.has(c));
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
return uniqueProviders.length;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Counts active procedures across all clinics in a clinic group.
|
|
96
|
+
*/
|
|
97
|
+
async function countProceduresInGroup(
|
|
98
|
+
db: Firestore,
|
|
99
|
+
clinicGroupId: string
|
|
100
|
+
): Promise<number> {
|
|
101
|
+
const clinicsQuery = query(
|
|
102
|
+
collection(db, CLINICS_COLLECTION),
|
|
103
|
+
where('clinicGroupId', '==', clinicGroupId),
|
|
104
|
+
where('isActive', '==', true)
|
|
105
|
+
);
|
|
106
|
+
const clinicsSnap = await getDocs(clinicsQuery);
|
|
107
|
+
const clinicIds = clinicsSnap.docs.map(d => d.id);
|
|
108
|
+
|
|
109
|
+
if (clinicIds.length === 0) return 0;
|
|
110
|
+
|
|
111
|
+
let totalProcedures = 0;
|
|
112
|
+
for (let i = 0; i < clinicIds.length; i += 30) {
|
|
113
|
+
const batch = clinicIds.slice(i, i + 30);
|
|
114
|
+
const proceduresQuery = query(
|
|
115
|
+
collection(db, PROCEDURES_COLLECTION),
|
|
116
|
+
where('clinicBranchId', 'in', batch),
|
|
117
|
+
where('isActive', '==', true)
|
|
118
|
+
);
|
|
119
|
+
const proceduresSnap = await getDocs(proceduresQuery);
|
|
120
|
+
totalProcedures += proceduresSnap.size;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return totalProcedures;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Counts active clinic branches in a clinic group.
|
|
128
|
+
*/
|
|
129
|
+
async function countBranchesInGroup(
|
|
130
|
+
db: Firestore,
|
|
131
|
+
clinicGroupId: string
|
|
132
|
+
): Promise<number> {
|
|
133
|
+
const clinicsQuery = query(
|
|
134
|
+
collection(db, CLINICS_COLLECTION),
|
|
135
|
+
where('clinicGroupId', '==', clinicGroupId),
|
|
136
|
+
where('isActive', '==', true)
|
|
137
|
+
);
|
|
138
|
+
const clinicsSnap = await getDocs(clinicsQuery);
|
|
139
|
+
return clinicsSnap.size;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Enforces tier limit before adding a provider (practitioner/doctor/nurse/etc.).
|
|
144
|
+
* Throws TierLimitError if the limit would be exceeded.
|
|
145
|
+
*/
|
|
146
|
+
export async function enforceProviderLimit(
|
|
147
|
+
db: Firestore,
|
|
148
|
+
clinicGroupId: string
|
|
149
|
+
): Promise<void> {
|
|
150
|
+
const tier = await getEffectiveTier(db, clinicGroupId);
|
|
151
|
+
const config = TIER_CONFIG[tier];
|
|
152
|
+
if (!config) return;
|
|
153
|
+
|
|
154
|
+
const max = config.limits.maxProviders;
|
|
155
|
+
if (max === -1) return;
|
|
156
|
+
|
|
157
|
+
const currentCount = await countProvidersInGroup(db, clinicGroupId);
|
|
158
|
+
if (currentCount + 1 > max) {
|
|
159
|
+
throw new TierLimitError('providers', tier, currentCount, max);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Enforces tier limit before creating a procedure.
|
|
165
|
+
* @param count - Number of procedures being created (default 1, higher for bulk)
|
|
166
|
+
*/
|
|
167
|
+
export async function enforceProcedureLimit(
|
|
168
|
+
db: Firestore,
|
|
169
|
+
clinicGroupId: string,
|
|
170
|
+
count: number = 1
|
|
171
|
+
): Promise<void> {
|
|
172
|
+
const tier = await getEffectiveTier(db, clinicGroupId);
|
|
173
|
+
const config = TIER_CONFIG[tier];
|
|
174
|
+
if (!config) return;
|
|
175
|
+
|
|
176
|
+
const max = config.limits.maxProcedures;
|
|
177
|
+
if (max === -1) return;
|
|
178
|
+
|
|
179
|
+
const currentCount = await countProceduresInGroup(db, clinicGroupId);
|
|
180
|
+
if (currentCount + count > max) {
|
|
181
|
+
throw new TierLimitError('procedures', tier, currentCount, max);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Enforces tier limit before creating a clinic branch.
|
|
187
|
+
*/
|
|
188
|
+
export async function enforceBranchLimit(
|
|
189
|
+
db: Firestore,
|
|
190
|
+
clinicGroupId: string
|
|
191
|
+
): Promise<void> {
|
|
192
|
+
const tier = await getEffectiveTier(db, clinicGroupId);
|
|
193
|
+
const config = TIER_CONFIG[tier];
|
|
194
|
+
if (!config) return;
|
|
195
|
+
|
|
196
|
+
const max = config.limits.maxBranches;
|
|
197
|
+
if (max === -1) return;
|
|
198
|
+
|
|
199
|
+
const currentCount = await countBranchesInGroup(db, clinicGroupId);
|
|
200
|
+
if (currentCount + 1 > max) {
|
|
201
|
+
throw new TierLimitError('clinic branches', tier, currentCount, max);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Enforces tier limit before creating a staff member.
|
|
207
|
+
*/
|
|
208
|
+
export async function enforceStaffLimit(
|
|
209
|
+
db: Firestore,
|
|
210
|
+
clinicGroupId: string
|
|
211
|
+
): Promise<void> {
|
|
212
|
+
const tier = await getEffectiveTier(db, clinicGroupId);
|
|
213
|
+
const config = TIER_CONFIG[tier];
|
|
214
|
+
if (!config) return;
|
|
215
|
+
|
|
216
|
+
const max = config.limits.maxStaff;
|
|
217
|
+
if (max === -1) return;
|
|
218
|
+
|
|
219
|
+
const staffQuery = query(
|
|
220
|
+
collection(db, 'clinic_staff_members'),
|
|
221
|
+
where('clinicGroupId', '==', clinicGroupId),
|
|
222
|
+
where('isActive', '==', true)
|
|
223
|
+
);
|
|
224
|
+
const staffSnap = await getDocs(staffQuery);
|
|
225
|
+
const currentCount = staffSnap.size;
|
|
226
|
+
|
|
227
|
+
if (currentCount + 1 > max) {
|
|
228
|
+
throw new TierLimitError('staff members', tier, currentCount, max);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Enforces tier limit before creating an appointment.
|
|
234
|
+
* Reads the monthly counter from clinic_groups/{id}/usage_counters/{YYYY-MM}.
|
|
235
|
+
*/
|
|
236
|
+
export async function enforceAppointmentLimit(
|
|
237
|
+
db: Firestore,
|
|
238
|
+
clinicGroupId: string
|
|
239
|
+
): Promise<void> {
|
|
240
|
+
const tier = await getEffectiveTier(db, clinicGroupId);
|
|
241
|
+
const config = TIER_CONFIG[tier];
|
|
242
|
+
if (!config) return;
|
|
243
|
+
|
|
244
|
+
const max = config.limits.maxAppointmentsPerMonth;
|
|
245
|
+
if (max === -1) return;
|
|
246
|
+
|
|
247
|
+
const now = new Date();
|
|
248
|
+
const yearMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
|
|
249
|
+
|
|
250
|
+
const counterRef = doc(
|
|
251
|
+
db,
|
|
252
|
+
`${CLINIC_GROUPS_COLLECTION}/${clinicGroupId}/usage_counters/${yearMonth}`
|
|
253
|
+
);
|
|
254
|
+
const counterSnap = await getDoc(counterRef);
|
|
255
|
+
const currentCount = counterSnap.exists() ? (counterSnap.data()?.appointmentsCreated || 0) : 0;
|
|
256
|
+
|
|
257
|
+
if (currentCount + 1 > max) {
|
|
258
|
+
throw new TierLimitError('appointments this month', tier, currentCount, max);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Enforces tier limit before sending a message.
|
|
264
|
+
* Reads the monthly counter from clinic_groups/{id}/usage_counters/{YYYY-MM}.
|
|
265
|
+
*/
|
|
266
|
+
export async function enforceMessageLimit(
|
|
267
|
+
db: Firestore,
|
|
268
|
+
clinicGroupId: string
|
|
269
|
+
): Promise<void> {
|
|
270
|
+
const tier = await getEffectiveTier(db, clinicGroupId);
|
|
271
|
+
const config = TIER_CONFIG[tier];
|
|
272
|
+
if (!config) return;
|
|
273
|
+
|
|
274
|
+
const max = config.limits.maxMessagesPerMonth;
|
|
275
|
+
if (max === -1) return;
|
|
276
|
+
|
|
277
|
+
const now = new Date();
|
|
278
|
+
const yearMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
|
|
279
|
+
|
|
280
|
+
const counterRef = doc(
|
|
281
|
+
db,
|
|
282
|
+
`${CLINIC_GROUPS_COLLECTION}/${clinicGroupId}/usage_counters/${yearMonth}`
|
|
283
|
+
);
|
|
284
|
+
const counterSnap = await getDoc(counterRef);
|
|
285
|
+
const currentCount = counterSnap.exists() ? (counterSnap.data()?.messagesCount || 0) : 0;
|
|
286
|
+
|
|
287
|
+
if (currentCount + 1 > max) {
|
|
288
|
+
throw new TierLimitError('messages this month', tier, currentCount, max);
|
|
289
|
+
}
|
|
290
|
+
}
|