Skip to main content

Plan-Based Permissions Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
Goal: Auto-assign correct permission defaults at login based on the clinic’s subscription plan (Pro vs Pro+), using ssh & Associates as the Pro+ golden standard. Architecture: Add PRO_PERMISSIONS_BY_ROLE and PRO_PLUS_PERMISSIONS_BY_ROLE constants and a getDefaultsForPlan() resolver to permissions.ts. Update getEffectivePermissions() to accept an optional subscriptionPlanId param and pick the right default set. Update the /me endpoint to pass the clinic’s plan. Improve the superadmin plan-assignment dialog for clarity. Tech Stack: TypeScript, Drizzle ORM (Neon/Postgres), Hono, Vitest, React

File Map

ActionFileWhat changes
Modifyserver/src/lib/permissions.tsAdd PRO_PLUS_PERMISSIONS_BY_ROLE, PRO_PERMISSIONS_BY_ROLE, getDefaultsForPlan(), update getEffectivePermissions() signature
Createserver/src/lib/permissions.test.tsUnit tests for getDefaultsForPlan and getEffectivePermissions plan-routing
Modifyserver/src/api.ts:1120-1135Add subscriptionPlanId to clinic select, pass to getEffectivePermissions
Modifyui/src/components/superadmin/ClinicDetailsPage.tsxImprove upgrade dialog — show current plan, plan descriptions, note on existing users
server/src/routes/public-documents-protected.ts — no change needed; its getEffectivePermissions(user, clinicId) call leaves subscriptionPlanId undefined which falls through to the Pro fallback, correct for a public doc endpoint.

Task 1: Write failing tests for getDefaultsForPlan

Files:
  • Create: server/src/lib/permissions.test.ts
  • Step 1: Create the test file
// server/src/lib/permissions.test.ts
import { describe, it, expect } from 'vitest';
import { getDefaultsForPlan, PERMISSION_KEYS } from './permissions';

describe('getDefaultsForPlan', () => {
  it('returns Pro+ receptionist defaults for price_pro_plus', () => {
    const keys = getDefaultsForPlan('receptionist', 'price_pro_plus');
    // Pro+ receptionist has full inventory write
    expect(keys).toContain('inventory.create');
    expect(keys).toContain('inventory.edit');
    expect(keys).toContain('inventory.manage_suppliers');
    // Pro+ receptionist has signatures management
    expect(keys).toContain('settings.signatures.view');
    expect(keys).toContain('settings.signatures.manage');
    // Pro+ receptionist does NOT have reports.stats or audit.activity.view
    expect(keys).not.toContain('reports.stats');
    expect(keys).not.toContain('audit.activity.view');
  });

  it('returns Pro receptionist defaults for price_pro — no inventory write or signatures manage', () => {
    const keys = getDefaultsForPlan('receptionist', 'price_pro');
    expect(keys).toContain('inventory.view');
    expect(keys).toContain('inventory.adjust_stock');
    expect(keys).not.toContain('inventory.create');
    expect(keys).not.toContain('inventory.edit');
    expect(keys).not.toContain('inventory.manage_suppliers');
    expect(keys).toContain('settings.signatures.view');
    expect(keys).not.toContain('settings.signatures.manage');
    expect(keys).not.toContain('ai.daily_brief');
    expect(keys).not.toContain('lab.laboratories.view');
  });

  it('returns Pro+ doctor defaults — full clinical including ipd, bridge, insurance', () => {
    const keys = getDefaultsForPlan('doctor', 'price_pro_plus');
    expect(keys).toContain('clinical.ipd.view');
    expect(keys).toContain('clinical.ipd.admit');
    expect(keys).toContain('bridge.view');
    expect(keys).toContain('bridge.capture');
    expect(keys).toContain('billing.insurance.view');
    expect(keys).toContain('ai.dicom_analysis.view');
    expect(keys).toContain('ai.dicom_analysis.run');
    expect(keys).toContain('reports.revenue');
    expect(keys).toContain('ai.churn_risk');
  });

  it('returns Pro doctor defaults — no bridge, no dicom, no insurance, no ipd, no business AI', () => {
    const keys = getDefaultsForPlan('doctor', 'price_pro');
    expect(keys).not.toContain('bridge.view');
    expect(keys).not.toContain('bridge.capture');
    expect(keys).not.toContain('ai.dicom_analysis.view');
    expect(keys).not.toContain('ai.dicom_analysis.run');
    expect(keys).not.toContain('billing.insurance.view');
    expect(keys).not.toContain('clinical.ipd.view');
    expect(keys).not.toContain('reports.financial');
    expect(keys).not.toContain('reports.revenue');
    expect(keys).not.toContain('ai.churn_risk');
    expect(keys).not.toContain('ai.revenue_forecast');
    expect(keys).not.toContain('ai.monthly_summary');
    // Pro doctor keeps core clinical
    expect(keys).toContain('clinical.dental_chart.view');
    expect(keys).toContain('clinical.dental_chart.edit');
    expect(keys).toContain('reports.stats');
  });

  it('admin always gets all permissions regardless of plan', () => {
    const proKeys = getDefaultsForPlan('admin', 'price_pro');
    const proPlusKeys = getDefaultsForPlan('admin', 'price_pro_plus');
    expect(proKeys).toEqual(proPlusKeys);
    expect(proKeys.length).toBe(PERMISSION_KEYS.length);
  });

  it('falls back to Pro defaults for null/undefined/unknown plan', () => {
    const nullKeys = getDefaultsForPlan('doctor', null);
    const undefinedKeys = getDefaultsForPlan('doctor', undefined);
    const unknownKeys = getDefaultsForPlan('doctor', 'price_enterprise');
    const proKeys = getDefaultsForPlan('doctor', 'price_pro');
    expect(nullKeys).toEqual(proKeys);
    expect(undefinedKeys).toEqual(proKeys);
    expect(unknownKeys).toEqual(proKeys);
  });

  it('patient permissions are plan-invariant', () => {
    const proKeys = getDefaultsForPlan('patient', 'price_pro');
    const proPlusKeys = getDefaultsForPlan('patient', 'price_pro_plus');
    expect(proKeys).toEqual(proPlusKeys);
  });
});
  • Step 2: Run tests to confirm they fail
cd /Users/ssh/Documents/Beta-App/odontoX/server && npx vitest run src/lib/permissions.test.ts
Expected: all tests fail with “getDefaultsForPlan is not a function” or import error.

Task 2: Add permission constants and getDefaultsForPlan to permissions.ts

Files:
  • Modify: server/src/lib/permissions.ts (after line 333, before buildPermissionMap)
  • Step 1: Add PRO_PLUS_PERMISSIONS_BY_ROLE constant
Insert after the closing }; of DEFAULT_PERMISSIONS_BY_ROLE (after line 333):
export const PRO_PLUS_PERMISSIONS_BY_ROLE: Record<string, PermissionKey[]> = {
  admin: [...PERMISSION_KEYS],
  doctor: [...(DEFAULT_PERMISSIONS_BY_ROLE.doctor as PermissionKey[])],
  receptionist: [
    // Appointments
    'appointments.view', 'appointments.view_detail', 'appointments.create', 'appointments.edit',
    'appointments.delete', 'appointments.change_status', 'appointments.send_summary',
    'appointments.view_all_doctors',
    // Patients
    'patients.view', 'patients.view_detail', 'patients.create', 'patients.edit',
    'patients.invite_portal', 'patients.view_medical_history',
    // Clinical (view/support, no chart editing or notes writing)
    'clinical.treatment_plans.view',
    'clinical.prescriptions.view',
    'clinical.patient_files.view', 'clinical.patient_files.upload',
    'clinical.consent.view', 'clinical.consent.upload',
    'clinical.recalls.view', 'clinical.recalls.create', 'clinical.recalls.edit',
    'clinical.recalls.delete', 'clinical.recalls.batch_generate',
    // Billing — full operational
    'billing.invoices.view', 'billing.invoices.view_detail', 'billing.invoices.create',
    'billing.invoices.send', 'billing.invoices.share',
    'billing.receipts.view', 'billing.receipts.create', 'billing.receipts.send',
    'billing.payments.view', 'billing.payments.record',
    'billing.quotations.view', 'billing.quotations.create', 'billing.quotations.send',
    'billing.installments.view',
    // Inventory — full (Pro+ upgrade over Pro)
    'inventory.view', 'inventory.create', 'inventory.edit',
    'inventory.adjust_stock', 'inventory.view_alerts', 'inventory.manage_suppliers',
    // Lab
    'lab.cases.view', 'lab.cases.create', 'lab.cases.update_status',
    'lab.services.view', 'lab.laboratories.view',
    // Bridge
    'bridge.view',
    // Comms
    'comms.messages.view', 'comms.messages.send_patient', 'comms.messages.send_staff',
    'comms.templates.view', 'comms.bulk.send',
    // AI — operational nudges
    'ai.appointment_nudges', 'ai.daily_brief', 'ai.payment_reminder',
    // Settings
    'settings.staff.view',
    'settings.signatures.view', 'settings.signatures.manage',
    // Notifications
    'notifications.manage',
  ],
  patient: [...(DEFAULT_PERMISSIONS_BY_ROLE.patient as PermissionKey[])],
};

export const PRO_PERMISSIONS_BY_ROLE: Record<string, PermissionKey[]> = {
  admin: [...PERMISSION_KEYS],
  doctor: [
    // Appointments
    'appointments.view', 'appointments.view_detail', 'appointments.create', 'appointments.edit',
    'appointments.delete', 'appointments.change_status', 'appointments.send_summary',
    'appointments.view_all_doctors', 'appointments.block_time',
    // Patients
    'patients.view', 'patients.view_detail', 'patients.create', 'patients.edit', 'patients.delete',
    'patients.invite_portal', 'patients.view_medical_history',
    // Clinical — full, NO ipd
    'clinical.dental_chart.view', 'clinical.dental_chart.create', 'clinical.dental_chart.edit', 'clinical.dental_chart.initialize',
    'clinical.notes.view', 'clinical.notes.create', 'clinical.notes.edit', 'clinical.notes.delete',
    'clinical.treatment_plans.view', 'clinical.treatment_plans.create', 'clinical.treatment_plans.edit',
    'clinical.treatment_plans.delete', 'clinical.treatment_plans.complete',
    'clinical.treatment_plans.approve', 'clinical.treatment_plans.ai_presentation',
    'clinical.treatment_plans.share', 'clinical.treatment_plans.revoke_share',
    'clinical.vital_signs.view', 'clinical.vital_signs.record',
    'clinical.consent.view', 'clinical.consent.upload',
    'clinical.prescriptions.view', 'clinical.prescriptions.create', 'clinical.prescriptions.edit',
    'clinical.prescriptions.delete', 'clinical.prescriptions.manage_template',
    'clinical.medications.view', 'clinical.medications.create', 'clinical.medications.delete',
    'clinical.procedures.view', 'clinical.procedures.create', 'clinical.procedures.edit', 'clinical.procedures.delete',
    'clinical.patient_files.view', 'clinical.patient_files.upload', 'clinical.patient_files.edit', 'clinical.patient_files.delete',
    'clinical.recalls.view', 'clinical.recalls.create', 'clinical.recalls.edit', 'clinical.recalls.delete', 'clinical.recalls.batch_generate',
    // NO clinical.ipd.*
    // Billing — NO insurance
    'billing.invoices.view', 'billing.invoices.view_detail', 'billing.invoices.create',
    'billing.invoices.send', 'billing.invoices.share',
    'billing.receipts.view', 'billing.receipts.create', 'billing.receipts.send',
    'billing.payments.view', 'billing.payments.record',
    'billing.installments.view', 'billing.installments.create', 'billing.installments.generate_invoice',
    'billing.quotations.view', 'billing.quotations.create', 'billing.quotations.edit',
    'billing.quotations.send', 'billing.quotations.share',
    // NO billing.insurance.*
    // Inventory
    'inventory.view', 'inventory.view_alerts',
    // Lab
    'lab.cases.view', 'lab.cases.create', 'lab.cases.update_status',
    'lab.services.view', 'lab.services.manage', 'lab.services.delete',
    'lab.laboratories.view', 'lab.laboratories.create', 'lab.laboratories.edit', 'lab.laboratories.delete',
    // NO bridge.*
    // Comms
    'comms.messages.view', 'comms.messages.send_patient', 'comms.messages.send_staff',
    'comms.messages.delete', 'comms.messages.check_window', 'comms.messages.mark_read',
    'comms.templates.view',
    // Reports — stats only
    'reports.stats',
    // AI — core clinical + scheduling, NO business intelligence
    'ai.patient_brief', 'ai.clinical_assist', 'ai.shorten_expand', 'ai.recall_message',
    'ai.daily_brief', 'ai.appointment_nudges',
    'ai.treatment_plan_presentation',
    'ai.payment_reminder', 'ai.eod_summary', 'ai.grammar_rewrite',
    // Notifications & Audit
    'notifications.manage',
    'audit.activity.view',
  ],
  receptionist: [
    // Appointments
    'appointments.view', 'appointments.view_detail', 'appointments.create', 'appointments.edit',
    'appointments.delete', 'appointments.change_status', 'appointments.send_summary',
    'appointments.view_all_doctors',
    // Patients
    'patients.view', 'patients.view_detail', 'patients.create', 'patients.edit',
    'patients.invite_portal', 'patients.view_medical_history',
    // Clinical (view/support only)
    'clinical.treatment_plans.view',
    'clinical.prescriptions.view',
    'clinical.patient_files.view', 'clinical.patient_files.upload',
    'clinical.consent.view', 'clinical.consent.upload',
    'clinical.recalls.view', 'clinical.recalls.create', 'clinical.recalls.edit',
    'clinical.recalls.delete', 'clinical.recalls.batch_generate',
    // Billing
    'billing.invoices.view', 'billing.invoices.view_detail', 'billing.invoices.create',
    'billing.invoices.send', 'billing.invoices.share',
    'billing.receipts.view', 'billing.receipts.create', 'billing.receipts.send',
    'billing.payments.view', 'billing.payments.record',
    'billing.quotations.view', 'billing.quotations.create', 'billing.quotations.send',
    'billing.installments.view',
    // Inventory — view + adjust only (no write/suppliers)
    'inventory.view', 'inventory.adjust_stock', 'inventory.view_alerts',
    // Lab — cases + services only (no laboratories management)
    'lab.cases.view', 'lab.cases.create', 'lab.cases.update_status',
    'lab.services.view',
    // Bridge
    'bridge.view',
    // Comms
    'comms.messages.view', 'comms.messages.send_patient', 'comms.messages.send_staff',
    'comms.templates.view', 'comms.bulk.send',
    // AI — scheduling nudges only
    'ai.appointment_nudges', 'ai.payment_reminder',
    // Settings
    'settings.staff.view',
    'settings.signatures.view',
    // Notifications
    'notifications.manage',
  ],
  patient: [...(DEFAULT_PERMISSIONS_BY_ROLE.patient as PermissionKey[])],
};
  • Step 2: Add getDefaultsForPlan helper after the constants
Insert after PRO_PERMISSIONS_BY_ROLE closing };:
export function getDefaultsForPlan(role: string, planId?: string | null): PermissionKey[] {
  if (planId === 'price_pro_plus') {
    return PRO_PLUS_PERMISSIONS_BY_ROLE[role] || [];
  }
  return PRO_PERMISSIONS_BY_ROLE[role] || [];
}
  • Step 3: Run tests to confirm they pass
cd /Users/ssh/Documents/Beta-App/odontoX/server && npx vitest run src/lib/permissions.test.ts
Expected: all 6 tests pass.
  • Step 4: Commit
cd /Users/ssh/Documents/Beta-App/odontoX/server
git add src/lib/permissions.ts src/lib/permissions.test.ts
git commit -m "feat(permissions): add Pro/Pro+ plan-based default permission sets and getDefaultsForPlan helper"

Task 3: Update getEffectivePermissions to use plan-based defaults

Files:
  • Modify: server/src/lib/permissions.ts:359-388
  • Step 1: Update the function signature and default-selection logic
Replace the existing getEffectivePermissions function (lines 359–388) with:
export async function getEffectivePermissions(
  user: { id: string; role: string },
  clinicId?: string | null,
  subscriptionPlanId?: string | null
): Promise<PermissionMap> {
  const role = user.role?.toLowerCase?.() || '';

  if (role === 'superadmin') {
    return buildPermissionMap(PERMISSION_KEYS);
  }

  const defaults = buildPermissionMap(getDefaultsForPlan(role, subscriptionPlanId));

  if (!clinicId) return defaults;

  const databaseUrl = getDatabaseUrl();
  const db = await getDatabase(databaseUrl);

  const [assignment] = await db.select({
    permissions: userClinicAssignments.permissions,
  })
    .from(userClinicAssignments)
    .where(and(
      eq(userClinicAssignments.userId, user.id),
      eq(userClinicAssignments.clinicId, clinicId),
      eq(userClinicAssignments.status, 'active')
    ))
    .limit(1);

  return mergePermissions(defaults, assignment?.permissions || undefined);
}
  • Step 2: Add a targeted integration test to permissions.test.ts
Append to the existing test file:
import { buildPermissionMap, mergePermissions, getDefaultsForPlan } from './permissions';

describe('getEffectivePermissions plan routing (unit-level)', () => {
  it('Pro+ doctor gets bridge.view; Pro doctor does not', () => {
    const proPlusMap = buildPermissionMap(getDefaultsForPlan('doctor', 'price_pro_plus'));
    const proMap = buildPermissionMap(getDefaultsForPlan('doctor', 'price_pro'));
    expect(proPlusMap['bridge.view']).toBe(true);
    expect(proMap['bridge.view']).toBe(false);
  });

  it('mergePermissions still applies stored overrides on top of plan defaults', () => {
    const defaults = buildPermissionMap(getDefaultsForPlan('receptionist', 'price_pro'));
    // simulate admin granting reports.stats override on top of Pro defaults
    const merged = mergePermissions(defaults, { 'reports.stats': true });
    expect(merged['reports.stats']).toBe(true);
  });
});
  • Step 3: Run tests
cd /Users/ssh/Documents/Beta-App/odontoX/server && npx vitest run src/lib/permissions.test.ts
Expected: all 8 tests pass.
  • Step 4: Commit
cd /Users/ssh/Documents/Beta-App/odontoX/server
git add src/lib/permissions.ts src/lib/permissions.test.ts
git commit -m "feat(permissions): wire getEffectivePermissions to plan-based defaults"

Task 4: Pass subscriptionPlanId to getEffectivePermissions in the /me endpoint

Files:
  • Modify: server/src/api.ts:1120-1135
  • Step 1: Add subscriptionPlanId to the clinic select
In api.ts, the clinic data select starts at line 1120. Replace:
    const [cData] = await db.select({
      id: schema.clinics.id,
      name: schema.clinics.name,
      subscriptionStatus: schema.clinics.subscriptionStatus,
      trialEndDate: schema.clinics.trialEndDate,
      nextBillingDate: schema.clinics.nextBillingDate,
      isActive: schema.clinics.isActive,
    })
      .from(schema.clinics)
      .where(eq(schema.clinics.id, clinicId))
      .limit(1);

    clinicData = cData || null;
  }

  const permissions = await getEffectivePermissions(user, clinicId || null);
With:
    const [cData] = await db.select({
      id: schema.clinics.id,
      name: schema.clinics.name,
      subscriptionStatus: schema.clinics.subscriptionStatus,
      trialEndDate: schema.clinics.trialEndDate,
      nextBillingDate: schema.clinics.nextBillingDate,
      isActive: schema.clinics.isActive,
      subscriptionPlanId: schema.clinics.subscriptionPlanId,
    })
      .from(schema.clinics)
      .where(eq(schema.clinics.id, clinicId))
      .limit(1);

    clinicData = cData || null;
  }

  const permissions = await getEffectivePermissions(user, clinicId || null, clinicData?.subscriptionPlanId ?? null);
  • Step 2: Verify TypeScript compiles
cd /Users/ssh/Documents/Beta-App/odontoX/server && npx tsc --noEmit 2>&1 | head -20
Expected: no errors related to permissions.ts or api.ts.
  • Step 3: Commit
cd /Users/ssh/Documents/Beta-App/odontoX/server
git add src/api.ts
git commit -m "feat(api): pass clinic subscriptionPlanId to getEffectivePermissions in /me endpoint"

Task 5: Improve the superadmin plan-assignment dialog

Files:
  • Modify: ui/src/components/superadmin/ClinicDetailsPage.tsx
The “Upgrade Clinic Plan” dialog (around line 1372) needs three improvements:
  1. Rename from “Upgrade” to “Assign Plan” — it is used for both initial assignment and changes
  2. Show the current plan name before selection
  3. Add a brief note about existing users
  • Step 1: Update the dialog title and current-plan context
Locate the DialogTitle and DialogDescription block (around line 1375–1379) and replace:
            <DialogHeader>
              <DialogTitle>Upgrade Clinic Plan</DialogTitle>
              <DialogDescription>
                Select a plan for {clinic.name}. This will update the clinic's subscription and sync all users.
              </DialogDescription>
            </DialogHeader>
With:
            <DialogHeader>
              <DialogTitle>Assign Subscription Plan</DialogTitle>
              <DialogDescription className="space-y-1">
                <span className="block">
                  Clinic: <strong>{clinic.name}</strong>
                </span>
                {clinic.subscriptionPlanId && (
                  <span className="block text-xs text-muted-foreground">
                    Current plan: <code className="font-mono">{clinic.subscriptionPlanId}</code>
                  </span>
                )}
              </DialogDescription>
            </DialogHeader>
  • Step 2: Add plan feature hints to the dropdown items
Locate the availablePlans.map inside SelectContent (around line 1401–1409) and replace:
                      availablePlans.map((plan) => (
                        <SelectItem key={plan.id} value={plan.planName}>
                          {plan.planName} -{' '}
                          {plan.monthlyPrice
                            ? `PKR ${parseFloat(plan.monthlyPrice).toLocaleString()}/month`
                            : 'Custom Pricing'}
                        </SelectItem>
                      ))
With:
                      availablePlans.map((plan) => {
                        const hint =
                          plan.id === 'price_pro_plus'
                            ? '6 doctors · full inventory · DICOM bridge'
                            : plan.id === 'price_pro'
                            ? '3 doctors · basic inventory · no bridge'
                            : '';
                        return (
                          <SelectItem key={plan.id} value={plan.planName}>
                            <span className="font-medium">{plan.planName}</span>
                            {' — '}
                            {plan.monthlyPrice
                              ? `PKR ${parseFloat(plan.monthlyPrice).toLocaleString()}/mo`
                              : 'Custom'}
                            {hint && (
                              <span className="ml-1 text-xs text-muted-foreground">({hint})</span>
                            )}
                          </SelectItem>
                        );
                      })
  • Step 3: Add a note about existing users in the dialog footer
Locate the <DialogFooter> block (around line 1418) and add a note before it:
            <p className="text-xs text-muted-foreground px-1">
              Existing staff keep their current permissions. New invites will inherit the selected plan's defaults.
            </p>
            <DialogFooter>
  • Step 4: Update the success toast to say “Plan assigned” generically
Find the toast (around line 968):
      toast.success(`Clinic upgraded to ${selectedPlan} successfully`);
Replace with:
      toast.success(`Plan assigned: ${selectedPlan}`);
  • Step 5: Verify TypeScript
cd /Users/ssh/Documents/Beta-App/odontoX/ui && npx tsc --noEmit 2>&1 | head -20
Expected: no new errors.
  • Step 6: Commit
cd /Users/ssh/Documents/Beta-App/odontoX
git add ui/src/components/superadmin/ClinicDetailsPage.tsx
git commit -m "feat(superadmin): improve plan assignment dialog — current plan display, feature hints, user note"

Task 6: End-to-end smoke test

  • Step 1: Run the full server test suite
cd /Users/ssh/Documents/Beta-App/odontoX/server && npx vitest run
Expected: all tests pass, no regressions.
  • Step 2: Manual spot-check via API
Start the dev server, log in as the ssh & Associates doctor (email: [email protected]), and call GET /api/v1/protected/me. Confirm the response includes:
  • permissions['bridge.view'] === true (Pro+ doctor)
  • permissions['ai.dicom_analysis.run'] === true (Pro+ doctor)
  • permissions['clinical.ipd.view'] === true (Pro+ doctor)
Log in as a test Pro clinic doctor and confirm:
  • permissions['bridge.view'] === false
  • permissions['billing.insurance.view'] === false
  • permissions['reports.stats'] === true
  • Step 3: Final commit tag
cd /Users/ssh/Documents/Beta-App/odontoX
git add -p
git commit -m "chore: plan-based permissions complete — Pro/Pro+ defaults, /me wired, superadmin UI improved"