@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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@blackcode_sa/metaestetics-api",
3
3
  "private": false,
4
- "version": "1.15.8",
4
+ "version": "1.15.9",
5
5
  "description": "Firebase authentication service with anonymous upgrade support",
6
6
  "main": "dist/index.js",
7
7
  "module": "dist/index.mjs",
@@ -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
 
@@ -12,3 +12,4 @@ export * from "./procedure";
12
12
  export * from "./reviews";
13
13
  export * from "./user";
14
14
  export * from "./base.service";
15
+ export * from "./tier-enforcement";
@@ -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
+ }