feat: Implement user tier-based feature gating system (refs #8)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 4m35s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 27s
Deploy to Staging / Verify Staging (pull_request) Successful in 5s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 5s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped

Add subscription tier system to gate features behind Free/Pro/Enterprise tiers.

Backend:
- Create feature-tiers.ts with FEATURE_TIERS config and utilities
- Add /api/config/feature-tiers endpoint for frontend config fetch
- Create requireTier middleware for route-level tier enforcement
- Add subscriptionTier to request.userContext in auth plugin
- Gate scanForMaintenance in documents controller (Pro+ required)
- Add migration to reset scanForMaintenance for free users

Frontend:
- Create useTierAccess hook for tier checking
- Create UpgradeRequiredDialog component (responsive)
- Gate DocumentForm checkbox with lock icon for free users
- Add SubscriptionTier type to profile.types.ts

Documentation:
- Add TIER-GATING.md with usage guide

Tests: 30 passing (feature-tiers, tier-guard, controller)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Eric Gullickson
2026-01-04 14:34:47 -06:00
parent 453083b7db
commit f494f77150
18 changed files with 1544 additions and 7 deletions

View File

@@ -0,0 +1,104 @@
import {
TIER_LEVELS,
FEATURE_TIERS,
getTierLevel,
canAccessFeature,
getRequiredTier,
getFeatureConfig,
getAllFeatureConfigs,
} from '../feature-tiers';
describe('feature-tiers', () => {
describe('TIER_LEVELS', () => {
it('defines correct tier hierarchy', () => {
expect(TIER_LEVELS.free).toBe(0);
expect(TIER_LEVELS.pro).toBe(1);
expect(TIER_LEVELS.enterprise).toBe(2);
});
it('enterprise > pro > free', () => {
expect(TIER_LEVELS.enterprise).toBeGreaterThan(TIER_LEVELS.pro);
expect(TIER_LEVELS.pro).toBeGreaterThan(TIER_LEVELS.free);
});
});
describe('FEATURE_TIERS', () => {
it('includes scanMaintenanceSchedule feature', () => {
const feature = FEATURE_TIERS['document.scanMaintenanceSchedule'];
expect(feature).toBeDefined();
expect(feature.minTier).toBe('pro');
expect(feature.name).toBe('Scan for Maintenance Schedule');
expect(feature.upgradePrompt).toBeTruthy();
});
});
describe('getTierLevel', () => {
it('returns correct level for each tier', () => {
expect(getTierLevel('free')).toBe(0);
expect(getTierLevel('pro')).toBe(1);
expect(getTierLevel('enterprise')).toBe(2);
});
it('returns 0 for unknown tier', () => {
expect(getTierLevel('unknown' as any)).toBe(0);
});
});
describe('canAccessFeature', () => {
const featureKey = 'document.scanMaintenanceSchedule';
it('denies access for free tier to pro feature', () => {
expect(canAccessFeature('free', featureKey)).toBe(false);
});
it('allows access for pro tier to pro feature', () => {
expect(canAccessFeature('pro', featureKey)).toBe(true);
});
it('allows access for enterprise tier to pro feature (inheritance)', () => {
expect(canAccessFeature('enterprise', featureKey)).toBe(true);
});
it('allows access for unknown feature (fail open)', () => {
expect(canAccessFeature('free', 'unknown.feature')).toBe(true);
expect(canAccessFeature('pro', 'unknown.feature')).toBe(true);
expect(canAccessFeature('enterprise', 'unknown.feature')).toBe(true);
});
});
describe('getRequiredTier', () => {
it('returns required tier for known feature', () => {
expect(getRequiredTier('document.scanMaintenanceSchedule')).toBe('pro');
});
it('returns null for unknown feature', () => {
expect(getRequiredTier('unknown.feature')).toBeNull();
});
});
describe('getFeatureConfig', () => {
it('returns full config for known feature', () => {
const config = getFeatureConfig('document.scanMaintenanceSchedule');
expect(config).toEqual({
minTier: 'pro',
name: 'Scan for Maintenance Schedule',
upgradePrompt: expect.any(String),
});
});
it('returns undefined for unknown feature', () => {
expect(getFeatureConfig('unknown.feature')).toBeUndefined();
});
});
describe('getAllFeatureConfigs', () => {
it('returns copy of all feature configs', () => {
const configs = getAllFeatureConfigs();
expect(configs['document.scanMaintenanceSchedule']).toBeDefined();
// Verify it's a copy, not the original
configs['test'] = { minTier: 'free', name: 'test', upgradePrompt: '' };
expect(FEATURE_TIERS['test' as keyof typeof FEATURE_TIERS]).toBeUndefined();
});
});
});