feat: Add tier-based vehicle limit enforcement (refs #23)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 4m37s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 38s
Deploy to Staging / Verify Staging (pull_request) Successful in 6s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 6s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped

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 <noreply@anthropic.com>
This commit is contained in:
Eric Gullickson
2026-01-11 16:36:53 -06:00
parent dff743ca36
commit 20189a1d37
15 changed files with 1179 additions and 48 deletions

View File

@@ -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<VehicleResponse> {
@@ -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<VehicleResponse[]> {