Skip to content

collejas/collejas

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 

Repository files navigation

ATS (Talent CRM) – MVP con Supabase + Rails + Twilio

Autor: Collejas
Fecha: 2025-09-26
Stack: Supabase (Postgres + Auth + Storage + Edge Functions), Rails 7.1 + Turbo/Stimulus, Twilio (WhatsApp/voz)


0) Resumen ejecutivo

Qué es: un ATS (Talent CRM) en Supabase, con UI en Rails 7.1 + Turbo/Stimulus y omnicanalidad vía Twilio.
MVP: Vacantes, Candidatos, Postulaciones, Eventos (timeline), Skills, Kanban por etapas, carga de CVs (bucket privado), webhook WhatsApp, RPC segura para mover etapas, RLS por empresa_id.


1) Arquitectura (visión rápida)

  • DB (Supabase Postgres): tablas multi-tenant + RLS.
  • Auth: Supabase Auth; empresa_miembros enlaza auth.users ↔ empresa.
  • Storage: bucket privado cv con policies por carpeta empresa_id/….
  • Edge Functions (Deno):
    • twilio-incoming: ingesta WhatsApp/voz → crea/encuentra candidato → registra evento.
    • jobs-reminders (cron ejemplo): recordatorios de entrevista.
  • Rails 7.1:
    • SQL directo (jobs internos) o PostgREST con JWT (para honrar RLS por usuario).
    • Turbo/Stimulus: Kanban, detalle candidato, upload a Storage.
  • Twilio: número WhatsApp/voz → webhook a Edge Function.

Variables de entorno

SUPABASE_URL=...
SUPABASE_ANON_KEY=...
SUPABASE_SERVICE_ROLE_KEY=...   # Solo en servidor/Edge
POSTGREST_URL=...
TWILIO_AUTH_TOKEN=...           # si validas firma
SUPABASE_DB_URL=postgres://user:pass@host:6543/db?sslmode=require

2) Migración SQL (único archivo, idempotente)

Guarda como migrations/0001_ats.sql y ejecútalo con SQL Editor o supabase db push.

-- =========================
-- ATS (Talent CRM) – MVP
-- Multi-tenant + RLS + RPC + Vistas
-- =========================

-- Extensiones
create extension if not exists pgcrypto;
create extension if not exists pg_trgm;

-- Enums
do $$ begin
  create type etapa_postulacion as enum ('recibido','filtrado','entrevista1','tecnica','oferta','contratado','descartado');
exception when duplicate_object then null; end $$;

do $$ begin
  create type evento_tipo as enum ('nota','whatsapp_in','whatsapp_out','email_in','email_out','llamada','tarea','sistema');
exception when duplicate_object then null; end $$;

-- Empresas y miembros
create table if not exists public.empresas (
  id uuid primary key default gen_random_uuid(),
  nombre text not null,
  created_at timestamptz default now()
);

create table if not exists public.empresa_miembros (
  empresa_id uuid references public.empresas(id) on delete cascade,
  user_id uuid not null, -- = auth.users.id
  rol text check (rol in ('admin','reclutador','entrevistador','auditor')) not null default 'reclutador',
  primary key (empresa_id, user_id),
  created_at timestamptz default now()
);

-- Catálogos
create table if not exists public.skills (
  id uuid primary key default gen_random_uuid(),
  nombre text unique not null
);

-- Entidades
create table if not exists public.vacantes (
  id uuid primary key default gen_random_uuid(),
  empresa_id uuid not null references public.empresas(id) on delete cascade,
  titulo text not null,
  descripcion text,
  seniority text,
  modalidad text,
  ubicacion text,
  salario_min numeric, salario_max numeric,
  estado text not null default 'abierta',
  created_at timestamptz default now(),
  updated_at timestamptz default now()
);

create table if not exists public.candidatos (
  id uuid primary key default gen_random_uuid(),
  empresa_id uuid not null references public.empresas(id) on delete cascade,
  nombre text,
  email text,
  telefono_e164 text,
  ubicacion text,
  cv_storage_path text,
  linkedin_url text,
  portfolio_url text,
  notas text,
  created_at timestamptz default now(),
  updated_at timestamptz default now()
);

create table if not exists public.candidato_skills (
  candidato_id uuid references public.candidatos(id) on delete cascade,
  skill_id uuid references public.skills(id) on delete restrict,
  nivel int check (nivel between 1 and 5),
  primary key (candidato_id, skill_id)
);

create table if not exists public.postulaciones (
  id uuid primary key default gen_random_uuid(),
  empresa_id uuid not null references public.empresas(id) on delete cascade,
  vacante_id uuid not null references public.vacantes(id) on delete cascade,
  candidato_id uuid not null references public.candidatos(id) on delete cascade,
  etapa etapa_postulacion not null default 'recibido',
  estado text not null default 'activa',
  prioridad int not null default 0,
  score numeric,
  fuente text,
  created_at timestamptz default now(),
  updated_at timestamptz default now(),
  unique (vacante_id, candidato_id)
);

create table if not exists public.eventos (
  id uuid primary key default gen_random_uuid(),
  empresa_id uuid not null references public.empresas(id) on delete cascade,
  candidato_id uuid not null references public.candidatos(id) on delete cascade,
  postulacion_id uuid references public.postulaciones(id) on delete cascade,
  tipo evento_tipo not null,
  contenido jsonb,
  creado_por uuid,
  created_at timestamptz default now()
);

-- Índices
create index if not exists idx_candidatos_empresa on public.candidatos(empresa_id);
create index if not exists idx_postulaciones_empresa on public.postulaciones(empresa_id);
create index if not exists idx_vacantes_empresa on public.vacantes(empresa_id);
create index if not exists idx_eventos_empresa on public.eventos(empresa_id);
create index if not exists idx_candidatos_busq on public.candidatos using gin ((to_tsvector('spanish',
  coalesce(nombre,'')||' '||coalesce(email,'')||' '||coalesce(ubicacion,''))));

-- Triggers updated_at
create or replace function public.touch_updated_at() returns trigger as $$
begin new.updated_at = now(); return new; end $$ language plpgsql;

drop trigger if exists vacantes_touch on public.vacantes;
create trigger vacantes_touch before update on public.vacantes
for each row execute function public.touch_updated_at();

drop trigger if exists candidatos_touch on public.candidatos;
create trigger candidatos_touch before update on public.candidatos
for each row execute function public.touch_updated_at();

drop trigger if exists postulaciones_touch on public.postulaciones;
create trigger postulaciones_touch before update on public.postulaciones
for each row execute function public.touch_updated_at();

-- RLS
alter table public.empresas enable row level security;
alter table public.empresa_miembros enable row level security;
alter table public.vacantes enable row level security;
alter table public.candidatos enable row level security;
alter table public.candidato_skills enable row level security;
alter table public.postulaciones enable row level security;
alter table public.eventos enable row level security;

-- Vista helper de membresía
create or replace view public._mi_empresas as
select em.empresa_id, em.user_id from public.empresa_miembros em;

-- Función membership
create or replace function public.is_member_of(empresa uuid) returns boolean
language sql stable as $$
  select exists (select 1 from public._mi_empresas m where m.empresa_id = empresa and m.user_id = auth.uid());
$$;

-- Policies
do $$ begin
  create policy empresas_select on public.empresas
    for select using (exists (select 1 from public._mi_empresas m where m.empresa_id = id and m.user_id = auth.uid()));
exception when duplicate_object then null; end $$;

do $$ begin
  create policy em_select on public.empresa_miembros
    for select using (empresa_id in (select empresa_id from public._mi_empresas where user_id = auth.uid()));
exception when duplicate_object then null; end $$;

do $$ begin
  create policy sel_vacantes on public.vacantes for select using (public.is_member_of(empresa_id));
  create policy ins_vacantes on public.vacantes for insert with check (public.is_member_of(empresa_id));
  create policy upd_vacantes on public.vacantes for update using (public.is_member_of(empresa_id)) with check (public.is_member_of(empresa_id));
exception when duplicate_object then null; end $$;

do $$ begin
  create policy sel_candidatos on public.candidatos for select using (public.is_member_of(empresa_id));
  create policy ins_candidatos on public.candidatos for insert with check (public.is_member_of(empresa_id));
  create policy upd_candidatos on public.candidatos for update using (public.is_member_of(empresa_id)) with check (public.is_member_of(empresa_id));
exception when duplicate_object then null; end $$;

do $$ begin
  create policy sel_candidato_skills on public.candidato_skills for select using (
    exists (select 1 from public.candidatos c where c.id = candidato_id and public.is_member_of(c.empresa_id))
  );
  create policy ins_candidato_skills on public.candidato_skills for insert with check (
    exists (select 1 from public.candidatos c where c.id = candidato_id and public.is_member_of(c.empresa_id))
  );
exception when duplicate_object then null; end $$;

do $$ begin
  create policy sel_postulaciones on public.postulaciones for select using (public.is_member_of(empresa_id));
  create policy ins_postulaciones on public.postulaciones for insert with check (public.is_member_of(empresa_id));
  create policy upd_postulaciones on public.postulaciones for update using (public.is_member_of(empresa_id)) with check (public.is_member_of(empresa_id));
exception when duplicate_object then null; end $$;

do $$ begin
  create policy sel_eventos on public.eventos for select using (public.is_member_of(empresa_id));
  create policy ins_eventos on public.eventos for insert with check (public.is_member_of(empresa_id));
exception when duplicate_object then null; end $$;

-- RPC: mover etapa (seguro)
create or replace function public.mover_etapa(p_postulacion uuid, p_nueva etapa_postulacion)
returns void language plpgsql security definer set search_path = public as $$
declare v_empresa uuid;
begin
  select empresa_id into v_empresa from public.postulaciones where id = p_postulacion;
  if v_empresa is null then raise exception 'postulacion no existe'; end if;
  if not public.is_member_of(v_empresa) then raise exception 'forbidden'; end if;

  update public.postulaciones set etapa = p_nueva where id = p_postulacion;

  insert into public.eventos(empresa_id, candidato_id, postulacion_id, tipo, contenido)
    select empresa_id, candidato_id, id, 'sistema', jsonb_build_object('accion','mover_etapa','nueva',p_nueva)
    from public.postulaciones where id = p_postulacion;
end $$;

-- Vista para Kanban
create or replace view public.v_kanban as
select p.id as postulacion_id, p.empresa_id, p.vacante_id, p.candidato_id, p.etapa, p.prioridad, p.score,
       c.nombre, c.ubicacion, c.email, c.telefono_e164, v.titulo as vacante
from public.postulaciones p
join public.candidatos c on c.id = p.candidato_id
join public.vacantes v on v.id = p.vacante_id;

-- (Opcional) policy explícita de la vista
do $$ begin
  create policy sel_v_kanban on public.v_kanban for select using (true);
exception when duplicate_object then null; end $$;

3) Storage (CVs) – bucket privado y policies

  1. Crea bucket cv (privado).
  2. Convención: empresa_id/<candidato_id>/cv.pdf.
-- Policies storage.objects para bucket 'cv'
create policy if not exists cv_read on storage.objects
for select using (
  bucket_id = 'cv'
  and public.is_member_of( split_part(name, '/', 1)::uuid )
);

create policy if not exists cv_write on storage.objects
for insert with check (
  bucket_id = 'cv'
  and public.is_member_of( split_part(name, '/', 1)::uuid )
);

create policy if not exists cv_update_delete on storage.objects
for update using (
  bucket_id = 'cv'
  and public.is_member_of( split_part(name, '/', 1)::uuid )
) with check (
  bucket_id = 'cv'
  and public.is_member_of( split_part(name, '/', 1)::uuid )
);

Para compartir con externos, usa signed URLs temporales.


4) Edge Function: Twilio (WhatsApp/voz) → ingesta

Archivo: supabase/functions/twilio-incoming/index.ts

import { serve } from "https://deno.land/std/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";

serve(async (req) => {
  try {
    const url = new URL(req.url);
    const empresa_id = url.searchParams.get("empresa_id");
    if (!empresa_id) return new Response("empresa_id requerido", { status: 400 });

    const contentType = req.headers.get("content-type") || "";
    const isForm = contentType.includes("application/x-www-form-urlencoded");
    const body = isForm ? await req.formData() : await req.json().catch(() => ({}));

    const From = isForm ? body.get("From")?.toString() : body.From;
    const Body = isForm ? body.get("Body")?.toString() : body.Body || "";
    const MediaUrl0 = isForm ? body.get("MediaUrl0")?.toString() : body.MediaUrl0;

    const supabase = createClient(Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!);

    const { data: cand, error: e1 } = await supabase
      .from("candidatos")
      .upsert({ empresa_id, telefono_e164: From }, { onConflict: "empresa_id,telefono_e164" })
      .select("id")
      .single();
    if (e1) throw e1;

    const contenido: any = { from: From, text: Body };
    if (MediaUrl0) contenido.media = MediaUrl0;

    const { error: e2 } = await supabase.from("eventos").insert({
      empresa_id, candidato_id: cand!.id, tipo: "whatsapp_in", contenido
    });
    if (e2) throw e2;

    return new Response("OK", { status: 200 });
  } catch (err) {
    return new Response("ERR " + (err as Error).message, { status: 500 });
  }
});

supabase/functions/twilio-incoming/deno.json

{
  "tasks": {
    "start": "deno run --allow-net --allow-env index.ts"
  }
}

Deploy

supabase functions deploy twilio-incoming --no-verify-jwt
supabase functions list
# URL para Twilio:
# https://<project-ref>.functions.supabase.co/twilio-incoming?empresa_id=<UUID_EMPRESA>

Si validas firma de Twilio, añade verificación usando X-Twilio-Signature.


5) Edge Function (cron ejemplo): recordatorios

Archivo: supabase/functions/jobs-reminders/index.ts

import { serve } from "https://deno.land/std/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";

serve(async () => {
  const supabase = createClient(Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!);

  const { data, error } = await supabase
    .from("eventos")
    .select("id, empresa_id, candidato_id, contenido")
    .eq("tipo", "tarea");
  if (error) return new Response("ERR " + error.message, { status: 500 });

  console.log("Reminders to process:", data?.length || 0);
  return new Response("OK", { status: 200 });
});

Programa con Supabase Scheduled Triggers (o un cron externo).


6) Rails 7.1 – Configuración y endpoints

6.1 config/database.yml (conexión directa)

production:
  adapter: postgresql
  encoding: unicode
  url: <%= ENV["SUPABASE_DB_URL"] %>
  pool: 10

Para RLS por usuario desde Rails, usa PostgREST con Authorization: Bearer <jwt>. Para jobs internos, usa service_role solo en servidor.

6.2 Cliente PostgREST (opcional)

# app/services/postgrest.rb
require "net/http"
class Postgrest
  def self.get(path, jwt:, params: {})
    uri = URI("#{ENV["POSTGREST_URL"]}/#{path}")
    uri.query = URI.encode_www_form(params) if params.present?
    req = Net::HTTP::Get.new(uri)
    req["apikey"] = ENV["SUPABASE_ANON_KEY"]
    req["Authorization"] = "Bearer #{jwt}"
    Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }
  end

  def self.rpc(name, jwt:, body: {})
    uri = URI("#{ENV["POSTGREST_URL"]}/rpc/#{name}")
    req = Net::HTTP::Post.new(uri)
    req["apikey"] = ENV["SUPABASE_ANON_KEY"]
    req["Authorization"] = "Bearer #{jwt}"
    req["Content-Type"] = "application/json"
    req.body = body.to_json
    Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }
  end
end

6.3 Controlador mínimo

# app/controllers/ats_controller.rb
class AtsController < ApplicationController
  protect_from_forgery with: :null_session

  def create_candidate
    empresa_id = params[:empresa_id]
    sql = <<~SQL
      insert into public.candidatos (empresa_id, nombre, email, telefono_e164, ubicacion)
      values ($1, $2, $3, $4, $5) returning id;
    SQL
    res = exec_sql(sql, [empresa_id, params[:nombre], params[:email], params[:telefono], params[:ubicacion]])
    if params[:vacante_id].present?
      exec_sql(
        "insert into public.postulaciones(empresa_id, vacante_id, candidato_id, fuente) values ($1,$2,$3,$4) on conflict do nothing",
        [empresa_id, params[:vacante_id], res["id"], params[:fuente] || "web"]
      )
    end
    render json: { ok: true, candidato_id: res["id"] }
  end

  def move_stage
    exec_sql("select public.mover_etapa($1::uuid, $2::etapa_postulacion)", [params[:postulacion_id], params[:etapa]])
    render json: { ok: true }
  end

  def kanban
    empresa_id = params[:empresa_id]
    rows = exec_sql_all("select * from public.v_kanban where empresa_id = $1", [empresa_id])
    render json: rows
  end

  private
  def exec_sql(sql, binds = [])
    result = ActiveRecord::Base.connection.exec_query(ActiveRecord::Base.send(:sanitize_sql_array, [sql, *binds]))
    result.first
  end

  def exec_sql_all(sql, binds = [])
    ActiveRecord::Base.connection.exec_query(ActiveRecord::Base.send(:sanitize_sql_array, [sql, *binds])).to_a
  end
end

6.4 Rutas

# config/routes.rb
Rails.application.routes.draw do
  scope :ats do
    post  "candidatos",  to: "ats#create_candidate"
    post  "mover_etapa", to: "ats#move_stage"
    get   "kanban",      to: "ats#kanban"
  end

  post "/signed-upload", to: "storage#signed_upload"
end

7) UI – Stimulus (Kanban + Upload)

7.1 Kanban

// app/javascript/controllers/kanban_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["column"]

  connect() {
    this.columns = this.columnTargets
    this.columns.forEach(col => {
      col.addEventListener("dragover", e => e.preventDefault())
      col.addEventListener("drop", this.drop.bind(this))
    })
  }

  dragStart(e) {
    e.dataTransfer.setData("text/plain", e.target.dataset.postulacionId)
  }

  drop(e) {
    const postulacionId = e.dataTransfer.getData("text/plain")
    const etapa = e.currentTarget.dataset.etapa
    fetch("/ats/mover_etapa", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ postulacion_id: postulacionId, etapa })
    }).then(() => {
      const card = this.element.querySelector(`[data-postulacion-id="${postulacionId}"]`)
      e.currentTarget.querySelector(".cards").appendChild(card)
    })
  }
}
<!-- app/views/ats/kanban.html.erb -->
<div data-controller="kanban" class="grid grid-cols-6 gap-4">
  <% ["recibido","filtrado","entrevista1","tecnica","oferta","contratado"].each do |etapa| %>
    <div class="p-2 bg-gray-50 rounded" data-kanban-target="column" data-etapa="<%= etapa %>">
      <div class="font-semibold mb-2"><%= etapa.titleize %></div>
      <div class="cards space-y-2 min-h-[200px]">
        <% (@kanban || []).select{|k| k["etapa"] == etapa }.each do |k| %>
          <div class="p-2 bg-white border rounded cursor-move"
               draggable="true"
               ondragstart="this.closest('[data-controller=kanban]').controller.dragStart(event)"
               data-postulacion-id="<%= k["postulacion_id"] %>">
            <div class="text-sm font-semibold"><%= k["nombre"] %><%= k["vacante"] %></div>
            <div class="text-xs text-gray-500"><%= k["ubicacion"] %> · Score: <%= k["score"] || "-" %></div>
          </div>
        <% end %>
      </div>
    </div>
  <% end %>
</div>

7.2 Upload de CV (signed URL)

// app/javascript/controllers/cv_upload_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["file", "status"]

  async upload() {
    const file = this.fileTarget.files[0]
    if (!file) return
    const empresaId = this.element.dataset.empresaId
    const candidatoId = this.element.dataset.candidatoId
    const path = `${empresaId}/${candidatoId}/cv.${file.name.split('.').pop()}`

    const res = await fetch("/signed-upload", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ bucket: "cv", path, contentType: file.type })
    })
    const { url } = await res.json()

    const put = await fetch(url, { method: "PUT", body: file, headers: { "Content-Type": file.type } })
    this.statusTarget.textContent = put.ok ? "CV subido" : "Error al subir"
  }
}
# app/controllers/storage_controller.rb
class StorageController < ApplicationController
  protect_from_forgery with: :null_session

  def signed_upload
    bucket = params[:bucket]
    path   = params[:path]
    content_type = params[:contentType]

    url = "#{ENV["SUPABASE_URL"]}/storage/v1/object/sign/#{bucket}/#{path}?download=0"
    render json: { url: url, contentType: content_type }
  end
end

8) Twilio – configuración mínima y prueba

Webhook en Twilio (WhatsApp/Voice URL):

https://<project-ref>.functions.supabase.co/twilio-incoming?empresa_id=<UUID_EMPRESA>

Prueba:

curl -X POST "https://<project-ref>.functions.supabase.co/twilio-incoming?empresa_id=<UUID>" \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  --data-urlencode 'From=whatsapp:+5215555555555' \
  --data-urlencode 'Body=Hola, busco vacante de backend'

9) Checklist de despliegue

  1. DB
    • Ejecuta migrations/0001_ats.sql.
    • Inserta 1 empresa y tu usuario en empresa_miembros.
  2. Storage
    • Crea bucket cv (privado) y aplica policies.
  3. Edge
    • supabase functions deploy twilio-incoming --no-verify-jwt
    • (Opcional) jobs-reminders + cron.
  4. Rails
    • Variables de entorno listas.
    • Rutas y controladores (AtsController, StorageController).
    • Compila assets, arranca.
  5. Twilio
    • Webhook apuntando a la función.
  6. Smoke tests
    • POST /ats/candidatos → crea candidato/postulación.
    • GET /ats/kanban?empresa_id=... → lista kanban.
    • Curl a twilio-incoming → evento en eventos.

10) Roadmap “Pro”

  • Embeddings para match CV ↔ vacante (score IA).
  • Entrevistas automáticas (bot Q&A).
  • Evaluaciones técnicas.
  • Referencias y firma de oferta (e-sign).
  • Onboarding checklist.
  • Reportes: tiempo a contratación, conversión por etapa, canal.

Listo para producción-lite. Si quieres, te lo empaqueto en un ZIP con estructura de carpetas (supabase/functions/*, app/controllers/*, app/javascript/controllers/*, migrations/*).

About

Config files for my GitHub profile.

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published