Add role-based filtering and imporve code

This commit is contained in:
Urtzi Alfaro
2025-10-15 16:12:49 +02:00
parent 96ad5c6692
commit 8f9e9a7edc
158 changed files with 11033 additions and 1544 deletions

View File

@@ -0,0 +1,108 @@
/**
* Nominatim Geocoding API Service
* Provides address search and autocomplete functionality
*/
import apiClient from '../client';
export interface NominatimResult {
place_id: number;
lat: string;
lon: string;
display_name: string;
address: {
road?: string;
house_number?: string;
city?: string;
town?: string;
village?: string;
municipality?: string;
postcode?: string;
country?: string;
};
boundingbox: [string, string, string, string];
}
export interface NominatimSearchParams {
q: string;
format?: 'json';
addressdetails?: 1 | 0;
limit?: number;
countrycodes?: string;
}
class NominatimService {
private baseUrl = '/api/v1/nominatim';
/**
* Search for addresses matching a query
*/
async searchAddress(query: string, limit: number = 5): Promise<NominatimResult[]> {
if (!query || query.length < 3) {
return [];
}
try {
const response = await apiClient.get<NominatimResult[]>(`${this.baseUrl}/search`, {
params: {
q: query,
format: 'json',
addressdetails: 1,
limit,
countrycodes: 'es', // Spain only
},
});
return response.data;
} catch (error) {
console.error('Address search failed:', error);
return [];
}
}
/**
* Format a Nominatim result for display
*/
formatAddress(result: NominatimResult): string {
return result.display_name;
}
/**
* Extract structured address components
*/
parseAddress(result: NominatimResult) {
const { address } = result;
return {
street: address.road
? `${address.road}${address.house_number ? ' ' + address.house_number : ''}`
: '',
city: address.city || address.town || address.village || address.municipality || '',
postalCode: address.postcode || '',
latitude: parseFloat(result.lat),
longitude: parseFloat(result.lon),
displayName: result.display_name,
};
}
/**
* Geocode a structured address to coordinates
*/
async geocodeAddress(
street: string,
city: string,
postalCode?: string
): Promise<NominatimResult | null> {
const parts = [street, city];
if (postalCode) parts.push(postalCode);
parts.push('Spain');
const query = parts.join(', ');
const results = await this.searchAddress(query, 1);
return results.length > 0 ? results[0] : null;
}
}
export const nominatimService = new NominatimService();
export default nominatimService;

View File

@@ -1,25 +1,32 @@
import { apiClient } from '../client';
import {
SubscriptionLimits,
FeatureCheckResponse,
UsageCheckResponse,
UsageSummary,
// New types
SubscriptionTier,
SUBSCRIPTION_TIERS,
BillingCycle,
PlanMetadata,
AvailablePlans,
UsageSummary,
FeatureCheckResponse,
QuotaCheckResponse,
PlanUpgradeValidation,
PlanUpgradeResult,
SUBSCRIPTION_PLANS,
doesPlanMeetMinimum,
getPlanColor,
getYearlyDiscountPercentage,
PLAN_HIERARCHY,
// Analytics levels
ANALYTICS_LEVELS,
AnalyticsLevel,
SubscriptionPlanKey,
PLAN_HIERARCHY,
ANALYTICS_HIERARCHY
} from '../types/subscription';
// Map plan keys to analytics levels based on backend data
const PLAN_TO_ANALYTICS_LEVEL: Record<SubscriptionPlanKey, AnalyticsLevel> = {
[SUBSCRIPTION_PLANS.STARTER]: ANALYTICS_LEVELS.BASIC,
[SUBSCRIPTION_PLANS.PROFESSIONAL]: ANALYTICS_LEVELS.ADVANCED,
[SUBSCRIPTION_PLANS.ENTERPRISE]: ANALYTICS_LEVELS.PREDICTIVE
// Map plan tiers to analytics levels based on backend data
const TIER_TO_ANALYTICS_LEVEL: Record<SubscriptionTier, AnalyticsLevel> = {
[SUBSCRIPTION_TIERS.STARTER]: ANALYTICS_LEVELS.BASIC,
[SUBSCRIPTION_TIERS.PROFESSIONAL]: ANALYTICS_LEVELS.ADVANCED,
[SUBSCRIPTION_TIERS.ENTERPRISE]: ANALYTICS_LEVELS.PREDICTIVE
};
// Cache for available plans
@@ -29,11 +36,145 @@ const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
export class SubscriptionService {
private readonly baseUrl = '/tenants';
private readonly plansUrl = '/plans';
async getSubscriptionLimits(tenantId: string): Promise<SubscriptionLimits> {
return apiClient.get<SubscriptionLimits>(`${this.baseUrl}/subscriptions/${tenantId}/limits`);
// ============================================================================
// NEW METHODS - Centralized Plans API
// ============================================================================
/**
* Fetch available subscription plans with complete metadata
* Uses cached data if available and fresh (5 min cache)
*/
async fetchAvailablePlans(): Promise<AvailablePlans> {
const now = Date.now();
// Return cached data if it's still valid
if (cachedPlans && lastFetchTime && (now - lastFetchTime) < CACHE_DURATION) {
return cachedPlans;
}
try {
const plans = await apiClient.get<AvailablePlans>(this.plansUrl);
cachedPlans = plans;
lastFetchTime = now;
return plans;
} catch (error) {
console.error('Failed to fetch subscription plans:', error);
throw error;
}
}
/**
* Get metadata for a specific plan tier
*/
async getPlanMetadata(tier: SubscriptionTier): Promise<PlanMetadata | null> {
try {
const plans = await this.fetchAvailablePlans();
return plans.plans[tier] || null;
} catch (error) {
console.error('Failed to get plan metadata:', error);
return null;
}
}
/**
* Get all available features for a tier
*/
async getPlanFeatures(tier: SubscriptionTier): Promise<string[]> {
try {
const metadata = await this.getPlanMetadata(tier);
return metadata?.features || [];
} catch (error) {
console.error('Failed to get plan features:', error);
return [];
}
}
/**
* Check if a feature is available in a tier
*/
async hasFeatureInTier(tier: SubscriptionTier, featureName: string): Promise<boolean> {
try {
const features = await this.getPlanFeatures(tier);
return features.includes(featureName);
} catch (error) {
console.error('Failed to check feature availability:', error);
return false;
}
}
/**
* Get plan comparison data for pricing page
*/
async getPlanComparison(): Promise<{
tiers: SubscriptionTier[];
metadata: Record<SubscriptionTier, PlanMetadata>;
}> {
try {
const plans = await this.fetchAvailablePlans();
return {
tiers: [
SUBSCRIPTION_TIERS.STARTER,
SUBSCRIPTION_TIERS.PROFESSIONAL,
SUBSCRIPTION_TIERS.ENTERPRISE
],
metadata: plans.plans
};
} catch (error) {
console.error('Failed to get plan comparison:', error);
throw error;
}
}
/**
* Calculate savings for yearly billing
*/
calculateYearlySavings(monthlyPrice: number, yearlyPrice: number): {
savingsAmount: number;
savingsPercentage: number;
monthsFree: number;
} {
const yearlyAnnual = monthlyPrice * 12;
const savingsAmount = yearlyAnnual - yearlyPrice;
const savingsPercentage = getYearlyDiscountPercentage(monthlyPrice, yearlyPrice);
const monthsFree = Math.round(savingsAmount / monthlyPrice);
return {
savingsAmount,
savingsPercentage,
monthsFree
};
}
/**
* Check if user's plan meets minimum requirement
*/
checkPlanMeetsMinimum(userPlan: SubscriptionTier, requiredPlan: SubscriptionTier): boolean {
return doesPlanMeetMinimum(userPlan, requiredPlan);
}
/**
* Get plan display color
*/
getPlanDisplayColor(tier: SubscriptionTier): string {
return getPlanColor(tier);
}
// ============================================================================
// TENANT SUBSCRIPTION STATUS & USAGE
// ============================================================================
/**
* Get current usage summary for a tenant
*/
async getUsageSummary(tenantId: string): Promise<UsageSummary> {
return apiClient.get<UsageSummary>(`${this.baseUrl}/subscriptions/${tenantId}/usage`);
}
/**
* Check if tenant has access to a specific feature
*/
async checkFeatureAccess(
tenantId: string,
featureName: string
@@ -43,49 +184,24 @@ export class SubscriptionService {
);
}
async checkUsageLimit(
/**
* Check if tenant can perform an action within quota limits
*/
async checkQuotaLimit(
tenantId: string,
resourceType: 'users' | 'sales_records' | 'inventory_items' | 'api_requests',
quotaType: string,
requestedAmount?: number
): Promise<UsageCheckResponse> {
): Promise<QuotaCheckResponse> {
const queryParams = new URLSearchParams();
if (requestedAmount !== undefined) {
queryParams.append('requested_amount', requestedAmount.toString());
}
const url = queryParams.toString()
? `${this.baseUrl}/subscriptions/${tenantId}/usage/${resourceType}/check?${queryParams.toString()}`
: `${this.baseUrl}/subscriptions/${tenantId}/usage/${resourceType}/check`;
? `${this.baseUrl}/subscriptions/${tenantId}/quotas/${quotaType}/check?${queryParams.toString()}`
: `${this.baseUrl}/subscriptions/${tenantId}/quotas/${quotaType}/check`;
return apiClient.get<UsageCheckResponse>(url);
}
async recordUsage(
tenantId: string,
resourceType: 'users' | 'sales_records' | 'inventory_items' | 'api_requests',
amount: number = 1
): Promise<{ success: boolean; message: string }> {
return apiClient.post<{ success: boolean; message: string }>(
`${this.baseUrl}/subscriptions/${tenantId}/usage/${resourceType}/record`,
{ amount }
);
}
async getCurrentUsage(tenantId: string): Promise<{
users: number;
sales_records: number;
inventory_items: number;
api_requests_this_hour: number;
}> {
return apiClient.get(`${this.baseUrl}/subscriptions/${tenantId}/usage/current`);
}
async getUsageSummary(tenantId: string): Promise<UsageSummary> {
return apiClient.get<UsageSummary>(`${this.baseUrl}/subscriptions/${tenantId}/usage`);
}
async getAvailablePlans(): Promise<AvailablePlans> {
return apiClient.get<AvailablePlans>('/plans');
return apiClient.get<QuotaCheckResponse>(url);
}
async validatePlanUpgrade(tenantId: string, planKey: string): Promise<PlanUpgradeValidation> {
@@ -121,27 +237,6 @@ export class SubscriptionService {
}).format(amount);
}
/**
* Fetch available subscription plans from the backend
*/
async fetchAvailablePlans(): Promise<AvailablePlans> {
const now = Date.now();
// Return cached data if it's still valid
if (cachedPlans && lastFetchTime && (now - lastFetchTime) < CACHE_DURATION) {
return cachedPlans;
}
try {
const plans = await apiClient.get<AvailablePlans>('/plans');
cachedPlans = plans;
lastFetchTime = now;
return plans;
} catch (error) {
console.error('Failed to fetch subscription plans:', error);
throw error;
}
}
/**
* Get plan display information
@@ -149,17 +244,17 @@ export class SubscriptionService {
async getPlanDisplayInfo(planKey: string) {
try {
const plans = await this.fetchAvailablePlans();
const plan = plans.plans[planKey];
const plan = plans.plans[planKey as SubscriptionTier];
if (plan) {
return {
name: plan.name,
color: this.getPlanColor(planKey),
color: this.getPlanColor(planKey as SubscriptionTier),
description: plan.description,
monthlyPrice: plan.monthly_price
};
}
return { name: 'Desconocido', color: 'gray', description: '', monthlyPrice: 0 };
} catch (error) {
console.error('Failed to get plan display info:', error);
@@ -172,11 +267,11 @@ export class SubscriptionService {
*/
getPlanColor(planKey: string): string {
switch (planKey) {
case SUBSCRIPTION_PLANS.STARTER:
case SUBSCRIPTION_TIERS.STARTER:
return 'blue';
case SUBSCRIPTION_PLANS.PROFESSIONAL:
case SUBSCRIPTION_TIERS.PROFESSIONAL:
return 'purple';
case SUBSCRIPTION_PLANS.ENTERPRISE:
case SUBSCRIPTION_TIERS.ENTERPRISE:
return 'amber';
default:
return 'gray';
@@ -184,17 +279,18 @@ export class SubscriptionService {
}
/**
* Check if a plan meets minimum requirements
* Get analytics level for a plan tier
*/
doesPlanMeetMinimum(plan: SubscriptionPlanKey, minimumRequired: SubscriptionPlanKey): boolean {
return PLAN_HIERARCHY[plan] >= PLAN_HIERARCHY[minimumRequired];
getAnalyticsLevelForTier(tier: SubscriptionTier): AnalyticsLevel {
return TIER_TO_ANALYTICS_LEVEL[tier] || ANALYTICS_LEVELS.NONE;
}
/**
* Get analytics level for a plan
* Get analytics level for a plan (alias for getAnalyticsLevelForTier)
* @deprecated Use getAnalyticsLevelForTier instead
*/
getAnalyticsLevelForPlan(plan: SubscriptionPlanKey): AnalyticsLevel {
return PLAN_TO_ANALYTICS_LEVEL[plan] || ANALYTICS_LEVELS.NONE;
getAnalyticsLevelForPlan(tier: SubscriptionTier): AnalyticsLevel {
return this.getAnalyticsLevelForTier(tier);
}
/**
@@ -203,38 +299,6 @@ export class SubscriptionService {
doesAnalyticsLevelMeetMinimum(level: AnalyticsLevel, minimumRequired: AnalyticsLevel): boolean {
return ANALYTICS_HIERARCHY[level] >= ANALYTICS_HIERARCHY[minimumRequired];
}
/**
* Get plan features
*/
async getPlanFeatures(planKey: string) {
try {
const plans = await this.fetchAvailablePlans();
const plan = plans.plans[planKey];
if (plan) {
return plan.features || {};
}
return {};
} catch (error) {
console.error('Failed to get plan features:', error);
return {};
}
}
/**
* Check if a plan has a specific feature
*/
async planHasFeature(planKey: string, featureName: string) {
try {
const features = await this.getPlanFeatures(planKey);
return featureName in features;
} catch (error) {
console.error('Failed to check plan feature:', error);
return false;
}
}
}
export const subscriptionService = new SubscriptionService();