Files
inq-roi-simulador-web/sprints/sprint-7/ETAPA_1_PROMPT.md
Marcos Benítez bc9f70419c
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
Resumen de lo entregado
2026-07-07 09:47:31 -03:00

17 KiB

Etapa 1 — Migraciones de base de datos + SECURITY DEFINER helpers

Sprint: 7 Fecha: 2026-06-24 Alcance: solo archivos SQL en supabase/migrations/. Cero cambios en frontend.


Contexto

Esta etapa crea la fundación de datos sobre la que se construye todo el multi-usuario de Sprint 7. El esquema replica el patrón de InQ Sizing documentado en cowork/InQ ROI - Simulador de procesos/autenticacion-y-usuarios.md — leerlo antes de empezar.

Schema actual relevante (no tocar lo que ya existe):

  • public.users: id, name, avatar_url, platform_role ('platform_admin'|'member'|'guest'), company, org_id (no existe aún), created_at
  • public.processes: client (text — campo de display, se mantiene), owner_id, más campos de config. Sin org_id aún.
  • public.user_groups + group_memberships + public.process_access: sistema de acceso interno de InQuality — NO tocar
  • public.is_platform_admin(): función SECURITY DEFINER existente — NO tocar

Archivo 1: supabase/migrations/014_create_organizations.sql

-- Tabla de organizaciones clientes
-- is_provider = true identifica a InQuality (org administradora de la plataforma)
CREATE TABLE IF NOT EXISTS public.organizations (
  id          uuid         PRIMARY KEY DEFAULT gen_random_uuid(),
  name        text         NOT NULL,
  is_provider boolean      NOT NULL DEFAULT false,
  created_at  timestamptz  NOT NULL DEFAULT now()
);

ALTER TABLE public.organizations ENABLE ROW LEVEL SECURITY;

-- Insertar la organización de InQuality como proveedor
-- ON CONFLICT DO NOTHING: idempotente si ya existe
INSERT INTO public.organizations (name, is_provider)
VALUES ('InQuality', true)
ON CONFLICT DO NOTHING;

-- Agregar org_id a users (nullable — usuarios InQuality existentes no tienen org de cliente)
ALTER TABLE public.users
  ADD COLUMN IF NOT EXISTS org_id uuid REFERENCES public.organizations(id) ON DELETE SET NULL;

-- Agregar org_id a processes (nullable — procesos existentes se vinculan por migración)
ALTER TABLE public.processes
  ADD COLUMN IF NOT EXISTS org_id uuid REFERENCES public.organizations(id) ON DELETE SET NULL;

-- Ampliar check constraint de platform_role para incluir roles de cliente
-- Primero eliminar la restricción existente, luego recrear con los valores nuevos
ALTER TABLE public.users DROP CONSTRAINT IF EXISTS users_platform_role_check;
ALTER TABLE public.users ADD CONSTRAINT users_platform_role_check
  CHECK (platform_role IN ('platform_admin', 'member', 'client_editor', 'client_viewer', 'guest'));

-- ── Migración automática: crear orgs desde valores únicos de processes.client ──
-- Crea una organización por cada client name único y no vacío
-- ON CONFLICT DO NOTHING: si ya existe una org con ese nombre, no duplicar
INSERT INTO public.organizations (name, is_provider)
SELECT DISTINCT trim(client), false
FROM public.processes
WHERE client IS NOT NULL AND trim(client) != ''
ON CONFLICT (name) DO NOTHING;

-- Vincular procesos a sus orgs recién creadas
UPDATE public.processes p
SET org_id = o.id
FROM public.organizations o
WHERE trim(o.name) = trim(p.client)
  AND o.is_provider = false
  AND p.org_id IS NULL;

-- Asignar org_id de InQuality a usuarios InQuality existentes
-- (los que tienen email @inquality.com.py o platform_role = 'platform_admin'/'member')
UPDATE public.users u
SET org_id = (SELECT id FROM public.organizations WHERE is_provider = true LIMIT 1)
WHERE u.org_id IS NULL
  AND u.platform_role IN ('platform_admin', 'member');

Nota sobre el ON CONFLICT en organizations: la tabla no tiene un UNIQUE constraint en name aún. Agregar uno:

-- Agregar constraint UNIQUE en name para que ON CONFLICT funcione
ALTER TABLE public.organizations ADD CONSTRAINT organizations_name_unique UNIQUE (name);

Este constraint va ANTES del INSERT de InQuality y de la migración automática.


Archivo 2: supabase/migrations/015_client_roles_and_rls.sql

-- ── SECURITY DEFINER helpers ──────────────────────────────────────────────────

-- Retorna el org_id del usuario actual (null para usuarios sin org asignada)
CREATE OR REPLACE FUNCTION public.current_org()
RETURNS uuid LANGUAGE sql STABLE SECURITY DEFINER AS $$
  SELECT org_id FROM public.users WHERE id = auth.uid()
$$;

-- Retorna true si el usuario pertenece a la organización proveedora (InQuality)
CREATE OR REPLACE FUNCTION public.is_inquality()
RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER AS $$
  SELECT EXISTS (
    SELECT 1 FROM public.users u
    JOIN public.organizations o ON o.id = u.org_id
    WHERE u.id = auth.uid() AND o.is_provider = true
  )
$$;

-- Retorna el platform_role del usuario actual
CREATE OR REPLACE FUNCTION public.current_platform_role()
RETURNS text LANGUAGE sql STABLE SECURITY DEFINER AS $$
  SELECT platform_role FROM public.users WHERE id = auth.uid()
$$;

-- ── Trigger handle_new_user ───────────────────────────────────────────────────
-- Auto-crea fila en public.users para usuarios @inquality.com.py que se loguean
-- con Google OAuth. Los usuarios clientes se crean via Edge Function (invite),
-- no por este trigger.
-- ON CONFLICT (id) DO NOTHING: no sobrescribir si la Edge Function ya creó la fila.

CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER AS $$
DECLARE
  provider_org_id uuid;
BEGIN
  IF new.email LIKE '%@inquality.com.py' THEN
    SELECT id INTO provider_org_id
    FROM public.organizations
    WHERE is_provider = true
    LIMIT 1;

    INSERT INTO public.users (id, name, avatar_url, platform_role, org_id)
    VALUES (
      new.id,
      COALESCE(new.raw_user_meta_data->>'full_name', new.email),
      new.raw_user_meta_data->>'avatar_url',
      'member',
      provider_org_id
    )
    ON CONFLICT (id) DO NOTHING;
  END IF;
  RETURN new;
END;
$$;

-- Crear trigger (DROP IF EXISTS para idempotencia)
DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users;
CREATE TRIGGER on_auth_user_created
  AFTER INSERT ON auth.users
  FOR EACH ROW EXECUTE PROCEDURE public.handle_new_user();

-- ── RLS: organizations ────────────────────────────────────────────────────────

CREATE POLICY "orgs_select" ON public.organizations
  FOR SELECT USING (
    public.is_inquality()
    OR id = public.current_org()
  );

CREATE POLICY "orgs_insert" ON public.organizations
  FOR INSERT WITH CHECK (public.is_platform_admin());

CREATE POLICY "orgs_update" ON public.organizations
  FOR UPDATE USING (public.is_platform_admin());

CREATE POLICY "orgs_delete" ON public.organizations
  FOR DELETE USING (public.is_platform_admin());

-- ── RLS: users ───────────────────────────────────────────────────────────────
-- Reemplazar la política permisiva existente

DROP POLICY IF EXISTS "read_users" ON public.users;

CREATE POLICY "users_select_own" ON public.users
  FOR SELECT USING (id = auth.uid());

CREATE POLICY "users_select_admin" ON public.users
  FOR SELECT USING (public.is_platform_admin());

-- ── RLS: processes ────────────────────────────────────────────────────────────
-- Reemplazar select y update para incluir acceso por org de cliente

DROP POLICY IF EXISTS "select_processes" ON public.processes;

CREATE POLICY "select_processes" ON public.processes
  FOR SELECT USING (
    public.is_platform_admin()
    OR owner_id = auth.uid()
    OR (
      public.current_org() IS NOT NULL
      AND org_id = public.current_org()
    )
    OR EXISTS (
      SELECT 1 FROM public.process_access
      WHERE process_id = processes.id
        AND (
          (grantee_type = 'user'  AND grantee_id = auth.uid())
          OR (grantee_type = 'group' AND grantee_id IN (
            SELECT group_id FROM public.group_memberships WHERE user_id = auth.uid()
          ))
        )
    )
  );

DROP POLICY IF EXISTS "insert_processes" ON public.processes;

CREATE POLICY "insert_processes" ON public.processes
  FOR INSERT WITH CHECK (
    public.is_platform_admin()
    OR public.current_platform_role() = 'member'
    -- client_editor y client_viewer NO pueden crear procesos nuevos
  );

DROP POLICY IF EXISTS "update_processes" ON public.processes;

CREATE POLICY "update_processes" ON public.processes
  FOR UPDATE USING (
    public.is_platform_admin()
    OR owner_id = auth.uid()
    OR (
      public.current_platform_role() = 'client_editor'
      AND org_id = public.current_org()
    )
    OR EXISTS (
      SELECT 1 FROM public.process_access
      WHERE process_id = processes.id AND permission = 'edit'
        AND (
          (grantee_type = 'user'  AND grantee_id = auth.uid())
          OR (grantee_type = 'group' AND grantee_id IN (
            SELECT group_id FROM public.group_memberships WHERE user_id = auth.uid()
          ))
        )
    )
  );

-- ── RLS: activities ──────────────────────────────────────────────────────────
-- Reemplazar política permisiva "all_activities" por filtro via proceso padre

DROP POLICY IF EXISTS "all_activities" ON public.activities;

-- SELECT: si el usuario puede ver el proceso padre, puede ver sus actividades
CREATE POLICY "activities_select" ON public.activities
  FOR SELECT USING (
    EXISTS (SELECT 1 FROM public.processes WHERE id = activities.process_id)
  );

-- INSERT / UPDATE: InQuality + client_editor (no client_viewer)
CREATE POLICY "activities_insert" ON public.activities
  FOR INSERT WITH CHECK (
    EXISTS (SELECT 1 FROM public.processes WHERE id = activities.process_id)
    AND public.current_platform_role() NOT IN ('client_viewer', 'guest')
  );

CREATE POLICY "activities_update" ON public.activities
  FOR UPDATE USING (
    EXISTS (SELECT 1 FROM public.processes WHERE id = activities.process_id)
    AND public.current_platform_role() NOT IN ('client_viewer', 'guest')
  );

-- DELETE: solo InQuality
CREATE POLICY "activities_delete" ON public.activities
  FOR DELETE USING (
    EXISTS (SELECT 1 FROM public.processes WHERE id = activities.process_id)
    AND public.current_platform_role() IN ('platform_admin', 'member')
  );

-- ── RLS: resources ────────────────────────────────────────────────────────────

DROP POLICY IF EXISTS "all_resources" ON public.resources;

CREATE POLICY "resources_select" ON public.resources
  FOR SELECT USING (
    public.is_inquality()
    OR (
      process_id IS NULL  -- recursos del catálogo global: visibles a todos autenticados
      AND auth.uid() IS NOT NULL
    )
    OR EXISTS (SELECT 1 FROM public.processes WHERE id = resources.process_id)
  );

CREATE POLICY "resources_insert" ON public.resources
  FOR INSERT WITH CHECK (
    auth.uid() IS NOT NULL
    AND public.current_platform_role() NOT IN ('client_viewer', 'guest')
  );

CREATE POLICY "resources_update" ON public.resources
  FOR UPDATE USING (
    auth.uid() IS NOT NULL
    AND public.current_platform_role() NOT IN ('client_viewer', 'guest')
  );

CREATE POLICY "resources_delete" ON public.resources
  FOR DELETE USING (
    public.current_platform_role() IN ('platform_admin', 'member')
  );

-- ── RLS: simulations ─────────────────────────────────────────────────────────

DROP POLICY IF EXISTS "all_simulations" ON public.simulations;

CREATE POLICY "simulations_select" ON public.simulations
  FOR SELECT USING (
    EXISTS (SELECT 1 FROM public.processes WHERE id = simulations.process_id)
  );

CREATE POLICY "simulations_insert" ON public.simulations
  FOR INSERT WITH CHECK (
    EXISTS (SELECT 1 FROM public.processes WHERE id = simulations.process_id)
    AND public.current_platform_role() NOT IN ('client_viewer', 'guest')
  );

CREATE POLICY "simulations_delete" ON public.simulations
  FOR DELETE USING (
    EXISTS (SELECT 1 FROM public.processes WHERE id = simulations.process_id)
    AND public.current_platform_role() IN ('platform_admin', 'member')
  );

-- ── RLS: activity_resource_assignments ───────────────────────────────────────
-- Heredan acceso del proceso via actividad

DROP POLICY IF EXISTS "all_activity_resource_assignments" ON public.activity_resource_assignments;

CREATE POLICY "ara_select" ON public.activity_resource_assignments
  FOR SELECT USING (
    EXISTS (
      SELECT 1 FROM public.activities a
      JOIN public.processes p ON p.id = a.process_id
      WHERE a.id = activity_resource_assignments.activity_id
    )
  );

CREATE POLICY "ara_write" ON public.activity_resource_assignments
  FOR INSERT WITH CHECK (
    EXISTS (
      SELECT 1 FROM public.activities a
      JOIN public.processes p ON p.id = a.process_id
      WHERE a.id = activity_resource_assignments.activity_id
    )
    AND public.current_platform_role() NOT IN ('client_viewer', 'guest')
  );

CREATE POLICY "ara_update" ON public.activity_resource_assignments
  FOR UPDATE USING (
    EXISTS (
      SELECT 1 FROM public.activities a
      JOIN public.processes p ON p.id = a.process_id
      WHERE a.id = activity_resource_assignments.activity_id
    )
    AND public.current_platform_role() NOT IN ('client_viewer', 'guest')
  );

CREATE POLICY "ara_delete" ON public.activity_resource_assignments
  FOR DELETE USING (
    EXISTS (
      SELECT 1 FROM public.activities a
      JOIN public.processes p ON p.id = a.process_id
      WHERE a.id = activity_resource_assignments.activity_id
    )
    AND public.current_platform_role() NOT IN ('client_viewer', 'guest')
  );

-- ── RLS: gateways ────────────────────────────────────────────────────────────

DROP POLICY IF EXISTS "all_gateways" ON public.gateways;

CREATE POLICY "gateways_select" ON public.gateways
  FOR SELECT USING (
    EXISTS (SELECT 1 FROM public.processes WHERE id = gateways.process_id)
  );

CREATE POLICY "gateways_write" ON public.gateways
  FOR INSERT WITH CHECK (
    EXISTS (SELECT 1 FROM public.processes WHERE id = gateways.process_id)
    AND public.current_platform_role() NOT IN ('client_viewer', 'guest')
  );

CREATE POLICY "gateways_update" ON public.gateways
  FOR UPDATE USING (
    EXISTS (SELECT 1 FROM public.processes WHERE id = gateways.process_id)
    AND public.current_platform_role() NOT IN ('client_viewer', 'guest')
  );

Qué verificar antes de aplicar en producción

  1. En Supabase Studio (SQL Editor): ejecutar primero en una rama o en el proyecto de staging si existe. Las migraciones son destructivas en las policies RLS existentes (DROP POLICY).

  2. Verificar nombres exactos de las policies a eliminar: los DROP POLICY IF EXISTS son seguros (no fallan si no existen), pero verificar que los nombres coincidan con los de las migraciones anteriores.

  3. Verificar tabla activity_resource_assignments: confirmar que existe con ese nombre exacto (puede ser activity_resource_assignments o similar según migration 007).

  4. Verificar tabla gateways: confirmar que existe y tiene política existente que eliminar.


Criterios de validación

  • Migration 014 aplica sin error en Supabase Studio
  • Migration 015 aplica sin error
  • public.organizations existe con fila InQuality (is_provider = true)
  • Procesos con client no vacío tienen org_id asignado (verificar en tabla)
  • Usuarios existentes de InQuality tienen org_id apuntando a la org de InQuality
  • Check constraint acepta 'client_editor' y 'client_viewer' (test: UPDATE manual de un usuario)
  • SELECT public.current_org() retorna el org_id del usuario autenticado
  • SELECT public.is_inquality() retorna true para usuario InQuality, false para usuario sin org
  • SELECT public.current_platform_role() retorna el rol correcto
  • Trigger on_auth_user_created existe en auth.users
  • Un usuario con platform_role = 'client_viewer' NO puede INSERT en activities (verificar con RLS test en Studio)
  • npm run test → 552+ tests verdes (los tests de dominio/UI no tocan DB directamente)
  • npm run build limpio (ningún cambio de TypeScript en esta etapa)

Archivos a crear

  • supabase/migrations/014_create_organizations.sql
  • supabase/migrations/015_client_roles_and_rls.sql

NO modificar

  • Ningún archivo TypeScript / React
  • Ningún archivo de tests existente
  • supabase/migrations/001 a 013 — son historia inmutable
  • public.user_groups, public.group_memberships, public.process_access — sistema interno de InQuality, no tocar sus políticas