feat: add vehicle selection and downgrade flow - M5 (refs #55)

This commit is contained in:
Eric Gullickson
2026-01-18 16:44:45 -06:00
parent 94d1c677bc
commit 6c1a100eb9
11 changed files with 509 additions and 7 deletions

View File

@@ -203,23 +203,85 @@ export class VehiclesService {
async getUserVehicles(userId: string): Promise<VehicleResponse[]> {
const cacheKey = `${this.cachePrefix}:user:${userId}`;
// Check cache
const cached = await cacheService.get<VehicleResponse[]>(cacheKey);
if (cached) {
logger.debug('Vehicle list cache hit', { userId });
return cached;
}
// Get from database
const vehicles = await this.repository.findByUserId(userId);
const response = vehicles.map((v: Vehicle) => this.toResponse(v));
// Cache result
await cacheService.set(cacheKey, response, this.listCacheTTL);
return response;
}
/**
* Get user vehicles with tier-gated status
* Returns vehicles with tierStatus: 'active' | 'locked'
*/
async getUserVehiclesWithTierStatus(userId: string): Promise<Array<VehicleResponse & { tierStatus: 'active' | 'locked' }>> {
// Get user's subscription tier
const userProfile = await this.userProfileRepository.getByAuth0Sub(userId);
if (!userProfile) {
throw new Error('User profile not found');
}
const userTier = userProfile.subscriptionTier;
// Get all vehicles
const vehicles = await this.repository.findByUserId(userId);
// Define tier limits
const tierLimits: Record<SubscriptionTier, number | null> = {
free: 2,
pro: 5,
enterprise: null, // unlimited
};
const tierLimit = tierLimits[userTier];
// If tier has unlimited vehicles, all are active
if (tierLimit === null) {
return vehicles.map((v: Vehicle) => ({
...this.toResponse(v),
tierStatus: 'active' as const,
}));
}
// If vehicle count is within tier limit, all are active
if (vehicles.length <= tierLimit) {
return vehicles.map((v: Vehicle) => ({
...this.toResponse(v),
tierStatus: 'active' as const,
}));
}
// Vehicle count exceeds tier limit - check for tier_vehicle_selections
// Get vehicle selections from subscriptions repository
const { SubscriptionsRepository } = await import('../../subscriptions/data/subscriptions.repository');
const subscriptionsRepo = new SubscriptionsRepository(this.pool);
const selections = await subscriptionsRepo.findVehicleSelectionsByUserId(userId);
const selectedVehicleIds = new Set(selections.map(s => s.vehicleId));
// If no selections exist, return all as active (selections only exist after downgrade)
if (selections.length === 0) {
return vehicles.map((v: Vehicle) => ({
...this.toResponse(v),
tierStatus: 'active' as const,
}));
}
// Mark vehicles as active or locked based on selections
return vehicles.map((v: Vehicle) => ({
...this.toResponse(v),
tierStatus: selectedVehicleIds.has(v.id) ? ('active' as const) : ('locked' as const),
}));
}
async getVehicle(id: string, userId: string): Promise<VehicleResponse> {
const vehicle = await this.repository.findById(id);

View File

@@ -195,6 +195,11 @@ export interface VehicleParams {
id: string;
}
// Vehicle with tier status (for tier-gated access)
export interface VehicleWithTierStatus extends Vehicle {
tierStatus: 'active' | 'locked';
}
// TCO (Total Cost of Ownership) response
export interface TCOResponse {
vehicleId: string;