feat(admin): filtro por empresa + contadores filtrados
This commit is contained in:
251
sprints/sprint-4/ETAPA_5_PROMPT.md
Normal file
251
sprints/sprint-4/ETAPA_5_PROMPT.md
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
# Sprint 4 — Etapa 5: Modelo de privacidad — RLS granular + UI de visibilidad
|
||||||
|
|
||||||
|
## Contexto
|
||||||
|
|
||||||
|
InQ ROI — React 18 + Vite + TypeScript + TanStack Router + Zustand + shadcn/ui + Tailwind.
|
||||||
|
Etapas 1–4 completadas: auth Google, procesos/grupos/settings/actividades/gateways/recursos/simulaciones en Supabase, Dexie eliminado.
|
||||||
|
|
||||||
|
**Antes de arrancar esta etapa:** commitear Etapas 3 y 4 juntas en un único commit con mensaje `feat(sprint-4/etapa-3-4): migración actividades/gateways/recursos/simulaciones a Supabase + limpieza Dexie`.
|
||||||
|
|
||||||
|
Esta etapa reemplaza la política RLS amplia `authenticated_all_processes` por un modelo granular basado en ownership y acceso explícito, e incorpora los elementos de UI correspondientes en la biblioteca.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisitos — verificar antes de empezar
|
||||||
|
|
||||||
|
- Scripts 001–009 ejecutados en Supabase Cloud
|
||||||
|
- `src/auth/AuthContext.tsx` con `useAuth()` devolviendo `{ user }` con al menos `user.id`
|
||||||
|
- `src/persistence/supabase/process-repo.ts` existente
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Alcance estricto — qué entra en esta etapa
|
||||||
|
|
||||||
|
### 1. Nuevos scripts SQL (crear en `supabase/migrations/`)
|
||||||
|
|
||||||
|
**`010_create_user_groups.sql`** — tablas de grupos de usuarios:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS public.user_groups (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name text NOT NULL,
|
||||||
|
created_by uuid REFERENCES public.users(id),
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE public.user_groups ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY "read_groups" ON public.user_groups
|
||||||
|
FOR SELECT USING (auth.uid() IS NOT NULL);
|
||||||
|
|
||||||
|
CREATE POLICY "manage_groups" ON public.user_groups
|
||||||
|
FOR ALL USING (
|
||||||
|
EXISTS (SELECT 1 FROM public.users WHERE id = auth.uid() AND platform_role = 'platform_admin')
|
||||||
|
OR created_by = auth.uid()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.group_memberships (
|
||||||
|
user_id uuid NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
|
||||||
|
group_id uuid NOT NULL REFERENCES public.user_groups(id) ON DELETE CASCADE,
|
||||||
|
role text NOT NULL DEFAULT 'member' CHECK (role IN ('owner','member')),
|
||||||
|
PRIMARY KEY (user_id, group_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE public.group_memberships ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY "read_memberships" ON public.group_memberships
|
||||||
|
FOR SELECT USING (auth.uid() IS NOT NULL);
|
||||||
|
|
||||||
|
CREATE POLICY "manage_memberships" ON public.group_memberships
|
||||||
|
FOR ALL USING (
|
||||||
|
EXISTS (SELECT 1 FROM public.users WHERE id = auth.uid() AND platform_role = 'platform_admin')
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM public.user_groups
|
||||||
|
WHERE id = group_memberships.group_id AND created_by = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**`011_create_process_access.sql`** — tabla de acceso explícito a procesos:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS public.process_access (
|
||||||
|
process_id uuid NOT NULL REFERENCES public.processes(id) ON DELETE CASCADE,
|
||||||
|
grantee_type text NOT NULL CHECK (grantee_type IN ('user','group')),
|
||||||
|
grantee_id uuid NOT NULL,
|
||||||
|
permission text NOT NULL DEFAULT 'view' CHECK (permission IN ('view','edit')),
|
||||||
|
PRIMARY KEY (process_id, grantee_type, grantee_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE public.process_access ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY "read_process_access" ON public.process_access
|
||||||
|
FOR SELECT USING (auth.uid() IS NOT NULL);
|
||||||
|
|
||||||
|
CREATE POLICY "manage_process_access" ON public.process_access
|
||||||
|
FOR ALL USING (
|
||||||
|
EXISTS (SELECT 1 FROM public.users WHERE id = auth.uid() AND platform_role = 'platform_admin')
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM public.processes
|
||||||
|
WHERE id = process_access.process_id AND owner_id = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**`012_update_processes_rls.sql`** — reemplazar política amplia con políticas granulares:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Helper: retorna true si el usuario actual es platform_admin
|
||||||
|
CREATE OR REPLACE FUNCTION public.is_platform_admin()
|
||||||
|
RETURNS boolean
|
||||||
|
LANGUAGE sql SECURITY DEFINER
|
||||||
|
AS $$
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM public.users
|
||||||
|
WHERE id = auth.uid() AND platform_role = 'platform_admin'
|
||||||
|
);
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Eliminar política amplia de Sprint 4 Etapa 2
|
||||||
|
DROP POLICY IF EXISTS "authenticated_all_processes" ON public.processes;
|
||||||
|
|
||||||
|
-- Políticas granulares
|
||||||
|
CREATE POLICY "select_processes" ON public.processes
|
||||||
|
FOR SELECT USING (
|
||||||
|
public.is_platform_admin()
|
||||||
|
OR owner_id = auth.uid()
|
||||||
|
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()
|
||||||
|
))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY "insert_processes" ON public.processes
|
||||||
|
FOR INSERT WITH CHECK (auth.uid() IS NOT NULL);
|
||||||
|
|
||||||
|
CREATE POLICY "update_processes" ON public.processes
|
||||||
|
FOR UPDATE USING (
|
||||||
|
public.is_platform_admin()
|
||||||
|
OR owner_id = auth.uid()
|
||||||
|
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()
|
||||||
|
))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY "delete_processes" ON public.processes
|
||||||
|
FOR DELETE USING (public.is_platform_admin() OR owner_id = auth.uid());
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Tipo `Process` — agregar `ownerId`
|
||||||
|
|
||||||
|
En `src/domain/types.ts`, agregar el campo al final de la interfaz `Process`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface Process {
|
||||||
|
// ... campos existentes sin cambios ...
|
||||||
|
ownerId: string // 🆕 Sprint 4 — owner del proceso (auth.uid() al crear)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Repositorio de procesos — mapear `ownerId`
|
||||||
|
|
||||||
|
En `src/persistence/supabase/process-repo.ts`:
|
||||||
|
|
||||||
|
- `fromRow`: agregar `ownerId: (row.owner_id as string) ?? ''`
|
||||||
|
- `toRow`: el campo `owner_id` ya se escribe correctamente; verificar que no se sobreescriba en updates (en update, `owner_id` no debe cambiar — usar el valor del proceso existente, no `userId` del caller)
|
||||||
|
|
||||||
|
Agregar método para gestionar visibilidad:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async setShared(processId: string, shared: boolean): Promise<void>
|
||||||
|
// si shared=true: no hace nada (la visibilidad para platform_admin ya está por RLS)
|
||||||
|
// si shared=false: no hace nada en Sprint 4 (sin guests aún)
|
||||||
|
// En Sprint 4 este método existe para no romper la UI — implementación no-op con comentario explicativo
|
||||||
|
// La implementación real (insertar/borrar en process_access) llega cuando existan guests
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. LibraryPage — UI de visibilidad
|
||||||
|
|
||||||
|
Modificar `src/features/library/LibraryPage.tsx`:
|
||||||
|
|
||||||
|
**a) Toggle "Todos / Míos"**
|
||||||
|
|
||||||
|
Agregar estado local `filter: 'all' | 'mine'`. Aplicar antes del render:
|
||||||
|
- `'mine'`: mostrar solo procesos donde `process.ownerId === currentUser.id`
|
||||||
|
- `'all'`: sin filtro adicional
|
||||||
|
|
||||||
|
UI: dos botones pill en la barra de herramientas existente, junto al buscador. Usar los colores del sistema (naranja InQ `#F59845` para el activo, borde neutro para el inactivo).
|
||||||
|
|
||||||
|
**b) Badge de visibilidad en tarjetas de proceso**
|
||||||
|
|
||||||
|
En la tarjeta de cada proceso, agregar un badge pequeño:
|
||||||
|
- Proceso propio (`ownerId === currentUser.id`) → mostrar ícono 🔒 con tooltip "Solo vos"
|
||||||
|
- Proceso de otro usuario → mostrar ícono 👤 con tooltip "De otro consultor"
|
||||||
|
|
||||||
|
En Sprint 4 todos son `platform_admin` y ven todo, pero el badge refleja el ownership.
|
||||||
|
|
||||||
|
**c) Versión con wow — menú de contexto: compartir proceso**
|
||||||
|
|
||||||
|
En el menú `MoreVertical` de cada tarjeta propia, agregar opción "Compartir con equipo" (si `ownerId === currentUser.id`). Al hacer click, llama a `setShared(processId, true)` del repo y muestra un toast "Proceso compartido con el equipo". En Sprint 4 es una acción no-op en la DB (el método existe pero no escribe aún), pero la UI y el feedback visual funcionan completos. Agregar ícono `Share2` de Lucide.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Qué NO entra en esta etapa
|
||||||
|
|
||||||
|
- Implementación real de `setShared` con escritura en `process_access` → cuando existan guests
|
||||||
|
- "Actualizado por [nombre]" en tarjetas → Etapa 6
|
||||||
|
- Header con avatar de usuario → Etapa 6
|
||||||
|
- UX de catálogo de recursos → Etapa 7
|
||||||
|
- Tabla `user_groups` en la UI (gestión de grupos) → Sprint futuro
|
||||||
|
- Ningún cambio en PDF, simulación o workspace
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Orden de ejecución
|
||||||
|
|
||||||
|
1. Crear los tres archivos SQL en `supabase/migrations/`
|
||||||
|
2. Modificar `src/domain/types.ts` — agregar `ownerId`
|
||||||
|
3. Modificar `src/persistence/supabase/process-repo.ts` — mapear `ownerId` + agregar `setShared`
|
||||||
|
4. Modificar `src/features/library/LibraryPage.tsx` — toggle + badges + menú compartir
|
||||||
|
5. `npm run build` — debe pasar sin errores TypeScript
|
||||||
|
6. `npm run test` — debe mantener 521+ tests verdes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Criterios de validación
|
||||||
|
|
||||||
|
- [ ] Archivos SQL 010, 011, 012 creados correctamente en `supabase/migrations/`
|
||||||
|
- [ ] `Process.ownerId` existe en `types.ts`
|
||||||
|
- [ ] `fromRow` en process-repo mapea `owner_id` → `ownerId`
|
||||||
|
- [ ] `setShared` existe en process-repo (puede ser no-op con comentario)
|
||||||
|
- [ ] LibraryPage tiene toggle "Todos / Míos" funcional
|
||||||
|
- [ ] LibraryPage muestra badge de ownership en cada tarjeta
|
||||||
|
- [ ] Build limpio sin errores TypeScript
|
||||||
|
- [ ] 521+ tests verdes
|
||||||
|
- [ ] **Validación visual del director** antes del OK formal
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nota sobre los scripts SQL
|
||||||
|
|
||||||
|
Los tres scripts (010, 011, 012) se crean en esta etapa pero **el director los ejecuta manualmente** en Supabase Studio SQL Editor antes del QA visual, en orden numérico. Claude Code no tiene acceso al dashboard de Supabase.
|
||||||
Reference in New Issue
Block a user