# 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` ```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: ```sql -- 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` ```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