@blackcode_sa/metaestetics-api 1.15.17-staging.1 → 1.15.17-staging.2

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.17-staging.1",
4
+ "version": "1.15.17-staging.2",
5
5
  "description": "Firebase authentication service with anonymous upgrade support",
6
6
  "main": "dist/index.js",
7
7
  "module": "dist/index.mjs",
@@ -12,6 +12,9 @@ export {
12
12
  PERMISSION_KEYS,
13
13
  TIER_CONFIG,
14
14
  DEFAULT_ROLE_PERMISSIONS,
15
+ DEFAULT_PLAN_CONFIG,
16
+ PERMISSION_LABELS,
17
+ PERMISSION_CATEGORIES,
15
18
  resolveEffectiveTier,
16
19
  } from "./tiers.config";
17
20
  export type { PermissionKey } from "./tiers.config";
@@ -1,5 +1,6 @@
1
1
  import { ClinicRole } from '../types/clinic/rbac.types';
2
2
  import type { TierConfig } from '../types/clinic/rbac.types';
3
+ import type { PlanConfigDocument } from '../types/system/planConfig.types';
3
4
 
4
5
  /**
5
6
  * Permission keys used for role-based access control.
@@ -236,6 +237,143 @@ export const DEFAULT_ROLE_PERMISSIONS: Record<ClinicRole, Record<string, boolean
236
237
  },
237
238
  };
238
239
 
240
+ /**
241
+ * Default PlanConfigDocument — used as fallback when Firestore `system/planConfig`
242
+ * doesn't exist or is unavailable. Also used as the seed value.
243
+ *
244
+ * Stripe price IDs reference the SKS Innovation SA sandbox account.
245
+ */
246
+ export const DEFAULT_PLAN_CONFIG: PlanConfigDocument = {
247
+ version: 1,
248
+ updatedAt: null,
249
+ updatedBy: 'system',
250
+
251
+ tiers: {
252
+ free: {
253
+ name: 'Free',
254
+ limits: { maxProvidersPerBranch: 1, maxProceduresPerProvider: 3, maxBranches: 1 },
255
+ },
256
+ connect: {
257
+ name: 'Connect',
258
+ limits: { maxProvidersPerBranch: 3, maxProceduresPerProvider: 10, maxBranches: 1 },
259
+ },
260
+ pro: {
261
+ name: 'Pro',
262
+ limits: { maxProvidersPerBranch: 5, maxProceduresPerProvider: 20, maxBranches: 3 },
263
+ },
264
+ },
265
+
266
+ plans: {
267
+ FREE: {
268
+ priceId: null,
269
+ priceMonthly: 0,
270
+ currency: 'CHF',
271
+ description: 'Get started with basic access',
272
+ stripeProductId: null,
273
+ },
274
+ CONNECT: {
275
+ priceId: 'price_1TD0l7AaFI0fqFWNC8Lg3QJG',
276
+ priceMonthly: 199,
277
+ currency: 'CHF',
278
+ description: 'Consultation tools and integrated booking',
279
+ stripeProductId: null,
280
+ },
281
+ PRO: {
282
+ priceId: 'price_1TD0lEAaFI0fqFWN3tzCrfUb',
283
+ priceMonthly: 449,
284
+ currency: 'CHF',
285
+ description: 'Complete clinic management with CRM and analytics',
286
+ stripeProductId: null,
287
+ },
288
+ },
289
+
290
+ addons: {
291
+ seat: {
292
+ priceId: 'price_1TD0lKAaFI0fqFWNH9pQgTzI',
293
+ pricePerUnit: 39,
294
+ currency: 'CHF',
295
+ allowedTiers: ['connect', 'pro'],
296
+ },
297
+ branch: {
298
+ connect: { priceId: 'price_1TD0lSAaFI0fqFWNjsPvzW1T', pricePerUnit: 119, currency: 'CHF' },
299
+ pro: { priceId: 'price_1TD0lTAaFI0fqFWNYxUnvUxH', pricePerUnit: 279, currency: 'CHF' },
300
+ allowedTiers: ['connect', 'pro'],
301
+ },
302
+ procedure: {
303
+ priceId: 'price_1TCL3eAaFI0fqFWNJVuMYjii',
304
+ pricePerBlock: 19,
305
+ proceduresPerBlock: 5,
306
+ currency: 'CHF',
307
+ allowedTiers: ['connect', 'pro'],
308
+ },
309
+ },
310
+
311
+ priceToModel: {
312
+ 'price_1TD0l7AaFI0fqFWNC8Lg3QJG': 'connect',
313
+ 'price_1TD0lEAaFI0fqFWN3tzCrfUb': 'pro',
314
+ },
315
+
316
+ priceToType: {
317
+ 'price_1TD0l7AaFI0fqFWNC8Lg3QJG': 'CONNECT',
318
+ 'price_1TD0lEAaFI0fqFWN3tzCrfUb': 'PRO',
319
+ },
320
+
321
+ addonPriceIds: [
322
+ 'price_1TD0lKAaFI0fqFWNH9pQgTzI', // seat
323
+ 'price_1TD0lSAaFI0fqFWNjsPvzW1T', // branch connect
324
+ 'price_1TD0lTAaFI0fqFWNYxUnvUxH', // branch pro
325
+ 'price_1TCL3eAaFI0fqFWNJVuMYjii', // procedure
326
+ ],
327
+ };
328
+
329
+ /**
330
+ * Human-readable labels and grouping for permission keys.
331
+ * Used by the staff management UI for role/permission display.
332
+ */
333
+ export const PERMISSION_LABELS: Record<
334
+ string,
335
+ { label: string; description: string; category: string }
336
+ > = {
337
+ 'clinic.view': { label: 'View Clinic', description: 'See clinic information and profile', category: 'Clinic' },
338
+ 'clinic.edit': { label: 'Edit Clinic', description: 'Modify clinic settings and profile', category: 'Clinic' },
339
+ 'reviews.view': { label: 'View Reviews', description: 'See patient reviews and ratings', category: 'Clinic' },
340
+ 'calendar.view': { label: 'View Calendar', description: 'See the clinic calendar and schedules', category: 'Calendar' },
341
+ 'appointments.view': { label: 'View Appointments', description: 'See appointment list and details', category: 'Appointments' },
342
+ 'appointments.confirm': { label: 'Confirm Appointments', description: 'Confirm pending appointment requests', category: 'Appointments' },
343
+ 'appointments.cancel': { label: 'Cancel Appointments', description: 'Cancel existing appointments', category: 'Appointments' },
344
+ 'messaging': { label: 'Messaging', description: 'Send and receive messages with patients', category: 'Messaging' },
345
+ 'procedures.view': { label: 'View Procedures', description: 'See the procedures catalog', category: 'Procedures' },
346
+ 'procedures.create': { label: 'Create Procedures', description: 'Add new procedures to the catalog', category: 'Procedures' },
347
+ 'procedures.edit': { label: 'Edit Procedures', description: 'Modify existing procedures', category: 'Procedures' },
348
+ 'procedures.delete': { label: 'Delete Procedures', description: 'Remove procedures from the catalog', category: 'Procedures' },
349
+ 'resources.view': { label: 'View Resources', description: 'See clinic resources and equipment', category: 'Resources' },
350
+ 'resources.create': { label: 'Create Resources', description: 'Add new resources', category: 'Resources' },
351
+ 'resources.edit': { label: 'Edit Resources', description: 'Modify existing resources', category: 'Resources' },
352
+ 'resources.delete': { label: 'Delete Resources', description: 'Remove resources', category: 'Resources' },
353
+ 'patients.view': { label: 'View Patients', description: 'See patient list and profiles', category: 'Patients' },
354
+ 'patients.edit': { label: 'Edit Patients', description: 'Modify patient records', category: 'Patients' },
355
+ 'providers.view': { label: 'View Providers', description: 'See the practitioners/providers list', category: 'Providers' },
356
+ 'providers.manage': { label: 'Manage Providers', description: 'Add, edit, or remove providers', category: 'Providers' },
357
+ 'analytics.view': { label: 'View Analytics', description: 'Access analytics dashboards and reports', category: 'Analytics' },
358
+ 'staff.manage': { label: 'Manage Staff', description: 'Add, remove, and assign roles to staff', category: 'Administration' },
359
+ 'settings.manage': { label: 'Manage Settings', description: 'Modify clinic group settings', category: 'Administration' },
360
+ 'billing.manage': { label: 'Manage Billing', description: 'Access billing, subscriptions, and payments', category: 'Administration' },
361
+ };
362
+
363
+ /** All unique permission categories in display order. */
364
+ export const PERMISSION_CATEGORIES = [
365
+ 'Clinic',
366
+ 'Calendar',
367
+ 'Appointments',
368
+ 'Messaging',
369
+ 'Procedures',
370
+ 'Resources',
371
+ 'Patients',
372
+ 'Providers',
373
+ 'Analytics',
374
+ 'Administration',
375
+ ] as const;
376
+
239
377
  /**
240
378
  * Maps subscription model strings to tier keys.
241
379
  */
@@ -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();
@@ -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;
@@ -14,6 +14,15 @@ export enum ClinicRole {
14
14
  ASSISTANT = 'assistant',
15
15
  }
16
16
 
17
+ /**
18
+ * Display info denormalized onto a staff record for fast list rendering.
19
+ */
20
+ export interface StaffDisplayInfo {
21
+ firstName: string;
22
+ lastName: string;
23
+ email: string;
24
+ }
25
+
17
26
  /**
18
27
  * Represents a staff member within a clinic group
19
28
  */
@@ -25,6 +34,8 @@ export interface ClinicStaffMember {
25
34
  role: ClinicRole;
26
35
  permissions: Record<string, boolean>;
27
36
  isActive: boolean;
37
+ displayInfo?: StaffDisplayInfo;
38
+ invitedBy?: string;
28
39
  createdAt?: Timestamp;
29
40
  updatedAt?: Timestamp;
30
41
  }
@@ -38,6 +49,31 @@ export interface RolePermissionConfig {
38
49
  permissions: Record<string, boolean>;
39
50
  }
40
51
 
52
+ /**
53
+ * Status of a staff invitation
54
+ */
55
+ export enum StaffInviteStatus {
56
+ PENDING = 'pending',
57
+ ACCEPTED = 'accepted',
58
+ EXPIRED = 'expired',
59
+ CANCELLED = 'cancelled',
60
+ }
61
+
62
+ /**
63
+ * An invitation for a new staff member to join a clinic group
64
+ */
65
+ export interface ClinicStaffInvite {
66
+ id: string;
67
+ email: string;
68
+ clinicGroupId: string;
69
+ role: ClinicRole;
70
+ token: string;
71
+ status: StaffInviteStatus;
72
+ invitedBy: string;
73
+ createdAt?: Timestamp;
74
+ expiresAt?: Timestamp;
75
+ }
76
+
41
77
  /**
42
78
  * Usage limits for a given subscription tier.
43
79
  * All features are available on every tier — only usage is capped.
@@ -43,6 +43,9 @@ export * from "./resource";
43
43
  // Reviews types
44
44
  export * from "./reviews";
45
45
 
46
+ // System config types
47
+ export * from "./system";
48
+
46
49
  // Analytics types
47
50
  export * from "./analytics";
48
51
 
@@ -0,0 +1 @@
1
+ export * from './planConfig.types';
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Dynamic Plan Configuration — stored in Firestore at `system/planConfig`.
3
+ * Editable from the admin dashboard. Hardcoded defaults in tiers.config.ts
4
+ * serve as fallback when the Firestore document doesn't exist.
5
+ */
6
+
7
+ export interface TierDefinition {
8
+ name: string;
9
+ limits: {
10
+ maxProvidersPerBranch: number;
11
+ maxProceduresPerProvider: number;
12
+ maxBranches: number;
13
+ };
14
+ }
15
+
16
+ export interface PlanDefinition {
17
+ /** Stripe price ID. null for the free plan. */
18
+ priceId: string | null;
19
+ /** Monthly price in the plan currency (e.g. 199 for CHF 199). */
20
+ priceMonthly: number;
21
+ currency: string;
22
+ description: string;
23
+ /** Stripe product ID — needed when auto-creating new prices. */
24
+ stripeProductId?: string | null;
25
+ }
26
+
27
+ export interface SeatAddonDefinition {
28
+ priceId: string;
29
+ pricePerUnit: number;
30
+ currency: string;
31
+ allowedTiers: string[];
32
+ }
33
+
34
+ export interface BranchAddonTierPrice {
35
+ priceId: string;
36
+ pricePerUnit: number;
37
+ currency: string;
38
+ }
39
+
40
+ export interface BranchAddonDefinition {
41
+ connect: BranchAddonTierPrice;
42
+ pro: BranchAddonTierPrice;
43
+ allowedTiers: string[];
44
+ }
45
+
46
+ export interface ProcedureAddonDefinition {
47
+ priceId: string;
48
+ pricePerBlock: number;
49
+ proceduresPerBlock: number;
50
+ currency: string;
51
+ allowedTiers: string[];
52
+ }
53
+
54
+ export interface PlanConfigDocument {
55
+ version: number;
56
+ updatedAt: any; // Firestore Timestamp (any to avoid SDK coupling)
57
+ updatedBy: string;
58
+
59
+ /** Tier definitions with usage limits. Keys: 'free', 'connect', 'pro'. */
60
+ tiers: Record<string, TierDefinition>;
61
+
62
+ /** Plan pricing and Stripe config. Keys: 'FREE', 'CONNECT', 'PRO'. */
63
+ plans: Record<string, PlanDefinition>;
64
+
65
+ /** Add-on pricing and Stripe config. */
66
+ addons: {
67
+ seat: SeatAddonDefinition;
68
+ branch: BranchAddonDefinition;
69
+ procedure: ProcedureAddonDefinition;
70
+ };
71
+
72
+ /** Auto-computed: maps Stripe priceId → Firestore subscriptionModel ('connect'/'pro'). */
73
+ priceToModel: Record<string, string>;
74
+
75
+ /** Auto-computed: maps Stripe priceId → plan type label ('CONNECT'/'PRO'). */
76
+ priceToType: Record<string, string>;
77
+
78
+ /** Auto-computed: all add-on price IDs for webhook filtering. */
79
+ addonPriceIds: string[];
80
+ }
81
+
82
+ /** Firestore path for the plan config document. */
83
+ export const PLAN_CONFIG_PATH = 'system/planConfig';
84
+
85
+ /** Firestore subcollection path for version history. */
86
+ export const PLAN_CONFIG_HISTORY_PATH = 'system/planConfig/history';