Gestión Multi-Tenant
Visión General
El CDP maneja múltiples tenants (clientes/empresas) de forma aislada y segura. Actualmente gestiona 5 tenants principales con un total de 202,808 consumidores y más de 10,863 órdenes procesadas.
Tenants Activos
1. Chelsea IO - Exit (ID: 56)
- Tipo: E-commerce de Moda
- Consumidores: 65,226
- Órdenes: 3,245
- Revenue Total: $45.2M ARS
- Segmentos RFM: 8 activos
- Status: ✅ Producción
2. Celada SA - BAPRO (ID: 52)
- Tipo: Retail General
- Consumidores: 45,282
- Órdenes: 2,891
- Revenue Total: $32.1M ARS
- Segmentos RFM: 7 activos
- Status: ✅ Producción
3. Cooperativa de Trabajo (ID: 53)
- Tipo: Cooperativa
- Consumidores: 15,900
- Órdenes: 1,234
- Revenue Total: $12.3M ARS
- Segmentos RFM: 6 activos
- Status: ✅ Producción
4. EL DORADO SOCIEDAD (ID: 55)
- Tipo: Retail Premium
- Consumidores: 11,729
- Órdenes: 2,145
- Revenue Total: $28.7M ARS
- Segmentos RFM: 8 activos
- Status: ✅ Producción
5. PZ Interamericana Textiles (ID: 1)
- Tipo: B2B Textiles
- Consumidores: 11,385
- Órdenes: 1,348
- Revenue Total: $18.9M ARS
- Segmentos RFM: 5 activos
- Status: ✅ Producción
Arquitectura Multi-Tenant
Aislamiento de Datos
// Cada request incluye tenant_id
const fetchTenantData = async (tenantId, endpoint) => {
const response = await fetch(
`${API_URL}${endpoint}?tenant_id=${tenantId}`
);
return response.json();
};
// Validación de acceso
const validateTenantAccess = (userId, tenantId) => {
const userTenants = getUserTenants(userId);
if (!userTenants.includes(tenantId)) {
throw new Error('Unauthorized tenant access');
}
};
Schema de Base de Datos
-- Cada tabla tiene tenant_id
CREATE TABLE cdp.customers (
customer_id SERIAL PRIMARY KEY,
tenant_id INTEGER NOT NULL,
customer_email VARCHAR(255),
customer_name VARCHAR(255),
-- ... más campos
INDEX idx_tenant (tenant_id)
);
-- Vista materializada por tenant
CREATE MATERIALIZED VIEW cdp.tenant_56_summary AS
SELECT
COUNT(DISTINCT customer_id) as total_customers,
SUM(total_spent) as total_revenue,
AVG(clv_score) as avg_clv
FROM cdp.customers
WHERE tenant_id = 56;
Selector de Tenants UI
Componente TenantSelector
const TenantSelector = ({ value, onChange }) => {
const [tenants, setTenants] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadTenants();
}, []);
const loadTenants = async () => {
try {
const response = await fetch(`${API_URL}/api/tenants`);
const data = await response.json();
setTenants(data.tenants);
} catch (error) {
console.error('Error loading tenants:', error);
} finally {
setLoading(false);
}
};
return (
<Select
value={value}
onValueChange={onChange}
disabled={loading}
>
<SelectTrigger className="w-[300px]">
<SelectValue placeholder="Seleccionar tenant" />
</SelectTrigger>
<SelectContent>
{tenants.map(tenant => (
<SelectItem key={tenant.id} value={tenant.id.toString()}>
<div className="flex items-center justify-between w-full">
<span>{tenant.name}</span>
<Badge color={tenant.active ? 'emerald' : 'gray'}>
{tenant.consumers.toLocaleString()} clientes
</Badge>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
);
};
Estado Global del Tenant
// Context API para tenant actual
const TenantContext = createContext();
export const TenantProvider = ({ children }) => {
const [currentTenant, setCurrentTenant] = useState(56); // Default
const [tenantInfo, setTenantInfo] = useState(null);
useEffect(() => {
loadTenantInfo(currentTenant);
}, [currentTenant]);
const loadTenantInfo = async (tenantId) => {
const info = await fetchTenantDetails(tenantId);
setTenantInfo(info);
};
return (
<TenantContext.Provider value={{
currentTenant,
setCurrentTenant,
tenantInfo
}}>
{children}
</TenantContext.Provider>
);
};
// Hook para usar el tenant actual
export const useCurrentTenant = () => {
const context = useContext(TenantContext);
if (!context) {
throw new Error('useCurrentTenant must be used within TenantProvider');
}
return context;
};
Configuración por Tenant
Configuraciones Disponibles
const tenantConfigurations = {
56: { // Chelsea IO - Exit
features: {
rfmAnalysis: true,
churnPrediction: true,
emailAutomation: true,
vtexIntegration: true,
googleAdsIntegration: true
},
thresholds: {
highValueCustomer: 5000,
churnRiskDays: 90,
vipTier: 10000
},
branding: {
primaryColor: '#1E40AF',
logo: '/logos/chelsea-io.png'
}
},
52: { // Celada SA
features: {
rfmAnalysis: true,
churnPrediction: true,
emailAutomation: false,
vtexIntegration: false,
googleAdsIntegration: true
},
thresholds: {
highValueCustomer: 3000,
churnRiskDays: 120,
vipTier: 8000
},
branding: {
primaryColor: '#059669',
logo: '/logos/celada-sa.png'
}
}
// ... más configuraciones
};
Feature Flags por Tenant
const FeatureGate = ({ feature, children }) => {
const { currentTenant, tenantInfo } = useCurrentTenant();
const isEnabled = tenantInfo?.features?.[feature] || false;
if (!isEnabled) {
return null;
}
return children;
};
// Uso
<FeatureGate feature="vtexIntegration">
<VTEXIntegrationConfig />
</FeatureGate>
Métricas por Tenant
Dashboard de Métricas
const TenantMetrics = ({ tenantId }) => {
const [metrics, setMetrics] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadMetrics();
}, [tenantId]);
const loadMetrics = async () => {
setLoading(true);
try {
const data = await fetchTenantMetrics(tenantId);
setMetrics(data);
} finally {
setLoading(false);
}
};
if (loading) return <LoadingSpinner />;
return (
<Grid numItems={1} numItemsSm={2} numItemsLg={4} className="gap-4">
<Card>
<Text>Total Consumidores</Text>
<Metric>{metrics.totalCustomers.toLocaleString()}</Metric>
<ProgressBar
value={metrics.growthRate}
color="emerald"
className="mt-2"
/>
</Card>
<Card>
<Text>Revenue Total</Text>
<Metric>{formatCurrency(metrics.totalRevenue)}</Metric>
<Badge color={metrics.revenueChange > 0 ? 'emerald' : 'rose'}>
{metrics.revenueChange > 0 ? '+' : ''}{metrics.revenueChange}%
</Badge>
</Card>
<Card>
<Text>Ticket Promedio</Text>
<Metric>{formatCurrency(metrics.avgTicket)}</Metric>
<Text className="text-xs mt-1">
Últimos 30 días
</Text>
</Card>
<Card>
<Text>Tasa de Retención</Text>
<Metric>{metrics.retentionRate}%</Metric>
<LineChart
data={metrics.retentionHistory}
index="month"
categories={["rate"]}
className="h-20 mt-2"
showLegend={false}
showXAxis={false}
showYAxis={false}
/>
</Card>
</Grid>
);
};
Comparación entre Tenants
const TenantComparison = () => {
const [comparison, setComparison] = useState(null);
useEffect(() => {
loadComparison();
}, []);
const loadComparison = async () => {
const data = await fetchAllTenantsMetrics();
setComparison(data);
};
return (
<Card>
<Title>Comparación de Tenants</Title>
<BarChart
data={comparison}
index="tenant_name"
categories={["customers", "revenue", "orders"]}
colors={["blue", "emerald", "amber"]}
valueFormatter={(value) => value.toLocaleString()}
yAxisWidth={48}
className="mt-4 h-72"
/>
<Table className="mt-6">
<TableHead>
<TableRow>
<TableHeaderCell>Tenant</TableHeaderCell>
<TableHeaderCell>Consumidores</TableHeaderCell>
<TableHeaderCell>Revenue</TableHeaderCell>
<TableHeaderCell>AOV</TableHeaderCell>
<TableHeaderCell>Churn Rate</TableHeaderCell>
</TableRow>
</TableHead>
<TableBody>
{comparison?.map(tenant => (
<TableRow key={tenant.id}>
<TableCell>
<Text>{tenant.name}</Text>
</TableCell>
<TableCell>
<Text>{tenant.customers.toLocaleString()}</Text>
</TableCell>
<TableCell>
<Text>{formatCurrency(tenant.revenue)}</Text>
</TableCell>
<TableCell>
<Text>{formatCurrency(tenant.aov)}</Text>
</TableCell>
<TableCell>
<Badge color={tenant.churnRate < 10 ? 'emerald' : 'amber'}>
{tenant.churnRate}%
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
);
};
Gestión de Permisos
Roles y Permisos
const tenantRoles = {
SUPER_ADMIN: {
canViewAllTenants: true,
canCreateTenant: true,
canDeleteTenant: true,
canModifySettings: true
},
TENANT_ADMIN: {
canViewOwnTenant: true,
canModifySettings: true,
canManageUsers: true,
canExportData: true
},
TENANT_USER: {
canViewOwnTenant: true,
canViewReports: true,
canExportData: false
}
};
const checkPermission = (user, action, tenantId) => {
const role = user.roles.find(r => r.tenantId === tenantId);
return tenantRoles[role?.type]?.[action] || false;
};
Control de Acceso UI
const PermissionGate = ({ permission, tenantId, children, fallback }) => {
const { user } = useAuth();
const hasPermission = checkPermission(user, permission, tenantId);
if (!hasPermission) {
return fallback || <UnauthorizedMessage />;
}
return children;
};
// Uso
<PermissionGate
permission="canExportData"
tenantId={currentTenant}
fallback={<DisabledExportButton />}
>
<ExportButton onClick={handleExport} />
</PermissionGate>
Sincronización de Datos
Scheduler de Sincronización
const tenantSyncSchedule = {
56: { // Chelsea IO
frequency: 'HOURLY',
lastSync: '2024-01-15T10:00:00Z',
nextSync: '2024-01-15T11:00:00Z',
status: 'ACTIVE'
},
52: { // Celada SA
frequency: 'DAILY',
lastSync: '2024-01-15T00:00:00Z',
nextSync: '2024-01-16T00:00:00Z',
status: 'ACTIVE'
}
// ... más schedules
};
const syncTenantData = async (tenantId) => {
const syncLog = {
tenantId,
startTime: new Date(),
status: 'IN_PROGRESS'
};
try {
// Sync customers
await syncCustomers(tenantId);
// Sync orders
await syncOrders(tenantId);
// Recalculate RFM
await recalculateRFM(tenantId);
syncLog.status = 'SUCCESS';
syncLog.endTime = new Date();
} catch (error) {
syncLog.status = 'FAILED';
syncLog.error = error.message;
}
await saveSyncLog(syncLog);
};
Monitor de Sincronización
const SyncMonitor = ({ tenantId }) => {
const [syncStatus, setSyncStatus] = useState(null);
const [logs, setLogs] = useState([]);
useEffect(() => {
loadSyncStatus();
const interval = setInterval(loadSyncStatus, 5000);
return () => clearInterval(interval);
}, [tenantId]);
const loadSyncStatus = async () => {
const status = await fetchSyncStatus(tenantId);
setSyncStatus(status);
const recentLogs = await fetchSyncLogs(tenantId, 10);
setLogs(recentLogs);
};
return (
<Card>
<Title>Estado de Sincronización</Title>
<div className="flex items-center gap-2 mt-4">
{syncStatus?.status === 'IN_PROGRESS' ? (
<ArrowPathIcon className="h-5 w-5 animate-spin text-blue-500" />
) : (
<CheckCircleIcon className="h-5 w-5 text-emerald-500" />
)}
<Text>
{syncStatus?.status === 'IN_PROGRESS'
? 'Sincronizando...'
: `Última sync: ${formatRelativeTime(syncStatus?.lastSync)}`}
</Text>
</div>
<div className="mt-4 space-y-2">
{logs.map(log => (
<div key={log.id} className="flex items-center justify-between">
<Text className="text-sm">
{formatDateTime(log.timestamp)}
</Text>
<Badge color={log.status === 'SUCCESS' ? 'emerald' : 'rose'}>
{log.status}
</Badge>
</div>
))}
</div>
</Card>
);
};
Migración entre Tenants
Herramienta de Migración
const migrateTenant = async (sourceTenantId, targetTenantId, options) => {
const migration = {
source: sourceTenantId,
target: targetTenantId,
startTime: new Date(),
itemsMigrated: 0
};
try {
// Migrar configuraciones
if (options.includeSettings) {
await migrateSettings(sourceTenantId, targetTenantId);
}
// Migrar clientes
if (options.includeCustomers) {
const count = await migrateCustomers(sourceTenantId, targetTenantId);
migration.itemsMigrated += count;
}
// Migrar órdenes
if (options.includeOrders) {
const count = await migrateOrders(sourceTenantId, targetTenantId);
migration.itemsMigrated += count;
}
migration.status = 'COMPLETED';
} catch (error) {
migration.status = 'FAILED';
migration.error = error.message;
}
migration.endTime = new Date();
return migration;
};
Best Practices
1. Siempre incluir tenant_id
// ✅ Correcto
const data = await fetch(`/api/customers?tenant_id=${tenantId}`);
// ❌ Incorrecto
const data = await fetch('/api/customers'); // Sin tenant_id
2. Validar acceso al tenant
// ✅ Correcto
if (!hasAccessToTenant(userId, tenantId)) {
throw new UnauthorizedError();
}
// ❌ Incorrecto
// Asumir que el usuario tiene acceso
3. Cache por tenant
// ✅ Correcto
const cacheKey = `data_${tenantId}_${dataType}`;
// ❌ Incorrecto
const cacheKey = `data_${dataType}`; // Sin tenant isolation
4. Logs con contexto de tenant
// ✅ Correcto
console.log(`[Tenant ${tenantId}] Operation completed`, data);
// ❌ Incorrecto
console.log('Operation completed', data); // Sin contexto