Files
motovaultpro/frontend/src/core/hooks/useTierAccess.ts
Eric Gullickson 20189a1d37
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 4m37s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 38s
Deploy to Staging / Verify Staging (pull_request) Successful in 6s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 6s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
feat: Add tier-based vehicle limit enforcement (refs #23)
Backend:
- Add VEHICLE_LIMITS configuration to feature-tiers.ts
- Add getVehicleLimit, canAddVehicle helper functions
- Implement transaction-based limit check with FOR UPDATE locking
- Add VehicleLimitExceededError and 403 TIER_REQUIRED response
- Add countByUserId to VehiclesRepository
- Add comprehensive tests for all limit logic

Frontend:
- Add getResourceLimit, isAtResourceLimit to useTierAccess hook
- Create VehicleLimitDialog component with mobile/desktop modes
- Add useVehicleLimitCheck shared hook for limit state
- Update VehiclesPage with limit checks and lock icon
- Update VehiclesMobileScreen with limit checks
- Add tests for VehicleLimitDialog

Implements vehicle limits per tier (Free: 2, Pro: 5, Enterprise: unlimited)
with race condition prevention and consistent UX across mobile/desktop.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-11 16:36:53 -06:00

157 lines
4.4 KiB
TypeScript

/**
* @ai-summary React hook for tier-based feature access checking
* @ai-context Used to gate premium features based on user subscription tier
*/
import { useQuery } from '@tanstack/react-query';
import { useAuth0 } from '@auth0/auth0-react';
import { apiClient } from '../api/client';
import type { SubscriptionTier } from '../../features/settings/types/profile.types';
// Feature tier configuration (mirrors backend)
export interface FeatureConfig {
minTier: SubscriptionTier;
name: string;
upgradePrompt: string;
}
interface FeatureTiersResponse {
tiers: Record<SubscriptionTier, number>;
features: Record<string, FeatureConfig>;
}
interface AccessCheckResult {
allowed: boolean;
requiredTier: SubscriptionTier | null;
config: FeatureConfig | null;
}
// Tier hierarchy for comparison
const TIER_LEVELS: Record<SubscriptionTier, number> = {
free: 0,
pro: 1,
enterprise: 2,
};
// Resource limits per tier (mirrors backend VEHICLE_LIMITS)
const RESOURCE_LIMITS = {
vehicles: {
free: 2,
pro: 5,
enterprise: null, // unlimited
} as Record<SubscriptionTier, number | null>,
};
/**
* Hook to check if user can access tier-gated features
* Fetches user profile for tier and feature config from backend
*/
export const useTierAccess = () => {
const { isAuthenticated, isLoading: authLoading } = useAuth0();
// Fetch user profile for current tier
const profileQuery = useQuery({
queryKey: ['user-profile'],
queryFn: async () => {
const response = await apiClient.get('/user/profile');
return response.data;
},
enabled: isAuthenticated && !authLoading,
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000,
});
// Fetch feature tier config from backend (single source of truth)
const featureConfigQuery = useQuery({
queryKey: ['feature-tiers'],
queryFn: async () => {
const response = await apiClient.get<FeatureTiersResponse>('/config/feature-tiers');
return response.data;
},
enabled: isAuthenticated && !authLoading,
staleTime: 30 * 60 * 1000, // 30 minutes - config rarely changes
gcTime: 60 * 60 * 1000, // 1 hour cache
refetchOnWindowFocus: false,
refetchOnMount: false,
});
const tier: SubscriptionTier = profileQuery.data?.subscriptionTier || 'free';
const features = featureConfigQuery.data?.features || {};
/**
* Check if user can access a feature by key
*/
const hasAccess = (featureKey: string): boolean => {
const config = features[featureKey];
if (!config) {
// Unknown features are allowed (fail open for safety)
return true;
}
return TIER_LEVELS[tier] >= TIER_LEVELS[config.minTier];
};
/**
* Get detailed access information for a feature
*/
const checkAccess = (featureKey: string): AccessCheckResult => {
const config = features[featureKey] || null;
if (!config) {
return {
allowed: true,
requiredTier: null,
config: null,
};
}
return {
allowed: TIER_LEVELS[tier] >= TIER_LEVELS[config.minTier],
requiredTier: config.minTier,
config,
};
};
/**
* Get resource limit for current user's tier
* Resource-agnostic method for count-based limits (vehicles, documents, etc.)
*
* @param resourceType - Type of resource (e.g., 'vehicles')
* @returns Maximum allowed count, or null for unlimited
*/
const getResourceLimit = (resourceType: keyof typeof RESOURCE_LIMITS): number | null => {
const limits = RESOURCE_LIMITS[resourceType];
if (!limits) {
return null; // Unknown resource type = unlimited
}
return limits[tier] ?? null;
};
/**
* Check if user is at or over their resource limit
* Resource-agnostic method for count-based limits (vehicles, documents, etc.)
*
* @param resourceType - Type of resource (e.g., 'vehicles')
* @param currentCount - Current number of resources user has
* @returns true if user is at or over limit, false if under limit or unlimited
*/
const isAtResourceLimit = (
resourceType: keyof typeof RESOURCE_LIMITS,
currentCount: number
): boolean => {
const limit = getResourceLimit(resourceType);
if (limit === null) {
return false; // Unlimited
}
return currentCount >= limit;
};
return {
tier,
loading: profileQuery.isLoading || featureConfigQuery.isLoading,
hasAccess,
checkAccess,
getResourceLimit,
isAtResourceLimit,
};
};
export default useTierAccess;