diff --git a/OBSERVATIONS.md b/OBSERVATIONS.md index 4ad274c..34de655 100644 --- a/OBSERVATIONS.md +++ b/OBSERVATIONS.md @@ -24,6 +24,12 @@ ## Hallazgos resueltos durante el sprint +### [RESOLVED] Etapa 3 — Fix localStorage key + setTimeout cleanup + specs E2E de auth +**Bug real confirmado:** `ProfileSheet.getAccessToken()` leía `'supabase.auth.token'` (clave inexistente en supabase-js v2). El guardado de nombre y empresa nunca llegó a Supabase desde que se implementó ProfileSheet en Sprint 4. Fix: calcular `sb-${projectRef}-auth-token` igual que auth-setup.ts. Verificado end-to-end: PATCH 204 + reload confirma persistencia. +**setTimeout(350) eliminado:** fix de sheet.tsx es suficiente por sí solo. +**Specs E2E:** `auth-localStorage-key.spec.ts` (diagnóstico de clave) y `auth-real.spec.ts` (validación de auth real). 552 tests, 11/11 E2E verdes. +**OK formal del director:** 2026-06-24. + ### [RESOLVED] Etapa 1 — Logo InQuality en header de PDFs (págs 2+) **Observación 1:** Claude Code implementó inicialmente el logo con dimensiones hardcodeadas (32×9mm, ratio 3.56:1) cuando el PNG real es 724×474px (ratio 1.53:1) → logo aplastado horizontalmente. Fix: pasar dimensiones reales del bitmap y calcular el alto proporcional en `drawSharedHeader`. **Observación 2:** Claude Code agregó "UN PRODUCTO DE InQ InQuality" y logo en la portada (página 1) sin que estuviera en el scope del prompt — violación de la restricción explícita "No agregar el logo en páginas 1 con su propio diseño de portada." Revertido en fix posterior. @@ -33,9 +39,17 @@ --- +### [RESOLVED] Etapa 2 — Panel de análisis de sensibilidad +**Resultado:** SensitivityPanel con dos sliders nativos (volumen -50%/+100%, inversión -50%/+100%), badge "Análisis activo", botón Restablecer. adjustedRoi via useMemo en ReportPage, sin re-simulación. Versión wow implementada: doble línea en CumulativeSavingsChart (base gris dashed + área sombreada + ajustada naranja). 552 tests (542 + 10 nuevos). +**Fix post-validación:** layout del tab ROI reordenado — CumulativeSavingsChart sube por encima de ComparisonTable para mantener adyacencia con los sliders y la doble línea del gráfico. ComparisonTable queda como detalle granular al final. +**Nota técnica a documentar:** CumulativeSavingsChart deriva la inversión original del base case desde `roiBase.cumulativeSavingsHorizon` algebraicamente en lugar de recibir prop `investmentBase`. Correcto hoy (horizonYears del chart = horizonYears de calculateRoi), frágil si divergen. Registrar en TECH_DEBT.md al cerrar el sprint. +**OK formal del director:** 2026-06-24. + ## Hallazgos diferidos (promocionar al cerrar sprint) -*(vacío)* +### [DEFERRED-TECH-DEBT] Selector frágil en auth-real.spec.ts +`tests/e2e/auth-real.spec.ts` usa `header button[title]` para encontrar el avatar del usuario en AppHeader. Si el atributo `title` cambia, el test rompe sin advertencia. Candidato a agregar `data-testid="app-header-user-btn"` en una etapa futura (mismo patrón que `save-activity-btn`). +**Migrar a:** TECH_DEBT.md al cerrar Sprint 6. --- diff --git a/STRATEGIC_DIRECTION.md b/STRATEGIC_DIRECTION.md index 79f1efa..0067fa9 100644 --- a/STRATEGIC_DIRECTION.md +++ b/STRATEGIC_DIRECTION.md @@ -294,4 +294,32 @@ Fix crítico workspace loading (race condition auth/processId), zoom BPMN (fit-v **Hallazgo operativo del sprint:** el bug de freeze al guardar perfil (Etapa 4) requirió 12 commits y ~10 iteraciones de diagnóstico basado en lectura de código fuente de Supabase/Radix antes de resolverse. El quiebre llegó cuando el director compartió el HTML real de la pantalla congelada en vez de seguir teorizando sobre el comportamiento esperado de las librerías — un dato observable (`document.body.style.pointerEvents = 'none'`) colapsó el espacio de hipótesis de inmediato. Ver `sprints/sprint-5/INFORME_ETAPA_4_FREEZE_PERFIL.md` y memoria de feedback asociada. -**Cerrado:** 2026-06-23 (pendiente validación visual final del director sobre Etapas 5 y 6). \ No newline at end of file +**Cerrado:** 2026-06-23 (pendiente validación visual final del director sobre Etapas 5 y 6). + +--- + +## Reflexiones del cierre de Sprint 6 + +> Agregado el 2026-06-24 al cerrar formalmente el sprint. + +### Decisión estratégica + +Sprint orientado a "client-readiness": cerrar los dos gaps que impedían usar InQ ROI en vivo con un cliente externo — PDF sin marca visual y ROI sin capacidad de análisis what-if. Se abrió también la planificación de Sprint 7 (multi-usuario y acceso de clientes) a partir de una evaluación estratégica de posicionamiento del producto. + +### Resultado + +**Etapa 1:** logo InQuality en header de PDFs (páginas 2+, isotipo 15mm proporcional, fallback a texto si falla el fetch); `whitespace-nowrap` en MetricCell de Eficiencia. Bug detectado en proceso: Claude Code agregó logo en portada sin autorización (fuera del scope explícito del prompt) — revertido. Aprendizaje de método: auditar archivos adyacentes al scope declarado. + +**Etapa 2:** panel de análisis de sensibilidad con dos sliders (volumen −50%/+100%, inversión −50%/+100%), recálculo de KPIs en tiempo real vía `calculateRoi()` sin re-simulación, badge "Análisis activo", versión wow con doble línea en `CumulativeSavingsChart` (base gris dashed + área sombreada + ajustada naranja). Layout del tab ROI reordenado post-validación: `CumulativeSavingsChart` sube por encima de `ComparisonTable` para mantener adyacencia visual con los sliders. + +**Etapa 3:** bug real confirmado y corregido — `ProfileSheet.getAccessToken()` leía `'supabase.auth.token'` (clave inexistente en supabase-js v2); el guardado de perfil nunca llegó a Supabase desde Sprint 4. Fix: calcular `sb-${projectRef}-auth-token`. `setTimeout(350)` eliminado de ProfileSheet; fix de `sheet.tsx` es suficiente. Specs E2E de diagnóstico y validación de auth. 552 tests al cierre. + +### Decisiones estratégicas tomadas durante el sprint + +**Modelo de negocio clarificado:** InQ ROI es herramienta de consultor, no de cliente final. El "trabajo de prep" (modelar BPMN, cargar costos, configurar recursos) es el relevamiento de consultoría facturable. Funnel: relevamiento pago → diagnóstico ROI → venta de automatización. El consultor usa InQ ROI como argumento de precio (de "horas de desarrollo" a "valor percibido por ahorro potencial"). + +**Clientes piloto definidos:** Banco Solar → Banco Itaú → Aseguradora Yacyretá → Negofin (en ese orden). + +**Sprint 7 abierto:** multi-usuario y acceso de clientes. Modelo de 4 roles (`platform_admin`, `member`, `client_editor`, `client_viewer`). Patrón de InQ Sizing (documento de referencia: `cowork/InQ ROI - Simulador de procesos/autenticacion-y-usuarios.md`). InQuality como organización administradora de la plataforma. + +**Cerrado:** 2026-06-24. diff --git a/sprints/sprint-6/ETAPA_3_PROMPT.md b/sprints/sprint-6/ETAPA_3_PROMPT.md new file mode 100644 index 0000000..1f15ed5 --- /dev/null +++ b/sprints/sprint-6/ETAPA_3_PROMPT.md @@ -0,0 +1,242 @@ +# Etapa 3 — Limpieza técnica y diagnóstico de auth + +**Sprint:** 6 +**Fecha:** 2026-06-24 +**Alcance:** fix confirmado de localStorage key en ProfileSheet, eliminación de setTimeout(350), spec E2E de auth, actualización de TECH_DEBT.md. + +--- + +## Contexto + +Esta etapa cierra deuda técnica documentada y resuelve una contradicción activa en el manejo de auth que podría ser un bug real de persistencia de perfil. + +--- + +## Parte A — Bug potencial: localStorage key en ProfileSheet + +### El problema + +`ProfileSheet.getAccessToken()` lee de la clave `'supabase.auth.token'`: + +```ts +// ProfileSheet.tsx línea 14-23 +function getAccessToken(): string | null { + try { + const raw = localStorage.getItem('supabase.auth.token') // ← clave potencialmente incorrecta + ... + } +} +``` + +Pero `auth-setup.ts` documenta y confirma empíricamente que `@supabase/supabase-js v2`'s `createClient()` escribe la sesión bajo `sb-${projectRef}-auth-token`, no bajo `supabase.auth.token`. El `projectRef` se deriva de la URL del proyecto. + +Si en producción la sesión está bajo `sb-${projectRef}-auth-token`, entonces `localStorage.getItem('supabase.auth.token')` retorna `null` → `getAccessToken()` retorna `null` → `if (!token) return` en línea 67 silencia el error → **el persist a Supabase de nombre y empresa nunca se ejecuta**. El usuario ve el cambio en UI (vía `updateUser()` en memoria) pero no se persiste. + +### Diagnóstico requerido + +Antes de modificar código, confirmar el estado real con un spec o con `console.log` en dev: + +**Opción A (spec):** agregar un spec E2E `tests/e2e/auth-localStorage-key.spec.ts` que: +1. Navega con `storageState` de auth +2. Evalúa `document.cookie` o `localStorage` en el contexto del browser +3. Afirma qué clave de auth existe realmente (`supabase.auth.token` vs `sb-${projectRef}-auth-token`) +4. Afirma que la clave correcta contiene un `access_token` válido (string no vacío) + +**Fix según diagnóstico:** + +Si la clave real es `sb-${projectRef}-auth-token` (probable): + +```ts +// Reemplazar getAccessToken() completa: +function getAccessToken(): string | null { + try { + // supabase-js v2 calcula la clave como sb-{projectRef}-auth-token + // donde projectRef = hostname.split('.')[0] de la URL del proyecto. + const projectRef = new URL(import.meta.env.VITE_SUPABASE_URL as string).hostname.split('.')[0] + const storageKey = `sb-${projectRef}-auth-token` + const raw = localStorage.getItem(storageKey) + if (!raw) return null + const parsed = JSON.parse(raw) as { access_token?: string } + return parsed.access_token ?? null + } catch { + return null + } +} +``` + +Si en producción ambas claves coexisten (posible si hay versiones mixtas de @supabase/auth-js), leer con fallback: +```ts +const raw = localStorage.getItem(storageKey) ?? localStorage.getItem('supabase.auth.token') +``` + +**Actualizar el comentario** en `ProfileSheet.tsx` para reflejar la clave real y el diagnóstico confirmado. El comentario actual dice "STORAGE_KEY = 'supabase.auth.token'" — eso era la documentación de auth-js v1/v2-old, no de supabase-js v2. + +### Criterios de Parte A + +- [ ] Spec E2E confirma qué clave existe en localStorage en producción bajo auth real +- [ ] `getAccessToken()` lee la clave correcta +- [ ] Comentario en ProfileSheet.tsx refleja la clave real y la razón +- [ ] El save de perfil (nombre, empresa) persiste en Supabase — verificar manualmente en Studio o via spec + +--- + +## Parte B — Eliminar setTimeout(350) de ProfileSheet + +### El problema + +`ProfileSheet.handleSave()` difiere `updateUser()` 350ms: + +```ts +// ProfileSheet.tsx líneas 57-60 +onOpenChange(false) +setTimeout(() => { + updateUser({ name: savedName, company: savedCompany }) +}, 350) +``` + +El comentario dice que es para evitar que `@radix-ui/react-remove-scroll` deje `pointer-events:none` permanente en el body. Pero `src/components/ui/sheet.tsx` ya tiene el fix definitivo (useEffect sobre prop `open` que restaura pointer-events defensivamente). El setTimeout es redundante. + +### Fix + +1. Reemplazar el bloque con llamada directa: + +```ts +onOpenChange(false) +updateUser({ name: savedName, company: savedCompany }) +``` + +2. Verificar manualmente que después de guardar el perfil, la UI responde (no hay pointer-events:none atascado en body). Si el fix de sheet.tsx es suficiente, el comportamiento es idéntico. Si reaparece el bug, restaurar el setTimeout y documentar que el fix de sheet.tsx no es suficiente solo. + +3. Actualizar el comentario para explicar que el fix vive en sheet.tsx. + +### Criterios de Parte B + +- [ ] setTimeout(350) eliminado de ProfileSheet.tsx +- [ ] Verificación manual: guardar perfil → UI responde correctamente, sin pointer-events bloqueados +- [ ] Comentario actualizado explicando que el fix está en sheet.tsx + +--- + +## Parte C — Spec E2E de validación de auth real + +Independientemente de la resolución de Parte A, agregar un spec que valide que el auth E2E funciona realmente (no solo que el storageState se carga, sino que Supabase devuelve datos reales del usuario). + +**Archivo:** `tests/e2e/auth-real.spec.ts` + +```ts +import { test, expect } from '@playwright/test' + +test.use({ storageState: 'tests/e2e/setup/auth-state.json' }) + +test('auth E2E: Supabase devuelve datos reales del usuario autenticado', async ({ page }) => { + // Navegar a la app autenticada + await page.goto('http://localhost:4173/') + + // Esperar a que AppHeader muestre el avatar/nombre (confirma que AuthContext cargó la sesión) + await expect(page.getByTestId('app-header-user-avatar')).toBeVisible({ timeout: 10000 }) + + // Verificar que el email del usuario de test aparece en el dropdown de perfil + // (o bien hacer una query directa a Supabase REST para confirmar que el token es válido) + const email = process.env.TEST_USER_EMAIL + if (email) { + await page.getByTestId('app-header-user-avatar').click() + await expect(page.getByText(email)).toBeVisible() + } +}) +``` + +Adaptar según los `data-testid` reales del AppHeader (consultarlos antes de escribir el spec). + +Si `TEST_USER_EMAIL` no está en `.env.test`, el spec hace skip con mensaje descriptivo (mismo patrón de `export-pdf.spec.ts`). + +### Criterios de Parte C + +- [ ] Spec en `tests/e2e/auth-real.spec.ts` que pasa cuando auth funciona +- [ ] Spec hace `test.skip()` descriptivo si `TEST_USER_EMAIL` no está configurado +- [ ] Si el spec falla, el diagnóstico es definitivo: el auth E2E no funciona realmente → fix en auth-setup.ts o en AuthContext + +--- + +## Parte D — Actualización de TECH_DEBT.md + +Al terminar A, B y C, actualizar TECH_DEBT.md: + +1. Marcar `[INVESTIGATING] Clave de localStorage del auth E2E vs producción` como `[RESOLVED]` con el resultado real del diagnóstico. + +2. Marcar `[BACKLOG] setTimeout(350) en ProfileSheet` como `[RESOLVED]`. + +3. Agregar nuevo ítem: + +```markdown +### [BACKLOG] CumulativeSavingsChart — derivación de inversión base desde cumulativeSavingsHorizon +**Estado actual:** cuando `roiBase` está presente (modo sensibilidad activo), el componente +recupera la inversión original algebraicamente: `investment_base = roiBase.annualSavings * horizonYears - roiBase.cumulativeSavingsHorizon`. +Es correcto mientras `horizonYears` del chart coincida con el usado en `calculateRoi` (hoy siempre es el mismo). +**Riesgo:** si en el futuro el chart recibe un horizonte diferente al del cálculo de ROI, el valor +de referencia del eje Y para la línea base será incorrecto, sin error visible. +**Solución propuesta:** agregar prop `investmentBase?: number` a `CumulativeSavingsChartProps` +y pasar `process.automationInvestment` directamente desde `ReportPage`. +**Costo:** bajo. **Origen:** Sprint 6 Etapa 2 — detectado en auditoría post-implementación. +``` + +--- + +## Parte E — Opcional: animación de expansión en ActivityPanel + +Solo implementar si las partes A-D no consumen el slot completo. + +La sección de automatización en `ActivityPanel` usa `max-height` para expandir/colapsar. La aceleración no es lineal en los últimos píxeles (se nota el "freno" al final). + +Fix: usar `useRef + scrollHeight` para animar a la altura real del contenido: + +```tsx +const contentRef = useRef(null) +// Al expandir: +contentRef.current.style.maxHeight = contentRef.current.scrollHeight + 'px' +// Al colapsar: +contentRef.current.style.maxHeight = '0' +``` + +Con transición CSS `transition-[max-height] duration-300 ease-in-out`. + +Verificar visualmente antes de commitear. + +--- + +## Orden de ejecución recomendado + +1. Parte A (diagnóstico y fix de localStorage key) — puede revelar un bug real, prioridad más alta +2. Parte B (setTimeout cleanup) — depende de confirmar que sheet.tsx fix es suficiente +3. Parte C (spec E2E) — complementa Parte A +4. Parte D (TECH_DEBT.md) — al final, cuando A-C estén resueltos +5. Parte E (animación) — solo si hay tiempo + +--- + +## Criterios de validación del conjunto + +- [ ] `getAccessToken()` en ProfileSheet.tsx usa la clave correcta de localStorage +- [ ] Save de perfil persiste en Supabase (nombre y empresa visibles al recargar) +- [ ] setTimeout(350) eliminado, UI funciona correctamente después de guardar perfil +- [ ] Spec E2E de auth pasa o hace skip descriptivo +- [ ] TECH_DEBT.md actualizado: ítems resueltos marcados, nueva nota de CumulativeSavingsChart +- [ ] `npm run lint` sin errores +- [ ] `npm run build` limpio +- [ ] `npm run test` → 552+ tests verdes +- [ ] Validación del director sobre el estado de la documentación antes del OK formal + +--- + +## Archivos a modificar + +- `src/components/ProfileSheet.tsx` — clave localStorage + eliminar setTimeout +- `tests/e2e/auth-localStorage-key.spec.ts` — spec de diagnóstico (nuevo) +- `tests/e2e/auth-real.spec.ts` — spec de validación E2E (nuevo) +- `TECH_DEBT.md` — marcar resueltos + agregar nota CumulativeSavingsChart + +**Opcional:** +- `src/features/workspace/ActivityPanel.tsx` — animación scrollHeight + +**No modificar:** +- `tests/e2e/setup/auth-setup.ts` — ya documentado correctamente +- Ningún otro archivo fuera de esta lista sin consulta previa diff --git a/sprints/sprint-6/REFERENCIA_GOOGLE_OAUTH_SUPABASE.md b/sprints/sprint-6/REFERENCIA_GOOGLE_OAUTH_SUPABASE.md new file mode 100644 index 0000000..5275ffb --- /dev/null +++ b/sprints/sprint-6/REFERENCIA_GOOGLE_OAUTH_SUPABASE.md @@ -0,0 +1,345 @@ +# Referencia: Google OAuth con Supabase para InQ Sizing + +> Extraído de la implementación de InQ ROI (Stack: Vite + React 18 + TS + Supabase cloud). +> InQ Sizing usa Supabase **self-hosted en Dokploy** — las variables de GoTrue son equivalentes al dashboard cloud. + +--- + +## 1. Configuración de GoTrue para Google OAuth (self-hosted) + +InQ ROI usa Supabase cloud, donde esto se configura en el dashboard. +Para Supabase self-hosted en Dokploy, las variables de entorno equivalentes son: + +```env +# En el servicio GoTrue (supabase-auth) +GOTRUE_EXTERNAL_GOOGLE_ENABLED=true +GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID= +GOTRUE_EXTERNAL_GOOGLE_SECRET= +GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI=https:///auth/v1/callback + +# URLs que deben coincidir entre GoTrue y la consola de Google OAuth +API_EXTERNAL_URL=https:// +SITE_URL=https:// +ADDITIONAL_REDIRECT_URLS=https://,http://localhost:5173 +``` + +En Google Cloud Console: crear credenciales OAuth 2.0 tipo "Aplicación web", +agregar `https:///auth/v1/callback` como "URI de redirección autorizado". + +--- + +## 2. Tabla `public.users` — estructura de InQ ROI + +```sql +-- supabase/migrations/001_create_users.sql +CREATE TABLE IF NOT EXISTS public.users ( + id uuid PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, + name text, + avatar_url text, + platform_role text NOT NULL DEFAULT 'guest' + CHECK (platform_role IN ('platform_admin', 'member', 'guest')), + created_at timestamptz NOT NULL DEFAULT now() +); + +ALTER TABLE public.users ENABLE ROW LEVEL SECURITY; + +-- Cualquier usuario autenticado puede leer la tabla users +CREATE POLICY "read_users" ON public.users FOR SELECT USING (auth.uid() IS NOT NULL); +-- Solo puede actualizar su propia fila +CREATE POLICY "update_own_user" ON public.users FOR UPDATE USING (id = auth.uid()); +-- El propio usuario puede insertar su fila (habilita el upsert application-side) +CREATE POLICY "insert_users" ON public.users FOR INSERT WITH CHECK (id = auth.uid()); +``` + +**Nota clave:** la política `insert_users` permite que el propio usuario inserte su fila. +Esto es lo que habilita el upsert desde la aplicación sin necesidad de un trigger SQL. + +--- + +## 3. Cómo se crea la fila en `public.users` al primer login + +InQ ROI resuelve esto **a nivel de aplicación**, no con un trigger SQL. + +### 3a. `upsertUserOnLogin()` en `SupabaseAuthService.ts` + +```ts +async upsertUserOnLogin(user: AuthUser, signal?: AbortSignal): Promise { + const builder = supabase.from('users').upsert( + { + id: user.id, + name: user.name, + avatar_url: user.avatarUrl ?? null, + platform_role: user.platformRole, // derivado del email, ver sección 4 + }, + { onConflict: 'id', ignoreDuplicates: true } // no sobreescribe si ya existe + ) + const { error } = await (signal ? builder.abortSignal(signal) : builder) + if (error) console.error('Error al crear usuario en DB:', error) +} +``` + +### 3b. Llamada desde `AuthContext.tsx` — FUERA del lock de auth + +```tsx +useEffect(() => { + if (!user?.id) return + const userId = user.id + const capturedUser = user + + // 6s timeout: cancela si el servidor no responde, libera el processLock + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), 6000) + + ;(async () => { + try { + // Upsert idempotente: crea la fila si no existe (primer login con Google) + await authService.upsertUserOnLogin(capturedUser, controller.signal) + // Leer company y name desde DB (pueden diferir del user_metadata de Google) + const profile = await authService.syncProfileFromDB(userId, controller.signal) + if (profile) { + setUser((prev) => + prev?.id === userId + ? { ...prev, company: profile.company, name: profile.name || prev.name } + : prev + ) + } + } catch (err) { + if (err instanceof Error && err.name === 'AbortError') return + console.error('Error al sincronizar perfil:', err) + } finally { + clearTimeout(timeoutId) + } + })() + + return () => { + controller.abort() + clearTimeout(timeoutId) + } +}, [user?.id]) // dep primitiva: no cambia cuando el usuario edita nombre/empresa +``` + +--- + +## 4. Cómo se determina el rol — derivación desde el email + +Esta es la decisión arquitectónica central de InQ ROI: **el rol es determinista a partir del dominio del email**, sin asignación manual ni pantalla de "esperando aprobación". + +```ts +// En buildAuthUser() — síncrono, sin consultar DB +private buildAuthUser(supabaseUser: { id: string; email?: string; user_metadata?: Record }): AuthUser { + const email = supabaseUser.email ?? '' + return { + id: supabaseUser.id, + email, + name: (supabaseUser.user_metadata?.full_name as string | undefined) || email, + avatarUrl: supabaseUser.user_metadata?.avatar_url as string ?? undefined, + platformRole: email.endsWith('@inquality.com.py') ? 'platform_admin' : 'guest', + } +} +``` + +El upsert del primer login ya graba el rol correcto desde el inicio. + +--- + +## 5. Frontend — `signInWithOAuth` + +```ts +// src/auth/SupabaseAuthService.ts +async signInWithGoogle(): Promise { + const { error } = await supabase.auth.signInWithOAuth({ + provider: 'google', + options: { + redirectTo: window.location.origin, + queryParams: { + prompt: 'select_account', // fuerza el selector aunque haya sesión activa + }, + }, + }) + if (error) throw error +} +``` + +--- + +## 6. Manejo del redirect OAuth — evitar loop /login + +Cuando Google redirige de vuelta a la app, la URL tiene `#access_token=...` o `?code=...`. +Si el contexto lee localStorage antes de que Supabase procese ese hash, puede redirigir +a `/login` causando un flash o loop. InQ ROI lo previene con esta lógica en `AuthContext`: + +```ts +function hasOAuthCallback(): boolean { + const hash = window.location.hash + const search = window.location.search + return ( + hash.includes('access_token') || + hash.includes('refresh_token') || + search.includes('code=') + ) +} + +// En useState inicial: +const isOAuthRedirect = hasOAuthCallback() +const [user, setUser] = useState( + isOAuthRedirect ? null : getUserFromStorage() // no leer storage durante el callback +) +const [loading, setLoading] = useState(isOAuthRedirect) // loading=true hasta que onAuthStateChange dispare +``` + +### Lectura de localStorage sin red (para arranque inmediato) + +```ts +function getUserFromStorage(): AuthUser | null { + try { + const key = Object.keys(localStorage).find( + (k) => k.startsWith('sb-') && k.endsWith('-auth-token') + ) + if (!key) return null + const stored = JSON.parse(localStorage.getItem(key) ?? 'null') + const u = stored?.user + if (!u?.id) return null + const email = (u.email as string) ?? '' + return { + id: u.id as string, + email, + name: (u.user_metadata?.full_name as string | undefined) || email, + avatarUrl: u.user_metadata?.avatar_url as string | undefined, + platformRole: email.endsWith('@inquality.com.py') ? 'platform_admin' : 'guest', + } + } catch { + return null + } +} +``` + +**Nota:** La clave en localStorage es `sb-${projectRef}-auth-token` (donde `projectRef` es +el hostname del `SUPABASE_URL` sin dominio). La clave `supabase.auth.token` que documenta +`@supabase/auth-js` en aislamiento **no existe** — `createClient()` de `@supabase/supabase-js` +la sobreescribe con la clave prefijada `sb-`. Confirmado por diagnóstico E2E en Sprint 6 Etapa 3. + +--- + +## 7. Restricción crítica: `onAuthStateChange` callback DEBE ser síncrono + +El callback de `onAuthStateChange` se ejecuta mientras GoTrueClient mantiene su lock interno activo. +Hacer `await` de cualquier operación Supabase dentro de ese callback causa un deadlock permanente. + +```ts +// MAL — deadlock garantizado: +supabase.auth.onAuthStateChange(async (event, session) => { + await supabase.from('profiles').select(...) // bloquea el processLock para siempre +}) + +// BIEN — síncrono, queries afuera del lock: +supabase.auth.onAuthStateChange((event, session) => { + // Solo datos del token en memoria, sin queries + callback(this.buildAuthUser(session.user)) +}) +// Las queries a profiles van en useEffect([user?.id]) fuera del lock +``` + +--- + +## 8. Configuración del cliente Supabase — processLock + +```ts +// src/lib/supabase.ts +export const supabase = createClient(supabaseUrl, supabaseAnonKey, { + auth: { + // processLock usa coordinación solo en memoria (sin navigator.locks). + // Evita deadlocks entre tabs a costa de no sincronizar refresh entre tabs + // (aceptable para producto mayormente single-tab). + lock: processLock, + // 30s en lugar del default de 5s: margen para refresh en redes lentas / cold starts. + lockAcquireTimeout: 30000, + }, +}) +``` + +--- + +## 9. Variables de entorno del frontend + +```env +VITE_SUPABASE_URL=https:// +VITE_SUPABASE_ANON_KEY= +``` + +No hay configuración especial en el cliente para Google OAuth. +`signInWithOAuth` funciona con el cliente estándar una vez que GoTrue tiene habilitado el proveedor. + +--- + +## 10. La pregunta crítica para InQ Sizing: el problema del rol vacío + +InQ ROI tiene 2 roles y la regla es trivial (dominio del email). InQ Sizing tiene 4 roles +(`admin|comercial|cliente_coordinador|cliente_gerente`) donde el rol define visibilidad +de datos via RLS — **no hay forma de derivarlo del email automáticamente**. + +### Opción A: Trigger SQL en `auth.users` (recomendada cuando rol vacío rompe RLS) + +```sql +CREATE OR REPLACE FUNCTION public.handle_new_user() +RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER AS $$ +BEGIN + INSERT INTO public.profiles (id, organization_id, full_name, role) + VALUES ( + NEW.id, + NULL, -- admin asigna org después + NEW.raw_user_meta_data->>'full_name', + 'pendiente' -- rol especial hasta asignación manual + ) + ON CONFLICT (id) DO NOTHING; + RETURN NEW; +END; +$$; + +CREATE TRIGGER on_auth_user_created + AFTER INSERT ON auth.users + FOR EACH ROW EXECUTE FUNCTION public.handle_new_user(); +``` + +La fila existe siempre desde el primer login, incluso antes de que la app se cargue. +El admin asigna `role` + `organization_id` después. RLS puede permitir que `role = 'pendiente'` +acceda solo a una pantalla de "acceso pendiente" en lugar de romper totalmente. + +### Opción B: Upsert application-side (igual que InQ ROI) + rol default `'pendiente'` + +Misma estructura que InQ ROI pero con `role: 'pendiente'` en el upsert. +Riesgo: ventana de tiempo entre el login y el `useEffect` donde RLS puede romper +las primeras queries (devuelven vacío o error silencioso). + +### Comparativa + +| Aspecto | InQ ROI | InQ Sizing (a decidir) | +|---|---|---| +| Roles | 2, derivados de email | 4, no derivables de email | +| Creación de fila en profiles | Upsert app-side en `useEffect` | Recomiendo trigger SQL en `auth.users` | +| Rol en primer login | Inmediato y determinista | Necesita flujo "pendiente" + asignación manual por admin | +| RLS sin fila existente | Queries devuelven vacío (aceptable) | Probablemente rompe — evaluar antes de elegir opción | + +--- + +## 11. Clave de localStorage — diagnóstico + +Si necesitan leer el token directamente (como InQ ROI en `ProfileSheet.getAccessToken()` +para evitar contención con processLock al guardar perfil): + +```ts +function getAccessToken(): string | null { + try { + // @supabase/supabase-js createClient() calcula la clave como sb-{projectRef}-auth-token + // donde projectRef = hostname de SUPABASE_URL sin dominio. + // La clave 'supabase.auth.token' de @supabase/auth-js en aislamiento NO existe. + const projectRef = new URL(SUPABASE_URL).hostname.split('.')[0] + const storageKey = `sb-${projectRef}-auth-token` + const raw = localStorage.getItem(storageKey) + if (!raw) return null + const parsed = JSON.parse(raw) as { access_token?: string } + return parsed.access_token ?? null + } catch { + return null + } +} +``` diff --git a/sprints/sprint-7/BRIEF.md b/sprints/sprint-7/BRIEF.md new file mode 100644 index 0000000..1fc9cfb --- /dev/null +++ b/sprints/sprint-7/BRIEF.md @@ -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 (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