Merge pull request 'feat: prompt vehicle selection on login after auto-downgrade (#60)' (#62) from issue-60-vehicle-selection-prompt into main
All checks were successful
Deploy to Staging / Build Images (push) Successful in 25s
Deploy to Staging / Deploy to Staging (push) Successful in 29s
Deploy to Staging / Verify Staging (push) Successful in 7s
Deploy to Staging / Notify Staging Ready (push) Successful in 6s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
Mirror Base Images / Mirror Base Images (push) Successful in 21s

Reviewed-on: #62
This commit was merged in pull request #62.
This commit is contained in:
2026-01-24 17:56:23 +00:00
10 changed files with 228 additions and 15 deletions

View File

@@ -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<void> {
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 * POST /api/subscriptions/checkout - Create Stripe checkout session
*/ */

View File

@@ -19,6 +19,12 @@ export const subscriptionsRoutes: FastifyPluginAsync = async (
handler: subscriptionsController.getSubscription.bind(subscriptionsController) 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 // POST /api/subscriptions/checkout - Create Stripe checkout session
fastify.post('/subscriptions/checkout', { fastify.post('/subscriptions/checkout', {
preHandler: [fastify.authenticate], preHandler: [fastify.authenticate],

View File

@@ -15,7 +15,9 @@ import {
BillingCycle, BillingCycle,
SubscriptionStatus, SubscriptionStatus,
UpdateSubscriptionData, UpdateSubscriptionData,
NeedsVehicleSelectionResponse,
} from './subscriptions.types'; } from './subscriptions.types';
import { VehiclesRepository } from '../../vehicles/data/vehicles.repository';
interface StripeWebhookEvent { interface StripeWebhookEvent {
id: string; id: string;
@@ -27,6 +29,7 @@ interface StripeWebhookEvent {
export class SubscriptionsService { export class SubscriptionsService {
private userProfileRepository: UserProfileRepository; private userProfileRepository: UserProfileRepository;
private vehiclesRepository: VehiclesRepository;
constructor( constructor(
private repository: SubscriptionsRepository, private repository: SubscriptionsRepository,
@@ -34,6 +37,7 @@ export class SubscriptionsService {
pool: Pool pool: Pool
) { ) {
this.userProfileRepository = new UserProfileRepository(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<NeedsVehicleSelectionResponse> {
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) * Create new subscription (Stripe customer + initial free tier record)
*/ */

View File

@@ -131,3 +131,10 @@ export interface UpdateSubscriptionData {
export interface UpdateDonationData { export interface UpdateDonationData {
status?: DonationStatus; status?: DonationStatus;
} }
// Needs vehicle selection check response
export interface NeedsVehicleSelectionResponse {
needsSelection: boolean;
vehicleCount: number;
maxAllowed: number;
}

View File

@@ -28,7 +28,12 @@ export class VehiclesController {
async getUserVehicles(request: FastifyRequest, reply: FastifyReply) { async getUserVehicles(request: FastifyRequest, reply: FastifyReply) {
try { try {
const userId = (request as any).user.sub; 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); return reply.code(200).send(vehicles);
} catch (error) { } catch (error) {
@@ -108,6 +113,20 @@ export class VehiclesController {
const userId = (request as any).user.sub; const userId = (request as any).user.sub;
const { id } = request.params; 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); const vehicle = await this.vehiclesService.getVehicle(id, userId);
return reply.code(200).send(vehicle); return reply.code(200).send(vehicle);

View File

@@ -82,6 +82,9 @@ import { CreateVehicleRequest } from './features/vehicles/types/vehicles.types';
import { MobileSettingsScreen } from './features/settings/mobile/MobileSettingsScreen'; import { MobileSettingsScreen } from './features/settings/mobile/MobileSettingsScreen';
import { SecurityMobileScreen } from './features/settings/mobile/SecurityMobileScreen'; import { SecurityMobileScreen } from './features/settings/mobile/SecurityMobileScreen';
import { useNavigationStore, useUserStore } from './core/store'; 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 { useDataSync } from './core/hooks/useDataSync';
import { MobileDebugPanel } from './core/debug/MobileDebugPanel'; import { MobileDebugPanel } from './core/debug/MobileDebugPanel';
import { MobileErrorBoundary } from './core/error-boundaries/MobileErrorBoundary'; import { MobileErrorBoundary } from './core/error-boundaries/MobileErrorBoundary';
@@ -319,6 +322,25 @@ function App() {
// Initialize login notifications // Initialize login notifications
useLoginNotifications(); 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 // Enhanced navigation and user state management
const { const {
activeScreen, 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 (
<ThemeProvider>
<VehicleSelectionDialog
open={true}
blocking={true}
onClose={() => {}} // No-op - dialog is blocking
onConfirm={handleVehicleSelectionConfirm}
vehicles={vehicleList}
maxSelections={vehicleMaxAllowed}
targetTier="free"
/>
</ThemeProvider>
);
}
// Mobile app rendering // Mobile app rendering
if (mobileMode) { if (mobileMode) {
return ( return (

View File

@@ -1,8 +1,12 @@
import { apiClient } from '../../../core/api/client'; 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 = { export const subscriptionApi = {
getSubscription: () => apiClient.get('/subscriptions'), getSubscription: () => apiClient.get('/subscriptions'),
needsVehicleSelection: async (): Promise<NeedsVehicleSelectionResponse> => {
const response = await apiClient.get<NeedsVehicleSelectionResponse>('/subscriptions/needs-vehicle-selection');
return response.data;
},
checkout: (data: CheckoutRequest) => apiClient.post('/subscriptions/checkout', data), checkout: (data: CheckoutRequest) => apiClient.post('/subscriptions/checkout', data),
cancel: () => apiClient.post('/subscriptions/cancel'), cancel: () => apiClient.post('/subscriptions/cancel'),
reactivate: () => apiClient.post('/subscriptions/reactivate'), reactivate: () => apiClient.post('/subscriptions/reactivate'),

View File

@@ -29,6 +29,8 @@ interface VehicleSelectionDialogProps {
vehicles: Vehicle[]; vehicles: Vehicle[];
maxSelections: number; maxSelections: number;
targetTier: SubscriptionTier; targetTier: SubscriptionTier;
/** When true, dialog cannot be dismissed - user must make a selection */
blocking?: boolean;
} }
export const VehicleSelectionDialog = ({ export const VehicleSelectionDialog = ({
@@ -38,6 +40,7 @@ export const VehicleSelectionDialog = ({
vehicles, vehicles,
maxSelections, maxSelections,
targetTier, targetTier,
blocking = false,
}: VehicleSelectionDialogProps) => { }: VehicleSelectionDialogProps) => {
const [selectedVehicleIds, setSelectedVehicleIds] = useState<string[]>([]); const [selectedVehicleIds, setSelectedVehicleIds] = useState<string[]>([]);
@@ -77,14 +80,42 @@ export const VehicleSelectionDialog = ({
const canConfirm = selectedVehicleIds.length > 0 && selectedVehicleIds.length <= maxSelections; 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 ( return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth> <Dialog
<DialogTitle>Select Vehicles to Keep</DialogTitle> open={open}
onClose={handleClose}
maxWidth="sm"
fullWidth
disableEscapeKeyDown={blocking}
>
<DialogTitle>
{blocking ? 'Vehicle Selection Required' : 'Select Vehicles to Keep'}
</DialogTitle>
<DialogContent> <DialogContent>
<Alert severity="warning" sx={{ mb: 2 }}> <Alert severity={blocking ? 'info' : 'warning'} sx={{ mb: 2 }}>
{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 You are downgrading to the {targetTier} tier, which allows {maxSelections} vehicle
{maxSelections > 1 ? 's' : ''}. Select which vehicles you want to keep active. Unselected {maxSelections > 1 ? 's' : ''}. Select which vehicles you want to keep active.
vehicles will be hidden but not deleted, and you can unlock them by upgrading later. Unselected vehicles will be hidden but not deleted, and you can unlock them by
upgrading later.
</>
)}
</Alert> </Alert>
<Box sx={{ mb: 2 }}> <Box sx={{ mb: 2 }}>
@@ -125,9 +156,9 @@ export const VehicleSelectionDialog = ({
)} )}
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={onClose}>Cancel</Button> {!blocking && <Button onClick={onClose}>Cancel</Button>}
<Button onClick={handleConfirm} variant="contained" disabled={!canConfirm}> <Button onClick={handleConfirm} variant="contained" disabled={!canConfirm}>
Confirm Downgrade {blocking ? 'Confirm Selection' : 'Confirm Downgrade'}
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>

View File

@@ -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 = () => { export const useCheckout = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({

View File

@@ -41,3 +41,9 @@ export interface DowngradeRequest {
targetTier: SubscriptionTier; targetTier: SubscriptionTier;
vehicleIdsToKeep: string[]; vehicleIdsToKeep: string[];
} }
export interface NeedsVehicleSelectionResponse {
needsSelection: boolean;
vehicleCount: number;
maxAllowed: number;
}