docs(sprint-6): cierre formal — observaciones Etapas 1-3 + reflexiones estratégicas
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled

This commit is contained in:
2026-07-06 18:52:24 -03:00
parent b117ad258d
commit 2922d4c2f3
5 changed files with 1186 additions and 2 deletions

View File

@@ -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<HTMLDivElement>(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

View File

@@ -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=<client_id_de_google_cloud_console>
GOTRUE_EXTERNAL_GOOGLE_SECRET=<client_secret>
GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI=https://<tu-dominio-supabase>/auth/v1/callback
# URLs que deben coincidir entre GoTrue y la consola de Google OAuth
API_EXTERNAL_URL=https://<tu-dominio-supabase>
SITE_URL=https://<tu-app>
ADDITIONAL_REDIRECT_URLS=https://<tu-app>,http://localhost:5173
```
En Google Cloud Console: crear credenciales OAuth 2.0 tipo "Aplicación web",
agregar `https://<tu-dominio-supabase>/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<void> {
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<string, unknown> }): 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<void> {
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<AuthUser | null>(
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://<tu-dominio-supabase>
VITE_SUPABASE_ANON_KEY=<anon-public-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
}
}
```