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
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>
157 lines
4.4 KiB
TypeScript
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;
|