Autor: Collejas
Fecha: 2025-09-26
Stack: Supabase (Postgres + Auth + Storage + Edge Functions), Rails 7.1 + Turbo/Stimulus, Twilio (WhatsApp/voz)
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
.
- DB (Supabase Postgres): tablas multi-tenant + RLS.
- Auth: Supabase Auth;
empresa_miembros
enlazaauth.users
↔ empresa. - Storage: bucket privado
cv
con policies por carpetaempresa_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
Guarda como
migrations/0001_ats.sql
y ejecútalo con SQL Editor osupabase 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 $$;
- Crea bucket
cv
(privado). - 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.
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
.
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).
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, usaservice_role
solo en servidor.
# 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
# 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
# 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
// 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>
// 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
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'
- DB
- Ejecuta
migrations/0001_ats.sql
. - Inserta 1 empresa y tu usuario en
empresa_miembros
.
- Ejecuta
- Storage
- Crea bucket
cv
(privado) y aplica policies.
- Crea bucket
- Edge
supabase functions deploy twilio-incoming --no-verify-jwt
- (Opcional)
jobs-reminders
+ cron.
- Rails
- Variables de entorno listas.
- Rutas y controladores (
AtsController
,StorageController
). - Compila assets, arranca.
- Twilio
- Webhook apuntando a la función.
- Smoke tests
POST /ats/candidatos
→ crea candidato/postulación.GET /ats/kanban?empresa_id=...
→ lista kanban.- Curl a
twilio-incoming
→ evento eneventos
.
- 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/*
).