# 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 (001–013):
- `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
