This commit is contained in:
430
sprints/sprint-7/ETAPA_1_PROMPT.md
Normal file
430
sprints/sprint-7/ETAPA_1_PROMPT.md
Normal file
@@ -0,0 +1,430 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user