TF

TalentFlow — Capa Semántica BI

Implementación Cube.dev · PostgreSQL · React SDK · Multi-tenant · CULTIVA IA

Cube.dev v0.35 PostgreSQL React SDK
0
Arquitectura: Single Source of Truth
— antes vs. después
🗄️
PostgreSQL
accounts / jobs
candidates / hires
Cube.dev
Semantic Layer
REST + GraphQL API
📊
Metabase
Internal BI
⚛️
Dashboard React
Customer-facing
analytics
🔌
REST API
Retool / Zapier
/ 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
4.812
100%
Screened
3.465
72.0%
Interview
2.310
48.0%
Offer
1.107
23.0%
Hired
867
18.0%
Rejected
3.945
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)
28.3
Ingeniería
24.1
Producto
19.7
Ventas
18.4
Diseño
14.2
Operaciones
11.8
Soporte
9.3
Marketing
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 },
// ]
Retención mensual por plan — Junio 2026 PRE-AGG RESULT
Plan Cuentas Activas (30d) Retención Avg TTH Contrat.
Enterprise 87 84 97.3% 12.4d 412
Pro 634 578 91.2% 19.1d 3.108
Free 129 56 43.8% 31.7d 287
Total 850 718 84.5% 18.4d 3.807
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.