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
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:
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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)
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -41,3 +41,9 @@ export interface DowngradeRequest {
|
||||
targetTier: SubscriptionTier;
|
||||
vehicleIdsToKeep: string[];
|
||||
}
|
||||
|
||||
export interface NeedsVehicleSelectionResponse {
|
||||
needsSelection: boolean;
|
||||
vehicleCount: number;
|
||||
maxAllowed: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user