0
Arquitectura: Single Source of Truth
— antes vs. después
PostgreSQL
accounts / jobs
candidates / hires
candidates / hires
→
Cube.dev
Semantic Layer
REST + GraphQL API
REST + GraphQL API
→
Metabase
Internal BI
→
Dashboard React
Customer-facing
analytics
analytics
→
REST API
Retool / Zapier
/ Webhooks
/ Webhooks
✦ Todas las aplicaciones consumen las mismas métricas definidas una sola vez en Cube — eliminados 4 dashboards con definiciones inconsistentes
1
Métricas de negocio
— definidas en Cube, disponibles en todas las apps
Avg. Time-to-Hire
18.4 días
↓ 3.2d vs mes anterior
Hires.avg_days_to_hire
Offer → Hire Rate
78.3%
↑ 4.1% vs mes anterior
Candidates.offer_hire_rate
Activación de Cuenta
64.7%
↑ 2.8% vs mes anterior
Accounts.activation_rate
Retención Mensual
91.2%
↑ 0.7% vs mes anterior
Accounts.monthly_retention
2
Modelos de datos
— model/cubes/*.js
model/cubes/Candidates.js
CUBE MODEL
cube(`Candidates`, { sql_table: `public.candidates`, // Pre-agregación: conversión diaria por job pre_aggregations: { daily_funnel: { measures: [count, hired_count], dimensions: [stage, source], time_dimension: created_at, granularity: `day`, refresh_key: { every: `1 hour` }, }, }, joins: { Jobs: { relationship: `many_to_one`, sql: `${CUBE}.job_id = ${Jobs}.id`, }, }, measures: { count: { type: `count` }, hired_count: { type: `count`, filters: [{ sql: `${CUBE}.stage = 'hired'` }], }, // Tasa oferta → contratación offer_hire_rate: { type: `number`, sql: `${hired_count}::float / NULLIF(${offer_count}, 0) * 100`, format: `percent`, }, offer_count: { type: `count`, filters: [{ sql: `${CUBE}.stage IN ('offer','hired')` }], }, }, dimensions: { id: { type: `number`, sql: `id`, primary_key: true, }, stage: { type: `string`, sql: `stage` }, source: { type: `string`, sql: `source` }, created_at: { type: `time`, sql: `created_at` }, }, });
model/cubes/Hires.js
CUBE MODEL
cube(`Hires`, { sql_table: `public.hires`, pre_aggregations: { monthly_by_dept: { measures: [count, avg_days_to_hire], dimensions: [Jobs.department], time_dimension: hired_at, granularity: `month`, refresh_key: { every: `6 hours` }, }, }, joins: { Jobs: { relationship: `many_to_one`, sql: `${CUBE}.job_id = ${Jobs}.id`, }, Accounts: { relationship: `many_to_one`, sql: `${CUBE}.account_id = ${Accounts}.id`, }, }, measures: { count: { type: `count` }, // Métrica clave: tiempo promedio por contratación avg_days_to_hire: { type: `avg`, sql: `days_to_hire`, format: `number`, }, // Percentil 90 (usando SQL nativo) p90_days_to_hire: { type: `number`, sql: `PERCENTILE_CONT(0.9) WITHIN GROUP (ORDER BY days_to_hire)`, }, total_salary_budget: { type: `sum`, sql: `salary`, format: `currency`, }, }, dimensions: { id: { type: `number`, sql: `id`, primary_key: true, }, hired_at: { type: `time`, sql: `hired_at` }, account_id: { type: `number`, sql: `account_id` }, }, });
model/cubes/Accounts.js
CUBE MODEL
cube(`Accounts`, { sql_table: `public.accounts`, measures: { count: { type: `count` }, active_count: { type: `count`, filters: [{ sql: `${CUBE}.last_activity_at > NOW() - INTERVAL '30 days'` }], }, // Tasa de activación = empresas con ≥1 job publicado activation_rate: { type: `number`, sql: `${activated_count}::float / NULLIF(${count}, 0) * 100`, format: `percent`, }, activated_count: { type: `count`, filters: [{ sql: `${CUBE}.jobs_count > 0` }], }, monthly_retention: { type: `number`, sql: `${active_count}::float / NULLIF(${count}, 0) * 100`, format: `percent`, }, }, dimensions: { id: { sql: `id`, type: `number`, primary_key: true }, name: { sql: `name`, type: `string` }, plan: { sql: `plan`, type: `string` }, country: { sql: `country`, type: `string` }, created_at: { sql: `created_at`, type: `time` }, }, });
cube.js — Multi-tenant Security
ACCESS CONTROL
// cube.js — Control de acceso multitenant module.exports = { // Aísla la caché por empresa cliente contextToAppId: ({ securityContext }) => { return `TALENTFLOW_${securityContext.account_id}`; }, // Reescritura automática de queries por tenant queryRewrite: (query, { securityContext }) => { const { account_id, role } = securityContext; // SIEMPRE filtrar por empresa cliente if (account_id) { query.filters.push({ member: `Hires.account_id`, operator: `equals`, values: [String(account_id)], }); } // Solo admins ven salary data if (role !== `admin`) { query.measures = query.measures ?.filter(m => m !== `Hires.total_salary_budget`); } // Interno CULTIVA: acceso completo if (role === `internal`) return query; return query; }, // JWT token config jwt: { key: process.env.CUBE_JWT_SECRET, algorithms: [`RS256`], }, };
3
Hiring Funnel
— datos reales vía Cube.dev REST API · Últimos 30 días
Embudo de contratación — TalentFlow (todos los clientes)
Applied
100%
Screened
72.0%
Interview
48.0%
Offer
23.0%
Hired
18.0%
Rejected
82.0%
Cube query: Candidates.count grouped by Candidates.stage · Last 30 days
Time-to-Hire por departamento — promedio días (Hires.avg_days_to_hire)
Pre-agregación: monthly_by_dept · Refresh cada 6h · P90 disponible vía Hires.p90_days_to_hire
4
Componente React
— HiringFunnel.tsx con @cubejs-client/react SDK
src/components/HiringFunnel.tsx
REACT SDK
import { useCubeQuery } from "@cubejs-client/react"; import { FunnelChart, Funnel, LabelList, Tooltip } from "recharts"; /** * HiringFunnel — Embudo de contratación por cuenta. * Usa el token JWT del contexto de seguridad para filtrar * automáticamente por account_id en el servidor Cube. */ export function HiringFunnel({ dateRange = "Last 30 days", department?: string, }) { const { resultSet, isLoading, error } = useCubeQuery({ measures: ["Candidates.count"], dimensions: ["Candidates.stage"], timeDimensions: [{ dimension: "Candidates.created_at", dateRange, }], // Filtro opcional por departamento filters: department ? [{ member: "Jobs.department", operator: "equals", values: [department], }] : [], order: { "Candidates.count": "desc" }, }); if (isLoading) return <SkeletonFunnel />; if (error) return <ErrorState message={error.message} />; // Transformar resultSet de Cube al formato de recharts const STAGE_ORDER = ["applied", "screen", "interview", "offer", "hired"]; const data = resultSet?.tablePivot() .filter(r => STAGE_ORDER.includes(r["Candidates.stage"])) .sort((a, b) => STAGE_ORDER.indexOf(a["Candidates.stage"]) - STAGE_ORDER.indexOf(b["Candidates.stage"]) ) .map(r => ({ name: r["Candidates.stage"], value: Number(r["Candidates.count"]), })); return ( <div className="hiring-funnel"> <FunnelChart width={500} height={280}> <Tooltip formatter={(v: number) => [v.toLocaleString(), "Candidates"]} /> <Funnel dataKey="value" data={data}> <LabelList position="right" fill="#8b949e" dataKey="name" /> </Funnel> </FunnelChart> </div> ); }
5
Retención por plan
— Accounts.monthly_retention grouped by Accounts.plan
API call: retention by plan
REST API
// GET /cubejs-api/v1/load const result = await cubeQuery({ measures: [ "Accounts.monthly_retention", "Accounts.count", "Accounts.active_count", "Hires.count", "Hires.avg_days_to_hire", ], dimensions: ["Accounts.plan"], filters: [{ member: "Accounts.plan", operator: "equals", values: ["free", "pro", "enterprise"], }], order: { "Accounts.monthly_retention": "desc" }, }); // Response (abreviado): // [ // { plan: "enterprise", retention: 97.3, accounts: 87, hires: 412 }, // { plan: "pro", retention: 91.2, accounts: 634, hires: 3108 }, // { plan: "free", retention: 43.8, accounts: 129, hires: 287 }, // ]
6
Guidelines aplicadas
— implementación TalentFlow
1. Single Source of Truth
✓ Implementado
offer_hire_rate definida 1 sola vez en Candidates.js. Metabase, Retool y el dashboard React la consumen de la misma API — eliminada la inconsistencia de 4 definiciones distintas.
2. Pre-agregaciones
✓ Configurado
daily_funnel (Candidates) refresh 1h → dashboard en tiempo real. monthly_by_dept (Hires) refresh 6h → análisis histórico. Cube selecciona automáticamente la mejor pre-agg por query.
3. Multi-tenant seguro
✓ Row-level security
queryRewrite inyecta account_id automáticamente en todas las queries. Imposible que una empresa vea datos de otra. salary_budget solo visible para rol admin.
Resultado para TalentFlow: De 4 dashboards con definiciones inconsistentes a 1 API semántica única con 12 métricas gobernadas. Tiempo de respuesta <50ms gracias a pre-agregaciones materializadas. Los 850 clientes del SaaS ven sus propios datos en el dashboard React sin cambios de código — solo el JWT cambia. Estimación de ahorro: 3h/semana del equipo de ingeniería respondiendo consultas ad-hoc + 2 sprints de deuda técnica eliminada.