# 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