From 20189a1d37be16ed34e3b88806e520a7c0dd6568 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sun, 11 Jan 2026 16:36:53 -0600 Subject: [PATCH 1/2] feat: Add tier-based vehicle limit enforcement (refs #23) Backend: - Add VEHICLE_LIMITS configuration to feature-tiers.ts - Add getVehicleLimit, canAddVehicle helper functions - Implement transaction-based limit check with FOR UPDATE locking - Add VehicleLimitExceededError and 403 TIER_REQUIRED response - Add countByUserId to VehiclesRepository - Add comprehensive tests for all limit logic Frontend: - Add getResourceLimit, isAtResourceLimit to useTierAccess hook - Create VehicleLimitDialog component with mobile/desktop modes - Add useVehicleLimitCheck shared hook for limit state - Update VehiclesPage with limit checks and lock icon - Update VehiclesMobileScreen with limit checks - Add tests for VehicleLimitDialog Implements vehicle limits per tier (Free: 2, Pro: 5, Enterprise: unlimited) with race condition prevention and consistent UX across mobile/desktop. Co-Authored-By: Claude Sonnet 4.5 --- backend/src/core/config/feature-tiers.ts | 72 ++++++ .../core/config/tests/feature-tiers.test.ts | 97 ++++++++ .../vehicles/api/vehicles.controller.ts | 27 ++- .../vehicles/data/vehicles.repository.ts | 12 +- .../vehicles/domain/vehicles.service.ts | 157 ++++++++++-- .../integration/vehicles.integration.test.ts | 190 ++++++++++++++- .../tests/unit/vehicles.repository.test.ts | 66 +++++ .../tests/unit/vehicles.service.test.ts | 11 +- frontend/src/core/hooks/useTierAccess.ts | 45 ++++ .../vehicles/hooks/useVehicleLimitCheck.ts | 23 ++ .../vehicles/mobile/VehiclesMobileScreen.tsx | 33 ++- .../features/vehicles/pages/VehiclesPage.tsx | 55 ++++- .../components/VehicleLimitDialog.test.tsx | 225 ++++++++++++++++++ .../components/VehicleLimitDialog.tsx | 213 +++++++++++++++++ .../src/shared-minimal/components/index.ts | 1 + 15 files changed, 1179 insertions(+), 48 deletions(-) create mode 100644 backend/src/features/vehicles/tests/unit/vehicles.repository.test.ts create mode 100644 frontend/src/features/vehicles/hooks/useVehicleLimitCheck.ts create mode 100644 frontend/src/shared-minimal/components/VehicleLimitDialog.test.tsx create mode 100644 frontend/src/shared-minimal/components/VehicleLimitDialog.tsx diff --git a/backend/src/core/config/feature-tiers.ts b/backend/src/core/config/feature-tiers.ts index 7fd4e6a..f2b0fbd 100644 --- a/backend/src/core/config/feature-tiers.ts +++ b/backend/src/core/config/feature-tiers.ts @@ -76,3 +76,75 @@ export function getFeatureConfig(featureKey: string): FeatureConfig | undefined export function getAllFeatureConfigs(): Record { return { ...FEATURE_TIERS }; } + +// Vehicle limits per tier +// null indicates unlimited (enterprise tier) +export const VEHICLE_LIMITS: Record = { + free: 2, + pro: 5, + enterprise: null, +} as const; + +/** + * Vehicle limits vary by subscription tier and must be queryable + * at runtime for both backend enforcement and frontend UI state. + * + * @param tier - User's subscription tier + * @returns Maximum vehicles allowed, or null for unlimited (enterprise tier) + */ +export function getVehicleLimit(tier: SubscriptionTier): number | null { + return VEHICLE_LIMITS[tier] ?? null; +} + +/** + * Check if a user can add another vehicle based on their tier and current count. + * + * @param tier - User's subscription tier + * @param currentCount - Number of vehicles user currently has + * @returns true if user can add another vehicle, false if at/over limit + */ +export function canAddVehicle(tier: SubscriptionTier, currentCount: number): boolean { + const limit = getVehicleLimit(tier); + // null limit means unlimited (enterprise) + if (limit === null) { + return true; + } + return currentCount < limit; +} + +/** + * Vehicle limit configuration with upgrade prompt. + * Structure supports additional resource types in the future. + */ +export interface VehicleLimitConfig { + limit: number | null; + tier: SubscriptionTier; + upgradePrompt: string; +} + +/** + * Get vehicle limit configuration with upgrade prompt for a tier. + * + * @param tier - User's subscription tier + * @returns Configuration with limit and upgrade prompt + */ +export function getVehicleLimitConfig(tier: SubscriptionTier): VehicleLimitConfig { + const limit = getVehicleLimit(tier); + + const defaultPrompt = 'Upgrade to access additional vehicles.'; + + let upgradePrompt: string; + if (tier === 'free') { + upgradePrompt = 'Free tier is limited to 2 vehicles. Upgrade to Pro for up to 5 vehicles, or Enterprise for unlimited.'; + } else if (tier === 'pro') { + upgradePrompt = 'Pro tier is limited to 5 vehicles. Upgrade to Enterprise for unlimited vehicles.'; + } else { + upgradePrompt = defaultPrompt; + } + + return { + limit, + tier, + upgradePrompt, + }; +} diff --git a/backend/src/core/config/tests/feature-tiers.test.ts b/backend/src/core/config/tests/feature-tiers.test.ts index 34aa03b..4025e98 100644 --- a/backend/src/core/config/tests/feature-tiers.test.ts +++ b/backend/src/core/config/tests/feature-tiers.test.ts @@ -1,11 +1,15 @@ import { TIER_LEVELS, FEATURE_TIERS, + VEHICLE_LIMITS, getTierLevel, canAccessFeature, getRequiredTier, getFeatureConfig, getAllFeatureConfigs, + getVehicleLimit, + canAddVehicle, + getVehicleLimitConfig, } from '../feature-tiers'; describe('feature-tiers', () => { @@ -101,4 +105,97 @@ describe('feature-tiers', () => { expect(FEATURE_TIERS['test' as keyof typeof FEATURE_TIERS]).toBeUndefined(); }); }); + + describe('VEHICLE_LIMITS', () => { + it('defines correct limits for each tier', () => { + expect(VEHICLE_LIMITS.free).toBe(2); + expect(VEHICLE_LIMITS.pro).toBe(5); + expect(VEHICLE_LIMITS.enterprise).toBeNull(); + }); + }); + + describe('getVehicleLimit', () => { + it('returns 2 for free tier', () => { + expect(getVehicleLimit('free')).toBe(2); + }); + + it('returns 5 for pro tier', () => { + expect(getVehicleLimit('pro')).toBe(5); + }); + + it('returns null for enterprise tier (unlimited)', () => { + expect(getVehicleLimit('enterprise')).toBeNull(); + }); + }); + + describe('canAddVehicle', () => { + describe('free tier (limit 2)', () => { + it('returns true when below limit', () => { + expect(canAddVehicle('free', 0)).toBe(true); + expect(canAddVehicle('free', 1)).toBe(true); + }); + + it('returns false when at limit', () => { + expect(canAddVehicle('free', 2)).toBe(false); + }); + + it('returns false when over limit', () => { + expect(canAddVehicle('free', 3)).toBe(false); + }); + }); + + describe('pro tier (limit 5)', () => { + it('returns true when below limit', () => { + expect(canAddVehicle('pro', 0)).toBe(true); + expect(canAddVehicle('pro', 4)).toBe(true); + }); + + it('returns false when at limit', () => { + expect(canAddVehicle('pro', 5)).toBe(false); + }); + + it('returns false when over limit', () => { + expect(canAddVehicle('pro', 6)).toBe(false); + }); + }); + + describe('enterprise tier (unlimited)', () => { + it('always returns true regardless of count', () => { + expect(canAddVehicle('enterprise', 0)).toBe(true); + expect(canAddVehicle('enterprise', 100)).toBe(true); + expect(canAddVehicle('enterprise', 999999)).toBe(true); + }); + }); + }); + + describe('getVehicleLimitConfig', () => { + it('returns correct config for free tier', () => { + const config = getVehicleLimitConfig('free'); + expect(config.limit).toBe(2); + expect(config.tier).toBe('free'); + expect(config.upgradePrompt).toContain('Free tier is limited to 2 vehicles'); + expect(config.upgradePrompt).toContain('Pro'); + expect(config.upgradePrompt).toContain('Enterprise'); + }); + + it('returns correct config for pro tier', () => { + const config = getVehicleLimitConfig('pro'); + expect(config.limit).toBe(5); + expect(config.tier).toBe('pro'); + expect(config.upgradePrompt).toContain('Pro tier is limited to 5 vehicles'); + expect(config.upgradePrompt).toContain('Enterprise'); + }); + + it('returns correct config for enterprise tier', () => { + const config = getVehicleLimitConfig('enterprise'); + expect(config.limit).toBeNull(); + expect(config.tier).toBe('enterprise'); + expect(config.upgradePrompt).toBeTruthy(); + }); + + it('provides default upgradePrompt fallback', () => { + const config = getVehicleLimitConfig('enterprise'); + expect(config.upgradePrompt).toBe('Upgrade to access additional vehicles.'); + }); + }); }); diff --git a/backend/src/features/vehicles/api/vehicles.controller.ts b/backend/src/features/vehicles/api/vehicles.controller.ts index 6fbc6d6..3e94dfa 100644 --- a/backend/src/features/vehicles/api/vehicles.controller.ts +++ b/backend/src/features/vehicles/api/vehicles.controller.ts @@ -4,7 +4,7 @@ */ import { FastifyRequest, FastifyReply } from 'fastify'; -import { VehiclesService } from '../domain/vehicles.service'; +import { VehiclesService, VehicleLimitExceededError } from '../domain/vehicles.service'; import { VehiclesRepository } from '../data/vehicles.repository'; import { pool } from '../../../core/config/database'; import { logger } from '../../../core/logging/logger'; @@ -21,7 +21,7 @@ export class VehiclesController { constructor() { const repository = new VehiclesRepository(pool); - this.vehiclesService = new VehiclesService(repository); + this.vehiclesService = new VehiclesService(repository, pool); this.nhtsaClient = new NHTSAClient(pool); } @@ -62,25 +62,40 @@ export class VehiclesController { const userId = (request as any).user.sub; const vehicle = await this.vehiclesService.createVehicle(request.body, userId); - + return reply.code(201).send(vehicle); } catch (error: any) { logger.error('Error creating vehicle', { error, userId: (request as any).user?.sub }); - + + if (error instanceof VehicleLimitExceededError) { + return reply.code(403).send({ + error: 'TIER_REQUIRED', + requiredTier: error.tier === 'free' ? 'pro' : 'enterprise', + currentTier: error.tier, + feature: 'vehicle.addBeyondLimit', + featureName: 'Additional Vehicles', + upgradePrompt: error.upgradePrompt, + context: { + limit: error.limit, + count: error.currentCount + } + }); + } + if (error.message === 'Invalid VIN format') { return reply.code(400).send({ error: 'Bad Request', message: error.message }); } - + if (error.message === 'Vehicle with this VIN already exists') { return reply.code(400).send({ error: 'Bad Request', message: error.message }); } - + return reply.code(500).send({ error: 'Internal server error', message: 'Failed to create vehicle' diff --git a/backend/src/features/vehicles/data/vehicles.repository.ts b/backend/src/features/vehicles/data/vehicles.repository.ts index 6e58b26..cd9311b 100644 --- a/backend/src/features/vehicles/data/vehicles.repository.ts +++ b/backend/src/features/vehicles/data/vehicles.repository.ts @@ -47,11 +47,21 @@ export class VehiclesRepository { WHERE user_id = $1 AND is_active = true ORDER BY created_at DESC `; - + const result = await this.pool.query(query, [userId]); return result.rows.map(row => this.mapRow(row)); } + async countByUserId(userId: string): Promise { + const query = ` + SELECT COUNT(*) as count FROM vehicles + WHERE user_id = $1 AND is_active = true + `; + + const result = await this.pool.query(query, [userId]); + return parseInt(result.rows[0].count, 10); + } + async findById(id: string): Promise { const query = 'SELECT * FROM vehicles WHERE id = $1'; const result = await this.pool.query(query, [id]); diff --git a/backend/src/features/vehicles/domain/vehicles.service.ts b/backend/src/features/vehicles/domain/vehicles.service.ts index 4df852c..a03ebc1 100644 --- a/backend/src/features/vehicles/domain/vehicles.service.ts +++ b/backend/src/features/vehicles/domain/vehicles.service.ts @@ -3,6 +3,7 @@ * @ai-context Handles VIN decoding, caching, and business rules */ +import { Pool } from 'pg'; import { VehiclesRepository } from '../data/vehicles.repository'; import { Vehicle, @@ -21,13 +22,33 @@ import { normalizeMakeName, normalizeModelName } from './name-normalizer'; import { getVehicleDataService, getPool } from '../../platform'; import { auditLogService } from '../../audit-log'; import { NHTSAClient, NHTSADecodeResponse, DecodedVehicleData, MatchedField } from '../external/nhtsa'; +import { canAddVehicle, getVehicleLimitConfig } from '../../../core/config/feature-tiers'; +import { UserProfileRepository } from '../../user-profile/data/user-profile.repository'; +import { SubscriptionTier } from '../../user-profile/domain/user-profile.types'; + +export class VehicleLimitExceededError extends Error { + constructor( + public tier: SubscriptionTier, + public currentCount: number, + public limit: number, + public upgradePrompt: string + ) { + super('Vehicle limit exceeded'); + this.name = 'VehicleLimitExceededError'; + } +} export class VehiclesService { private readonly cachePrefix = 'vehicles'; private readonly listCacheTTL = 300; // 5 minutes + private userProfileRepository: UserProfileRepository; - constructor(private repository: VehiclesRepository) { + constructor( + private repository: VehiclesRepository, + private pool: Pool + ) { // VIN decode service is now provided by platform feature + this.userProfileRepository = new UserProfileRepository(pool); } async createVehicle(data: CreateVehicleRequest, userId: string): Promise { @@ -52,29 +73,123 @@ export class VehiclesService { } } - // Create vehicle with user-provided data - const vehicle = await this.repository.create({ - ...data, - userId, - make: data.make ? normalizeMakeName(data.make) : undefined, - model: data.model ? normalizeModelName(data.model) : undefined, - }); + // Get user's tier for limit enforcement + const userProfile = await this.userProfileRepository.getByAuth0Sub(userId); + if (!userProfile) { + throw new Error('User profile not found'); + } + const userTier = userProfile.subscriptionTier; - // Invalidate user's vehicle list cache - await this.invalidateUserCache(userId); + // Tier limit enforcement with transaction + FOR UPDATE locking to prevent race condition + const client = await this.pool.connect(); + try { + await client.query('BEGIN'); - // Log vehicle creation to unified audit log - const vehicleDesc = [vehicle.year, vehicle.make, vehicle.model].filter(Boolean).join(' '); - await auditLogService.info( - 'vehicle', - userId, - `Vehicle created: ${vehicleDesc || vehicle.id}`, - 'vehicle', - vehicle.id, - { vin: vehicle.vin, make: vehicle.make, model: vehicle.model, year: vehicle.year } - ).catch(err => logger.error('Failed to log vehicle create audit event', { error: err })); + // Lock user's vehicle rows and get count + const countResult = await client.query( + 'SELECT COUNT(*) as count FROM vehicles WHERE user_id = $1 AND is_active = true FOR UPDATE', + [userId] + ); + const currentCount = parseInt(countResult.rows[0].count, 10); - return this.toResponse(vehicle); + // Check if user can add another vehicle + if (!canAddVehicle(userTier, currentCount)) { + await client.query('ROLLBACK'); + const limitConfig = getVehicleLimitConfig(userTier); + throw new VehicleLimitExceededError( + userTier, + currentCount, + limitConfig.limit!, + limitConfig.upgradePrompt + ); + } + + // Create vehicle with user-provided data (within transaction) + const query = ` + INSERT INTO vehicles ( + user_id, vin, make, model, year, + engine, transmission, trim_level, drive_type, fuel_type, + nickname, color, license_plate, odometer_reading + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) + RETURNING * + `; + + const values = [ + userId, + (data.vin && data.vin.trim().length > 0) ? data.vin.trim() : null, + data.make ? normalizeMakeName(data.make) : null, + data.model ? normalizeModelName(data.model) : null, + data.year, + data.engine, + data.transmission, + data.trimLevel, + data.driveType, + data.fuelType, + data.nickname, + data.color, + data.licensePlate, + data.odometerReading || 0 + ]; + + const result = await client.query(query, values); + await client.query('COMMIT'); + + const vehicle = this.mapVehicleRow(result.rows[0]); + + // Invalidate user's vehicle list cache + await this.invalidateUserCache(userId); + + // Log vehicle creation to unified audit log + const vehicleDesc = [vehicle.year, vehicle.make, vehicle.model].filter(Boolean).join(' '); + await auditLogService.info( + 'vehicle', + userId, + `Vehicle created: ${vehicleDesc || vehicle.id}`, + 'vehicle', + vehicle.id, + { vin: vehicle.vin, make: vehicle.make, model: vehicle.model, year: vehicle.year } + ).catch(err => logger.error('Failed to log vehicle create audit event', { error: err })); + + return this.toResponse(vehicle); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } + } + + /** + * Map database row to Vehicle domain object + */ + private mapVehicleRow(row: any): Vehicle { + return { + id: row.id, + userId: row.user_id, + vin: row.vin, + make: row.make, + model: row.model, + year: row.year, + engine: row.engine, + transmission: row.transmission, + trimLevel: row.trim_level, + driveType: row.drive_type, + fuelType: row.fuel_type, + nickname: row.nickname, + color: row.color, + licensePlate: row.license_plate, + odometerReading: row.odometer_reading, + isActive: row.is_active, + deletedAt: row.deleted_at, + createdAt: row.created_at, + updatedAt: row.updated_at, + imageStorageBucket: row.image_storage_bucket, + imageStorageKey: row.image_storage_key, + imageFileName: row.image_file_name, + imageContentType: row.image_content_type, + imageFileSize: row.image_file_size, + }; } async getUserVehicles(userId: string): Promise { diff --git a/backend/src/features/vehicles/tests/integration/vehicles.integration.test.ts b/backend/src/features/vehicles/tests/integration/vehicles.integration.test.ts index b0cf767..5ef2912 100644 --- a/backend/src/features/vehicles/tests/integration/vehicles.integration.test.ts +++ b/backend/src/features/vehicles/tests/integration/vehicles.integration.test.ts @@ -4,7 +4,6 @@ */ import request from 'supertest'; -import { app } from '../../../../app'; import pool from '../../../../core/config/database'; import { cacheService } from '../../../../core/config/redis'; import { readFileSync } from 'fs'; @@ -13,15 +12,19 @@ import fastifyPlugin from 'fastify-plugin'; // Mock auth plugin to bypass JWT validation in tests jest.mock('../../../../core/plugins/auth.plugin', () => { + const fp = require('fastify-plugin'); return { - default: fastifyPlugin(async function(fastify) { - fastify.decorate('authenticate', async function(request, _reply) { + default: fp(async function(fastify: any) { + fastify.decorate('authenticate', async function(request: any, _reply: any) { request.user = { sub: 'test-user-123' }; }); }, { name: 'auth-plugin' }) }; }); +// Import app after mocking +import app from '../../../../app'; + describe('Vehicles Integration Tests', () => { beforeAll(async () => { @@ -263,7 +266,7 @@ describe('Vehicles Integration Tests', () => { it('should return 404 for non-existent vehicle', async () => { const fakeId = '550e8400-e29b-41d4-a716-446655440000'; - + const response = await request(app) .delete(`/api/vehicles/${fakeId}`) .expect(404); @@ -271,4 +274,183 @@ describe('Vehicles Integration Tests', () => { expect(response.body.error).toBe('Vehicle not found'); }); }); + + describe('Vehicle Limit Enforcement', () => { + beforeEach(async () => { + // Ensure user_profiles table exists and has test user + await pool.query(` + CREATE TABLE IF NOT EXISTS user_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + auth0_sub VARCHAR(255) NOT NULL UNIQUE, + email VARCHAR(255) NOT NULL, + display_name VARCHAR(255), + subscription_tier VARCHAR(50) NOT NULL DEFAULT 'free', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + `); + + // Create or update test user with free tier + await pool.query(` + INSERT INTO user_profiles (auth0_sub, email, subscription_tier) + VALUES ($1, $2, $3) + ON CONFLICT (auth0_sub) DO UPDATE SET subscription_tier = EXCLUDED.subscription_tier + `, ['test-user-123', 'test@example.com', 'free']); + }); + + it('should enforce free tier limit (2 vehicles)', async () => { + // Create 2 vehicles (at limit) + await pool.query(` + INSERT INTO vehicles (user_id, year, make, model, license_plate) + VALUES + ($1, 2020, 'Honda', 'Civic', 'ABC123'), + ($1, 2021, 'Toyota', 'Camry', 'XYZ789') + `, ['test-user-123']); + + // Attempt to create a 3rd vehicle + const response = await request(app) + .post('/api/vehicles') + .send({ + year: 2022, + make: 'Ford', + model: 'F-150', + licensePlate: 'TEST123' + }) + .expect(403); + + expect(response.body).toMatchObject({ + error: 'TIER_REQUIRED', + currentTier: 'free', + requiredTier: 'pro', + feature: 'vehicle.addBeyondLimit', + context: { + limit: 2, + count: 2 + } + }); + expect(response.body.upgradePrompt).toContain('Free tier is limited to 2 vehicles'); + }); + + it('should allow free tier user to add vehicle when below limit', async () => { + // Create 1 vehicle (below limit) + await pool.query(` + INSERT INTO vehicles (user_id, year, make, model, license_plate) + VALUES ($1, 2020, 'Honda', 'Civic', 'ABC123') + `, ['test-user-123']); + + // Should be able to add 2nd vehicle + const response = await request(app) + .post('/api/vehicles') + .send({ + year: 2021, + make: 'Toyota', + model: 'Camry', + licensePlate: 'XYZ789' + }) + .expect(201); + + expect(response.body).toMatchObject({ + year: 2021, + make: 'Toyota', + model: 'Camry' + }); + }); + + it('should enforce pro tier limit (5 vehicles)', async () => { + // Update user to pro tier + await pool.query(` + UPDATE user_profiles SET subscription_tier = 'pro' WHERE auth0_sub = $1 + `, ['test-user-123']); + + // Create 5 vehicles (at limit) + for (let i = 1; i <= 5; i++) { + await pool.query(` + INSERT INTO vehicles (user_id, year, make, model, license_plate) + VALUES ($1, 2020, 'Honda', 'Civic', $2) + `, ['test-user-123', `PLATE${i}`]); + } + + // Attempt to create a 6th vehicle + const response = await request(app) + .post('/api/vehicles') + .send({ + year: 2022, + make: 'Ford', + model: 'F-150', + licensePlate: 'TEST123' + }) + .expect(403); + + expect(response.body).toMatchObject({ + error: 'TIER_REQUIRED', + currentTier: 'pro', + requiredTier: 'enterprise', + context: { + limit: 5, + count: 5 + } + }); + }); + + it('should allow enterprise tier unlimited vehicles', async () => { + // Update user to enterprise tier + await pool.query(` + UPDATE user_profiles SET subscription_tier = 'enterprise' WHERE auth0_sub = $1 + `, ['test-user-123']); + + // Create 10 vehicles (well beyond free/pro limits) + for (let i = 1; i <= 10; i++) { + await pool.query(` + INSERT INTO vehicles (user_id, year, make, model, license_plate) + VALUES ($1, 2020, 'Honda', 'Civic', $2) + `, ['test-user-123', `PLATE${i}`]); + } + + // Should still be able to add another vehicle + const response = await request(app) + .post('/api/vehicles') + .send({ + year: 2022, + make: 'Ford', + model: 'F-150', + licensePlate: 'ENTERPRISE1' + }) + .expect(201); + + expect(response.body).toMatchObject({ + year: 2022, + make: 'Ford', + model: 'F-150' + }); + }); + + it('should only count active vehicles towards limit', async () => { + // Create 1 active vehicle and 1 deleted vehicle + await pool.query(` + INSERT INTO vehicles (user_id, year, make, model, license_plate) + VALUES ($1, 2020, 'Honda', 'Civic', 'ABC123') + `, ['test-user-123']); + + await pool.query(` + INSERT INTO vehicles (user_id, year, make, model, license_plate, is_active, deleted_at) + VALUES ($1, 2019, 'Toyota', 'Corolla', 'OLD123', false, NOW()) + `, ['test-user-123']); + + // Should be able to add 2nd active vehicle (deleted one doesn't count) + const response = await request(app) + .post('/api/vehicles') + .send({ + year: 2021, + make: 'Toyota', + model: 'Camry', + licensePlate: 'XYZ789' + }) + .expect(201); + + expect(response.body).toMatchObject({ + year: 2021, + make: 'Toyota' + }); + }); + }); }); \ No newline at end of file diff --git a/backend/src/features/vehicles/tests/unit/vehicles.repository.test.ts b/backend/src/features/vehicles/tests/unit/vehicles.repository.test.ts new file mode 100644 index 0000000..79d9eff --- /dev/null +++ b/backend/src/features/vehicles/tests/unit/vehicles.repository.test.ts @@ -0,0 +1,66 @@ +/** + * @ai-summary Unit tests for VehiclesRepository + * @ai-context Tests repository data access methods with mocked database + */ + +import { Pool } from 'pg'; +import { VehiclesRepository } from '../../data/vehicles.repository'; + +describe('VehiclesRepository', () => { + let pool: Pool; + let repository: VehiclesRepository; + + beforeEach(() => { + pool = { + query: jest.fn(), + } as any; + repository = new VehiclesRepository(pool); + }); + + describe('countByUserId', () => { + it('returns accurate count of active vehicles', async () => { + const mockResult = { + rows: [{ count: '3' }], + }; + (pool.query as jest.Mock).mockResolvedValue(mockResult); + + const count = await repository.countByUserId('user-123'); + + expect(count).toBe(3); + expect(pool.query).toHaveBeenCalledWith( + expect.stringContaining('SELECT COUNT(*) as count FROM vehicles'), + ['user-123'] + ); + expect(pool.query).toHaveBeenCalledWith( + expect.stringContaining('is_active = true'), + ['user-123'] + ); + }); + + it('returns 0 for user with no vehicles', async () => { + const mockResult = { + rows: [{ count: '0' }], + }; + (pool.query as jest.Mock).mockResolvedValue(mockResult); + + const count = await repository.countByUserId('user-empty'); + + expect(count).toBe(0); + }); + + it('only counts active vehicles (excludes deleted)', async () => { + const mockResult = { + rows: [{ count: '2' }], + }; + (pool.query as jest.Mock).mockResolvedValue(mockResult); + + const count = await repository.countByUserId('user-with-deleted'); + + expect(count).toBe(2); + expect(pool.query).toHaveBeenCalledWith( + expect.stringContaining('is_active = true'), + ['user-with-deleted'] + ); + }); + }); +}); diff --git a/backend/src/features/vehicles/tests/unit/vehicles.service.test.ts b/backend/src/features/vehicles/tests/unit/vehicles.service.test.ts index 40c5279..2f413dd 100644 --- a/backend/src/features/vehicles/tests/unit/vehicles.service.test.ts +++ b/backend/src/features/vehicles/tests/unit/vehicles.service.test.ts @@ -53,10 +53,19 @@ describe('VehiclesService', () => { findByUserAndVIN: jest.fn(), update: jest.fn(), softDelete: jest.fn(), + countByUserId: jest.fn(), + } as any; + + const mockPool = { + query: jest.fn(), + connect: jest.fn().mockResolvedValue({ + query: jest.fn(), + release: jest.fn(), + }), } as any; mockRepository.mockImplementation(() => repositoryInstance); - service = new VehiclesService(repositoryInstance); + service = new VehiclesService(repositoryInstance, mockPool); }); describe('dropdown data integration', () => { diff --git a/frontend/src/core/hooks/useTierAccess.ts b/frontend/src/core/hooks/useTierAccess.ts index b1ce10c..afd2c5c 100644 --- a/frontend/src/core/hooks/useTierAccess.ts +++ b/frontend/src/core/hooks/useTierAccess.ts @@ -33,6 +33,15 @@ const TIER_LEVELS: Record = { enterprise: 2, }; +// Resource limits per tier (mirrors backend VEHICLE_LIMITS) +const RESOURCE_LIMITS = { + vehicles: { + free: 2, + pro: 5, + enterprise: null, // unlimited + } as Record, +}; + /** * Hook to check if user can access tier-gated features * Fetches user profile for tier and feature config from backend @@ -100,11 +109,47 @@ export const useTierAccess = () => { }; }; + /** + * Get resource limit for current user's tier + * Resource-agnostic method for count-based limits (vehicles, documents, etc.) + * + * @param resourceType - Type of resource (e.g., 'vehicles') + * @returns Maximum allowed count, or null for unlimited + */ + const getResourceLimit = (resourceType: keyof typeof RESOURCE_LIMITS): number | null => { + const limits = RESOURCE_LIMITS[resourceType]; + if (!limits) { + return null; // Unknown resource type = unlimited + } + return limits[tier] ?? null; + }; + + /** + * Check if user is at or over their resource limit + * Resource-agnostic method for count-based limits (vehicles, documents, etc.) + * + * @param resourceType - Type of resource (e.g., 'vehicles') + * @param currentCount - Current number of resources user has + * @returns true if user is at or over limit, false if under limit or unlimited + */ + const isAtResourceLimit = ( + resourceType: keyof typeof RESOURCE_LIMITS, + currentCount: number + ): boolean => { + const limit = getResourceLimit(resourceType); + if (limit === null) { + return false; // Unlimited + } + return currentCount >= limit; + }; + return { tier, loading: profileQuery.isLoading || featureConfigQuery.isLoading, hasAccess, checkAccess, + getResourceLimit, + isAtResourceLimit, }; }; diff --git a/frontend/src/features/vehicles/hooks/useVehicleLimitCheck.ts b/frontend/src/features/vehicles/hooks/useVehicleLimitCheck.ts new file mode 100644 index 0000000..5f6808f --- /dev/null +++ b/frontend/src/features/vehicles/hooks/useVehicleLimitCheck.ts @@ -0,0 +1,23 @@ +/** + * @ai-summary Hook for checking vehicle limit and managing limit dialog + * @ai-context Shared between desktop and mobile vehicle pages + */ + +import { useState } from 'react'; +import { useTierAccess } from '../../../core/hooks/useTierAccess'; + +export const useVehicleLimitCheck = (vehicleCount: number) => { + const { tier, isAtResourceLimit, getResourceLimit } = useTierAccess(); + const [showLimitDialog, setShowLimitDialog] = useState(false); + + const isAtLimit = isAtResourceLimit('vehicles', vehicleCount); + const limit = getResourceLimit('vehicles'); + + return { + isAtLimit, + limit, + tier, + showLimitDialog, + setShowLimitDialog, + }; +}; diff --git a/frontend/src/features/vehicles/mobile/VehiclesMobileScreen.tsx b/frontend/src/features/vehicles/mobile/VehiclesMobileScreen.tsx index 00a7c38..2c9b753 100644 --- a/frontend/src/features/vehicles/mobile/VehiclesMobileScreen.tsx +++ b/frontend/src/features/vehicles/mobile/VehiclesMobileScreen.tsx @@ -6,10 +6,13 @@ import React, { useTransition, useMemo } from 'react'; import { Box, Typography, Grid, Button } from '@mui/material'; import AddIcon from '@mui/icons-material/Add'; +import LockIcon from '@mui/icons-material/Lock'; import { useVehicles } from '../hooks/useVehicles'; import { useOptimisticVehicles } from '../hooks/useOptimisticVehicles'; import { useVehicleSearch } from '../hooks/useVehicleTransitions'; +import { useVehicleLimitCheck } from '../hooks/useVehicleLimitCheck'; import { MobileVehiclesSuspense } from '../../../components/SuspenseWrappers'; +import { VehicleLimitDialog } from '../../../shared-minimal/components'; import { VehicleMobileCard } from './VehicleMobileCard'; import { Vehicle } from '../types/vehicles.types'; @@ -56,6 +59,15 @@ export const VehiclesMobileScreen: React.FC = ({ // Enhanced search with transitions (auto-syncs when vehicles change) const { filteredVehicles } = useVehicleSearch(optimisticVehicles); + // Vehicle limit check + const { + isAtLimit, + limit, + tier, + showLimitDialog, + setShowLimitDialog, + } = useVehicleLimitCheck(safeVehicles.length); + const handleVehicleSelect = (vehicle: Vehicle) => { // Use transition to avoid blocking UI during navigation startTransition(() => { @@ -63,6 +75,14 @@ export const VehiclesMobileScreen: React.FC = ({ }); }; + const handleAddVehicleClick = () => { + if (isAtLimit) { + setShowLimitDialog(true); + } else { + onAddVehicle?.(); + } + }; + if (isLoading) { return ( @@ -92,8 +112,8 @@ export const VehiclesMobileScreen: React.FC = ({ + + + + ); +}; + +export default VehicleLimitDialog; diff --git a/frontend/src/shared-minimal/components/index.ts b/frontend/src/shared-minimal/components/index.ts index cc4e7a3..e32d678 100644 --- a/frontend/src/shared-minimal/components/index.ts +++ b/frontend/src/shared-minimal/components/index.ts @@ -5,3 +5,4 @@ export { Button } from './Button'; export { Card } from './Card'; export { UpgradeRequiredDialog } from './UpgradeRequiredDialog'; +export { VehicleLimitDialog } from './VehicleLimitDialog'; -- 2.49.1 From 8703e7758a5c59d8a669d4c15e3cff3199cdeb81 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sun, 11 Jan 2026 18:08:49 -0600 Subject: [PATCH 2/2] fix: Replace COUNT(*) with SELECT id in FOR UPDATE query (refs #23) PostgreSQL error 0A000 (feature_not_supported) occurs when using FOR UPDATE with aggregate functions like COUNT(*). Row-level locking requires actual rows to lock. Changes: - Select id column instead of COUNT(*) aggregate - Count rows in application using .length - Maintains transaction isolation and race condition prevention Co-Authored-By: Claude Sonnet 4.5 --- backend/src/features/vehicles/domain/vehicles.service.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/backend/src/features/vehicles/domain/vehicles.service.ts b/backend/src/features/vehicles/domain/vehicles.service.ts index a03ebc1..cde91b5 100644 --- a/backend/src/features/vehicles/domain/vehicles.service.ts +++ b/backend/src/features/vehicles/domain/vehicles.service.ts @@ -86,11 +86,12 @@ export class VehiclesService { await client.query('BEGIN'); // Lock user's vehicle rows and get count - const countResult = await client.query( - 'SELECT COUNT(*) as count FROM vehicles WHERE user_id = $1 AND is_active = true FOR UPDATE', + // Note: Cannot use COUNT(*) with FOR UPDATE, so we select IDs and count in app + const lockResult = await client.query( + 'SELECT id FROM vehicles WHERE user_id = $1 AND is_active = true FOR UPDATE', [userId] ); - const currentCount = parseInt(countResult.rows[0].count, 10); + const currentCount = lockResult.rows.length; // Check if user can add another vehicle if (!canAddVehicle(userTier, currentCount)) { -- 2.49.1