feat: Implement user tier-based feature gating system #18
@@ -10,6 +10,7 @@ import fastifyMultipart from '@fastify/multipart';
|
||||
// Core plugins
|
||||
import authPlugin from './core/plugins/auth.plugin';
|
||||
import adminGuardPlugin, { setAdminGuardPool } from './core/plugins/admin-guard.plugin';
|
||||
import tierGuardPlugin from './core/plugins/tier-guard.plugin';
|
||||
import loggingPlugin from './core/plugins/logging.plugin';
|
||||
import errorPlugin from './core/plugins/error.plugin';
|
||||
import { appConfig } from './core/config/config-loader';
|
||||
@@ -30,6 +31,7 @@ import { onboardingRoutes } from './features/onboarding';
|
||||
import { userPreferencesRoutes } from './features/user-preferences';
|
||||
import { userExportRoutes } from './features/user-export';
|
||||
import { pool } from './core/config/database';
|
||||
import { configRoutes } from './core/config/config.routes';
|
||||
|
||||
async function buildApp(): Promise<FastifyInstance> {
|
||||
const app = Fastify({
|
||||
@@ -80,13 +82,16 @@ async function buildApp(): Promise<FastifyInstance> {
|
||||
await app.register(adminGuardPlugin);
|
||||
setAdminGuardPool(pool);
|
||||
|
||||
// Tier guard plugin - for subscription tier enforcement
|
||||
await app.register(tierGuardPlugin);
|
||||
|
||||
// Health check
|
||||
app.get('/health', async (_request, reply) => {
|
||||
return reply.code(200).send({
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
environment: process.env['NODE_ENV'],
|
||||
features: ['admin', 'auth', 'onboarding', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile', 'user-preferences', 'user-export']
|
||||
features: ['admin', 'auth', 'config', 'onboarding', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile', 'user-preferences', 'user-export']
|
||||
});
|
||||
});
|
||||
|
||||
@@ -96,7 +101,7 @@ async function buildApp(): Promise<FastifyInstance> {
|
||||
status: 'healthy',
|
||||
scope: 'api',
|
||||
timestamp: new Date().toISOString(),
|
||||
features: ['admin', 'auth', 'onboarding', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile', 'user-preferences', 'user-export']
|
||||
features: ['admin', 'auth', 'config', 'onboarding', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile', 'user-preferences', 'user-export']
|
||||
});
|
||||
});
|
||||
|
||||
@@ -136,6 +141,7 @@ async function buildApp(): Promise<FastifyInstance> {
|
||||
await app.register(userProfileRoutes, { prefix: '/api' });
|
||||
await app.register(userPreferencesRoutes, { prefix: '/api' });
|
||||
await app.register(userExportRoutes, { prefix: '/api' });
|
||||
await app.register(configRoutes, { prefix: '/api' });
|
||||
|
||||
// 404 handler
|
||||
app.setNotFoundHandler(async (_request, reply) => {
|
||||
|
||||
18
backend/src/core/config/config.routes.ts
Normal file
18
backend/src/core/config/config.routes.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* @ai-summary Configuration API routes
|
||||
* @ai-context Exposes feature tier configuration for frontend consumption
|
||||
*/
|
||||
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import { getAllFeatureConfigs, TIER_LEVELS } from './feature-tiers';
|
||||
|
||||
export const configRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
// GET /api/config/feature-tiers - Get all feature tier configurations
|
||||
// Public endpoint - no auth required (config is not sensitive)
|
||||
fastify.get('/config/feature-tiers', async (_request, reply) => {
|
||||
return reply.code(200).send({
|
||||
tiers: TIER_LEVELS,
|
||||
features: getAllFeatureConfigs(),
|
||||
});
|
||||
});
|
||||
};
|
||||
73
backend/src/core/config/feature-tiers.ts
Normal file
73
backend/src/core/config/feature-tiers.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* @ai-summary Feature tier configuration and utilities
|
||||
* @ai-context Defines feature-to-tier mapping for gating premium features
|
||||
*/
|
||||
|
||||
import { SubscriptionTier } from '../../features/user-profile/domain/user-profile.types';
|
||||
|
||||
// Tier hierarchy: higher number = higher access level
|
||||
export const TIER_LEVELS: Record<SubscriptionTier, number> = {
|
||||
free: 0,
|
||||
pro: 1,
|
||||
enterprise: 2,
|
||||
} as const;
|
||||
|
||||
// Feature configuration interface
|
||||
export interface FeatureConfig {
|
||||
minTier: SubscriptionTier;
|
||||
name: string;
|
||||
upgradePrompt: string;
|
||||
}
|
||||
|
||||
// Feature registry - add new gated features here
|
||||
export const FEATURE_TIERS: Record<string, FeatureConfig> = {
|
||||
'document.scanMaintenanceSchedule': {
|
||||
minTier: 'pro',
|
||||
name: 'Scan for Maintenance Schedule',
|
||||
upgradePrompt: 'Upgrade to Pro to automatically extract maintenance schedules from your vehicle manuals.',
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Get numeric level for a subscription tier
|
||||
*/
|
||||
export function getTierLevel(tier: SubscriptionTier): number {
|
||||
return TIER_LEVELS[tier] ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user tier can access a feature
|
||||
* Higher tiers inherit access to all lower tier features
|
||||
*/
|
||||
export function canAccessFeature(userTier: SubscriptionTier, featureKey: string): boolean {
|
||||
const feature = FEATURE_TIERS[featureKey];
|
||||
if (!feature) {
|
||||
// Unknown features are accessible by all (fail open for unlisted features)
|
||||
return true;
|
||||
}
|
||||
return getTierLevel(userTier) >= getTierLevel(feature.minTier);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the minimum required tier for a feature
|
||||
* Returns null if feature is not gated
|
||||
*/
|
||||
export function getRequiredTier(featureKey: string): SubscriptionTier | null {
|
||||
const feature = FEATURE_TIERS[featureKey];
|
||||
return feature?.minTier ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full feature configuration
|
||||
* Returns undefined if feature is not registered
|
||||
*/
|
||||
export function getFeatureConfig(featureKey: string): FeatureConfig | undefined {
|
||||
return FEATURE_TIERS[featureKey];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all feature configurations (for API endpoint)
|
||||
*/
|
||||
export function getAllFeatureConfigs(): Record<string, FeatureConfig> {
|
||||
return { ...FEATURE_TIERS };
|
||||
}
|
||||
104
backend/src/core/config/tests/feature-tiers.test.ts
Normal file
104
backend/src/core/config/tests/feature-tiers.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import { logger } from '../logging/logger';
|
||||
import { UserProfileRepository } from '../../features/user-profile/data/user-profile.repository';
|
||||
import { pool } from '../config/database';
|
||||
import { auth0ManagementClient } from '../auth/auth0-management.client';
|
||||
import { SubscriptionTier } from '../../features/user-profile/domain/user-profile.types';
|
||||
|
||||
// Routes that don't require email verification
|
||||
const VERIFICATION_EXEMPT_ROUTES = [
|
||||
@@ -56,6 +57,7 @@ declare module 'fastify' {
|
||||
onboardingCompleted: boolean;
|
||||
isAdmin: boolean;
|
||||
adminRecord?: any;
|
||||
subscriptionTier: SubscriptionTier;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -129,6 +131,7 @@ const authPlugin: FastifyPluginAsync = async (fastify) => {
|
||||
let displayName: string | undefined;
|
||||
let emailVerified = false;
|
||||
let onboardingCompleted = false;
|
||||
let subscriptionTier: SubscriptionTier = 'free';
|
||||
|
||||
try {
|
||||
// If JWT doesn't have email, fetch from Auth0 Management API
|
||||
@@ -170,6 +173,7 @@ const authPlugin: FastifyPluginAsync = async (fastify) => {
|
||||
displayName = profile.displayName || undefined;
|
||||
emailVerified = profile.emailVerified;
|
||||
onboardingCompleted = profile.onboardingCompletedAt !== null;
|
||||
subscriptionTier = profile.subscriptionTier || 'free';
|
||||
|
||||
// Sync email verification status from Auth0 if needed
|
||||
if (!emailVerified) {
|
||||
@@ -208,6 +212,7 @@ const authPlugin: FastifyPluginAsync = async (fastify) => {
|
||||
emailVerified,
|
||||
onboardingCompleted,
|
||||
isAdmin: false, // Default to false; admin status checked by admin guard
|
||||
subscriptionTier,
|
||||
};
|
||||
|
||||
// Email verification guard - block unverified users from non-exempt routes
|
||||
|
||||
205
backend/src/core/plugins/tests/tier-guard.plugin.test.ts
Normal file
205
backend/src/core/plugins/tests/tier-guard.plugin.test.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import Fastify, { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import tierGuardPlugin from '../tier-guard.plugin';
|
||||
|
||||
const createReply = (): Partial<FastifyReply> & { payload?: unknown; statusCode?: number } => {
|
||||
return {
|
||||
sent: false,
|
||||
code: jest.fn(function(this: any, status: number) {
|
||||
this.statusCode = status;
|
||||
return this;
|
||||
}),
|
||||
send: jest.fn(function(this: any, payload: unknown) {
|
||||
this.payload = payload;
|
||||
this.sent = true;
|
||||
return this;
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
describe('tier guard plugin', () => {
|
||||
let fastify: FastifyInstance;
|
||||
let authenticateMock: jest.Mock;
|
||||
|
||||
beforeEach(async () => {
|
||||
fastify = Fastify();
|
||||
|
||||
// Mock authenticate to set userContext
|
||||
authenticateMock = jest.fn(async (request: FastifyRequest) => {
|
||||
request.userContext = {
|
||||
userId: 'auth0|user123',
|
||||
email: 'user@example.com',
|
||||
emailVerified: true,
|
||||
onboardingCompleted: true,
|
||||
isAdmin: false,
|
||||
subscriptionTier: 'free',
|
||||
};
|
||||
});
|
||||
fastify.decorate('authenticate', authenticateMock);
|
||||
|
||||
await fastify.register(tierGuardPlugin);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fastify.close();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('requireTier with minTier', () => {
|
||||
it('allows access when user tier meets minimum', async () => {
|
||||
authenticateMock.mockImplementation(async (request: FastifyRequest) => {
|
||||
request.userContext = {
|
||||
userId: 'auth0|user123',
|
||||
email: 'user@example.com',
|
||||
emailVerified: true,
|
||||
onboardingCompleted: true,
|
||||
isAdmin: false,
|
||||
subscriptionTier: 'pro',
|
||||
};
|
||||
});
|
||||
|
||||
const request = {} as FastifyRequest;
|
||||
const reply = createReply();
|
||||
|
||||
const handler = fastify.requireTier({ minTier: 'pro' });
|
||||
await handler(request, reply as FastifyReply);
|
||||
|
||||
expect(authenticateMock).toHaveBeenCalledTimes(1);
|
||||
expect(reply.code).not.toHaveBeenCalled();
|
||||
expect(reply.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows access when user tier exceeds minimum', async () => {
|
||||
authenticateMock.mockImplementation(async (request: FastifyRequest) => {
|
||||
request.userContext = {
|
||||
userId: 'auth0|user123',
|
||||
email: 'user@example.com',
|
||||
emailVerified: true,
|
||||
onboardingCompleted: true,
|
||||
isAdmin: false,
|
||||
subscriptionTier: 'enterprise',
|
||||
};
|
||||
});
|
||||
|
||||
const request = {} as FastifyRequest;
|
||||
const reply = createReply();
|
||||
|
||||
const handler = fastify.requireTier({ minTier: 'pro' });
|
||||
await handler(request, reply as FastifyReply);
|
||||
|
||||
expect(reply.code).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('denies access when user tier is below minimum', async () => {
|
||||
const request = {} as FastifyRequest;
|
||||
const reply = createReply();
|
||||
|
||||
const handler = fastify.requireTier({ minTier: 'pro' });
|
||||
await handler(request, reply as FastifyReply);
|
||||
|
||||
expect(reply.code).toHaveBeenCalledWith(403);
|
||||
expect(reply.send).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: 'TIER_REQUIRED',
|
||||
requiredTier: 'pro',
|
||||
currentTier: 'free',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('requireTier with featureKey', () => {
|
||||
it('denies free tier access to pro feature', async () => {
|
||||
const request = {} as FastifyRequest;
|
||||
const reply = createReply();
|
||||
|
||||
const handler = fastify.requireTier({ featureKey: 'document.scanMaintenanceSchedule' });
|
||||
await handler(request, reply as FastifyReply);
|
||||
|
||||
expect(reply.code).toHaveBeenCalledWith(403);
|
||||
expect(reply.send).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: 'TIER_REQUIRED',
|
||||
requiredTier: 'pro',
|
||||
currentTier: 'free',
|
||||
feature: 'document.scanMaintenanceSchedule',
|
||||
featureName: 'Scan for Maintenance Schedule',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('allows pro tier access to pro feature', async () => {
|
||||
authenticateMock.mockImplementation(async (request: FastifyRequest) => {
|
||||
request.userContext = {
|
||||
userId: 'auth0|user123',
|
||||
email: 'user@example.com',
|
||||
emailVerified: true,
|
||||
onboardingCompleted: true,
|
||||
isAdmin: false,
|
||||
subscriptionTier: 'pro',
|
||||
};
|
||||
});
|
||||
|
||||
const request = {} as FastifyRequest;
|
||||
const reply = createReply();
|
||||
|
||||
const handler = fastify.requireTier({ featureKey: 'document.scanMaintenanceSchedule' });
|
||||
await handler(request, reply as FastifyReply);
|
||||
|
||||
expect(reply.code).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows access for unknown feature (fail open)', async () => {
|
||||
const request = {} as FastifyRequest;
|
||||
const reply = createReply();
|
||||
|
||||
const handler = fastify.requireTier({ featureKey: 'unknown.feature' });
|
||||
await handler(request, reply as FastifyReply);
|
||||
|
||||
expect(reply.code).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('returns 500 when authenticate handler is not a function', async () => {
|
||||
const brokenFastify = Fastify();
|
||||
// Decorate with a non-function value to simulate missing handler
|
||||
brokenFastify.decorate('authenticate', 'not-a-function' as any);
|
||||
await brokenFastify.register(tierGuardPlugin);
|
||||
|
||||
const request = {} as FastifyRequest;
|
||||
const reply = createReply();
|
||||
|
||||
const handler = brokenFastify.requireTier({ minTier: 'pro' });
|
||||
await handler(request, reply as FastifyReply);
|
||||
|
||||
expect(reply.code).toHaveBeenCalledWith(500);
|
||||
expect(reply.send).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: 'Internal server error',
|
||||
message: 'Authentication handler missing',
|
||||
})
|
||||
);
|
||||
|
||||
await brokenFastify.close();
|
||||
});
|
||||
|
||||
it('defaults to free tier when userContext is missing', async () => {
|
||||
authenticateMock.mockImplementation(async () => {
|
||||
// Don't set userContext
|
||||
});
|
||||
|
||||
const request = {} as FastifyRequest;
|
||||
const reply = createReply();
|
||||
|
||||
const handler = fastify.requireTier({ minTier: 'pro' });
|
||||
await handler(request, reply as FastifyReply);
|
||||
|
||||
expect(reply.code).toHaveBeenCalledWith(403);
|
||||
expect(reply.send).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
currentTier: 'free',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
126
backend/src/core/plugins/tier-guard.plugin.ts
Normal file
126
backend/src/core/plugins/tier-guard.plugin.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* @ai-summary Fastify tier authorization plugin
|
||||
* @ai-context Enforces subscription tier requirements for protected routes
|
||||
*/
|
||||
|
||||
import { FastifyPluginAsync, FastifyRequest, FastifyReply, FastifyInstance } from 'fastify';
|
||||
import fp from 'fastify-plugin';
|
||||
import { logger } from '../logging/logger';
|
||||
import { SubscriptionTier } from '../../features/user-profile/domain/user-profile.types';
|
||||
import { canAccessFeature, getFeatureConfig, getTierLevel } from '../config/feature-tiers';
|
||||
|
||||
// Tier check options
|
||||
export interface TierCheckOptions {
|
||||
minTier?: SubscriptionTier;
|
||||
featureKey?: string;
|
||||
}
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
requireTier: (options: TierCheckOptions) => (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
|
||||
}
|
||||
}
|
||||
|
||||
const tierGuardPlugin: FastifyPluginAsync = async (fastify) => {
|
||||
/**
|
||||
* Creates a preHandler that enforces tier requirements
|
||||
*
|
||||
* Usage:
|
||||
* fastify.get('/premium-route', {
|
||||
* preHandler: [fastify.requireTier({ minTier: 'pro' })],
|
||||
* handler: controller.method
|
||||
* });
|
||||
*
|
||||
* Or with feature key:
|
||||
* fastify.post('/documents', {
|
||||
* preHandler: [fastify.requireTier({ featureKey: 'document.scanMaintenanceSchedule' })],
|
||||
* handler: controller.method
|
||||
* });
|
||||
*/
|
||||
fastify.decorate('requireTier', function(this: FastifyInstance, options: TierCheckOptions) {
|
||||
const { minTier, featureKey } = options;
|
||||
|
||||
return async (request: FastifyRequest, reply: FastifyReply): Promise<void> => {
|
||||
try {
|
||||
// Ensure user is authenticated first
|
||||
if (typeof this.authenticate !== 'function') {
|
||||
logger.error('Tier guard: authenticate handler missing');
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Authentication handler missing',
|
||||
});
|
||||
}
|
||||
|
||||
await this.authenticate(request, reply);
|
||||
if (reply.sent) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get user's subscription tier from context
|
||||
const userTier = request.userContext?.subscriptionTier || 'free';
|
||||
|
||||
// Determine required tier and check access
|
||||
let hasAccess = false;
|
||||
let requiredTier: SubscriptionTier = 'free';
|
||||
let upgradePrompt: string | undefined;
|
||||
let featureName: string | undefined;
|
||||
|
||||
if (featureKey) {
|
||||
// Feature-based tier check
|
||||
hasAccess = canAccessFeature(userTier, featureKey);
|
||||
const config = getFeatureConfig(featureKey);
|
||||
requiredTier = config?.minTier || 'pro';
|
||||
upgradePrompt = config?.upgradePrompt;
|
||||
featureName = config?.name;
|
||||
} else if (minTier) {
|
||||
// Direct tier comparison
|
||||
hasAccess = getTierLevel(userTier) >= getTierLevel(minTier);
|
||||
requiredTier = minTier;
|
||||
} else {
|
||||
// No tier requirement specified - allow access
|
||||
hasAccess = true;
|
||||
}
|
||||
|
||||
if (!hasAccess) {
|
||||
logger.warn('Tier guard: user tier insufficient', {
|
||||
userId: request.userContext?.userId?.substring(0, 8) + '...',
|
||||
userTier,
|
||||
requiredTier,
|
||||
featureKey,
|
||||
});
|
||||
|
||||
return reply.code(403).send({
|
||||
error: 'TIER_REQUIRED',
|
||||
requiredTier,
|
||||
currentTier: userTier,
|
||||
feature: featureKey || null,
|
||||
featureName: featureName || null,
|
||||
upgradePrompt: upgradePrompt || `Upgrade to ${requiredTier} to access this feature.`,
|
||||
});
|
||||
}
|
||||
|
||||
logger.debug('Tier guard: access granted', {
|
||||
userId: request.userContext?.userId?.substring(0, 8) + '...',
|
||||
userTier,
|
||||
featureKey,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Tier guard: authorization check failed', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
userId: request.userContext?.userId?.substring(0, 8) + '...',
|
||||
});
|
||||
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Tier check failed',
|
||||
});
|
||||
}
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export default fp(tierGuardPlugin, {
|
||||
name: 'tier-guard-plugin',
|
||||
// Note: Requires auth-plugin to be registered first for authenticate decorator
|
||||
// Dependency check removed to allow testing with mock authenticate
|
||||
});
|
||||
@@ -28,7 +28,10 @@ describe('admin guard plugin', () => {
|
||||
request.userContext = {
|
||||
userId: 'auth0|admin',
|
||||
email: 'admin@motovaultpro.com',
|
||||
emailVerified: true,
|
||||
onboardingCompleted: true,
|
||||
isAdmin: false,
|
||||
subscriptionTier: 'free',
|
||||
};
|
||||
});
|
||||
fastify.decorate('authenticate', authenticateMock);
|
||||
|
||||
@@ -8,6 +8,8 @@ import { Transform, TransformCallback } from 'stream';
|
||||
import crypto from 'crypto';
|
||||
import FileType from 'file-type';
|
||||
import { Readable } from 'stream';
|
||||
import { canAccessFeature, getFeatureConfig } from '../../../core/config/feature-tiers';
|
||||
import { SubscriptionTier } from '../../user-profile/domain/user-profile.types';
|
||||
|
||||
export class DocumentsController {
|
||||
private readonly service = new DocumentsService();
|
||||
@@ -73,6 +75,7 @@ export class DocumentsController {
|
||||
|
||||
async create(request: FastifyRequest<{ Body: CreateBody }>, reply: FastifyReply) {
|
||||
const userId = (request as any).user?.sub as string;
|
||||
const userTier: SubscriptionTier = request.userContext?.subscriptionTier || 'free';
|
||||
|
||||
logger.info('Document create requested', {
|
||||
operation: 'documents.create',
|
||||
@@ -82,6 +85,26 @@ export class DocumentsController {
|
||||
title: request.body.title,
|
||||
});
|
||||
|
||||
// Tier validation: scanForMaintenance requires Pro tier
|
||||
const featureKey = 'document.scanMaintenanceSchedule';
|
||||
if (request.body.scanForMaintenance && !canAccessFeature(userTier, featureKey)) {
|
||||
const config = getFeatureConfig(featureKey);
|
||||
logger.warn('Tier required for scanForMaintenance', {
|
||||
operation: 'documents.create.tier_required',
|
||||
userId,
|
||||
userTier,
|
||||
requiredTier: config?.minTier,
|
||||
});
|
||||
return reply.code(403).send({
|
||||
error: 'TIER_REQUIRED',
|
||||
requiredTier: config?.minTier || 'pro',
|
||||
currentTier: userTier,
|
||||
feature: featureKey,
|
||||
featureName: config?.name || null,
|
||||
upgradePrompt: config?.upgradePrompt || 'Upgrade to Pro to access this feature.',
|
||||
});
|
||||
}
|
||||
|
||||
const created = await this.service.createDocument(userId, request.body);
|
||||
|
||||
logger.info('Document created', {
|
||||
@@ -98,6 +121,7 @@ export class DocumentsController {
|
||||
|
||||
async update(request: FastifyRequest<{ Params: IdParams; Body: UpdateBody }>, reply: FastifyReply) {
|
||||
const userId = (request as any).user?.sub as string;
|
||||
const userTier: SubscriptionTier = request.userContext?.subscriptionTier || 'free';
|
||||
const documentId = request.params.id;
|
||||
|
||||
logger.info('Document update requested', {
|
||||
@@ -107,6 +131,27 @@ export class DocumentsController {
|
||||
updateFields: Object.keys(request.body),
|
||||
});
|
||||
|
||||
// Tier validation: scanForMaintenance requires Pro tier
|
||||
const featureKey = 'document.scanMaintenanceSchedule';
|
||||
if (request.body.scanForMaintenance && !canAccessFeature(userTier, featureKey)) {
|
||||
const config = getFeatureConfig(featureKey);
|
||||
logger.warn('Tier required for scanForMaintenance', {
|
||||
operation: 'documents.update.tier_required',
|
||||
userId,
|
||||
documentId,
|
||||
userTier,
|
||||
requiredTier: config?.minTier,
|
||||
});
|
||||
return reply.code(403).send({
|
||||
error: 'TIER_REQUIRED',
|
||||
requiredTier: config?.minTier || 'pro',
|
||||
currentTier: userTier,
|
||||
feature: featureKey,
|
||||
featureName: config?.name || null,
|
||||
upgradePrompt: config?.upgradePrompt || 'Upgrade to Pro to access this feature.',
|
||||
});
|
||||
}
|
||||
|
||||
const updated = await this.service.updateDocument(userId, documentId, request.body);
|
||||
if (!updated) {
|
||||
logger.warn('Document not found for update', {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Migration: Reset scanForMaintenance for free tier users
|
||||
-- This migration is part of the tier-gating feature implementation.
|
||||
-- scanForMaintenance is now a Pro feature, so existing free users with it enabled need to be reset.
|
||||
|
||||
UPDATE documents d
|
||||
SET scan_for_maintenance = false
|
||||
FROM user_profiles u
|
||||
WHERE d.user_id = u.auth0_sub
|
||||
AND u.subscription_tier = 'free'
|
||||
AND d.scan_for_maintenance = true;
|
||||
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* @ai-summary Unit tests for tier validation in DocumentsController
|
||||
* @ai-context Tests that free users cannot use scanForMaintenance feature
|
||||
*/
|
||||
|
||||
// Mock config and dependencies first (before any imports that might use them)
|
||||
jest.mock('../../../../core/config/config-loader', () => ({
|
||||
appConfig: {
|
||||
getDatabaseUrl: () => 'postgresql://mock:mock@localhost/mock',
|
||||
getRedisUrl: () => 'redis://localhost',
|
||||
get: () => ({}),
|
||||
},
|
||||
config: {
|
||||
database: { connectionString: 'mock' },
|
||||
redis: { url: 'mock' },
|
||||
auth0: { domain: 'mock', clientId: 'mock', audience: 'mock' },
|
||||
storage: { provider: 'filesystem', root: '/tmp' },
|
||||
logging: { level: 'error' },
|
||||
},
|
||||
}));
|
||||
jest.mock('../../../../core/config/database', () => ({
|
||||
pool: {
|
||||
query: jest.fn(),
|
||||
connect: jest.fn(),
|
||||
end: jest.fn(),
|
||||
},
|
||||
default: {
|
||||
query: jest.fn(),
|
||||
connect: jest.fn(),
|
||||
end: jest.fn(),
|
||||
},
|
||||
}));
|
||||
jest.mock('../../../../core/logging/logger', () => ({
|
||||
logger: {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
},
|
||||
}));
|
||||
jest.mock('../../../../core/storage/storage.service', () => ({
|
||||
getStorageService: jest.fn(() => ({
|
||||
putObject: jest.fn(),
|
||||
getObjectStream: jest.fn(),
|
||||
deleteObject: jest.fn(),
|
||||
headObject: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
jest.mock('../../domain/documents.service');
|
||||
|
||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { DocumentsController } from '../../api/documents.controller';
|
||||
import { DocumentsService } from '../../domain/documents.service';
|
||||
|
||||
const MockedService = jest.mocked(DocumentsService);
|
||||
|
||||
describe('DocumentsController - Tier Validation', () => {
|
||||
let controller: DocumentsController;
|
||||
let mockServiceInstance: jest.Mocked<DocumentsService>;
|
||||
|
||||
const createMockRequest = (overrides: Partial<FastifyRequest> = {}): FastifyRequest => ({
|
||||
user: { sub: 'user-123' },
|
||||
userContext: {
|
||||
userId: 'user-123',
|
||||
email: 'test@example.com',
|
||||
emailVerified: true,
|
||||
onboardingCompleted: true,
|
||||
isAdmin: false,
|
||||
subscriptionTier: 'free',
|
||||
},
|
||||
body: {},
|
||||
params: {},
|
||||
query: {},
|
||||
...overrides,
|
||||
} as unknown as FastifyRequest);
|
||||
|
||||
const createMockReply = (): Partial<FastifyReply> & { payload?: unknown; statusCode?: number } => ({
|
||||
sent: false,
|
||||
code: jest.fn(function(this: any, status: number) {
|
||||
this.statusCode = status;
|
||||
return this;
|
||||
}),
|
||||
send: jest.fn(function(this: any, payload: unknown) {
|
||||
this.payload = payload;
|
||||
this.sent = true;
|
||||
return this;
|
||||
}),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
mockServiceInstance = {
|
||||
createDocument: jest.fn(),
|
||||
updateDocument: jest.fn(),
|
||||
getDocument: jest.fn(),
|
||||
listDocuments: jest.fn(),
|
||||
deleteDocument: jest.fn(),
|
||||
} as any;
|
||||
|
||||
MockedService.mockImplementation(() => mockServiceInstance);
|
||||
controller = new DocumentsController();
|
||||
});
|
||||
|
||||
describe('create - scanForMaintenance tier gating', () => {
|
||||
const baseDocumentBody = {
|
||||
vehicleId: 'vehicle-123',
|
||||
documentType: 'manual',
|
||||
title: 'Service Manual',
|
||||
};
|
||||
|
||||
it('allows free user to create document without scanForMaintenance', async () => {
|
||||
const request = createMockRequest({
|
||||
body: { ...baseDocumentBody, scanForMaintenance: false },
|
||||
});
|
||||
const reply = createMockReply();
|
||||
|
||||
mockServiceInstance.createDocument.mockResolvedValue({
|
||||
id: 'doc-123',
|
||||
userId: 'user-123',
|
||||
vehicleId: 'vehicle-123',
|
||||
documentType: 'manual',
|
||||
title: 'Service Manual',
|
||||
scanForMaintenance: false,
|
||||
} as any);
|
||||
|
||||
await controller.create(request as any, reply as FastifyReply);
|
||||
|
||||
expect(reply.code).toHaveBeenCalledWith(201);
|
||||
expect(mockServiceInstance.createDocument).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('blocks free user from using scanForMaintenance=true', async () => {
|
||||
const request = createMockRequest({
|
||||
body: { ...baseDocumentBody, scanForMaintenance: true },
|
||||
});
|
||||
const reply = createMockReply();
|
||||
|
||||
await controller.create(request as any, reply as FastifyReply);
|
||||
|
||||
expect(reply.code).toHaveBeenCalledWith(403);
|
||||
expect(reply.send).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: 'TIER_REQUIRED',
|
||||
requiredTier: 'pro',
|
||||
currentTier: 'free',
|
||||
feature: 'document.scanMaintenanceSchedule',
|
||||
featureName: 'Scan for Maintenance Schedule',
|
||||
})
|
||||
);
|
||||
expect(mockServiceInstance.createDocument).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows pro user to use scanForMaintenance=true', async () => {
|
||||
const request = createMockRequest({
|
||||
body: { ...baseDocumentBody, scanForMaintenance: true },
|
||||
userContext: {
|
||||
userId: 'user-123',
|
||||
email: 'pro@example.com',
|
||||
emailVerified: true,
|
||||
onboardingCompleted: true,
|
||||
isAdmin: false,
|
||||
subscriptionTier: 'pro',
|
||||
},
|
||||
});
|
||||
const reply = createMockReply();
|
||||
|
||||
mockServiceInstance.createDocument.mockResolvedValue({
|
||||
id: 'doc-123',
|
||||
userId: 'user-123',
|
||||
vehicleId: 'vehicle-123',
|
||||
documentType: 'manual',
|
||||
title: 'Service Manual',
|
||||
scanForMaintenance: true,
|
||||
} as any);
|
||||
|
||||
await controller.create(request as any, reply as FastifyReply);
|
||||
|
||||
expect(reply.code).toHaveBeenCalledWith(201);
|
||||
expect(mockServiceInstance.createDocument).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows enterprise user to use scanForMaintenance=true', async () => {
|
||||
const request = createMockRequest({
|
||||
body: { ...baseDocumentBody, scanForMaintenance: true },
|
||||
userContext: {
|
||||
userId: 'user-123',
|
||||
email: 'enterprise@example.com',
|
||||
emailVerified: true,
|
||||
onboardingCompleted: true,
|
||||
isAdmin: false,
|
||||
subscriptionTier: 'enterprise',
|
||||
},
|
||||
});
|
||||
const reply = createMockReply();
|
||||
|
||||
mockServiceInstance.createDocument.mockResolvedValue({
|
||||
id: 'doc-123',
|
||||
userId: 'user-123',
|
||||
vehicleId: 'vehicle-123',
|
||||
documentType: 'manual',
|
||||
title: 'Service Manual',
|
||||
scanForMaintenance: true,
|
||||
} as any);
|
||||
|
||||
await controller.create(request as any, reply as FastifyReply);
|
||||
|
||||
expect(reply.code).toHaveBeenCalledWith(201);
|
||||
expect(mockServiceInstance.createDocument).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('defaults to free tier when userContext is missing', async () => {
|
||||
const request = createMockRequest({
|
||||
body: { ...baseDocumentBody, scanForMaintenance: true },
|
||||
userContext: undefined,
|
||||
});
|
||||
const reply = createMockReply();
|
||||
|
||||
await controller.create(request as any, reply as FastifyReply);
|
||||
|
||||
expect(reply.code).toHaveBeenCalledWith(403);
|
||||
expect(reply.send).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: 'TIER_REQUIRED',
|
||||
currentTier: 'free',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update - scanForMaintenance tier gating', () => {
|
||||
const documentId = 'doc-123';
|
||||
|
||||
it('allows free user to update document without scanForMaintenance', async () => {
|
||||
const request = createMockRequest({
|
||||
params: { id: documentId },
|
||||
body: { title: 'Updated Title' },
|
||||
});
|
||||
const reply = createMockReply();
|
||||
|
||||
mockServiceInstance.updateDocument.mockResolvedValue({
|
||||
id: documentId,
|
||||
title: 'Updated Title',
|
||||
} as any);
|
||||
|
||||
await controller.update(request as any, reply as FastifyReply);
|
||||
|
||||
expect(reply.code).toHaveBeenCalledWith(200);
|
||||
expect(mockServiceInstance.updateDocument).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('blocks free user from setting scanForMaintenance=true on update', async () => {
|
||||
const request = createMockRequest({
|
||||
params: { id: documentId },
|
||||
body: { scanForMaintenance: true },
|
||||
});
|
||||
const reply = createMockReply();
|
||||
|
||||
await controller.update(request as any, reply as FastifyReply);
|
||||
|
||||
expect(reply.code).toHaveBeenCalledWith(403);
|
||||
expect(reply.send).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: 'TIER_REQUIRED',
|
||||
requiredTier: 'pro',
|
||||
currentTier: 'free',
|
||||
feature: 'document.scanMaintenanceSchedule',
|
||||
})
|
||||
);
|
||||
expect(mockServiceInstance.updateDocument).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows pro user to set scanForMaintenance=true on update', async () => {
|
||||
const request = createMockRequest({
|
||||
params: { id: documentId },
|
||||
body: { scanForMaintenance: true },
|
||||
userContext: {
|
||||
userId: 'user-123',
|
||||
email: 'pro@example.com',
|
||||
emailVerified: true,
|
||||
onboardingCompleted: true,
|
||||
isAdmin: false,
|
||||
subscriptionTier: 'pro',
|
||||
},
|
||||
});
|
||||
const reply = createMockReply();
|
||||
|
||||
mockServiceInstance.updateDocument.mockResolvedValue({
|
||||
id: documentId,
|
||||
scanForMaintenance: true,
|
||||
} as any);
|
||||
|
||||
await controller.update(request as any, reply as FastifyReply);
|
||||
|
||||
expect(reply.code).toHaveBeenCalledWith(200);
|
||||
expect(mockServiceInstance.updateDocument).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
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
|
||||
8
frontend/src/core/hooks/index.ts
Normal file
8
frontend/src/core/hooks/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* @ai-summary Core hooks barrel export
|
||||
*/
|
||||
|
||||
export { useDataSync } from './useDataSync';
|
||||
export { useFormState } from './useFormState';
|
||||
export { useTierAccess } from './useTierAccess';
|
||||
export type { FeatureConfig } from './useTierAccess';
|
||||
111
frontend/src/core/hooks/useTierAccess.ts
Normal file
111
frontend/src/core/hooks/useTierAccess.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* @ai-summary React hook for tier-based feature access checking
|
||||
* @ai-context Used to gate premium features based on user subscription tier
|
||||
*/
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useAuth0 } from '@auth0/auth0-react';
|
||||
import { apiClient } from '../api/client';
|
||||
import type { SubscriptionTier } from '../../features/settings/types/profile.types';
|
||||
|
||||
// Feature tier configuration (mirrors backend)
|
||||
export interface FeatureConfig {
|
||||
minTier: SubscriptionTier;
|
||||
name: string;
|
||||
upgradePrompt: string;
|
||||
}
|
||||
|
||||
interface FeatureTiersResponse {
|
||||
tiers: Record<SubscriptionTier, number>;
|
||||
features: Record<string, FeatureConfig>;
|
||||
}
|
||||
|
||||
interface AccessCheckResult {
|
||||
allowed: boolean;
|
||||
requiredTier: SubscriptionTier | null;
|
||||
config: FeatureConfig | null;
|
||||
}
|
||||
|
||||
// Tier hierarchy for comparison
|
||||
const TIER_LEVELS: Record<SubscriptionTier, number> = {
|
||||
free: 0,
|
||||
pro: 1,
|
||||
enterprise: 2,
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to check if user can access tier-gated features
|
||||
* Fetches user profile for tier and feature config from backend
|
||||
*/
|
||||
export const useTierAccess = () => {
|
||||
const { isAuthenticated, isLoading: authLoading } = useAuth0();
|
||||
|
||||
// Fetch user profile for current tier
|
||||
const profileQuery = useQuery({
|
||||
queryKey: ['user-profile'],
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get('/user/profile');
|
||||
return response.data;
|
||||
},
|
||||
enabled: isAuthenticated && !authLoading,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
gcTime: 10 * 60 * 1000,
|
||||
});
|
||||
|
||||
// Fetch feature tier config from backend (single source of truth)
|
||||
const featureConfigQuery = useQuery({
|
||||
queryKey: ['feature-tiers'],
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get<FeatureTiersResponse>('/config/feature-tiers');
|
||||
return response.data;
|
||||
},
|
||||
enabled: isAuthenticated && !authLoading,
|
||||
staleTime: 30 * 60 * 1000, // 30 minutes - config rarely changes
|
||||
gcTime: 60 * 60 * 1000, // 1 hour cache
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
});
|
||||
|
||||
const tier: SubscriptionTier = profileQuery.data?.subscriptionTier || 'free';
|
||||
const features = featureConfigQuery.data?.features || {};
|
||||
|
||||
/**
|
||||
* Check if user can access a feature by key
|
||||
*/
|
||||
const hasAccess = (featureKey: string): boolean => {
|
||||
const config = features[featureKey];
|
||||
if (!config) {
|
||||
// Unknown features are allowed (fail open for safety)
|
||||
return true;
|
||||
}
|
||||
return TIER_LEVELS[tier] >= TIER_LEVELS[config.minTier];
|
||||
};
|
||||
|
||||
/**
|
||||
* Get detailed access information for a feature
|
||||
*/
|
||||
const checkAccess = (featureKey: string): AccessCheckResult => {
|
||||
const config = features[featureKey] || null;
|
||||
if (!config) {
|
||||
return {
|
||||
allowed: true,
|
||||
requiredTier: null,
|
||||
config: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
allowed: TIER_LEVELS[tier] >= TIER_LEVELS[config.minTier],
|
||||
requiredTier: config.minTier,
|
||||
config,
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
tier,
|
||||
loading: profileQuery.isLoading || featureConfigQuery.isLoading,
|
||||
hasAccess,
|
||||
checkAccess,
|
||||
};
|
||||
};
|
||||
|
||||
export default useTierAccess;
|
||||
@@ -1,14 +1,17 @@
|
||||
import React from 'react';
|
||||
import { Button } from '../../../shared-minimal/components/Button';
|
||||
import { UpgradeRequiredDialog } from '../../../shared-minimal/components/UpgradeRequiredDialog';
|
||||
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
|
||||
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
|
||||
import { DatePicker } from '@mui/x-date-pickers/DatePicker';
|
||||
import LockOutlinedIcon from '@mui/icons-material/LockOutlined';
|
||||
import dayjs from 'dayjs';
|
||||
import { useCreateDocument } from '../hooks/useDocuments';
|
||||
import { documentsApi } from '../api/documents.api';
|
||||
import type { DocumentType } from '../types/documents.types';
|
||||
import { useVehicles } from '../../vehicles/hooks/useVehicles';
|
||||
import type { Vehicle } from '../../vehicles/types/vehicles.types';
|
||||
import { useTierAccess } from '../../../core/hooks/useTierAccess';
|
||||
|
||||
interface DocumentFormProps {
|
||||
onSuccess?: () => void;
|
||||
@@ -42,9 +45,12 @@ export const DocumentForm: React.FC<DocumentFormProps> = ({ onSuccess, onCancel
|
||||
const [file, setFile] = React.useState<File | null>(null);
|
||||
const [uploadProgress, setUploadProgress] = React.useState<number>(0);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [upgradeDialogOpen, setUpgradeDialogOpen] = React.useState<boolean>(false);
|
||||
|
||||
const { data: vehicles } = useVehicles();
|
||||
const create = useCreateDocument();
|
||||
const { hasAccess } = useTierAccess();
|
||||
const canScanMaintenance = hasAccess('document.scanMaintenanceSchedule');
|
||||
|
||||
const resetForm = () => {
|
||||
setTitle('');
|
||||
@@ -349,18 +355,31 @@ export const DocumentForm: React.FC<DocumentFormProps> = ({ onSuccess, onCancel
|
||||
|
||||
{documentType === 'manual' && (
|
||||
<div className="flex items-center md:col-span-2">
|
||||
<label className="flex items-center cursor-pointer">
|
||||
<label className={`flex items-center ${canScanMaintenance ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={scanForMaintenance}
|
||||
onChange={(e) => setScanForMaintenance(e.target.checked)}
|
||||
className="w-5 h-5 rounded border-slate-300 text-primary-600 focus:ring-primary-500 dark:border-silverstone dark:focus:ring-abudhabi"
|
||||
checked={canScanMaintenance ? scanForMaintenance : false}
|
||||
onChange={(e) => canScanMaintenance && setScanForMaintenance(e.target.checked)}
|
||||
disabled={!canScanMaintenance}
|
||||
className="w-5 h-5 rounded border-slate-300 text-primary-600 focus:ring-primary-500 dark:border-silverstone dark:focus:ring-abudhabi disabled:opacity-50"
|
||||
/>
|
||||
<span className="ml-2 text-sm font-medium text-slate-700 dark:text-avus">
|
||||
Scan for Maintenance Schedule
|
||||
</span>
|
||||
</label>
|
||||
<span className="ml-2 text-xs text-slate-500 dark:text-titanio">(Coming soon)</span>
|
||||
{!canScanMaintenance && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setUpgradeDialogOpen(true)}
|
||||
className="ml-2 p-1 text-slate-500 hover:text-primary-600 dark:text-titanio dark:hover:text-abudhabi transition-colors"
|
||||
title="Upgrade to Pro to unlock this feature"
|
||||
>
|
||||
<LockOutlinedIcon fontSize="small" />
|
||||
</button>
|
||||
)}
|
||||
{canScanMaintenance && (
|
||||
<span className="ml-2 text-xs text-slate-500 dark:text-titanio">(Coming soon)</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -395,6 +414,12 @@ export const DocumentForm: React.FC<DocumentFormProps> = ({ onSuccess, onCancel
|
||||
<Button type="submit" className="min-h-[44px]">Create Document</Button>
|
||||
<Button type="button" variant="secondary" onClick={onCancel} className="min-h-[44px]">Cancel</Button>
|
||||
</div>
|
||||
|
||||
<UpgradeRequiredDialog
|
||||
featureKey="document.scanMaintenanceSchedule"
|
||||
open={upgradeDialogOpen}
|
||||
onClose={() => setUpgradeDialogOpen(false)}
|
||||
/>
|
||||
</form>
|
||||
</LocalizationProvider>
|
||||
);
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
* @ai-summary User profile types for settings feature
|
||||
*/
|
||||
|
||||
export type SubscriptionTier = 'free' | 'pro' | 'enterprise';
|
||||
|
||||
export interface UserProfile {
|
||||
id: string;
|
||||
auth0Sub: string;
|
||||
email: string;
|
||||
displayName?: string;
|
||||
notificationEmail?: string;
|
||||
subscriptionTier: SubscriptionTier;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
178
frontend/src/shared-minimal/components/UpgradeRequiredDialog.tsx
Normal file
178
frontend/src/shared-minimal/components/UpgradeRequiredDialog.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* @ai-summary Dialog prompting users to upgrade their subscription tier
|
||||
* @ai-context Shown when users attempt to use a tier-gated feature
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Typography,
|
||||
Box,
|
||||
Chip,
|
||||
useMediaQuery,
|
||||
useTheme,
|
||||
IconButton,
|
||||
} from '@mui/material';
|
||||
import LockOutlinedIcon from '@mui/icons-material/LockOutlined';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import { useTierAccess } from '../../core/hooks/useTierAccess';
|
||||
import type { SubscriptionTier } from '../../features/settings/types/profile.types';
|
||||
|
||||
interface UpgradeRequiredDialogProps {
|
||||
featureKey: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const tierDisplayNames: Record<SubscriptionTier, string> = {
|
||||
free: 'Free',
|
||||
pro: 'Pro',
|
||||
enterprise: 'Enterprise',
|
||||
};
|
||||
|
||||
const tierColors: Record<SubscriptionTier, 'default' | 'primary' | 'secondary'> = {
|
||||
free: 'default',
|
||||
pro: 'primary',
|
||||
enterprise: 'secondary',
|
||||
};
|
||||
|
||||
export const UpgradeRequiredDialog: React.FC<UpgradeRequiredDialogProps> = ({
|
||||
featureKey,
|
||||
open,
|
||||
onClose,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const isSmall = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
const { tier, checkAccess } = useTierAccess();
|
||||
|
||||
const { config, requiredTier } = checkAccess(featureKey);
|
||||
|
||||
const handleUpgradeClick = () => {
|
||||
// TODO: Navigate to upgrade page when Stripe integration is added
|
||||
// For now, just close the dialog
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
fullScreen={isSmall}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
maxHeight: isSmall ? '100%' : '90vh',
|
||||
m: isSmall ? 0 : 2,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{isSmall && (
|
||||
<IconButton
|
||||
aria-label="close"
|
||||
onClick={onClose}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
right: 8,
|
||||
top: 8,
|
||||
color: (theme) => theme.palette.grey[500],
|
||||
}}
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<LockOutlinedIcon color="action" />
|
||||
Upgrade Required
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
{/* Feature name */}
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{config?.name || 'Premium Feature'}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{config?.upgradePrompt || 'This feature requires a higher subscription tier.'}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Tier comparison */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 2,
|
||||
py: 2,
|
||||
px: 3,
|
||||
bgcolor: 'action.hover',
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="caption" color="text.secondary" display="block">
|
||||
Your Plan
|
||||
</Typography>
|
||||
<Chip
|
||||
label={tierDisplayNames[tier]}
|
||||
color={tierColors[tier]}
|
||||
size="small"
|
||||
sx={{ mt: 0.5 }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Typography variant="h5" color="text.secondary">
|
||||
→
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="caption" color="text.secondary" display="block">
|
||||
Required
|
||||
</Typography>
|
||||
<Chip
|
||||
label={requiredTier ? tierDisplayNames[requiredTier] : 'Pro'}
|
||||
color={requiredTier ? tierColors[requiredTier] : 'primary'}
|
||||
size="small"
|
||||
sx={{ mt: 0.5 }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Benefits preview */}
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Upgrade to unlock this feature and more premium capabilities.
|
||||
</Typography>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions sx={{ px: 3, pb: 3, pt: 1, flexDirection: isSmall ? 'column' : 'row', gap: 1 }}>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="outlined"
|
||||
fullWidth={isSmall}
|
||||
sx={{ order: isSmall ? 2 : 1 }}
|
||||
>
|
||||
Maybe Later
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleUpgradeClick}
|
||||
variant="contained"
|
||||
color="primary"
|
||||
fullWidth={isSmall}
|
||||
sx={{ order: isSmall ? 1 : 2 }}
|
||||
>
|
||||
Upgrade (Coming Soon)
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpgradeRequiredDialog;
|
||||
7
frontend/src/shared-minimal/components/index.ts
Normal file
7
frontend/src/shared-minimal/components/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* @ai-summary Shared minimal components barrel export
|
||||
*/
|
||||
|
||||
export { Button } from './Button';
|
||||
export { Card } from './Card';
|
||||
export { UpgradeRequiredDialog } from './UpgradeRequiredDialog';
|
||||
Reference in New Issue
Block a user