From 684615a8a2a051306ba5a3e7715ac91026f425e8 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sat, 24 Jan 2026 11:25:57 -0600 Subject: [PATCH 1/5] feat: add needs-vehicle-selection endpoint (refs #60) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add GET /api/subscriptions/needs-vehicle-selection endpoint - Returns { needsSelection, vehicleCount, maxAllowed } - Checks: free tier, >2 vehicles, no existing selections 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../api/subscriptions.controller.ts | 22 +++++++ .../subscriptions/api/subscriptions.routes.ts | 6 ++ .../domain/subscriptions.service.ts | 63 +++++++++++++++++++ .../domain/subscriptions.types.ts | 7 +++ 4 files changed, 98 insertions(+) diff --git a/backend/src/features/subscriptions/api/subscriptions.controller.ts b/backend/src/features/subscriptions/api/subscriptions.controller.ts index 719eba3..36616c1 100644 --- a/backend/src/features/subscriptions/api/subscriptions.controller.ts +++ b/backend/src/features/subscriptions/api/subscriptions.controller.ts @@ -49,6 +49,28 @@ export class SubscriptionsController { } } + /** + * GET /api/subscriptions/needs-vehicle-selection - Check if user needs vehicle selection + */ + async checkNeedsVehicleSelection(request: FastifyRequest, reply: FastifyReply): Promise { + try { + const userId = (request as any).user.sub; + + const result = await this.service.checkNeedsVehicleSelection(userId); + + reply.status(200).send(result); + } catch (error: any) { + logger.error('Failed to check needs vehicle selection', { + userId: (request as any).user?.sub, + error: error.message, + }); + reply.status(500).send({ + error: 'Failed to check needs vehicle selection', + message: error.message, + }); + } + } + /** * POST /api/subscriptions/checkout - Create Stripe checkout session */ diff --git a/backend/src/features/subscriptions/api/subscriptions.routes.ts b/backend/src/features/subscriptions/api/subscriptions.routes.ts index cca8439..7ef9595 100644 --- a/backend/src/features/subscriptions/api/subscriptions.routes.ts +++ b/backend/src/features/subscriptions/api/subscriptions.routes.ts @@ -19,6 +19,12 @@ export const subscriptionsRoutes: FastifyPluginAsync = async ( handler: subscriptionsController.getSubscription.bind(subscriptionsController) }); + // GET /api/subscriptions/needs-vehicle-selection - Check if user needs vehicle selection + fastify.get('/subscriptions/needs-vehicle-selection', { + preHandler: [fastify.authenticate], + handler: subscriptionsController.checkNeedsVehicleSelection.bind(subscriptionsController) + }); + // POST /api/subscriptions/checkout - Create Stripe checkout session fastify.post('/subscriptions/checkout', { preHandler: [fastify.authenticate], diff --git a/backend/src/features/subscriptions/domain/subscriptions.service.ts b/backend/src/features/subscriptions/domain/subscriptions.service.ts index e760f0f..4a2665e 100644 --- a/backend/src/features/subscriptions/domain/subscriptions.service.ts +++ b/backend/src/features/subscriptions/domain/subscriptions.service.ts @@ -15,7 +15,9 @@ import { BillingCycle, SubscriptionStatus, UpdateSubscriptionData, + NeedsVehicleSelectionResponse, } from './subscriptions.types'; +import { VehiclesRepository } from '../../vehicles/data/vehicles.repository'; interface StripeWebhookEvent { id: string; @@ -27,6 +29,7 @@ interface StripeWebhookEvent { export class SubscriptionsService { private userProfileRepository: UserProfileRepository; + private vehiclesRepository: VehiclesRepository; constructor( private repository: SubscriptionsRepository, @@ -34,6 +37,7 @@ export class SubscriptionsService { pool: Pool ) { this.userProfileRepository = new UserProfileRepository(pool); + this.vehiclesRepository = new VehiclesRepository(pool); } /** @@ -57,6 +61,65 @@ export class SubscriptionsService { } } + /** + * Check if user needs to make a vehicle selection after auto-downgrade + * Returns true if: user is on free tier, has >2 vehicles, and hasn't made selections + */ + async checkNeedsVehicleSelection(userId: string): Promise { + const FREE_TIER_VEHICLE_LIMIT = 2; + + try { + // Get current subscription + const subscription = await this.repository.findByUserId(userId); + + // No subscription or not on free tier = no selection needed + if (!subscription || subscription.tier !== 'free') { + return { + needsSelection: false, + vehicleCount: 0, + maxAllowed: FREE_TIER_VEHICLE_LIMIT, + }; + } + + // Count user's active vehicles + const vehicleCount = await this.vehiclesRepository.countByUserId(userId); + + // If within limit, no selection needed + if (vehicleCount <= FREE_TIER_VEHICLE_LIMIT) { + return { + needsSelection: false, + vehicleCount, + maxAllowed: FREE_TIER_VEHICLE_LIMIT, + }; + } + + // Check if user already has vehicle selections + const existingSelections = await this.repository.findVehicleSelectionsByUserId(userId); + + // If user already made selections, no selection needed + if (existingSelections.length > 0) { + return { + needsSelection: false, + vehicleCount, + maxAllowed: FREE_TIER_VEHICLE_LIMIT, + }; + } + + // User is on free tier, has >2 vehicles, and hasn't made selections + return { + needsSelection: true, + vehicleCount, + maxAllowed: FREE_TIER_VEHICLE_LIMIT, + }; + } catch (error: any) { + logger.error('Failed to check needs vehicle selection', { + userId, + error: error.message, + }); + throw error; + } + } + /** * Create new subscription (Stripe customer + initial free tier record) */ diff --git a/backend/src/features/subscriptions/domain/subscriptions.types.ts b/backend/src/features/subscriptions/domain/subscriptions.types.ts index 2bcab80..1d6ca83 100644 --- a/backend/src/features/subscriptions/domain/subscriptions.types.ts +++ b/backend/src/features/subscriptions/domain/subscriptions.types.ts @@ -131,3 +131,10 @@ export interface UpdateSubscriptionData { export interface UpdateDonationData { status?: DonationStatus; } + +// Needs vehicle selection check response +export interface NeedsVehicleSelectionResponse { + needsSelection: boolean; + vehicleCount: number; + maxAllowed: number; +} -- 2.49.1 From baf576f5cb15ef608cfb2a73d7205b9a692f4c11 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sat, 24 Jan 2026 11:27:02 -0600 Subject: [PATCH 2/5] feat: add needsVehicleSelection frontend hook (refs #60) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add NeedsVehicleSelectionResponse type - Add needsVehicleSelection API method - Add useNeedsVehicleSelection hook with staleTime: 0 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../src/features/subscription/api/subscription.api.ts | 3 ++- .../features/subscription/hooks/useSubscription.ts | 11 +++++++++++ .../features/subscription/types/subscription.types.ts | 6 ++++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/frontend/src/features/subscription/api/subscription.api.ts b/frontend/src/features/subscription/api/subscription.api.ts index de97eac..06ea085 100644 --- a/frontend/src/features/subscription/api/subscription.api.ts +++ b/frontend/src/features/subscription/api/subscription.api.ts @@ -1,8 +1,9 @@ import { apiClient } from '../../../core/api/client'; -import type { CheckoutRequest, PaymentMethodUpdateRequest, DowngradeRequest } from '../types/subscription.types'; +import type { CheckoutRequest, PaymentMethodUpdateRequest, DowngradeRequest, NeedsVehicleSelectionResponse } from '../types/subscription.types'; export const subscriptionApi = { getSubscription: () => apiClient.get('/subscriptions'), + needsVehicleSelection: () => apiClient.get('/subscriptions/needs-vehicle-selection'), checkout: (data: CheckoutRequest) => apiClient.post('/subscriptions/checkout', data), cancel: () => apiClient.post('/subscriptions/cancel'), reactivate: () => apiClient.post('/subscriptions/reactivate'), diff --git a/frontend/src/features/subscription/hooks/useSubscription.ts b/frontend/src/features/subscription/hooks/useSubscription.ts index f091be7..64c1580 100644 --- a/frontend/src/features/subscription/hooks/useSubscription.ts +++ b/frontend/src/features/subscription/hooks/useSubscription.ts @@ -19,6 +19,17 @@ export const useSubscription = () => { }); }; +export const useNeedsVehicleSelection = () => { + const { isAuthenticated, isLoading } = useAuth0(); + + return useQuery({ + queryKey: ['needs-vehicle-selection'], + queryFn: () => subscriptionApi.needsVehicleSelection(), + enabled: isAuthenticated && !isLoading, + staleTime: 0, // Always fetch fresh on login + }); +}; + export const useCheckout = () => { const queryClient = useQueryClient(); return useMutation({ diff --git a/frontend/src/features/subscription/types/subscription.types.ts b/frontend/src/features/subscription/types/subscription.types.ts index 9f6398d..f8a6ac4 100644 --- a/frontend/src/features/subscription/types/subscription.types.ts +++ b/frontend/src/features/subscription/types/subscription.types.ts @@ -41,3 +41,9 @@ export interface DowngradeRequest { targetTier: SubscriptionTier; vehicleIdsToKeep: string[]; } + +export interface NeedsVehicleSelectionResponse { + needsSelection: boolean; + vehicleCount: number; + maxAllowed: number; +} -- 2.49.1 From de7aa8c13c07a8122d4939c928da1761e4b7035b Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sat, 24 Jan 2026 11:28:02 -0600 Subject: [PATCH 3/5] feat: add blocking mode to VehicleSelectionDialog (refs #60) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add blocking prop to prevent dismissal - Disable backdrop click and escape key when blocking - Hide Cancel button in blocking mode - Update messaging for auto-downgrade scenario 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../components/VehicleSelectionDialog.tsx | 47 +++++++++++++++---- 1 file changed, 39 insertions(+), 8 deletions(-) diff --git a/frontend/src/features/subscription/components/VehicleSelectionDialog.tsx b/frontend/src/features/subscription/components/VehicleSelectionDialog.tsx index 14ba3cf..e0bf2fc 100644 --- a/frontend/src/features/subscription/components/VehicleSelectionDialog.tsx +++ b/frontend/src/features/subscription/components/VehicleSelectionDialog.tsx @@ -29,6 +29,8 @@ interface VehicleSelectionDialogProps { vehicles: Vehicle[]; maxSelections: number; targetTier: SubscriptionTier; + /** When true, dialog cannot be dismissed - user must make a selection */ + blocking?: boolean; } export const VehicleSelectionDialog = ({ @@ -38,6 +40,7 @@ export const VehicleSelectionDialog = ({ vehicles, maxSelections, targetTier, + blocking = false, }: VehicleSelectionDialogProps) => { const [selectedVehicleIds, setSelectedVehicleIds] = useState([]); @@ -77,14 +80,42 @@ export const VehicleSelectionDialog = ({ const canConfirm = selectedVehicleIds.length > 0 && selectedVehicleIds.length <= maxSelections; + // Handle dialog close - prevent if blocking + const handleClose = (_event: object, reason: string) => { + if (blocking && (reason === 'backdropClick' || reason === 'escapeKeyDown')) { + return; + } + onClose(); + }; + return ( - - Select Vehicles to Keep + + + {blocking ? 'Vehicle Selection Required' : 'Select Vehicles to Keep'} + - - You are downgrading to the {targetTier} tier, which allows {maxSelections} vehicle - {maxSelections > 1 ? 's' : ''}. Select which vehicles you want to keep active. Unselected - vehicles will be hidden but not deleted, and you can unlock them by upgrading later. + + {blocking ? ( + <> + Your subscription has been downgraded to the {targetTier} tier, which allows{' '} + {maxSelections} vehicle{maxSelections > 1 ? 's' : ''}. Please select which vehicles + you want to keep active to continue using the app. Unselected vehicles will be hidden + but not deleted, and you can unlock them by upgrading later. + + ) : ( + <> + You are downgrading to the {targetTier} tier, which allows {maxSelections} vehicle + {maxSelections > 1 ? 's' : ''}. Select which vehicles you want to keep active. + Unselected vehicles will be hidden but not deleted, and you can unlock them by + upgrading later. + + )} @@ -125,9 +156,9 @@ export const VehicleSelectionDialog = ({ )} - + {!blocking && } -- 2.49.1 From b06a5e692b6521552f4d0f3f0bf9531e10aa82e9 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sat, 24 Jan 2026 11:31:26 -0600 Subject: [PATCH 4/5] feat: integrate vehicle selection dialog on login (refs #60) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add useNeedsVehicleSelection and useVehicles hooks in App.tsx - Show blocking VehicleSelectionDialog after auth gate ready - Call downgrade API on confirm to save vehicle selections - Invalidate queries after selection to proceed to app 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- frontend/src/App.tsx | 44 +++++++++++++++++++ .../subscription/api/subscription.api.ts | 5 ++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4e534b5..f66ea77 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -82,6 +82,9 @@ import { CreateVehicleRequest } from './features/vehicles/types/vehicles.types'; import { MobileSettingsScreen } from './features/settings/mobile/MobileSettingsScreen'; import { SecurityMobileScreen } from './features/settings/mobile/SecurityMobileScreen'; import { useNavigationStore, useUserStore } from './core/store'; +import { useNeedsVehicleSelection, useDowngrade } from './features/subscription/hooks/useSubscription'; +import { useVehicles } from './features/vehicles/hooks/useVehicles'; +import { VehicleSelectionDialog } from './features/subscription/components/VehicleSelectionDialog'; import { useDataSync } from './core/hooks/useDataSync'; import { MobileDebugPanel } from './core/debug/MobileDebugPanel'; import { MobileErrorBoundary } from './core/error-boundaries/MobileErrorBoundary'; @@ -319,6 +322,25 @@ function App() { // Initialize login notifications useLoginNotifications(); + // Vehicle selection check for auto-downgraded users + const needsVehicleSelectionQuery = useNeedsVehicleSelection(); + const vehiclesQuery = useVehicles(); + const downgrade = useDowngrade(); + const queryClient = useQueryClient(); + + // Handle vehicle selection confirmation + const handleVehicleSelectionConfirm = useCallback((selectedVehicleIds: string[]) => { + downgrade.mutate( + { targetTier: 'free', vehicleIdsToKeep: selectedVehicleIds }, + { + onSuccess: () => { + // Invalidate the needs-vehicle-selection query to clear the dialog + queryClient.invalidateQueries({ queryKey: ['needs-vehicle-selection'] }); + }, + } + ); + }, [downgrade, queryClient]); + // Enhanced navigation and user state management const { activeScreen, @@ -620,6 +642,28 @@ function App() { ); } + // Check if user needs to make vehicle selection after auto-downgrade + // This blocks the app until user selects which vehicles to keep + const needsVehicleSelection = needsVehicleSelectionQuery.data?.needsSelection; + const vehicleMaxAllowed = needsVehicleSelectionQuery.data?.maxAllowed ?? 2; + const vehicleList = vehiclesQuery.data ?? []; + + if (needsVehicleSelection && vehicleList.length > 0) { + return ( + + {}} // No-op - dialog is blocking + onConfirm={handleVehicleSelectionConfirm} + vehicles={vehicleList} + maxSelections={vehicleMaxAllowed} + targetTier="free" + /> + + ); + } + // Mobile app rendering if (mobileMode) { return ( diff --git a/frontend/src/features/subscription/api/subscription.api.ts b/frontend/src/features/subscription/api/subscription.api.ts index 06ea085..d5a9458 100644 --- a/frontend/src/features/subscription/api/subscription.api.ts +++ b/frontend/src/features/subscription/api/subscription.api.ts @@ -3,7 +3,10 @@ import type { CheckoutRequest, PaymentMethodUpdateRequest, DowngradeRequest, Nee export const subscriptionApi = { getSubscription: () => apiClient.get('/subscriptions'), - needsVehicleSelection: () => apiClient.get('/subscriptions/needs-vehicle-selection'), + needsVehicleSelection: async (): Promise => { + const response = await apiClient.get('/subscriptions/needs-vehicle-selection'); + return response.data; + }, checkout: (data: CheckoutRequest) => apiClient.post('/subscriptions/checkout', data), cancel: () => apiClient.post('/subscriptions/cancel'), reactivate: () => apiClient.post('/subscriptions/reactivate'), -- 2.49.1 From 68948484a4aebf7e369cf2370fe0aa279ca3ad8e Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sat, 24 Jan 2026 11:51:36 -0600 Subject: [PATCH 5/5] fix: filter locked vehicles after tier downgrade selection (refs #60) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /api/vehicles now uses getUserVehiclesWithTierStatus() and filters out vehicles with tierStatus='locked' so only selected vehicles appear in the vehicle list - GET /api/vehicles/:id now checks tier status and returns 403 TIER_REQUIRED if user tries to access a locked vehicle directly This ensures that after a user selects 2 vehicles during downgrade to free tier, only those 2 vehicles appear in the summary and details screens. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../vehicles/api/vehicles.controller.ts | 31 +++++++++++++++---- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/backend/src/features/vehicles/api/vehicles.controller.ts b/backend/src/features/vehicles/api/vehicles.controller.ts index 4bf7870..9b7ee56 100644 --- a/backend/src/features/vehicles/api/vehicles.controller.ts +++ b/backend/src/features/vehicles/api/vehicles.controller.ts @@ -28,8 +28,13 @@ export class VehiclesController { async getUserVehicles(request: FastifyRequest, reply: FastifyReply) { try { const userId = (request as any).user.sub; - const vehicles = await this.vehiclesService.getUserVehicles(userId); - + // Use tier-aware method to filter out locked vehicles after downgrade + const vehiclesWithStatus = await this.vehiclesService.getUserVehiclesWithTierStatus(userId); + // Only return active vehicles (filter out locked ones) + const vehicles = vehiclesWithStatus + .filter(v => v.tierStatus === 'active') + .map(({ tierStatus, ...vehicle }) => vehicle); + return reply.code(200).send(vehicles); } catch (error) { logger.error('Error getting user vehicles', { error, userId: (request as any).user?.sub }); @@ -107,20 +112,34 @@ export class VehiclesController { try { const userId = (request as any).user.sub; const { id } = request.params; - + + // Check tier status - block access to locked vehicles + const vehiclesWithStatus = await this.vehiclesService.getUserVehiclesWithTierStatus(userId); + const vehicleStatus = vehiclesWithStatus.find(v => v.id === id); + if (vehicleStatus && vehicleStatus.tierStatus === 'locked') { + return reply.code(403).send({ + error: 'TIER_REQUIRED', + requiredTier: 'pro', + feature: 'vehicle.access', + featureName: 'Vehicle Access', + upgradePrompt: 'Upgrade to Pro to access all your vehicles', + message: 'This vehicle is not available on your current subscription tier' + }); + } + const vehicle = await this.vehiclesService.getVehicle(id, userId); - + return reply.code(200).send(vehicle); } catch (error: any) { logger.error('Error getting vehicle', { error, vehicleId: request.params.id, userId: (request as any).user?.sub }); - + if (error.message === 'Vehicle not found' || error.message === 'Unauthorized') { return reply.code(404).send({ error: 'Not Found', message: 'Vehicle not found' }); } - + return reply.code(500).send({ error: 'Internal server error', message: 'Failed to get vehicle' -- 2.49.1