23 KiB
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.pyactivos sonplatform_adminomember. El rolguestsolo 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_atpublic.processes: tiene campoclient(text) para nombre del cliente — se mantiene para display (PDF, header); se agregaorg_idFK para control de accesopublic.user_groups+group_memberships: agrupaciones internas de InQuality — se conservan sin cambiospublic.process_access: access grants por usuario o grupo — se conserva sin cambiospublic.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 = nullsignifica "sin organización asignada" — los procesos existentes sinclientquedan 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:
-- 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:
-- 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
organizationsexiste con la fila de InQuality (is_provider = true) - Procesos con
clientno vacío tienenorg_idasignado - Check constraint acepta los 5 roles (incluidos los nuevos)
- Un usuario
client_editorconorg_id = Xpuede SELECT procesos dondeorg_id = Xy no otros - Un usuario
client_viewerno 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):
// 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:
// 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:
// 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-usercon payload válido crea usuario en Supabase Auth y fila enpublic.usersPOST /functions/v1/delete-userelimina el usuario y su fila (cascade)POST /functions/v1/suspend-userbloquea 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 = truedevuelve 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:
// 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:
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
// 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):
// 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
signInWithEmailPasswordretorna 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_editorcon email no-InQuality puede iniciar sesión con email/password - Después del
syncProfileFromDB,user.platformRoleesclient_editor(noguest) /reset-passwordfunciona 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 llamadelete-usercon 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_adminpuede crear una organización y ver sus procesos/usuariosplatform_adminpuede invitar un usuario con rolclient_editora una org- El usuario invitado recibe email con magic link
platform_adminpuede suspender y reactivar un usuarioplatform_adminpuede eliminar un usuario (con confirmación)- Un
memberoclient_editorque navega a/admines redirigido npm run buildlimpio
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:
// 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(noactual) - 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_editorde Banco Solar ve solo los procesos de Banco Solar en la bibliotecaclient_editorpuede editar parámetros de actividades, simular y exportar PDFclient_editorno ve el botón "Importar BPMN"client_viewerque navega a/workspace/:ides redirigido a/report/:idclient_viewerve 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_groupspor 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_adminpuede invitar, suspender y eliminar usuarios desde el panel adminnpm run buildlimpionpm run lintsin warnings nuevos- Tests Vitest: mantener o superar 552
- OBSERVATIONS.md con hallazgos del sprint
- STRATEGIC_DIRECTION.md actualizado al cerrar el sprint