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
*/

View File

@@ -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],

View File

@@ -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<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)
*/

View File

@@ -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;
}

View File

@@ -28,7 +28,12 @@ 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) {
@@ -108,6 +113,20 @@ export class VehiclesController {
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);

View File

@@ -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 (
<ThemeProvider>
<VehicleSelectionDialog
open={true}
blocking={true}
onClose={() => {}} // No-op - dialog is blocking
onConfirm={handleVehicleSelectionConfirm}
vehicles={vehicleList}
maxSelections={vehicleMaxAllowed}
targetTier="free"
/>
</ThemeProvider>
);
}
// Mobile app rendering
if (mobileMode) {
return (

View File

@@ -1,8 +1,12 @@
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: async (): Promise<NeedsVehicleSelectionResponse> => {
const response = await apiClient.get<NeedsVehicleSelectionResponse>('/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'),

View File

@@ -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<string[]>([]);
@@ -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 (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>Select Vehicles to Keep</DialogTitle>
<Dialog
open={open}
onClose={handleClose}
maxWidth="sm"
fullWidth
disableEscapeKeyDown={blocking}
>
<DialogTitle>
{blocking ? 'Vehicle Selection Required' : 'Select Vehicles to Keep'}
</DialogTitle>
<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
{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.
{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.
</>
)}
</Alert>
<Box sx={{ mb: 2 }}>
@@ -125,9 +156,9 @@ export const VehicleSelectionDialog = ({
)}
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
{!blocking && <Button onClick={onClose}>Cancel</Button>}
<Button onClick={handleConfirm} variant="contained" disabled={!canConfirm}>
Confirm Downgrade
{blocking ? 'Confirm Selection' : 'Confirm Downgrade'}
</Button>
</DialogActions>
</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 = () => {
const queryClient = useQueryClient();
return useMutation({

View File

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