SUPABASE BACKEND Β· PRODUCCIΓ“N

LeadFlow CRM β€” Backend Completo

Arquitectura Supabase full-stack para agencia SaaS B2B: Auth multiproveedor, RLS multitenancy, Kanban en tiempo real, Storage privado y Edge Functions.

πŸ—„οΈ PostgreSQL + RLS
πŸ” Auth OAuth + Magic Link
⚑ Tiempo Real WebSockets
πŸ“ Storage Policies
πŸ¦• Edge Functions Deno
πŸ›‘οΈ Multitenancy por Workspace
β†’
Arquitectura General
Frontend
Next.js 14 App Router
React Server Components
@supabase/ssr
Auth Layer
Email / Password
Google OAuth 2.0
Magic Link
Database (Postgres)
workspaces
profiles
leads
workspace_members
Platform
Storage: lead-files
Realtime Channels
Edge Functions (Deno)
Integraciones
Resend (email)
Webhooks DB triggers
1
Schema de Base de Datos Β· Migraciones SQL
workspaces RLS βœ“
PKiduuid
nametext
slugtext unique
FKowner_id→ profiles
plantext
created_attimestamptz
profiles RLS βœ“
PKid→ auth.users
full_nametext
avatar_urltext
roletext
created_attimestamptz
workspace_members RLS βœ“
FKworkspace_id→ workspaces
FKuser_id→ profiles
rolemember|admin
invited_attimestamptz
accepted_attimestamptz
leads RLS βœ“
PKiduuid
FKworkspace_id→ workspaces
nametext
emailtext
companytext
statusenum
value_eurnumeric
FKassigned_to→ profiles
updated_attimestamptz
lead_files RLS βœ“
PKiduuid
FKlead_id→ leads
FKuploaded_by→ profiles
storage_pathtext
filenametext
size_bytesbigint
activity_log RLS βœ“
PKiduuid
FKlead_id→ leads
FKuser_id→ profiles
actiontext
metadatajsonb
created_attimestamptz
πŸ“ supabase/migrations/20260616_leadflow_init.sql SQL
-- ================================================
-- LeadFlow CRM Β· MigraciΓ³n Inicial Β· Supabase
-- ================================================

-- Tipo enum para estado de leads
create type lead_status as enum ('new', 'contacted', 'qualified', 'won', 'lost');

-- Tabla: workspaces (multitenancy)
create table public.workspaces (
  id          uuid default gen_random_uuid() primary key,
  name        text not null,
  slug        text unique not null,
  owner_id    uuid references public.profiles(id) on delete cascade,
  plan        text default 'starter',
  created_at  timestamptz default now()
);

-- Tabla: profiles (auto-creado en signup via trigger)
create table public.profiles (
  id          uuid references auth.users on delete cascade primary key,
  full_name   text,
  avatar_url  text,
  role        text default 'user',
  created_at  timestamptz default now()
);

-- Tabla: leads (core de la app)
create table public.leads (
  id            uuid default gen_random_uuid() primary key,
  workspace_id  uuid references public.workspaces(id) on delete cascade not null,
  name          text not null,
  email         text,
  company       text,
  status        lead_status default 'new',
  value_eur     numeric(10,2) default 0,
  assigned_to   uuid references public.profiles(id),
  notes         text,
  created_at    timestamptz default now(),
  updated_at    timestamptz default now()
);

-- Auto-update updated_at
create or replace function update_updated_at()
returns trigger as $$
begin
  new.updated_at = now();
  return new;
end; $$ language plpgsql;

create trigger leads_updated_at
  before update on public.leads
  for each row execute procedure update_updated_at();

-- Trigger: crear profile al registrarse
create or replace function public.handle_new_user()
returns trigger as $$
begin
  insert into public.profiles (id, full_name)
  values (new.id, new.raw_user_meta_data->>>'full_name');
  return new;
end; $$ language plpgsql security definer;

create trigger on_auth_user_created
  after insert on auth.users
  for each row execute procedure public.handle_new_user();

-- Habilitar RLS en todas las tablas
alter table public.workspaces        enable row level security;
alter table public.profiles          enable row level security;
alter table public.workspace_members  enable row level security;
alter table public.leads             enable row level security;
alter table public.lead_files        enable row level security;
2
Politicas RLS β€” Multitenancy por Workspace
SELECT
"Members see workspace leads"
ON leads USING (workspace_id IN (
  SELECT workspace_id FROM workspace_members
  WHERE user_id = auth.uid()))
INSERT
"Members create leads"
ON leads WITH CHECK (workspace_id IN (
  SELECT workspace_id FROM workspace_members
  WHERE user_id = auth.uid()))
UPDATE
"Admins and assigned update leads"
ON leads USING (assigned_to = auth.uid() OR
  EXISTS (SELECT 1 FROM workspace_members
  WHERE user_id = auth.uid() AND role = 'admin'))
DELETE
"Only admins delete leads"
ON leads USING (EXISTS (
  SELECT 1 FROM workspace_members
  WHERE user_id = auth.uid() AND role = 'admin'))
SELECT
"Workspace members see each other"
ON workspace_members USING (
  workspace_id IN (SELECT workspace_id
  FROM workspace_members WHERE user_id = auth.uid()))
INSERT
"Workspace files by members only"
ON lead_files WITH CHECK (
  uploaded_by = auth.uid() AND
  lead_id IN (SELECT id FROM leads WHERE workspace_id IN (...)))
3
Autenticacion β€” lib/supabase/auth.ts
lib/supabase/client.ts TypeScript
import { createBrowserClient } from '@supabase/ssr';
import type { Database } from './types';

// Cliente browser β€” RLS activo
export function createClient() {
  return createBrowserClient<Database>(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  );
}

// Auth: signup con metadata
export async function signUp(
  email: string,
  password: string,
  fullName: string
) {
  const sb = createClient();
  return sb.auth.signUp({
    email, password,
    options: { data: { full_name: fullName } }
  });
}

// Google OAuth
export async function signInWithGoogle() {
  const sb = createClient();
  return sb.auth.signInWithOAuth({
    provider: 'google',
    options: {
      redirectTo: `${location.origin}/auth/callback`,
      queryParams: { access_type: 'offline', prompt: 'consent' }
    }
  });
}

// Magic link (invitacion de equipo)
export async function inviteMember(email: string) {
  const sb = createClient();
  return sb.auth.signInWithOtp({
    email,
    options: { emailRedirectTo: `${location.origin}/join` }
  });
}
lib/supabase/leads.ts TypeScript
import { createClient } from './client';
import type { Lead, LeadStatus } from './types';

// Obtener leads del workspace con join
export async function getLeads(workspaceId: string) {
  const sb = createClient();
  return sb
    .from('leads')
    .select(`
      *,
      assignee:profiles!assigned_to(
        full_name, avatar_url
      )
    `)
    .eq('workspace_id', workspaceId)
    .order('created_at', { ascending: false });
}

// Mover lead en el kanban
export async function updateLeadStatus(
  id: string,
  status: LeadStatus,
  assignedTo?: string
) {
  const sb = createClient();
  return sb
    .from('leads')
    .update({ status, assigned_to: assignedTo })
    .eq('id', id)
    .select()
    .single();
}

// RPC: stats del pipeline
export async function getPipelineStats(
  workspaceId: string
) {
  const sb = createClient();
  return sb.rpc('pipeline_stats', {
    p_workspace_id: workspaceId
  });
}
4
Tiempo Real β€” Kanban con WebSockets
hooks/useLeadRealtime.ts TypeScript
export function useLeadRealtime(workspaceId: string) {
  const [leads, setLeads] = useState<Lead[]>([]);

  useEffect(() => {
    const channel = createClient()
      .channel(`workspace-${workspaceId}`)
      .on('postgres_changes', {
        event: '*',
        schema: 'public',
        table: 'leads',
        filter: `workspace_id=eq.${workspaceId}`
      }, (payload) => {
        if (payload.eventType === 'UPDATE') {
          setLeads(prev => prev.map(l =>
            l.id === payload.new.id ? { ...l, ...payload.new } : l
          ));
        }
      })
      .subscribe();

    return () => { createClient().removeChannel(channel); };
  }, [workspaceId]);

  return leads;
}
NUEVO 3
Marta Garcia
TechRetail SL
€ 4.200
Luis Herrera
LogisticsPro
€ 8.900
CONTACTADO 2
Ana Morera
Foodie Group
€ 12.500
CALIFICADO 1
Carlos Pena
EduSaaS ES
€ 22.000
GANADO 2
Sofia Blanco
Innova Design
€ 9.800
Pedro Vidal
HealthTech BCN
€ 31.000
PERDIDO 1
Maria Torres
OldRetail
€ 3.100
WebSocket activo Β· Canal workspace-a8f3c21 Β· 3 usuarios conectados Β· Cambios en tiempo real
5
Storage β€” Archivos Privados por Workspace
πŸ”’
lead-files
Bucket privado Β· PDFs, contratos, capturas Β· Max 50 MB/archivo
INSERT: bucket_id='lead-files' AND auth.uid()::text = folder[1]
SELECT: member del workspace propietario del lead
DELETE: solo admin del workspace
🌐
avatars
Bucket publico Β· Fotos de perfil Β· Max 2 MB/archivo
INSERT: auth.uid()::text = (foldername(name))[1]
SELECT: publico (using true)
UPDATE: solo el mismo usuario
lib/supabase/storage.ts TypeScript
// Subir propuesta PDF a un lead
export async function uploadLeadFile(
  leadId: string, workspaceId: string, file: File
) {
  const sb = createClient();
  const path = `${workspaceId}/${leadId}/${Date.now()}_${file.name}`;

  const { error } = await sb.storage
    .from('lead-files')
    .upload(path, file, { upsert: false, contentType: file.type });

  if (error) throw error;

  // Registrar en BD
  await sb.from('lead_files').insert({
    lead_id: leadId,
    storage_path: path,
    filename: file.name,
    size_bytes: file.size
  });

  // URL firmada (expira en 1h)
  return sb.storage.from('lead-files')
    .createSignedUrl(path, 3600);
}
6
Edge Function β€” Notificacion al Asignar Lead
πŸ—„οΈ
DB Trigger
leads.assigned_to UPDATE
β†’
πŸ”—
Supabase Webhook
HTTP POST payload
β†’
πŸ¦•
notify-assigned
Deno Edge Function
β†’
πŸ“§
Resend API
Email al asignado
supabase/functions/notify-assigned/index.ts Deno Β· TypeScript
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';

serve(async (req) => {
  const { record, old_record } = await req.json();

  // Solo notificar si cambiΓ³ assigned_to
  if (record.assigned_to === old_record.assigned_to)
    return new Response('no change');

  const sb = createClient(
    Deno.env.get('SUPABASE_URL')!,
    Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
  );

  // Obtener email del nuevo asignado
  const { data: user } = await sb.auth.admin
    .getUserById(record.assigned_to);

  await fetch('https://api.resend.com/emails', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${Deno.env.get('RESEND_API_KEY')}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      from: 'noreply@leadflow.app',
      to: user?.email,
      subject: `[LeadFlow] Nuevo lead asignado: ${record.name}`,
      html: `<h2>Tienes un nuevo lead: <strong>${record.name}</strong></h2>
             <p>Empresa: ${record.company} | Valor: €${record.value_eur}</p>
             <a href="https://app.leadflow.app/leads/${record.id}">
               Ver lead β†’
             </a>`
    })
  });

  return new Response(JSON.stringify({ sent: true }),
    { headers: { 'Content-Type': 'application/json' } });
});
$ supabase functions deploy notify-assigned
$ supabase secrets set RESEND_API_KEY=re_xxxxxxxx
βœ“
Checklist de Seguridad y Buenas Practicas
βœ“
RLS habilitado en TODAS las tablas
Sin RLS, cualquier anon key expone los datos completos
βœ“
auth.uid() en policies β€” nunca user_id del cliente
El cliente no puede falsificar auth.uid(); sΓ­ puede falsificar columnas libres
βœ“
service_role key solo en Edge Functions y servidor
NEXT_PUBLIC_ no aplica al service role key. Nunca en el bundle del cliente
βœ“
Migraciones versionadas via CLI (supabase db push)
Nunca editar esquema de produccion a mano desde el dashboard
βœ“
Indexes en columnas de RLS (workspace_id, assigned_to)
Sin index, cada query con RLS hace seq scan β€” degrade severo con escala
βœ“
removeChannel() en cleanup de useEffect
Evita memory leaks y conexiones WebSocket zombi en SPA
βœ“
Triggers security definer para handle_new_user
El trigger se ejecuta con permisos del definer, no del llamador