Files
inq-roi-simulador-web/sprints/sprint-7/BRIEF.md
2026-07-06 18:52:24 -03:00

556 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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