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
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:
311
docs/TIER-GATING.md
Normal file
311
docs/TIER-GATING.md
Normal file
@@ -0,0 +1,311 @@
|
||||
# Tier-Based Feature Gating
|
||||
|
||||
This document describes the user subscription tier system and how to gate features behind specific tiers.
|
||||
|
||||
## Overview
|
||||
|
||||
MotoVaultPro supports three subscription tiers with hierarchical access:
|
||||
|
||||
| Tier | Level | Description |
|
||||
|------|-------|-------------|
|
||||
| `free` | 0 | Default tier, limited features |
|
||||
| `pro` | 1 | Mid-tier, most features |
|
||||
| `enterprise` | 2 | Full access to all features |
|
||||
|
||||
Higher tiers automatically have access to all lower-tier features (tier hierarchy).
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Frontend │
|
||||
│ ┌─────────────────┐ ┌──────────────────────────────┐ │
|
||||
│ │ useTierAccess │───▶│ UpgradeRequiredDialog │ │
|
||||
│ │ hook │ │ component │ │
|
||||
│ └────────┬────────┘ └──────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ fetches │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Backend API │
|
||||
│ ┌─────────────────┐ ┌──────────────────────────────┐ │
|
||||
│ │ /api/config/ │ │ /api/documents │ │
|
||||
│ │ feature-tiers │ │ (tier validation) │ │
|
||||
│ └────────┬────────┘ └────────┬─────────────────────┘ │
|
||||
│ │ │ │
|
||||
│ ▼ ▼ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ feature-tiers.ts (single source of truth) │ │
|
||||
│ │ - FEATURE_TIERS config │ │
|
||||
│ │ - canAccessFeature(), getTierLevel(), etc. │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Backend Implementation
|
||||
|
||||
### Feature Configuration
|
||||
|
||||
All gated features are defined in `backend/src/core/config/feature-tiers.ts`:
|
||||
|
||||
```typescript
|
||||
export const FEATURE_TIERS = {
|
||||
'document.scanMaintenanceSchedule': {
|
||||
minTier: 'pro',
|
||||
name: 'Scan for Maintenance Schedule',
|
||||
upgradePrompt: 'Upgrade to Pro to automatically extract maintenance schedules from your manuals.'
|
||||
},
|
||||
// Add new features here
|
||||
} as const;
|
||||
```
|
||||
|
||||
### Utility Functions
|
||||
|
||||
```typescript
|
||||
import {
|
||||
canAccessFeature,
|
||||
getTierLevel,
|
||||
getRequiredTier,
|
||||
getFeatureConfig
|
||||
} from '../core/config/feature-tiers';
|
||||
|
||||
// Check if user can access a feature
|
||||
canAccessFeature('pro', 'document.scanMaintenanceSchedule'); // true
|
||||
canAccessFeature('free', 'document.scanMaintenanceSchedule'); // false
|
||||
|
||||
// Get numeric tier level for comparison
|
||||
getTierLevel('pro'); // 1
|
||||
|
||||
// Get minimum required tier for a feature
|
||||
getRequiredTier('document.scanMaintenanceSchedule'); // 'pro'
|
||||
```
|
||||
|
||||
### Route-Level Protection (Middleware)
|
||||
|
||||
Use `requireTier` for routes that require a minimum tier:
|
||||
|
||||
```typescript
|
||||
// In routes file
|
||||
fastify.post('/premium-endpoint', {
|
||||
preHandler: [fastify.requireTier({ minTier: 'pro' })],
|
||||
handler: controller.premiumAction
|
||||
});
|
||||
|
||||
// Or by feature key
|
||||
fastify.post('/scan-maintenance', {
|
||||
preHandler: [fastify.requireTier({ featureKey: 'document.scanMaintenanceSchedule' })],
|
||||
handler: controller.scanMaintenance
|
||||
});
|
||||
```
|
||||
|
||||
### Controller-Level Validation
|
||||
|
||||
For conditional feature checks within a controller:
|
||||
|
||||
```typescript
|
||||
async create(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userTier = request.userContext?.subscriptionTier || 'free';
|
||||
|
||||
if (request.body.scanForMaintenance && !canAccessFeature(userTier, 'document.scanMaintenanceSchedule')) {
|
||||
const config = getFeatureConfig('document.scanMaintenanceSchedule');
|
||||
return reply.code(403).send({
|
||||
error: 'TIER_REQUIRED',
|
||||
requiredTier: config?.minTier || 'pro',
|
||||
currentTier: userTier,
|
||||
feature: 'document.scanMaintenanceSchedule',
|
||||
featureName: config?.name || null,
|
||||
upgradePrompt: config?.upgradePrompt || 'Upgrade to access this feature.',
|
||||
});
|
||||
}
|
||||
// ... continue with operation
|
||||
}
|
||||
```
|
||||
|
||||
### 403 Error Response Format
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "TIER_REQUIRED",
|
||||
"requiredTier": "pro",
|
||||
"currentTier": "free",
|
||||
"feature": "document.scanMaintenanceSchedule",
|
||||
"featureName": "Scan for Maintenance Schedule",
|
||||
"upgradePrompt": "Upgrade to Pro to automatically extract maintenance schedules from your manuals."
|
||||
}
|
||||
```
|
||||
|
||||
## Frontend Implementation
|
||||
|
||||
### useTierAccess Hook
|
||||
|
||||
```typescript
|
||||
import { useTierAccess } from '@/core/hooks';
|
||||
|
||||
const MyComponent = () => {
|
||||
const { tier, loading, hasAccess, checkAccess } = useTierAccess();
|
||||
|
||||
// Simple boolean check
|
||||
if (!hasAccess('document.scanMaintenanceSchedule')) {
|
||||
// Show upgrade prompt or disable feature
|
||||
}
|
||||
|
||||
// Detailed access info
|
||||
const access = checkAccess('document.scanMaintenanceSchedule');
|
||||
// {
|
||||
// allowed: false,
|
||||
// requiredTier: 'pro',
|
||||
// config: { name: '...', upgradePrompt: '...' }
|
||||
// }
|
||||
};
|
||||
```
|
||||
|
||||
### UpgradeRequiredDialog Component
|
||||
|
||||
```tsx
|
||||
import { UpgradeRequiredDialog } from '@/shared-minimal/components';
|
||||
|
||||
const MyComponent = () => {
|
||||
const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false);
|
||||
const { hasAccess } = useTierAccess();
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
disabled={!hasAccess('document.scanMaintenanceSchedule')}
|
||||
onClick={() => {
|
||||
if (!hasAccess('document.scanMaintenanceSchedule')) {
|
||||
setUpgradeDialogOpen(true);
|
||||
} else {
|
||||
// Perform action
|
||||
}
|
||||
}}
|
||||
>
|
||||
Premium Feature
|
||||
</button>
|
||||
|
||||
<UpgradeRequiredDialog
|
||||
featureKey="document.scanMaintenanceSchedule"
|
||||
open={upgradeDialogOpen}
|
||||
onClose={() => setUpgradeDialogOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### Gating a Checkbox (Example)
|
||||
|
||||
```tsx
|
||||
const { hasAccess } = useTierAccess();
|
||||
const canScanMaintenance = hasAccess('document.scanMaintenanceSchedule');
|
||||
|
||||
<label className={canScanMaintenance ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={canScanMaintenance ? scanForMaintenance : false}
|
||||
onChange={(e) => canScanMaintenance && setScanForMaintenance(e.target.checked)}
|
||||
disabled={!canScanMaintenance}
|
||||
/>
|
||||
<span>Scan for Maintenance Schedule</span>
|
||||
</label>
|
||||
{!canScanMaintenance && (
|
||||
<button onClick={() => setUpgradeDialogOpen(true)}>
|
||||
<LockIcon />
|
||||
</button>
|
||||
)}
|
||||
```
|
||||
|
||||
## Adding a New Gated Feature
|
||||
|
||||
### Step 1: Add to Feature Config (Backend)
|
||||
|
||||
```typescript
|
||||
// backend/src/core/config/feature-tiers.ts
|
||||
export const FEATURE_TIERS = {
|
||||
// ... existing features
|
||||
'reports.advancedAnalytics': {
|
||||
minTier: 'enterprise',
|
||||
name: 'Advanced Analytics',
|
||||
upgradePrompt: 'Upgrade to Enterprise for advanced fleet analytics and reporting.'
|
||||
},
|
||||
} as const;
|
||||
```
|
||||
|
||||
### Step 2: Add Backend Validation
|
||||
|
||||
Either use middleware on the route:
|
||||
```typescript
|
||||
fastify.get('/reports/analytics', {
|
||||
preHandler: [fastify.requireTier({ featureKey: 'reports.advancedAnalytics' })],
|
||||
handler: controller.getAnalytics
|
||||
});
|
||||
```
|
||||
|
||||
Or validate in the controller for conditional features.
|
||||
|
||||
### Step 3: Add Frontend Check
|
||||
|
||||
```tsx
|
||||
const { hasAccess } = useTierAccess();
|
||||
|
||||
if (!hasAccess('reports.advancedAnalytics')) {
|
||||
return <UpgradePrompt featureKey="reports.advancedAnalytics" />;
|
||||
}
|
||||
```
|
||||
|
||||
## API Endpoint
|
||||
|
||||
The frontend fetches tier configuration from:
|
||||
|
||||
```
|
||||
GET /api/config/feature-tiers
|
||||
|
||||
Response:
|
||||
{
|
||||
"tiers": { "free": 0, "pro": 1, "enterprise": 2 },
|
||||
"features": {
|
||||
"document.scanMaintenanceSchedule": {
|
||||
"minTier": "pro",
|
||||
"name": "Scan for Maintenance Schedule",
|
||||
"upgradePrompt": "..."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## User Tier Management
|
||||
|
||||
### Admin UI
|
||||
|
||||
Admins can change user tiers via the Admin Users page dropdown.
|
||||
|
||||
### Database
|
||||
|
||||
User tiers are stored in `user_profiles.subscription_tier` column (enum: free, pro, enterprise).
|
||||
|
||||
### Auth Context
|
||||
|
||||
The user's tier is included in `request.userContext.subscriptionTier` after authentication.
|
||||
|
||||
## Testing
|
||||
|
||||
### Backend Tests
|
||||
|
||||
```bash
|
||||
# Run tier-related tests
|
||||
npm test -- --testPathPattern="feature-tiers|tier-guard|documents.controller.tier"
|
||||
```
|
||||
|
||||
### Test Cases to Cover
|
||||
|
||||
1. Free user attempting to use Pro feature returns 403
|
||||
2. Pro user can use Pro features
|
||||
3. Enterprise user can use all features
|
||||
4. Unknown features are allowed (fail open)
|
||||
5. Missing userContext defaults to free tier
|
||||
|
||||
## Future Considerations
|
||||
|
||||
- Stripe billing integration for tier upgrades
|
||||
- Subscription expiration handling
|
||||
- Grace periods for downgraded users
|
||||
- Feature usage analytics
|
||||
Reference in New Issue
Block a user