docs(sprint-6): cierre formal — observaciones Etapas 1-3 + reflexiones estratégicas
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled

This commit is contained in:
2026-07-06 18:52:24 -03:00
parent b117ad258d
commit 2922d4c2f3
5 changed files with 1186 additions and 2 deletions

555
sprints/sprint-7/BRIEF.md Normal file
View File

@@ -0,0 +1,555 @@
# Sprint 7 — BRIEF
> InQ ROI — Multi-usuario y acceso de clientes
> Inicio: post-cierre Sprint 6
> Foco: primer acceso de equipo cliente con roles diferenciados e InQuality como administrador de plataforma
---
## Contexto
Sprint 6 cerró el producto listo para uso interno y para primera presentación a clientes con datos reales. Sprint 7 abre el producto a usuarios del lado del cliente: pueden editar parámetros de sus procesos, simular, ver reportes y exportar PDFs — con visibilidad estrictamente acotada a su organización. InQuality mantiene el control total de invitaciones, roles y gestión de usuarios.
El esquema de autenticación replica el patrón de **InQ Sizing** (documentado en `cowork/InQ ROI - Simulador de procesos/autenticacion-y-usuarios.md`) que ya resolvió estos problemas. Este sprint adapta ese esquema al stack de InQ ROI (TanStack Router en lugar de React Router, `public.users` en lugar de `profiles`, etc.).
---
## Modelo de roles (definición final)
| Rol | Quién | Acceso |
|---|---|---|
| `platform_admin` | InQuality | Todo: crear procesos, gestionar orgs, invitar/suspender/eliminar usuarios |
| `member` | InQuality | Crear y gestionar procesos, asignar a orgs — sin gestión de usuarios |
| `client_editor` | Cliente, operativo | Workspace completo de los procesos de su org (editar costos/tiempos/recursos, simular, reporte, PDF) — sin importar BPMNs nuevos |
| `client_viewer` | Cliente, decisor | Solo reporte + sensibilidad + PDF de los procesos de su org — sin edición |
> **Nota sobre roles existentes:** `platform_role = 'guest'` existe en la DB pero queda deprecado. Los usuarios `@inquality.com.py` activos son `platform_admin` o `member`. El rol `guest` solo se mantiene en el check constraint por compatibilidad; ningún usuario nuevo recibirá ese rol.
---
## Estado actual del schema relevante
Migraciones ya aplicadas (001013):
- `public.users`: `id`, `name`, `avatar_url`, `platform_role` ('platform_admin' | 'member' | 'guest'), `company`, `created_at`
- `public.processes`: tiene campo `client` (text) para nombre del cliente — se mantiene para display (PDF, header); se agrega `org_id` FK para control de acceso
- `public.user_groups` + `group_memberships`: agrupaciones internas de InQuality — se conservan sin cambios
- `public.process_access`: access grants por usuario o grupo — se conserva sin cambios
- `public.is_platform_admin()`: función SECURITY DEFINER existente — se mantiene
Problema crítico identificado en auditoría (Sprint 6 Etapa 3): `AuthContext.getUserFromStorage()` y `SupabaseAuthService.buildAuthUser()` derivan `platformRole` del dominio del email en tiempo de ejecución (`@inquality.com.py``platform_admin`, else → `guest`). Para que los roles de cliente funcionen, `syncProfileFromDB` debe leer también `platform_role` desde la DB. Los nuevos roles (`client_editor`, `client_viewer`) con emails no-InQuality quedarían atrapados en `guest` sin este fix.
---
## Decisión de migración: `client` text → `org_id` FK
Estrategia **Opción A** (decidida por el director):
- Script de migración crea una organización por cada valor único de `processes.client`
- Vincula los procesos a esa org via `org_id`
- El campo `client` (text) **se mantiene** para display (PDF, header del proceso) — los dos campos coexisten
- `org_id = null` significa "sin organización asignada" — los procesos existentes sin `client` quedan sin org y solo InQuality los ve
---
## Etapas
### Etapa 1 — Migraciones de base de datos + helpers
**Archivos a crear:** `supabase/migrations/014_create_organizations.sql`, `supabase/migrations/015_client_roles_and_rls.sql`
**014 — Organizations + org_id:**
```sql
-- Tabla organizations
CREATE TABLE 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;
-- InQuality como org proveedora
INSERT INTO public.organizations (name, is_provider)
VALUES ('InQuality', true);
-- org_id en users (nullable — usuarios InQuality no necesitan org de cliente)
ALTER TABLE public.users ADD COLUMN IF NOT EXISTS org_id uuid
REFERENCES public.organizations(id) ON DELETE SET NULL;
-- org_id en processes (nullable — procesos sin cliente asignado)
ALTER TABLE public.processes ADD COLUMN IF NOT EXISTS org_id uuid
REFERENCES public.organizations(id) ON DELETE SET NULL;
-- Migración automática: crear orgs desde valores únicos de processes.client
INSERT INTO public.organizations (name, is_provider)
SELECT DISTINCT client, false
FROM public.processes
WHERE client IS NOT NULL AND trim(client) != ''
ON CONFLICT DO NOTHING;
-- Vincular procesos a sus orgs
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;
-- Ampliar check constraint de platform_role para incluir roles de cliente
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'));
```
**015 — SECURITY DEFINER helpers + RLS actualizada:**
```sql
-- Helper: org del usuario actual
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()
$$;
-- Helper: ¿es usuario InQuality? (tiene org con is_provider=true)
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
)
$$;
-- Helper: rol 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-crear fila en public.users para Google OAuth @inquality.com.py
-- Usuarios clientes se crean via Edge Function (inviteUserByEmail), no por trigger.
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', -- rol conservador; platform_admin se asigna manualmente
provider_org_id
)
ON CONFLICT (id) DO NOTHING; -- no sobrescribir si Edge Function ya creó la fila
END IF;
RETURN new;
END;
$$;
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_visible" 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());
-- RLS: users — actualizar para que platform_admin vea todos
DROP POLICY IF EXISTS "read_users" ON public.users;
CREATE POLICY "read_own_user" ON public.users
FOR SELECT USING (id = auth.uid());
CREATE POLICY "admin_read_all_users" ON public.users
FOR SELECT USING (public.is_platform_admin());
-- RLS: processes — reemplazar select y update para incluir acceso por org
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'
);
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, resources, simulations, activity_resource_assignments
-- Reemplazar política amplia por filtro via proceso padre
-- (si el usuario no puede SELECT el proceso, tampoco accede a sus hijos)
DROP POLICY IF EXISTS "all_activities" ON public.activities;
CREATE POLICY "activities_via_process" ON public.activities
FOR SELECT USING (
EXISTS (SELECT 1 FROM public.processes WHERE id = activities.process_id)
);
CREATE POLICY "activities_write" ON public.activities
FOR INSERT WITH CHECK (
EXISTS (SELECT 1 FROM public.processes WHERE id = activities.process_id)
AND public.current_platform_role() != 'client_viewer'
);
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() != 'client_viewer'
);
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')
);
-- Mismo patrón para resources, simulations, activity_resource_assignments, gateways
-- (ver spec completo en el prompt de Etapa 1)
```
**Criterios Etapa 1:**
- [ ] Migraciones aplican sin error en Supabase Studio
- [ ] Tabla `organizations` existe con la fila de InQuality (`is_provider = true`)
- [ ] Procesos con `client` no vacío tienen `org_id` asignado
- [ ] Check constraint acepta los 5 roles (incluidos los nuevos)
- [ ] Un usuario `client_editor` con `org_id = X` puede SELECT procesos donde `org_id = X` y no otros
- [ ] Un usuario `client_viewer` no puede UPDATE activities
- [ ] `npm run test` → 552+ tests verdes (los tests de dominio no tocan DB)
---
### Etapa 2 — Edge Functions (invitar, suspender, eliminar usuarios)
Edge Functions corren en Deno (Supabase). La `service_role` key **nunca va al browser**. Crear la estructura:
```
supabase/functions/
├── invite-user/
│ └── index.ts
├── delete-user/
│ └── index.ts
└── suspend-user/
└── index.ts
```
**invite-user** (patrón exacto del doc de InQ Sizing, adaptado):
```typescript
// Validaciones en orden:
// 1. Verificar que el caller es platform_admin (leer profiles en DB, no confiar en JWT)
// 2. Verificar que la org destino existe y no es is_provider (evitar auto-elevación)
// 3. Invitar con inviteUserByEmail (manda email automático con magic link)
// 4. UPSERT del registro en public.users con onConflict: "id"
// (UPSERT, no INSERT — race condition con trigger handle_new_user si el email es @inquality.com.py)
await adminClient.auth.admin.inviteUserByEmail(body.email, {
data: { role: body.role, org_id: body.org_id }
})
await adminClient.from('users').upsert({
id: invited.user.id,
name: body.full_name,
platform_role: body.role, // 'client_editor' | 'client_viewer' | 'member'
org_id: body.org_id,
}, { onConflict: 'id' })
```
**delete-user:**
```typescript
// Validar caller = platform_admin, no puede eliminarse a sí mismo
await adminClient.auth.admin.deleteUser(body.user_id)
// ON DELETE CASCADE en public.users elimina la fila automáticamente
```
**suspend-user / unsuspend-user:**
```typescript
// Supabase ban: 876000h ≈ 100 años (equivalente a suspensión indefinida)
await adminClient.auth.admin.updateUserById(body.user_id, {
ban_duration: body.suspend ? '876000h' : 'none'
})
```
**Criterios Etapa 2:**
- [ ] `POST /functions/v1/invite-user` con payload válido crea usuario en Supabase Auth y fila en `public.users`
- [ ] `POST /functions/v1/delete-user` elimina el usuario y su fila (cascade)
- [ ] `POST /functions/v1/suspend-user` bloquea el login del usuario; `{ suspend: false }` lo restaura
- [ ] Un caller no-platform_admin recibe 403 en todas las funciones
- [ ] Invitar a una org con `is_provider = true` devuelve 400
---
### Etapa 3 — Capa de auth: email/password + fix de role desde DB
**3a — Fix crítico: leer `platform_role` desde DB (no inferir del email)**
Problema: `AuthContext.getUserFromStorage()` y `SupabaseAuthService.buildAuthUser()` asignan `platform_admin` si el email es `@inquality.com.py`, `guest` en cualquier otro caso. Un `client_editor` con email `@banco-solar.com` siempre sería `guest`.
Fix: extender `syncProfileFromDB` para leer también `platform_role`:
```typescript
// En SupabaseAuthService.syncProfileFromDB:
const { data } = await supabase
.from('users')
.select('name, company, platform_role, org_id') // agregar platform_role y org_id
.eq('id', userId)
.maybeSingle()
// En AuthContext, actualizar el estado con el rol real:
setUser(prev => prev?.id === userId
? { ...prev, company: data.company, name: data.name || prev.name,
platformRole: data.platform_role, orgId: data.org_id }
: prev
)
```
Actualizar `AuthUser` en `types.ts`:
```typescript
export interface AuthUser {
id: string
email: string
name: string
avatarUrl?: string
platformRole: 'platform_admin' | 'member' | 'client_editor' | 'client_viewer' | 'guest'
company?: string
orgId?: string // para routing y RLS del frontend
}
```
El `getUserFromStorage()` puede seguir usando la heurística del email como valor inicial optimista (evita el flash de login). El `syncProfileFromDB` lo corrige después del primer render.
**3b — Agregar `signInWithEmailPassword` a la capa de auth**
```typescript
// AuthService interface:
signInWithEmailPassword(email: string, password: string): Promise<{ error: string | null }>
// SupabaseAuthService implementation:
async signInWithEmailPassword(email: string, password: string) {
const { error } = await supabase.auth.signInWithPassword({ email, password })
return { error: error?.message ?? null }
}
```
**3c — Agregar guard TOKEN_REFRESHED en onAuthStateChange** (lección de InQ Sizing doc):
```typescript
// Problema: TOKEN_REFRESHED dispara cuando el usuario vuelve a la tab → loading=true brevemente → UI flashea
// Fix: solo setLoading en SIGNED_IN e INITIAL_SESSION
supabase.auth.onAuthStateChange(async (event, session) => {
if (event === 'SIGNED_IN' || event === 'INITIAL_SESSION') {
// ... handle login
} else if (event === 'SIGNED_OUT') {
setUser(null)
setLoading(false)
}
// TOKEN_REFRESHED, USER_UPDATED → ignorar (la sesión ya está activa)
})
```
**3d — LoginPage: agregar form email/password**
La LoginPage actual solo tiene "Continuar con Google". Agregar:
- Form email/password debajo del botón Google, separado por "o"
- Validación Zod: email válido, contraseña no vacía
- Mensaje de error inline si `signInWithEmailPassword` retorna error
- Sin registro público — los clientes solo acceden por invitación
**3e — Página `/reset-password`**
Cuando el usuario acepta el magic link del invite, Supabase redirige a `/reset-password?access_token=...`. Esta página:
- Lee el token del hash/query
- Muestra form de nueva contraseña (mínimo 8 caracteres)
- Llama `supabase.auth.updateUser({ password: newPassword })`
- Redirige al home después del éxito
**Criterios Etapa 3:**
- [ ] Un `client_editor` con email no-InQuality puede iniciar sesión con email/password
- [ ] Después del `syncProfileFromDB`, `user.platformRole` es `client_editor` (no `guest`)
- [ ] `/reset-password` funciona con el magic link del invite
- [ ] Volver a una tab inactiva no causa flash de loading (TOKEN_REFRESHED ignorado)
- [ ] `npm run test` → mantener 552+ tests verdes
---
### Etapa 4 — Panel de administración de InQuality
Ruta: `/admin` — solo accesible a `platform_admin`.
**4a — Organizations page (`/admin/organizations`)**
Lista de organizaciones de clientes (todas las que no son InQuality):
- Nombre, fecha de creación, cantidad de procesos asignados, cantidad de usuarios
- Botón "Nueva organización" → modal/sheet con campo nombre
- Click en org → detalle: procesos asignados + usuarios de esa org
- Desde el detalle: asignar un proceso existente a la org (dropdown de procesos sin org asignada o reasignar)
**4b — Users page (`/admin/users`)**
Lista de todos los usuarios, filtrables por org:
- Nombre, email, rol, org, estado (activo/suspendido)
- Botón "Invitar usuario" → sheet con campos: email, nombre, rol (client_editor | client_viewer | member), org (obligatorio para roles de cliente)
- Al invitar: llama Edge Function `invite-user` → si OK, muestra confirmación "Email de invitación enviado"
- Menú de acciones por usuario: Cambiar rol, Suspender/Reactivar, Eliminar
- Suspender llama Edge Function `suspend-user`; Eliminar llama `delete-user` con confirmación
**4c — Entrada en AppHeader para admins**
Un ícono de escudo o ajuste en el AppHeader visible solo para `platform_admin`, que navega a `/admin`.
**Criterios Etapa 4:**
- [ ] `platform_admin` puede crear una organización y ver sus procesos/usuarios
- [ ] `platform_admin` puede invitar un usuario con rol `client_editor` a una org
- [ ] El usuario invitado recibe email con magic link
- [ ] `platform_admin` puede suspender y reactivar un usuario
- [ ] `platform_admin` puede eliminar un usuario (con confirmación)
- [ ] Un `member` o `client_editor` que navega a `/admin` es redirigido
- [ ] `npm run build` limpio
---
### Etapa 5 — UX de roles de cliente y routing
**5a — Routing con roles (TanStack Router)**
Extender el guard de `beforeLoad` del router para verificar el rol además de la sesión:
```typescript
// Rutas de workspace y biblioteca: platform_admin | member | client_editor
// Ruta de reporte: todos los roles autenticados (RLS filtra visibilidad)
// Rutas de admin: platform_admin únicamente
// Import BPMN: platform_admin | member únicamente
// En el beforeLoad de workspace/:processId:
if (user.platformRole === 'client_viewer') {
throw redirect({ to: '/report/$processId', params: { processId } })
}
```
**5b — WorkspacePage con `client_editor`**
`client_editor` accede al workspace completo con estas restricciones de UI:
- Ocultar el botón "Importar BPMN" (no puede crear procesos nuevos)
- El resto del workspace (ActivityPanel, recursos, simulación) funciona igual
`client_viewer` en workspace → redirigido automáticamente al reporte.
**5c — Library page con roles de cliente**
La biblioteca ya filtra por RLS — los `client_editor` y `client_viewer` ven solo sus procesos. Ajustar la UI:
- Ocultar el botón "Nuevo proceso" para roles de cliente
- Ocultar grupos/tags de organización interna (process_groups) si no son relevantes para el cliente
- El "grupo" de bienvenida puede mostrar el nombre de su organización
**5d — Proceso visible para `client_viewer` en reporte**
En `ReportPage`, cuando `user.platformRole === 'client_viewer'`:
- Tab activa por defecto: `roi` (no `actual`)
- Los demás tabs (Actual, Automatizado) son visibles pero las actividades están en modo solo lectura
- El ActivityPanel no se muestra (es del workspace, no del reporte)
**Criterios Etapa 5:**
- [ ] `client_editor` de Banco Solar ve solo los procesos de Banco Solar en la biblioteca
- [ ] `client_editor` puede editar parámetros de actividades, simular y exportar PDF
- [ ] `client_editor` no ve el botón "Importar BPMN"
- [ ] `client_viewer` que navega a `/workspace/:id` es redirigido a `/report/:id`
- [ ] `client_viewer` ve el reporte con tab ROI activo por defecto
- [ ] Un usuario de Banco Solar no puede ver procesos de Aseguradora Yacyretá (verificar con dos sesiones)
- [ ] Validación visual del director antes del OK formal
---
## Distribución del sprint
| Área | Etapas | % estimado |
|---|---|---|
| DB / infraestructura | 1, 2 | ~30% |
| Auth layer | 3 | ~20% |
| Admin UI | 4 | ~30% |
| UX cliente / routing | 5 | ~20% |
Sprint más grande hasta la fecha — comparable a Sprint 4 (refactorización arquitectural). Planear 6-8 sesiones de trabajo.
---
## Referencia de patrones de InQ Sizing aplicados
| Patrón | Dónde en InQ Sizing | Adaptación en InQ ROI |
|---|---|---|
| SECURITY DEFINER helpers | `0001_init.sql` | Migraciones 014-015, misma lógica |
| Trigger handle_new_user + ON CONFLICT DO NOTHING | `0003_google_oauth_trigger.sql` | Migración 015, mismo patrón |
| UPSERT en Edge Function (race condition fix) | `invite-user/index.ts` | Edge Function invite-user |
| Caller validado en DB, no en JWT | `invite-user/index.ts` | Todas las Edge Functions |
| guard TOKEN_REFRESHED en onAuthStateChange | `AuthContext.tsx` | Etapa 3c |
| hasOAuthCallback() en estado inicial | `AuthContext.tsx` | Ya implementado en InQ ROI |
| ProtectedRoute devuelve null mientras loading | `ProtectedRoute.tsx` | Adaptar a TanStack Router beforeLoad |
| GerenteGuard: redirigir antes de montar | `App.tsx` | beforeLoad de workspace route para client_viewer |
---
## Qué NO entra en Sprint 7
- Compartir procesos individuales con usuarios específicos (ya existe via `process_access` — no ampliar en este sprint)
- Autenticación centralizada entre InQ Sizing e InQ ROI (decisión Sprint 8+)
- Gestión de `process_groups` por clientes (solo InQuality usa grupos)
- i18n, modo offline, PWA
- Export Excel
- Historial de simulaciones
---
## Definition of Done del sprint
- [ ] Un usuario cliente puede aceptar el invite, setear contraseña y acceder al workspace de su org
- [ ] RLS verificada con dos sesiones simultáneas de orgs distintas — ningún dato cross-org visible
- [ ] `platform_admin` puede invitar, suspender y eliminar usuarios desde el panel admin
- [ ] `npm run build` limpio
- [ ] `npm run lint` sin warnings nuevos
- [ ] Tests Vitest: mantener o superar 552
- [ ] OBSERVATIONS.md con hallazgos del sprint
- [ ] STRATEGIC_DIRECTION.md actualizado al cerrar el sprint