Skip to content
View collejas's full-sized avatar

Block or report collejas

Block user

Prevent this user from interacting with your repositories and sending you notifications. Learn more about blocking users.

You must be logged in to block users.

Maximum 250 characters. Please don't include any personal information such as legal names or email addresses. Markdown supported. This note will be visible to only you.
Report abuse

Contact GitHub support about this user’s behavior. Learn more about reporting abuse.

Report abuse
collejas/README.md

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/*).

Popular repositories Loading

  1. collejas collejas Public

    Config files for my GitHub profile.

    TypeScript

  2. RUBYGEO RUBYGEO Public

    Ruby

  3. gpmf-parser gpmf-parser Public

    Forked from gopro/gpmf-parser

    Parser for GPMF™ formatted telemetry data used within GoPro® cameras.

    C