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:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user