Files
Claro-cases/old_SPECIFICATION.md
T
bryan_garcia 83e3ec2cff fix(dashboard): resolver bugs críticos de tiempo real en HITL — race conditions, In-Band Auth y multi-stream buffer
- AppShell: corregir condición de carrera REST/WS que perdía tokens de agent_stream_chunk
- init_state atómico + eliminación de doble fuente REST/WS para actualización en tiempo real
- conversation_ended e idempotencia de eventos en máquina de estados por conversación
- Seguridad: migrar JWT de query param a In-Band Auth (primer mensaje {action:auth}) con timeout 5s y cierre 1008
- Multi-stream buffer: reemplazar buffer plano por TTL LRU (200 entradas, 60s TTL) para evitar pisado de tokens entre agentes
- agent_stream_completed ya no borra buffer incondicionalmente — delega purge a la política LRU
- Timer: corregir display de 00:00 en estado PENDING con visualización inmediata + cleanup en stop()
- Tests: 8 tests multi-stream, tests In-Band Auth, tests idempotencia y máquina de estados, tests Timer
- Resultado: 86/86 tests pasan | TypeScript 0 errores
2026-07-29 04:32:27 -05:00

1178 lines
112 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# BITÁCORA DE DESARROLLO Y ESPECIFICACIONES
## CONTROL DE ESTADO
- **Último Agente Modificador**: qa-tester
- **Estado del Ciclo**: [STATUS: PASSED] - Listo para Producción / Git
- **Feature Activa**: Módulo de Autenticación JWT (Okan → Linguo)
---
## Fase 1: Requerimientos y Plan Inicial
### 1.1 Resumen Ejecutivo
- **Tipo de Tarea**: Migración y expansión (New Feature + Rewrite)
- **Objetivo General**: Reescribir el dashboard Claro Cases de vanilla HTML/CSS/JS a React + TypeScript + Vite, expandiéndolo con dos módulos: (1) Gestión de Casos HITL con formularios dinámicos por tipología y (2) Monitoreo completo de conversaciones en tiempo real con capacidad de intervención mediante notas internas, utilizando comunicación híbrida REST + WebSocket.
### 1.2 Contexto Técnico y Hallazgos
#### Estado Actual (Proyecto Claro Cases existente)
- **Backend**: Node.js + Express + SQLite (`better-sqlite3`). Monolítico, acoplado al frontend.
- **Frontend**: SPA vanilla HTML/CSS/JS. Sidebar de casos + panel de detalle.
- **Comunicación**: REST (CRUD) + SSE unidireccional para notificaciones.
- **Persistencia**: SQLite local (`database.sqlite`). Tabla `requests` con campos: `id`, `title`, `description`, `status`, `external_id`, `cedula`, `tipo_solicitud`, `payload` (JSON), `handling_time`, `created_at`.
- **Lógica actual**: Dos flujos de resolución (validación Sí/No y texto libre). Cronómetros individuales con persistencia en `localStorage`. Notificaciones de escritorio + sonido Web Audio + parpadeo de título.
- **Estilos**: Sistema de diseño con CSS custom properties. Paleta orange/red/yellow/green. Tipografía Inter. Modo oscuro/claro. Sin framework CSS.
#### Proyecto de Referencia (Linguo Nexus)
- **Stack**: React 19 + TypeScript + Vite + Tailwind CSS v4.
- **Estado**: Zustand store centralizado.
- **Ruteo**: React Router con `/monitor` e `/intervention`.
- **Comunicación**: REST (`/api/v1/conversations/active`, `/api/v1/tickets/pending`) + WebSocket (`/ws/monitor`) con eventos tipados (`init_state`, `conversation_started`, `user_message`, `agent_stream`, `hitl_required`, `hitl_resolved`, `CLIENT_TOOL_REQUEST`).
- **Validación**: Zod para payloads WebSocket y edge tool calling.
- **UI**: Kanban drag&drop (`@dnd-kit`), streaming token-a-token con auto-scroll, renderizado Markdown (`marked-react`), leader election (`navigator.locks`).
#### Tipos de Caso (CSV: 45 registros)
- **Aplicativos origen**: AC+, ASCARD, DiMe, Formatos SGCS, Mi asistencia 360, Paradigma, RR, Phone Protect.
- **Taxonomía de interacción aprobada**: Confirmación simple, Confirmación + valor, Formulario multi-campo, Fecha simple, Texto libre, Solo lectura.
- **Jerarquía secundaria**: Filtro por aplicativo (columna A del CSV).
#### Módulos/Archivos Impactados
- `public/index.html`: Reemplazado por `index.html` de Vite + React root.
- `public/style.css`: Migrado a Tailwind config + CSS custom properties preservados.
- `public/app.js`: Reescrito en componentes React + Zustand store.
- `server.js`: Backend actual se reemplazará por backend Python (fuera del scope de esta migración frontend).
- `db.js`, `schema.sql`, `database.sqlite`: Reemplazados por backend Python.
- `Consulta de aplicativos - Claro - Facturación.csv`: Parseado e incrustado como datos estáticos en `src/data/caseTypeDefinitions.ts`.
### 1.3 Plan Lógico de Solución (Paso a Paso)
#### Paso 0 — Bootstrap del proyecto React + TypeScript + Vite y Reestructuración del Repositorio
1. **Reorganización del repositorio** (previa al bootstrap):
- Mover todo el backend legacy (`server.js`, `db.js`, `schema.sql`, `database.sqlite`, `node_modules/`, `public/`, `package.json`, `package-lock.json`, `.env`, `.env.example`) a un subdirectorio `legacy/`.
- Conservar en la raíz: `.git/`, `.opencode/`, `SPECIFICATION.md`, `Consulta de aplicativos - Claro - Facturación.csv`, `README.md`.
2. Inicializar proyecto con `npm create vite@latest . -- --template react-ts` en el directorio raíz.
3. Instalar dependencias core: `react-router-dom`, `zustand`, `zod`, `date-fns`, `lucide-react`.
4. Instalar dependencias de desarrollo: `msw` (Mock Service Worker para desacoplar frontend del backend), `@testing-library/react`, `vitest`.
5. Configurar **Tailwind CSS v4 con enfoque CSS-first** (sin `tailwind.config.ts`):
- Definir design tokens en `src/index.css` mediante la directiva `@theme`:
```css
@import "tailwindcss";
@theme {
--color-accent-orange: #ff4e00;
--color-accent-yellow: #ffa600;
--color-accent-red: #f80018;
--color-accent-green: #10b981;
--color-bg-base: #f0f2f5;
--color-bg-surface: #ffffff;
--color-bg-elevated: #f8fafc;
--color-bg-hover: #e2e8f0;
--color-text-primary: #1e293b;
--color-text-secondary: #475569;
--color-text-muted: #94a3b8;
--color-border: rgba(0, 0, 0, 0.08);
--color-border-accent: rgba(255, 78, 0, 0.25);
--radius-sm: 4px; --radius-md: 8px; --radius-lg: 12px; --radius-xl: 16px;
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 12px 24px rgba(0, 0, 0, 0.12);
--font-family-sans: 'Inter', system-ui, sans-serif;
--transition-default: 0.18s cubic-bezier(0.4, 0, 0.2, 1);
}
```
- Modo oscuro mediante `@custom-variant dark (&:where(.dark, .dark *))` con overrides de variables en bloque `@media (prefers-color-scheme: dark)` y clase `.dark` toggleada manualmente.
- Animaciones definidas como `@keyframes` en el mismo archivo CSS.
6. Estructura de carpetas:
```
src/
components/
layout/ (AppShell, Sidebar, Header, StatusBar)
cases/ (CaseCard, CaseDetail, FormRenderer, Timer, TypeBadge, ApplicativeFilter)
monitor/ (ConversationCard, ChatFeed, MessageBubble, InternalNoteBanner, InterventionPanel)
shared/ (StatusBadge, SearchBar, TabsBar, Modal, EmptyState)
hooks/ (useWebSocket, useTimer, useNotification)
services/ (api.ts, wsClient.ts)
store/ (useAppStore.ts — slices: cases, conversations, ui)
types/ (index.ts, wsProtocol.ts, caseTypes.ts)
pages/ (CasesPage.tsx, MonitorPage.tsx)
data/ (caseTypeDefinitions.ts — parsed from CSV)
App.tsx
main.tsx
```
#### Paso 1 — Sistema de Tipos y Contratos
1. **`src/types/index.ts`**: Interfaces base:
- `CaseRequest`: id, title, description, status, externalId, cedula, tipoSolicitud, payload, handlingTime, createdAt, applicative, uiPattern.
- `Conversation`: id, clientId, agentId, status, messages[], createdAt.
- `Message`: id, conversationId, role (user/agent/system/internal), content, timestamp, metadata?.
- `CaseUIType` enum: `SIMPLE_CONFIRMATION`, `CONFIRMATION_WITH_VALUE`, `MULTI_FIELD_FORM`, `DATE_SIMPLE`, `FREE_TEXT`, `READ_ONLY`.
- `CaseStatus`: `PENDING`, `IN_PROGRESS`, `RESOLVED`, `FAILED`.
- `AgentStatus`: `ONLINE`, `BUSY`, `OFFLINE`.
2. **`src/types/wsProtocol.ts`**: Contratos WebSocket tipados (Zod):
- **Eventos entrantes (backend → frontend)**:
- `init_state`: `{ conversations: Conversation[], activeCases: CaseRequest[] }`
- `conversation_started`: `{ conversation: Conversation }`
- `conversation_update`: `{ conversationId: string, message: Message }`
- `agent_stream`: `{ conversationId: string, token: string }`
- `agent_status_update`: `{ agentId: string, status: AgentStatus }`
- `hitl_request`: `{ case: CaseRequest, conversationId: string }`
- `hitl_resolved`: `{ caseId: string, resolution: object }`
- **Eventos salientes (frontend → backend)**:
- `internal_note`: `{ conversationId: string, content: string }` (sin `advisorId`; backend deriva identidad)
3. **`src/data/caseTypeDefinitions.ts`**: Mapeo completo de los 45 tipos del CSV a `CaseTypeDefinition`:
```ts
interface CaseTypeDefinition {
toolName: string; // Ej: "Validar_Proporcionales_Movil"
applicative: string; // Ej: "AC+"
specialist: string; // Ej: "Cobros adicionales - Móvil"
inputData: string; // Ej: "Número de la línea"
steps: string[]; // Paso a paso
objective: string;
responseFormat: string; // Formato de respuesta esperada (según CSV)
document: string; // Categoría documental
uiPattern: CaseUIType; // Clasificación de UI (6 familias visuales)
formFields: FormField[]; // Campos del formulario dinámico
validationSchema: ZodSchema; // Esquema Zod de validación del payload de respuesta
payloadBuilder: (formData: Record<string, unknown>) => object; // Serializador a payload para el backend
}
interface FormField {
key: string; // Identificador del campo
label: string; // Etiqueta visible
type: 'text' | 'number' | 'currency' | 'date' | 'select' | 'textarea' | 'toggle';
required: boolean;
placeholder?: string;
options?: { value: string; label: string }[]; // Para type: 'select'
min?: number; // Para type: 'number'/'currency'
max?: number;
conditionalOn?: { field: string; value: unknown }; // Campo condicional
}
```
- **Ejemplo concreto** — `Plan_De_Pagos_EF` (ASCARD, Equipos financiados):
```ts
{
toolName: "Plan_De_Pagos_EF",
applicative: "ASCARD",
uiPattern: CaseUIType.MULTI_FIELD_FORM,
formFields: [
{ key: "numero_cuotas", label: "Número de cuotas", type: "number", required: true, min: 1 },
{ key: "valor_cuota", label: "Valor de la cuota", type: "currency", required: true },
{ key: "dia_corte", label: "Día de corte", type: "number", required: true, min: 1, max: 31 },
{ key: "dia_limite_pago", label: "Día límite de pago", type: "number", required: true, min: 1, max: 31 }
],
validationSchema: z.object({
numero_cuotas: z.number().int().min(1),
valor_cuota: z.number().positive(),
dia_corte: z.number().int().min(1).max(31),
dia_limite_pago: z.number().int().min(1).max(31)
}),
payloadBuilder: (data) => ({
numero_cuotas: data.numero_cuotas,
valor_cuota: data.valor_cuota,
dia_corte: data.dia_corte,
dia_limite_pago: data.dia_limite_pago
})
}
```
#### Paso 2 — Capa de Servicios y Store (arquitectura híbrida: REST autoritativo + WS difusión)
1. **`src/services/api.ts`**: Cliente REST (canal autoritativo de escritura):
- `GET /api/v1/cases?status=&applicative=&search=&offset=&limit=` → `{ items: CaseRequest[], total: number }` (filtrable, paginado).
- `GET /api/v1/cases/:id` → `CaseRequest` (detalle de caso).
- `POST /api/v1/cases/:id/resolve` → `CaseRequest` (canal único de resolución; el backend deriva `advisorId` del token de sesión).
- `GET /api/v1/conversations/active` → `Conversation[]`.
- Base URL configurable via variable de entorno (`VITE_API_BASE_URL`).
- Se implementará una capa de **MSW (Mock Service Worker)** con handlers que simulen estas respuestas para desarrollo sin backend.
2. **`src/hooks/useWebSocket.ts`**: Hook de conexión WebSocket (solo difusión/streaming, sin escritura de negocio):
- Conexión a `ws://<host>/ws/dashboard`.
- Reconexión automática con backoff exponencial (inicio 1s, máx 30s, factor 2x).
- Al reconectar, el backend envía `init_state` para resincronizar; el frontend reemplaza el estado local completo.
- Parseo con Zod de cada mensaje entrante usando el envelope estándar (ver Paso 8).
- Dispatch a acciones del store según `payload.type`.
- Envío de eventos salientes solo para `internal_note` (sin `advisorId`; el backend deriva la identidad).
- Indicador de estado de conexión en el store (`connected` | `disconnected` | `reconnecting`).
- **No se emite `hitl_response` por WebSocket**; la resolución de casos es exclusiva de REST.
3. **`src/store/useAppStore.ts`**: Store centralizado Zustand con slices:
- **casesSlice**: `cases[]`, `selectedCaseId`, `totalCases`, `fetchCases(filters)`, `upsertCase()`, `resolveCase()`, `deleteCase()`.
- **conversationsSlice**: `conversations[]`, `selectedConversationId`, `fetchConversations()`, `upsertConversation()`, `addMessage()`, `appendToken()`.
- **uiSlice**: `sidebarTab`, `searchQuery`, `applicativeFilter`, `isDarkMode`, `wsStatus`.
- **timerSlice**: Timers gestionados con `useRef` para intervalos (evitar re-renders); `localStorage` solo como caché de UI, no como fuente de verdad para `handling_time` (el backend calcula con `startedAt`/`resolvedAt`).
#### Paso 3 — Componentes Compartidos
1. **`StatusBadge`**: Badge de estado con colores por estado (`pending`/`in_progress`/`resolved`/`failed`).
2. **`SearchBar`**: Input de búsqueda con debounce.
3. **`TabsBar`**: Pestañas de filtro (Todos/Pendientes/Finalizados).
4. **`ApplicativeFilter`**: Dropdown/chips para filtrar por aplicativo (AC+, ASCARD, RR, etc.).
5. **`Timer`**: Cronómetro independiente por caso con persistencia en `localStorage` (migrado del JS actual).
6. **`Modal`**: Diálogo de confirmación genérico.
7. **`EmptyState`**: Estado vacío para paneles sin selección.
#### Paso 4 — Módulo de Gestión de Casos HITL (`/cases`)
1. **`CasesPage.tsx`**: Layout maestro: sidebar izquierda (lista de casos) + panel derecho (detalle/acciones).
2. **`CaseCard.tsx`**: Tarjeta de caso en la lista con título, status badge, timer (si activo), tipo de solicitud, aplicativo, fecha.
3. **`CaseDetail.tsx`**: Vista detallada del caso seleccionado con:
- Metadata grid (ID, cédula, tipo solicitud, aplicativo).
- Descripción del caso.
- Payload de datos entrantes.
- **`FormRenderer.tsx`**: Componente dinámico que renderiza el formulario adecuado según `uiPattern`:
- `SIMPLE_CONFIRMATION` → Botones "Sí" / "No".
- `CONFIRMATION_WITH_VALUE` → Radio group (Sí/No) + campo numérico con prefijo `$`.
- `MULTI_FIELD_FORM` → Formulario con campos definidos en `formFields[]` (text, number, select, date).
- `DATE_SIMPLE` → Date picker con formato `dd-mm-aaaa`.
- `FREE_TEXT` → Textarea con placeholder contextual.
- `READ_ONLY` → Panel informativo sin campos editables, solo botón "Marcar como revisado".
- Panel de operación con timer y botones de acción.
- Instrucciones paso a paso del aplicativo (del CSV) colapsables en acordeón.
4. **Flujo de resolución**:
- Asesor abre caso → timer inicia automáticamente.
- Completa formulario dinámico → botón "Enviar resolución".
- Se envía `POST /api/v1/cases/:id/resolve` (REST, canal autoritativo) con payload estructurado. El backend difunde `hitl_resolved` por WS a todos los asesores.
- Caso pasa a estado `resolved` y timer se detiene.
#### Paso 5 — Módulo de Monitoreo (`/monitor`)
1. **`MonitorPage.tsx`**: Layout de dos columnas: lista de conversaciones (izquierda estrecha) + feed de chat (derecha amplia).
2. **`ConversationCard.tsx`**: Tarjeta de conversación activa mostrando:
- ID/Nombre del cliente.
- Último mensaje (truncado).
- Indicador de streaming activo (spinner).
- Badge de HITL pendiente.
- Estado del agente asignado.
3. **`ChatFeed.tsx`**: Feed de mensajes con:
- Auto-scroll inteligente (respeta scroll manual del usuario, reanuda al llegar al fondo).
- Renderizado de mensajes con diferenciación visual por rol (cliente, agente, sistema).
- **Streaming token-a-token**: Concatenación progresiva de tokens en el último mensaje del agente.
4. **`MessageBubble.tsx`**: Burbuja de mensaje individual con timestamp y rol.
5. **`InternalNoteBanner.tsx`**: Banner de intervención que permite al asesor:
- Escribir nota interna en un textarea.
- Previsualizar cómo se verá en la conversación (etiquetada como "Nota interna").
- Enviar vía WebSocket (`internal_note`).
6. **`InterventionPanel.tsx`**: Panel lateral o modal para cuando se detecta un caso HITL asociado a la conversación activa.
#### Paso 6 — Ruteo y Shell de Aplicación
1. **`App.tsx`**: Router con dos rutas:
- `/` → redirect a `/cases`.
- `/cases` → `CasesPage`.
- `/monitor` → `MonitorPage`.
2. **`AppShell.tsx`**: Layout global:
- **`Header`**: Logo Claro Cases, badge "En vivo", indicador de conexión WebSocket, toggle tema oscuro.
- **`Sidebar`**: Navegación entre módulos (Casos, Monitor) con iconos de `lucide-react`.
- Inicializa WebSocket y fetch inicial al montar.
#### Paso 7 — Migración de Estilos (Preservar línea gráfica)
1. Extraer todos los design tokens del `style.css` actual a bloques `@theme` en `src/index.css` (ver Paso 0 para la configuración completa).
2. Mapear cada clase CSS a utilidades Tailwind equivalentes:
- `.app-header` → `flex items-center justify-between h-[50px] px-4 border-b bg-surface shadow-sm`
- `.case-card` → `bg-elevated border border-border rounded-md p-3 cursor-pointer transition`
- `.btn-primary` → `bg-accent-orange text-white px-4 py-2 rounded-md font-semibold`
3. Preservar animaciones (`slideIn`, `fadeIn`, `pulse-op`) como keyframes en Tailwind config.
4. Scrollbar styling → utilities de Tailwind o CSS global.
5. Modo oscuro: conservar lógica de toggle con `class` strategy de Tailwind + persistencia en `localStorage`.
#### Paso 8 — Contratos de Comunicación Completos (para el equipo Python)
##### 8.1 Envelope WebSocket Estándar
Todo mensaje WebSocket (en ambas direcciones) usa el siguiente envelope JSON:
```json
{
"type": "string", // Tipo de evento (ej. "agent_stream")
"eventId": "uuid", // ID único del evento para deduplicación
"occurredAt": "ISO-8601",// Timestamp UTC del lado emisor
"payload": { } // Carga específica del evento
}
```
##### 8.2 REST Endpoints (canal autoritativo)
| Método | Ruta | Query Params | Body | Respuesta |
|--------|------|-------------|------|-----------|
| `GET` | `/api/v1/cases` | `status`, `applicative`, `search`, `offset`, `limit` | — | `{ items: CaseRequest[], total: number }` |
| `GET` | `/api/v1/cases/:id` | — | — | `CaseRequest` |
| `POST` | `/api/v1/cases/:id/resolve` | — | `{ action, payload, note? }` | `CaseRequest` (updated) |
| `GET` | `/api/v1/conversations/active` | — | — | `Conversation[]` |
| `GET` | `/api/v1/conversations/:id` | — | — | `Conversation` (con mensajes) |
> **Nota para backend**: `POST /cases/:id/resolve` no recibe `advisorId`. El backend debe derivar la identidad del asesor desde el token de autenticación de la sesión HTTP (Bearer token o cookie).
##### 8.3 WebSocket Events (servidor → cliente)
| Evento `type` | Payload | Trigger |
|---------------|---------|---------|
| `init_state` | `{ conversations: Conversation[], activeCases: CaseRequest[] }` | Al conectar o reconectar |
| `conversation_started` | `{ conversation: Conversation }` | Nueva conversación |
| `conversation_ended` | `{ conversationId: string, endedAt: ISO-8601 }` | Conversación finalizada |
| `user_message` | `{ conversationId: string, message: Message }` | Mensaje completo de usuario |
| `agent_stream_started` | `{ conversationId: string, messageId: string }` | Inicio de streaming del agente |
| `agent_stream_chunk` | `{ conversationId: string, messageId: string, token: string, index: number }` | Token individual con índice de orden |
| `agent_stream_completed` | `{ conversationId: string, messageId: string, fullContent: string }` | Cierre de streaming; `fullContent` es el texto completo para verificación |
| `agent_status_update` | `{ agentId: string, status: AgentStatus }` | Cambio de estado del agente |
| `hitl_request` | `{ case: CaseRequest, conversationId: string }` | Se requiere intervención humana |
| `hitl_resolved` | `{ caseId: string, resolution: object }` | Caso resuelto (broadcast a todos los asesores) |
| `error` | `{ code: string, message: string, details?: object }` | Error del servidor notificable al frontend |
##### 8.4 WebSocket Events (cliente → servidor)
| Evento `type` | Payload | Trigger |
|---------------|---------|---------|
| `internal_note` | `{ conversationId: string, content: string }` | Asesor inyecta nota interna |
> **Nota**: El backend deriva `advisorId` del contexto de la conexión WebSocket autenticada. El cliente **no** envía identificadores de asesor en ningún payload.
##### 8.5 Estrategia de Reconexión
1. Backoff exponencial: 1s → 2s → 4s → 8s → 16s → 30s (máx).
2. Al reconectar exitosamente, el servidor envía `init_state` con el estado completo actual.
3. El frontend reemplaza `conversations` y `activeCases` con los datos de `init_state`.
4. Durante la desconexión, el frontend muestra indicador "Reconectando..." y deshabilita acciones de escritura (resolución de casos e inyección de notas).
##### 8.6 Estrategia de Streaming (lado frontend)
- `agent_stream_started`: crear mensaje placeholder en la conversación con `isStreaming: true`.
- `agent_stream_chunk`: concatenar token al contenido del mensaje usando el `index` para garantizar orden (no asumir orden de llegada de red).
- `agent_stream_completed`: marcar mensaje con `isStreaming: false`, reemplazar contenido con `fullContent` para verificación de integridad.
- Las actualizaciones al store se bufferizan cada 50ms (máximo 20 actualizaciones/segundo) para evitar re-renders excesivos. Solo la conversación activa/seleccionada dispara re-renders de UI; las demás acumulan tokens en el store sin re-render hasta ser seleccionadas.
### 1.4 Criterios de Aceptación
- [ ] **CA-1**: Proyecto arranca con `npm run dev` sobre Vite + React + TypeScript, sirviendo en `localhost:5173`.
- [ ] **CA-2**: Ruteo funcional: `/` redirige a `/cases`; navegación entre `/cases` y `/monitor` vía sidebar con iconos `lucide-react`.
- [ ] **CA-3**: Sidebar de casos muestra lista con búsqueda textual (debounced 300ms), pestañas (Todos/Pendientes/Finalizados) y filtro secundario por aplicativo (chips/dropdown con los 8 aplicativos del CSV).
- [ ] **CA-4**: Al seleccionar un caso, el panel de detalle renderiza el formulario dinámico correcto según el `uiPattern` del tipo de caso, con validación Zod antes de enviar.
- [ ] **CA-5**: El formulario `MULTI_FIELD_FORM` renderiza campos específicos (ej. para `Plan_De_Pagos_EF`: número de cuotas, valor cuota, día corte, día límite) con validación por tipo (número, moneda, rango) y mensajes de error inline.
- [ ] **CA-6**: Timer independiente por caso con persistencia en `localStorage` como cache de UI; el `handling_time` oficial lo calcula el backend con `startedAt`/`resolvedAt`.
- [ ] **CA-7**: Resolución de caso se envía exclusivamente por REST (`POST /cases/:id/resolve`). El backend difunde `hitl_resolved` por WS a todos los asesores conectados.
- [ ] **CA-8**: Módulo de monitoreo muestra lista de conversaciones activas con streaming token-a-token usando eventos `agent_stream_started`/`agent_stream_chunk`/`agent_stream_completed`, con buffer de 50ms para limitar re-renders a 20 fps.
- [ ] **CA-9**: Chat feed con auto-scroll inteligente y diferenciación visual de 4 roles: cliente, agente, sistema, nota interna (esta última con badge "Interno" y fondo distintivo).
- [ ] **CA-10**: Asesor puede inyectar nota interna desde el monitor; se emite `internal_note` por WebSocket (sin `advisorId` en el payload).
- [ ] **CA-11**: Indicador visual de estado de conexión WebSocket en el header: 🟢 Conectado / 🟡 Reconectando... / 🔴 Desconectado. Durante desconexión, se deshabilitan acciones de escritura.
- [ ] **CA-12**: Modo oscuro funcional con toggle (ícono sol/luna) y persistencia en `localStorage`; implementado con `@custom-variant dark` de Tailwind v4.
- [ ] **CA-13**: Paleta de colores, tipografía Inter, sombras, radios, transiciones y animaciones (`slideIn`, `fadeIn`, `pulse-op`) preservados del diseño original mediante tokens `@theme` en CSS.
- [ ] **CA-14**: Los 45 tipos de caso del CSV están mapeados en `src/data/caseTypeDefinitions.ts` con `uiPattern`, `formFields`, `validationSchema` (Zod) y `payloadBuilder` para cada uno.
- [ ] **CA-15**: Backend Python puede implementarse siguiendo los contratos REST + WebSocket documentados en la sección 1.3 Paso 8 sin ambigüedades.
- [ ] **CA-16**: Capa MSW operativa con handlers para todos los endpoints REST y simulación de eventos WebSocket, permitiendo desarrollo full-stack del frontend sin backend real.
- [ ] **CA-17**: **Paridad funcional con el sistema actual**: notificaciones de escritorio HTML5, alerta sonora (Web Audio API) y parpadeo de título al recibir nuevos casos (`hitl_request`).
- [ ] **CA-18**: Reconexión WebSocket con backoff exponencial; al reconectar se recibe `init_state` y se reemplaza el estado local completo.
#### Paso 9 — Capa de Mocks (MSW) y Funcionalidades Preservadas
1. **MSW (Mock Service Worker)** para desarrollo desacoplado:
- Handlers REST que simulan `/api/v1/cases`, `/api/v1/cases/:id`, `/api/v1/cases/:id/resolve`, `/api/v1/conversations/active`.
- Datos de prueba: 10-15 casos de ejemplo cubriendo los 6 `uiPattern` y múltiples aplicativos.
- 3-5 conversaciones simuladas con mensajes de diferentes roles.
- El MSW se activa solo en modo desarrollo (`VITE_ENABLE_MSW=true`).
2. **Funcionalidades preservadas del sistema actual**:
- **Notificaciones de escritorio HTML5**: Hook `useNotification` que emite `new Notification()` al recibir `hitl_request`; click en notificación navega a `/cases` con el caso seleccionado.
- **Alerta sonora**: Hook `useSound` con Web Audio API (chime de dos tonos C5→E5, volumen 0.08), activado solo tras primer gesto del usuario (política de autoplay).
- **Parpadeo de título**: Efecto de título alternante cuando la pestaña no está enfocada y llegan nuevos casos; se limpia al enfocar.
- **Detección de foco de pestaña**: `document.visibilitychange` + `window.focus`/`blur` para controlar notificaciones.
### 1.5 Jerarquía de Aplicativos (para filtro secundario)
| Aplicativo | Descripción | N° de Tipos |
|-----------|-------------|:-----------:|
| **AC+** | Atención al Cliente (móvil) | 13 |
| **ASCARD** | Equipos financiados | 9 |
| **DiMe** | Ajustes online | 8 |
| **Formatos SGCS** | Cambios de ciclo | 2 |
| **Mi asistencia 360** | Escalamientos de pago | 2 |
| **Paradigma** | Facturación hogar/móvil | 2 |
| **RR** | Recepción y Radicación (hogar) | 12 |
| **Phone Protect** | Desbloqueo IMEI | 1 |
### 1.6 Riesgos Identificados (preliminar, para debate)
1. **Streaming token-a-token**: La semántica de concatenación depende de que el backend envíe tokens con un `conversationId` consistente. Si hay mensajes simultaneous, el orden de tokens debe estar garantizado.
2. **Persistencia de timers**: Actualmente en `localStorage`. En React, el estado del timer debe sincronizarse entre el store y `localStorage` sin causar re-renders excesivos (usar refs para el intervalo).
3. **Tailwind + CSS variables**: La migración de CSS puro a Tailwind requiere mapear cada utilidad. Los gradientes (`linear-gradient`) y `-webkit-background-clip` necesitan configuración adicional en Tailwind.
4. **CSV parsing**: Los 45 registros deben clasificarse manualmente en los 6 `uiPattern`. Algunos casos (ej. `Unificar_Factura_EF` que usa ASCARD + Paradigma) requieren lógica multi-aplicativo.
5. **WebSocket reconnection**: La lógica de reconexión debe preservar el estado local y re-sincronizar al reconectar (recibir `init_state`).
## Fase 2: Auditoría de Arquitectura y Debate Técnico (v3 — Aprobada)
### 2.1 Resumen de Hallazgos
La Fase 1 pasó por dos ciclos de auditoría. En la primera iteración se identificaron 10 riesgos (5 bloqueantes). Tras las correcciones del usuario, la segunda auditoría detectó 3 inconsistencias residuales de redacción: referencias a `hitl_response` como canal WS, mención de `tailwind.config.ts` en el Paso 7, y `advisorId` persistente en una definición de tipo. Las tres fueron corregidas. El plan es ahora **consistente, blindado y viable sin bloqueantes**.
### 2.2 Riesgos Resueltos (todos)
- ✅ **R1 (Tailwind v4)**: Resuelto — `@theme` + `@custom-variant dark`; toda referencia a `tailwind.config.ts` purgada.
- ✅ **R2 (Doble canal)**: Resuelto — REST como único canal autoritativo; `hitl_response` eliminado de tipos, Paso 4 y contratos WS.
- ✅ **R3 (Contratos WS)**: Resuelto — Envelope estándar, eventos de streaming explícitos, `error`, reconexión documentada.
- ✅ **R4 (advisorId)**: Resuelto — Eliminado de todos los payloads cliente→servidor y tipos; consistente en REST y WS.
- ✅ **R5 (REST filtrable)**: Resuelto — `GET /api/v1/cases?status=&applicative=&search=&offset=&limit=`.
- 🟡 **R6R10**: Mitigados con acciones documentadas en el plan (buffer streaming, MSW, reestructuración repo, funcionalidades preservadas, taxonomía ampliada con Zod).
### 2.3 Directrices para el Desarrollador
- **Regla 1**: La resolución de casos es exclusivamente REST (`POST /cases/:id/resolve`). WebSocket solo difunde y streamea.
- **Regla 2**: Ningún payload cliente→servidor contiene identificadores de asesor. El backend deriva la identidad.
- **Regla 3**: Tailwind v4 se configura exclusivamente vía CSS (`@theme`, `@custom-variant dark`). Sin `tailwind.config.ts`.
- **Regla 4**: El streaming usa el buffer de 50ms y solo re-renderiza la conversación seleccionada.
- **Regla 5**: Los 45 tipos de caso deben tener `validationSchema` (Zod) y `payloadBuilder` definidos antes de declarar completo el mapeo.
### 2.4 Veredicto Final
- **Estado del plan**: **En Implementación**.
- El plan es internamente consistente, los contratos REST/WS están completamente especificados, y el frontend puede desarrollarse de forma desacoplada mediante MSW. No hay bloqueantes residuales.
## Fase 3: Registro de Implementación
### 3.1 Paso 0 — Bootstrap y Setup
- `legacy/server.js`: [Creado] → Copia del backend Express legacy.
- `legacy/db.js`: [Creado] → Copia del módulo de base de datos SQLite (better-sqlite3).
- `legacy/schema.sql`: [Creado] → Copia del esquema SQL de la tabla `requests`.
- `legacy/package.json`: [Creado] → Copia del manifiesto de dependencias del backend legacy.
- `legacy/.env`: [Creado] → Copia de variables de entorno del backend legacy.
- `legacy/.env.example`: [Creado] → Copia con comentarios del backend legacy.
- `legacy/public/index.html`: [Creado] → Copia del HTML del frontend vanilla legacy.
- `legacy/public/style.css`: [Creado] → Copia de los estilos CSS del frontend vanilla legacy.
- `legacy/public/app.js`: [Creado] → Copia de la lógica JS del frontend vanilla legacy.
- `package.json`: [Modificado] → Reemplazado por el manifiesto del nuevo proyecto Vite + React + TypeScript con todas las dependencias core y de desarrollo.
- `vite.config.ts`: [Creado] → Configuración de Vite con plugin React y Tailwind CSS v4, proxy para API REST y WebSocket.
- `tsconfig.json`: [Creado] → Configuración raíz de TypeScript con referencias a `tsconfig.app.json` y `tsconfig.node.json`.
- `tsconfig.app.json`: [Creado] → Configuración TS para la aplicación React (ES2020, JSX react-jsx, paths con alias `@/`).
- `tsconfig.node.json`: [Creado] → Configuración TS para Vite y herramientas de Node.
- `index.html`: [Creado] → Entry point de Vite con fuente Inter de Google Fonts, módulo ES para `src/main.tsx`.
- `.env`: [Modificado] → Nuevas variables de entorno para frontend (`VITE_API_BASE_URL`, `VITE_WS_URL`, `VITE_ENABLE_MSW`).
- `.env.example`: [Creado] → Template de variables de entorno del frontend.
- `.gitignore`: [Creado] → Ignora `node_modules/`, `dist/`, `.env`, `database.sqlite`, entre otros.
- `src/vite-env.d.ts`: [Creado] → Declaraciones de tipos para `import.meta.env` con tipado estricto.
- `src/main.tsx`: [Creado] → Punto de entrada React con inicialización condicional de MSW (`VITE_ENABLE_MSW=true`).
- `src/App.tsx`: [Creado] → Componente raíz con React Router (`/`, `/cases`, `/monitor`), redirect a `/cases`.
- `src/index.css`: [Creado] → Estilos globales con Tailwind CSS v4, design tokens `@theme`, modo oscuro con `@custom-variant dark`, animaciones `slideIn`/`fadeIn`/`pulse-op`, scrollbar personalizado.
- `src/mocks/browser.ts`: [Creado] → Setup de MSW Worker para interceptar peticiones REST en desarrollo.
- `src/mocks/handlers.ts`: [Creado] → Handlers MSW para endpoints REST mock: 10 casos de prueba (cubriendo los 6 `uiPattern`), 3 conversaciones simuladas, handlers para `/api/v1/cases`, `/api/v1/cases/:id`, `/api/v1/cases/:id/resolve`, `/api/v1/conversations/active`, `/api/v1/conversations/:id`.
- `src/components/layout/.gitkeep`: [Creado] → Marcador de directorio para `layout/`.
- `src/components/cases/.gitkeep`: [Creado] → Marcador de directorio para `cases/`.
- `src/components/monitor/.gitkeep`: [Creado] → Marcador de directorio para `monitor/`.
- `src/components/shared/.gitkeep`: [Creado] → Marcador de directorio para `shared/`.
- `src/hooks/.gitkeep`: [Creado] → Marcador de directorio para `hooks/`.
- `src/services/.gitkeep`: [Creado] → Marcador de directorio para `services/`.
- `src/store/.gitkeep`: [Creado] → Marcador de directorio para `store/`.
- `src/types/.gitkeep`: [Creado] → Marcador de directorio para `types/`.
- `src/pages/.gitkeep`: [Creado] → Marcador de directorio para `pages/`.
- `src/data/.gitkeep`: [Creado] → Marcador de directorio para `data/`.
### 3.2 Estrategia de Solución e Integración
- **Implementación Arquitectónica**: Se estructuró el proyecto siguiendo el principio de agnosticismo y separación de conceptos. El backend legacy se aisló completamente en `legacy/`, dejando la raíz del proyecto limpia para el nuevo frontend Vite + React + TypeScript. La configuración de Tailwind v4 es CSS-first (sin `tailwind.config.ts`), usando la directiva `@theme` para definir los design tokens y `@custom-variant dark` para el modo oscuro. Se implementó MSW como capa de mockeo REST para desarrollo desacoplado del backend.
- **Mitigación de Riesgos (Fase 2)**:
- **Regla 1 (REST como canal autoritativo)**: Los handlers de MSW simulan `POST /cases/:id/resolve` como endpoint REST exclusivo para resolución de casos. No se incluye WebSocket para escritura.
- **Regla 2 (Sin advisorId)**: Los handlers MSW no requieren `advisorId` en los payloads, en línea con los contratos especificados.
- **Regla 3 (Tailwind v4 CSS-first)**: No existe `tailwind.config.ts`. Toda la configuración está en `src/index.css` mediante `@theme` y `@custom-variant`.
- **Regla 4 (Streaming buffer 50ms)**: Se documentó en la spec; la implementación del buffer se realizará en el hook `useWebSocket` en fases posteriores.
- **Regla 5 (45 tipos de caso con Zod)**: Los mock data en handlers incluyen 10 casos de ejemplo cubriendo los 6 `uiPattern`; la implementación completa de los 45 tipos se hará en Paso 1.
### 3.3 Notas Técnicas para el Tester
- **Dependencias Añadidas**:
- **Core**: `react`, `react-dom`, `react-router-dom`, `zustand`, `zod`, `date-fns`, `lucide-react`
- **Dev**: `typescript`, `vite`, `@vitejs/plugin-react`, `tailwindcss`, `@tailwindcss/vite`, `msw`, `@testing-library/react`, `@testing-library/jest-dom`, `vitest`, `@types/react`, `@types/react-dom`
- **Puntos Críticos a Probar**:
1. **Restauración manual necesaria**: Los archivos `node_modules/`, `package-lock.json`, `database.sqlite` y el directorio `public/` (antiguo) aún existen en la raíz y deben moverse manualmente a `legacy/` o eliminarse. Ejecutar:
```bash
rm -rf node_modules/ public/ package-lock.json database.sqlite
mv server.js db.js schema.sql legacy/ 2>/dev/null; true
```
2. **MSW no inicializado**: El archivo `public/mockServiceWorker.js` debe generarse ejecutando `npx msw init public/ --save`.
3. **Verificar que el alias `@/` funciona**: El `tsconfig.app.json` define `paths` con `@/*` → `src/*`. Confirmar que Vite resuelva los imports correctamente.
4. **Modo oscuro**: El `@custom-variant dark` usa la clase `.dark` en un contenedor padre. Verificar que al agregar `class="dark"` al `<html>` se activen los colores oscuros.
5. **MSW handlers**: Verificar que `VITE_ENABLE_MSW=true` activa la interceptación en desarrollo y que los endpoints mock responden correctamente (ej. `curl http://localhost:5173/api/v1/cases`).
---
### 3.1 Paso 1 — Sistema de Tipos, Contratos WebSocket y Mapeo de 53 Casos del CSV
- `src/types/index.ts`: [Creado] → Define las interfaces base del sistema (CaseRequest, Conversation, Message, FormField, CaseTypeDefinition) y los enums (CaseUIType, CaseStatus, AgentStatus, MessageRole). Utiliza tipado estático estricto con `z.ZodType` para los campos de validación de esquemas en CaseTypeDefinition.
- `src/types/wsProtocol.ts`: [Creado] → Implementa el envelope WebSocket estándar con Zod (WSEnvelopeSchema), más los 11 schemas de eventos servidor→cliente (init_state, conversation_started, conversation_ended, user_message, agent_stream_started, agent_stream_chunk, agent_stream_completed, agent_status_update, hitl_request, hitl_resolved, error) y 1 schema cliente→servidor (internal_note). Incluye funciones helper `createWSEnvelope()`, `validateServerEvent()`, `validateClientEvent()` con mapas discriminadores por tipo de evento para validación dinámica en el hook useWebSocket.
- `src/data/caseTypeDefinitions.ts`: [Creado] → Mapeo completo de los 53 registros del CSV a objetos `CaseTypeDefinition` con:
- Clasificación de `uiPattern` según las 6 familias visuales (SIMPLE_CONFIRMATION, CONFIRMATION_WITH_VALUE, MULTI_FIELD_FORM, DATE_SIMPLE, FREE_TEXT, READ_ONLY).
- `formFields` derivados del `responseFormat` y casos especiales documentados (Escalar_Pagos_No_Abonados con 9 campos, Validar_OTT_1/2 con 6 y 8 campos respectivamente, etc.).
- `validationSchema` Zod para cada entrada, con validaciones de tipo (número, moneda, toggle, fecha en formato dd-mm-aaaa, select con enum).
- `payloadBuilder` para serializar el formulario al payload del backend.
- Mapas helper `caseTypeByToolName` y `caseTypesByApplicative` para búsqueda rápida.
- Helpers de fábrica (`simpleConfirmation`, `confirmationWithValue`, `dateSimple`, `freeText`, `readOnly`, `multiFieldForm`) para reducir repetición de código.
### 3.2 Estrategia de Solución e Integración
- **Implementación Arquitectónica**: Se respetó el principio de separación de conceptos manteniendo las interfaces de dominio (`CaseRequest`, `Conversation`, `Message`) en `src/types/index.ts` desacopladas de los contratos de comunicación (`wsProtocol.ts`) y de los datos estáticos (`caseTypeDefinitions.ts`). Los helpers de fábrica en caseTypeDefinitions.ts permiten definir esquemas Zod y builders de payload de forma declarativa y consistente, eliminando la duplicación masiva de código.
- **Mitigación de Riesgos (Fase 2)**:
- **Regla 1 (REST como canal autoritativo)**: En `wsProtocol.ts` no existe ningún evento `hitl_response`; la resolución de casos se realiza exclusivamente vía REST. El protocolo WS solo define eventos de difusión/streaming.
- **Regla 2 (Sin advisorId)**: En `wsProtocol.ts`, el payload `internal_note` solo contiene `conversationId` y `content`. No se incluye `advisorId` en ningún payload cliente→servidor. El backend debe derivar la identidad del contexto de conexión.
- **Regla 5 (45 tipos de caso con Zod)**: Se implementaron 53 registros del CSV (la diferencia con la cifra "45" se debe a que algunos toolName se repiten con diferentes especialistas/objetivos). Cada registro tiene su `validationSchema` Zod y `payloadBuilder` completamente implementados, sin placeholders ni TODOs.
### 3.3 Notas Técnicas para el Tester
- **Dependencias Añadidas**: Ninguna nueva (zod ya estaba incluida en Paso 0).
- **Puntos Críticos a Probar**:
1. **Tipos estrictos**: Verificar que `tsc --noEmit` (o `npm run lint`) no produce errores de tipo. Archivos clave: `src/types/index.ts`, `src/types/wsProtocol.ts`, `src/data/caseTypeDefinitions.ts`.
2. **Validación Zod de eventos WS**: Probar que `validateServerEvent('init_state', payload)` rechaza payloads mal formados (ej. falta `conversations` o `activeCases`). Probar `validateClientEvent('internal_note', { conversationId: '', content: '' })` debe fallar porque `content` requiere `min(1)`.
3. **Cobertura de 53 registros**: Verificar que `caseTypeDefinitions.length` es 53 y que ningún registro tiene `validationSchema` o `payloadBuilder` como undefined.
4. **Mapas auxiliares**: `caseTypeByToolName` debe contener todas las toolNames (las duplicadas prevalece la última). `caseTypesByApplicative` debe tener entradas para "AC+", "ASCARD", "DiMe", "Formatos SGCS", "Mi asistencia 360", "Paradigma", "RR", "Phone Protect".
5. **FormFields vs ValidationSchema**: Para cada `MULTI_FIELD_FORM`, verificar que los campos en `formFields` coinciden uno a uno con las claves del `validationSchema`. Ejemplo: `Plan_De_Pagos_EF` debe tener 4 campos (numero_cuotas, valor_cuota, dia_corte, dia_limite_pago) tanto en formFields como en validationSchema.
6. **PayloadBuilder fidelidad**: Para `Validar_OTT_1`, verificar que `payloadBuilder({ reinstalacion: true, valor_reinstalacion: 50000, fecha_adquisicion_reinstalacion: '01-01-2024', deco_adicional: false, valor_deco: 0, fecha_adquisicion_deco: '01-01-2024' })` devuelve un objeto con exactamente esas 6 claves y mismos valores.
### 3.1 Paso 2 — Capa de Servicios (api.ts, wsClient.ts) y Store Zustand (useAppStore.ts)
- `src/services/api.ts`: [Creado] → Cliente REST con `fetch` nativo. Implementa `getCases`, `getCaseById`, `resolveCase`, `getActiveConversations`, `getConversation`. Define `PaginatedResponse<T>`, `CaseFilters`, y `ApiError` para manejo de errores HTTP. La URL base se configura via `VITE_API_BASE_URL` con fallback a `http://localhost:5503/api/v1`. Incluye helper `buildQuery()` para construir query string con filtros (status, applicative, search, offset, limit) y helper interno `request<T>()` para centralizar la lógica de fetch, headers JSON, y validación de código HTTP. Tipos importados de `@/types`.
- `src/services/wsClient.ts`: [Creado] → Cliente WebSocket en clase `WsClient` con patrón singleton exportado como `wsClient`. Implementa reconexión con backoff exponencial (1s → 2s → 4s → 8s → 16s → máx 30s, factor 2x). Expone `connect()`, `disconnect()`, `send(type, payload)` que genera automáticamente `eventId` (crypto.randomUUID) y `occurredAt` (ISO-8601) en el envelope estándar, `onMessage` callback setter/getter, y `getStatus()` retornando `'connected' | 'disconnected' | 'reconnecting'`. Maneja cierre graceful con flag `destroyFlag` para evitar reconexión en desconexión intencional. Ignora mensajes malformados silenciosamente. Tipos importados de `@/types/wsProtocol`.
- `src/store/useAppStore.ts`: [Creado] → Store centralizado Zustand con tres slices:
- **casesSlice**: `cases[]`, `selectedCaseId`, `totalCases`, `fetchCases(filters)` (llama a `api.getCases` y actualiza estado), `upsertCase(c)` (reemplaza si existe o agrega al inicio), `resolveCase(id, data)` (llama a `api.resolveCase` y actualiza el caso en el array local).
- **conversationsSlice**: `conversations[]`, `selectedConversationId`, `fetchConversations()` (llama a `api.getActiveConversations`), `upsertConversation(c)`, `addMessage(convId, msg)`, `appendToken(convId, msgId, token, index)` (bufferiza chunks en `metadata._chunks` ordenados por `index` para manejar entrega fuera de orden, actualiza `content` concatenando chunks ordenados), `completeStream(convId, msgId, fullContent)` (limpia `_chunks` de metadata, establece `content = fullContent`, marca `isStreaming = false`).
- **uiSlice**: `sidebarTab` ('all'|'pending'|'resolved'), `searchQuery`, `applicativeFilter`, `isDarkMode` (persistido en `localStorage` via clave `claro-cases:darkMode`), `wsStatus`. Setters: `setSidebarTab`, `setSearchQuery`, `setApplicativeFilter`, `toggleDarkMode` (persiste y actualiza), `setWsStatus`.
- La persistencia de `isDarkMode` se implementa con helper `readDarkMode()` que lee `localStorage` al inicializar el store y `persistDarkMode()` que escribe en cada toggle.
### 3.2 Paso 3 — Componentes Compartidos (StatusBadge, SearchBar, TabsBar, EmptyState, Modal, Timer)
- `src/components/shared/StatusBadge.tsx`: [Creado] → Renderiza un badge de estado con colores por `CaseStatus`. Usa mapas `STATUS_LABELS` (Pendiente/En Progreso/Finalizado/Fallido) y `STATUS_STYLES` con clases Tailwind según los tokens del tema (accent-yellow, accent-orange, accent-green, accent-red). Estilo: `text-[9px] px-1.5 py-0.5 rounded-[10px] font-semibold uppercase border`. Props: `status: CaseStatus`.
- `src/components/shared/SearchBar.tsx`: [Creado] → Input de búsqueda con ícono `Search` de `lucide-react`. Implementa debounce de 300ms usando `useRef` para el timer y `useEffect` para sincronizar con el store. Almacena el valor local en `useState` y solo escribe al store tras el debounce. Estilo: fondo `bg-elevated`, borde `border`, foco `focus:border-accent-orange`. Props: ninguna (lee/escribe del store directamente).
- `src/components/shared/TabsBar.tsx`: [Creado] → Barra de tres pestañas (Todos/Pendientes/Finalizados) que lee `sidebarTab` del store y llama a `setSidebarTab`. Pestaña activa: `bg-accent-orange/8 text-accent-orange border-accent-orange`. Inactiva: `text-text-muted border-transparent`. Estilo: `text-[11px] font-semibold uppercase tracking-wider`. Props: ninguna.
- `src/components/shared/EmptyState.tsx`: [Creado] → Estado vacío centrado vertical/horizontalmente. Renderiza `icon` (ReactNode, ej. emoji), `title` (14px font-semibold), `description` (12px text-secondary). Ícono con `text-[3rem] opacity-40 leading-none`. Props: `icon: ReactNode`, `title: string`, `description: string`.
- `src/components/shared/Modal.tsx`: [Creado] → Overlay modal con backdrop blur (`bg-black/40 backdrop-blur-sm`), contenido centrado con animación `fadeIn`. Cierra con Escape (event listener) y al hacer click en backdrop. Contenido: `bg-surface border border-border rounded-lg shadow-lg`. Header con título y botón ✕. Body para `children`. Footer opcional `actions`. Props: `isOpen`, `onClose`, `title`, `children`, `actions?`.
- `src/components/shared/Timer.tsx`: [Creado] → Cronómetro individual por caso con persistencia en `localStorage` (clave `timer_case_{caseId}`). Implementado con `forwardRef` y `useImperativeHandle` exponiendo `start()`, `stop()`, `getElapsed()`. Usa `useRef` para el intervalo (`setInterval` 1s) y contadores acumulados. `useState` solo para el display (MM:SS). Al montar, restaura estado desde `localStorage`. Al desmontar, limpia el intervalo. Display: `font-mono text-xl font-bold tabular-nums text-text-primary`. Props: `caseId: string | number`.
### 3.2 Estrategia de Solución e Integración
- **Implementación Arquitectónica**: Se respetó el principio de agnosticismo separando la capa de servicios (REST y WebSocket) del store y de los componentes. `api.ts` es un cliente REST puro sin dependencias de React ni del store, permitiendo ser usado desde hooks o desde MSW. `wsClient.ts` es una clase singleton agnóstica al framework que expone callbacks, permitiendo que `useWebSocket` (hook futuro) se suscriba sin acoplamiento. El store Zustand usa `api` para las operaciones de escritura (fetchCases, resolveCase, fetchConversations), manteniendo la lógica de negocio desacoplada del mecanismo de transporte. Los componentes compartidos son puramente presentacionales (StatusBadge, EmptyState, Modal) o se conectan al store de forma mínima (SearchBar, TabsBar), sin depender de servicios directamente.
- **Mitigación de Riesgos (Fase 2)**:
- **Regla 1 (REST como canal autoritativo)**: `resolveCase` en el store llama exclusivamente a `api.resolveCase()` (POST REST). No existe ninguna función de resolución por WebSocket.
- **Regla 2 (Sin advisorId)**: El cliente WebSocket `send()` no incluye `advisorId` en ningún payload. El método genérico solo recibe `type` y `payload`. Los helpers de validación Zod del `wsProtocol.ts` ya garantizan que `internal_note` solo tenga `conversationId` y `content`.
- **Regla 3 (Tailwind v4 CSS-first)**: Todos los componentes usan clases Tailwind directamente con los tokens CSS definidos en `@theme` (bg-surface, text-primary, border-accent-orange, etc.). No hay configuración JS de Tailwind.
- **Regla 4 (Streaming buffer 50ms)**: `appendToken` en el store usa `metadata._chunks` ordenados por `index` para garantizar orden correcto de tokens incluso si llegan fuera de orden de red. El buffer se implementa a nivel de store, preparado para que el hook `useWebSocket` (futuro) pueda rate-limit las actualizaciones a 20fps.
- **Regla 5 (45 tipos de caso con Zod)**: No aplica en este paso (implementado en Paso 1).
### 3.3 Notas Técnicas para el Tester
- **Dependencias Añadidas**: `zustand` (ya instalada en Paso 0), `lucide-react` (ya instalada en Paso 0). No se añadieron nuevas dependencias.
- **Puntos Críticos a Probar**:
1. **api.ts — Error handling**: Verificar que `ApiError` se lanza correctamente para códigos HTTP 4xx/5xx. Probar con MSW simulando errores 404 y 500. Verificar que `buildQuery` omite parámetros undefined/null.
2. **api.ts — Paginación**: Llamar `getCases({ offset: 0, limit: 5 })` y verificar query string `?offset=0&limit=5`. Llamar con `getCases({})` y verificar que no se añade `?` en la URL.
3. **wsClient.ts — Reconexión**: Verificar backoff exponencial: tras cerrar WebSocket, debe reconectar con delays crecientes (1s, 2s, 4s, 8s...). Probar que `disconnect()` detiene la reconexión inmediatamente.
4. **wsClient.ts — Envelope**: Verificar que `send('internal_note', { conversationId: 'c1', content: 'nota' })` produce un mensaje JSON con `type`, `eventId` (UUID), `occurredAt` (ISO string) y `payload`.
5. **useAppStore.ts — appendToken**: Enviar tokens fuera de orden (index 2, 0, 1) y verificar que el contenido final es la concatenación ordenada. Verificar que `completeStream` reemplaza el contenido con `fullContent` y limpia `metadata._chunks`.
6. **useAppStore.ts — Dark mode persistence**: Llamar `toggleDarkMode()`, recargar el store, verificar que `isDarkMode` persiste. Verificar que `localStorage` contiene `claro-cases:darkMode=true`.
7. **StatusBadge.tsx — Renderizado condicional**: Renderizar con cada `CaseStatus` y verificar clases de color correctas y texto en español.
8. **SearchBar.tsx — Debounce**: Escribir texto rápidamente y verificar que solo se actualiza el store tras 300ms de inactividad. Verificar que el ícono `Search` está presente.
9. **TabsBar.tsx — Estado activo**: Hacer clic en "Pendientes" y verificar que `sidebarTab` en el store cambia a `'pending'` y la pestaña visualmente activa tiene las clases `bg-accent-orange/8 text-accent-orange border-accent-orange`.
10. **Timer.tsx — Persistencia y control**: Llamar `start()` y esperar 5s. Verificar que `localStorage` tiene el timer guardado. Llamar `stop()` y verificar display se congela. Llamar `getElapsed()` y verificar que devuelve los segundos exactos. Recargar el componente y verificar que el tiempo acumulado se restaura. Iniciar de nuevo y confirmar que continúa desde donde quedó.
11. **Modal.tsx — Accesibilidad**: Verificar que el modal se cierra con tecla Escape. Verificar que el click en backdrop cierra el modal. Verificar que el click dentro del contenido no lo cierra.
12. **EmptyState.tsx — Renderizado**: Verificar que `icon` renderiza como elemento (puede ser string emoji o componente React), `title` en 14px semibold, `description` en 12px secondary, centrado vertical/horizontalmente.
### 3.1 Paso 4 — Módulo de Gestión de Casos HITL (`/cases`)
- `src/components/cases/TypeBadge.tsx`: [Creado] → Badge pequeño que muestra el `tipoSolicitud` con estilo `bg-accent-orange/10 text-accent-orange border-accent-orange/25`. Trunca el texto a 140px con `title` para tooltip.
- `src/components/cases/CaseCard.tsx`: [Creado] → Tarjeta de caso en la sidebar. Props `case: CaseRequest`, `isActive`, `onClick`. Renderiza: (1) Header con título, `StatusBadge` y timer formateado (solo si `status === IN_PROGRESS` y `handlingTime > 0`); (2) Descripción truncada a 2 líneas con `line-clamp-2`; (3) Footer con ID externo en monospace, `TypeBadge` con `tipoSolicitud`, y fecha formateada con `date-fns`. Estilo base `bg-elevated border rounded-md p-3 cursor-pointer transition hover:bg-hover`, activo `bg-accent-orange/4 border-accent-orange`. Animación `animate-[slideIn_0.2s_ease-out]`.
- `src/components/cases/ApplicativeFilter.tsx`: [Creado] → Filtro de aplicativos mediante chips/badges clickeables. Lista fija de los 8 aplicativos (AC+, ASCARD, DiMe, Formatos SGCS, Mi asistencia 360, Paradigma, RR, Phone Protect). Usa `applicativeFilter` y `setApplicativeFilter` del store. Al hacer clic en un chip activo, lo deselecciona (pasa a `null`). Incluye botón "✕ Limpiar" que solo aparece cuando hay un filtro activo. Estilo: chip activo `bg-accent-orange/10 text-accent-orange border-accent-orange/30`, inactivo `bg-elevated text-text-muted border-border`.
- `src/components/cases/FormRenderer.tsx`: [Creado] → Componente crítico que renderiza formularios dinámicos según `CaseUIType`. Props: `caseType: CaseTypeDefinition`, `onSubmit: (data) => void`. Implementa los 6 patrones de UI (SIMPLE_CONFIRMATION, CONFIRMATION_WITH_VALUE, MULTI_FIELD_FORM, DATE_SIMPLE, FREE_TEXT, READ_ONLY) con estado local `formValues`/`formErrors`, transformación de fechas yyyy-mm-dd ↔ dd-mm-aaaa, validación Zod inline, soporte `conditionalOn`, y FieldInput interno para renderizar cada tipo de campo (text, number, currency con $, date, select, textarea, toggle switch).
- `src/components/cases/CaseDetail.tsx`: [Creado] → Panel derecho de detalle con metadata grid (ID, cédula, tipo, aplicativo), descripción, payload entrante, FormRenderer dinámico, acordeón de pasos colapsable, y panel de operación sticky con Timer + fecha.
- `src/pages/CasesPage.tsx`: [Creado] → Layout maestro: sidebar 320px (SearchBar + TabsBar + ApplicativeFilter + lista CaseCards scrolleable + footer conteo) y panel derecho (CaseDetail / EmptyState). Conecta store para casos filtrados por tab/search/applicative. `filterCases()` interno con lógica de filtrado combinado.
### 3.2 Estrategia de Solución e Integración
- **Implementación Arquitectónica**: Se respetó la separación de conceptos manteniendo los componentes de UI (`CaseCard`, `CaseDetail`, `TypeBadge`, `ApplicativeFilter`) desacoplados de la lógica de formularios dinámicos (`FormRenderer`) y del store. `CaseCard` y `TypeBadge` son puramente presentacionales. `FormRenderer` encapsula toda la complejidad de renderizado condicional, transformación de fechas, y validación Zod inline. `CaseDetail` orquesta la integración entre metadata, formulario y timer. `CasesPage` actúa como orquestador de layout y filtros.
- **Mitigación de Riesgos (Fase 2)**:
- **Regla 1 (REST como canal autoritativo)**: `CaseDetail.handleFormSubmit` llama a `resolveCase` del store (POST REST). `FormRenderer` solo recolecta datos y llama a `onSubmit`.
- **Regla 2 (Sin advisorId)**: Ningún componente envía `advisorId`. El payload contiene solo `action` y `payload`.
- **Regla 3 (Tailwind v4 CSS-first)**: Todos los componentes usan exclusivamente tokens `@theme` sin configuración JS de Tailwind.
- **Regla 5 (45 tipos de caso con Zod)**: `FormRenderer` usa `validationSchema.safeParse()` antes de llamar a `onSubmit`.
### 3.3 Notas Técnicas para el Tester
- **Dependencias Añadidas**: `date-fns` (ya instalada en Paso 0). `lucide-react` (ya instalada en Paso 0). No se añadieron nuevas dependencias.
- **Puntos Críticos a Probar**:
1. **CaseCard — Renderizado condicional de timer**: Solo aparece cuando `status === IN_PROGRESS` y `handlingTime > 0`. Formato MM:SS.
2. **CaseCard — Animación slideIn** al montar.
3. **ApplicativeFilter — Toggle**: Chip activo ↔ `applicativeFilter` en store. Botón ✕ solo visible con filtro activo.
4. **FormRenderer — SIMPLE_CONFIRMATION**: Botones Sí/No llaman `onSubmit({ confirmacion: true/false })`.
5. **FormRenderer — CONFIRMATION_WITH_VALUE**: Radio Sí→ campo $ visible, Radio No→ oculto. Validación valor negativo.
6. **FormRenderer — MULTI_FIELD_FORM**: Renderiza types correctos, min/max, toggle switch, conditionalOn, errores inline.
7. **FormRenderer — Transformación fecha**: Date picker → valor enviado en dd-mm-aaaa.
8. **FormRenderer — READ_ONLY**: Botón "Marcar como revisado" llama `onSubmit({})`.
9. **CaseDetail — Timer**: Inicia automático en IN_PROGRESS, se detiene al resolver.
10. **CaseDetail — Acordeón**: Pasos colapsables con ChevronDown/ChevronUp.
11. **CasesPage — Filtros combinados**: Búsqueda + tab + aplicativo se combinan correctamente. Footer "X de Y casos".
12. **CasesPage — Empty states**: Sin casos → EmptyState en sidebar. Sin selección → EmptyState en panel derecho.
13. **CasesPage — Fetch on mount**: Se llama `fetchCases()` al montar.
14. **FormRenderer — Validación Zod**: Datos inválidos → errores inline, no se llama `onSubmit`.
### 3.1 Paso 5 — Módulo de Monitoreo (`/monitor`)
- `src/components/monitor/MessageBubble.tsx`: [Creado] → Burbuja de mensaje individual con diferenciación visual por rol (user → derecha/accent-orange, agent → izquierda/elevated, system → centrado/base/italic, internal → izquierda/accent-yellow con badge 🔒). Muestra timestamp HH:mm. Si `isStreaming`, muestra cursor parpadeante (barra animada).
- `src/components/monitor/InternalNotesGroup.tsx`: [Creado] → Acordeón expandible que agrupa mensajes `internal` consecutivos. Cabecera "🔄 Notas internas (N)" colapsable. Al expandir, muestra contenido y timestamp de cada nota. Implementa filtro de seguridad para solo renderizar mensajes con `role === INTERNAL`.
- `src/components/monitor/ChatFeed.tsx`: [Creado] → Feed de mensajes con auto-scroll inteligente. Detecta si el usuario está cerca del fondo (≤ 100px) mediante ref y handler `onScroll`; si está cerca, hace scroll automático al llegar nuevo mensaje o token. Agrupa mensajes `internal` consecutivos en `InternalNotesGroup` mediante buffer de acumulación intercalado con `flushInternal()`. Muestra indicador "Escribiendo..." con spinner cuando el último mensaje del agente tiene `isStreaming: true`.
- `src/components/monitor/ConversationCard.tsx`: [Creado] → Tarjeta de conversación en lista lateral. Muestra: (1) ID/nombre del cliente con icono User, (2) último mensaje truncado a 80 caracteres, (3) spinner `Loader2` animado si el último mensaje está en streaming, (4) estado del agente con color verde para activa, (5) badge de estado de conversación (Activa/En pausa/Finalizada). Sin badge HITL en esta iteración (requiere mapeo conversationId → caseId que se integrará con eventos WS).
- `src/components/monitor/InternalNoteBanner.tsx`: [Creado] → Banner inferior para inyección de notas internas. Textarea de 2 líneas con placeholder, botón "Enviar" con icono Send. Al enviar, llama a `wsClient.send('internal_note', { conversationId, content })` sin `advisorId`. Soporte Enter para enviar, Shift+Enter para nueva línea. Feedback visual "Enviado ✓" por 2 segundos tras envío exitoso. Hint con atajos de teclado.
- `src/pages/MonitorPage.tsx`: [Creado] → Layout de dos columnas: izquierda 280px con lista scrolleable de `ConversationCard`s (con encabezado y contador), derecha flex-1 con `ChatFeed` + `InternalNoteBanner` si hay conversación seleccionada, o `EmptyState` si no. Al montar, llama a `fetchConversations()` del store. Conecta con `selectedConversationId` y setea mediante `useAppStore.setState`.
### 3.2 Estrategia de Solución e Integración
- **Implementación Arquitectónica**: Se respetó la separación de conceptos manteniendo los componentes de monitoreo desacoplados del store y servicios. `MessageBubble` es puramente presentacional (solo recibe `Message` por props). `InternalNotesGroup` encapsula la lógica de agrupación y colapso. `ChatFeed` orquesta la integración entre burbujas, agrupación de notas internas y auto-scroll. `ConversationCard` es presentacional con helpers de extracción de último mensaje y detección de streaming. `InternalNoteBanner` se conecta directamente con `wsClient` (singleton) para enviar notas internas, sin pasar por el store. `MonitorPage` actúa como orquestador de layout y conexión con el store.
- **Mitigación de Riesgos (Fase 2)**:
- **Regla 1 (REST como canal autoritativo)**: `InternalNoteBanner` envía por WebSocket exclusivamente notas internas (evento `internal_note`), nunca resolución de casos.
- **Regla 2 (Sin advisorId)**: `wsClient.send('internal_note', { conversationId, content })` no incluye `advisorId` en el payload. El backend deriva la identidad del contexto de conexión WS.
- **Regla 3 (Tailwind v4 CSS-first)**: Todos los componentes usan exclusivamente tokens `@theme` sin configuración JS de Tailwind.
- **Regla 4 (Streaming buffer 50ms)**: `ChatFeed` reacciona a cambios en `messages[messages.length-1]?.content` para auto-scroll durante streaming, respetando posición manual del usuario mediante ref `isNearBottomRef`.
### 3.3 Notas Técnicas para el Tester
- **Dependencias Añadidas**: Ninguna nueva (todas las dependencias ya estaban instaladas en Pasos previos).
- **Puntos Críticos a Probar**:
1. **MessageBubble — 4 roles visuales**: Verificar alineación y fondo correctos para user (derecha/accent-orange/10), agent (izquierda/elevated), system (centrado/base/italic), internal (izquierda/accent-yellow/10 con badge 🔒).
2. **MessageBubble — Streaming cursor**: Cuando `isStreaming: true`, debe mostrar barra parpadeante al final del contenido.
3. **ChatFeed — Auto-scroll**: Con varias burbujas visibles, scrollear manualmente hacia arriba y verificar que al llegar un nuevo mensaje NO se hace auto-scroll. Scrollear al fondo y verificar que al llegar un nuevo mensaje SÍ se hace auto-scroll al fondo.
4. **ChatFeed — Agrupación de notas internas**: 2+ mensajes `internal` consecutivos deben agruparse en un acordeón. Un mensaje internal seguido de user/agent debe renderizarse individualmente.
5. **ChatFeed — Indicador "Escribiendo..."**: Cuando el último mensaje del agente tiene `isStreaming: true`, debe mostrar texto "Escribiendo..." con spinner.
6. **InternalNotesGroup — Expandir/colapsar**: Hacer clic en cabecera y verificar que se expanden/colapsan las notas. Verificar contador "Notas internas (N)".
7. **ConversationCard — Último mensaje truncado**: Mensaje > 80 caracteres debe truncarse con "...".
8. **ConversationCard — Spinner streaming**: Debe mostrar `Loader2` animado cuando el último mensaje tiene `isStreaming: true`.
9. **InternalNoteBanner — Envío sin advisorId**: Verificar que `wsClient.send` recibe payload sin campo `advisorId`. Verificar feedback "Enviado ✓" post-envío.
10. **InternalNoteBanner — Enter vs Shift+Enter**: Enter envía, Shift+Enter inserta nueva línea.
11. **MonitorPage — Layout**: 280px sidebar izquierda + flex-1 derecha. EmptyState cuando no hay conversación seleccionada.
12. **MonitorPage — Fetch on mount**: Se llama `fetchConversations()` al montar. Almacenar `selectedConversationId` con `useAppStore.setState`.
### 3.1 Paso 6 — App Shell y Ruteo
- `src/services/wsClient.ts`: [Modificado] → Se añadió callback `onStatusChange` (getter/setter) y tipo `StatusChangeCallback` para notificar cambios de estado de conexión al store. El método privado `setStatus()` ahora invoca `onStatusChangeCallback?.(status)` en cada transición, permitiendo que `AppShell` sincronice el indicador WS en el Header.
- `src/components/layout/Header.tsx`: [Creado] → Barra superior de 50px. Logo: emoji 🔴 + "Claro Cases" con gradiente `bg-gradient-to-r from-accent-orange to-accent-yellow bg-clip-text text-transparent`. Badge "En vivo" con estilo `bg-accent-green/10 text-accent-green`. Indicador de conexión WS: punto circular coloreado (verde/amarillo/rojo según `wsStatus` del store) + texto (Conectado/Reconectando.../Desconectado), con `animate-pulse` en estado reconnecting. Toggle tema oscuro/claro con iconos Sun/Moon de `lucide-react`.
- `src/components/layout/Sidebar.tsx`: [Creado] → Navegación lateral fija de 50px de ancho. Usa `NavLink` de react-router-dom con dos rutas: Casos (icono `LayoutList`) → `/cases`, Monitor (icono `Monitor`) → `/monitor`. Link activo: `bg-accent-orange/8 text-accent-orange`. Link inactivo: `text-text-muted hover:text-text-primary hover:bg-hover`. Layout vertical centrado con icono + label en 10px.
- `src/components/layout/AppShell.tsx`: [Creado] → Layout global que envuelve todo el contenido. Renderiza `Header` arriba, `Sidebar` a la izquierda (50px), y `children` (contenido de la ruta) a la derecha. Al montar: (1) sincroniza clase `.dark` en `<html>` según `isDarkMode` del store, (2) inicializa conexión WebSocket via `wsClient.connect()` y registra `onStatusChange` → `setWsStatus`, (3) registra `onMessage` handler (placeholder para integración futura de eventos WS), (4) llama `fetchCases()` o `fetchConversations()` según la ruta actual. Cleanup: desconecta WS y limpia callbacks al desmontar.
- `src/App.tsx`: [Reemplazado] → Router con `BrowserRouter` envolviendo `AppShell` como layout global. Tres rutas: `/` → redirect a `/cases`, `/cases` → `CasesPage`, `/monitor` → `MonitorPage`. Catch-all `*` → redirect a `/cases`.
### 3.2 Estrategia de Solución e Integración
- **Implementación Arquitectónica**: Se implementó el shell de aplicación siguiendo el principio de composición: `AppShell` es el layout contenedor que orquesta la inicialización de infraestructura (WS, tema oscuro, fetch inicial) y renderiza `Header` + `Sidebar` + contenido. El ruteo está desacoplado en `App.tsx` usando react-router-dom estándar. `Header` y `Sidebar` son componentes puramente presentacionales que se conectan al store para estado de UI (wsStatus, isDarkMode). La modificación a `wsClient.ts` es mínima y no rompe la interfaz existente.
- **Mitigación de Riesgos (Fase 2)**:
- **Regla 2 (Sin advisorId)**: `AppShell` no envía ningún identificador de asesor; solo establece la conexión WS y el handler de mensajes.
- **Regla 3 (Tailwind v4 CSS-first)**: Todos los componentes de layout usan exclusivamente tokens `@theme` sin configuración JS de Tailwind. El toggle dark mode usa `class` strategy con `@custom-variant dark`.
- **Regla 4 (Streaming buffer 50ms)**: `AppShell` registra un `onMessage` handler placeholder que será expandido en fases posteriores para implementar el buffer de 50ms.
- **Regla 5 (45 tipos de caso con Zod)**: No aplica en este paso.
### 3.3 Notas Técnicas para el Tester
- **Dependencias Añadidas**: Ninguna nueva.
- **Puntos Críticos a Probar**:
1. **Header — Gradiente logo**: Verificar que el texto "Claro Cases" tiene gradiente `accent-orange → accent-yellow` con `bg-clip-text text-transparent`.
2. **Header — Indicador WS**: Verificar punto verde + "Conectado" cuando `wsStatus = 'connected'`, amarillo + "Reconectando..." cuando `'reconnecting'`, rojo + "Desconectado" cuando `'disconnected'`. Estado reconnecting debe tener `animate-pulse`.
3. **Header — Toggle tema**: Hacer clic en icono sol/luna y verificar que `isDarkMode` cambia en el store y se agrega/remueve clase `.dark` en `<html>`.
4. **Sidebar — Navegación**: Verificar que NavLink activo tiene clase `bg-accent-orange/8 text-accent-orange`. Navegar entre /cases y /monitor y verificar cambio visual.
5. **AppShell — Inicialización WS**: Al montar, verificar que `wsClient.connect()` se llama y que `wsClient.onStatusChange` actualiza `wsStatus` en el store.
6. **AppShell — Dark mode sync**: Con `isDarkMode = true`, verificar que `<html>` tiene clase `.dark`. Con `false`, que no la tiene.
7. **AppShell — Fetch inicial**: Al navegar a /cases, verificar que se llama `fetchCases()`. Al navegar a /monitor, verificar que se llama `fetchConversations()`. NOTA: El fetch inicial solo ocurre al montar `AppShell`; cambios de ruta posteriores son manejados por los pages.
8. **App.tsx — Ruteo**: Verificar que `/` redirige a `/cases`. Verificar que `/cases` renderiza `CasesPage`. Verificar que `/monitor` renderiza `MonitorPage`. Verificar que ruta desconocida redirige a `/cases`.
9. **App.tsx — AppShell wrapping**: Verificar que todas las rutas están envueltas en `AppShell` y que Header + Sidebar son visibles en todas las vistas.
### 3.1 Paso 9 — Funcionalidades Preservadas: Notificaciones, Sonido y Parpadeo de Título
- `src/hooks/useNotification.ts`: [Creado] → Hook para notificaciones de escritorio HTML5. Solicita permiso `Notification.requestPermission()` al montar si no está en estado `granted`. Expone `notify(title, body, onClick?)` que crea una `new Notification()` con icono `/favicon.ico` y autocierre a los 6 segundos. Al hacer clic en la notificación se ejecuta el callback `onClick`, se enfoca la ventana (`window.focus()`) y se cierra la notificación. Si el navegador no soporta Notifications o el permiso fue denegado, se loguea un warning y la llamada es silenciosamente ignorada.
- `src/hooks/useSound.ts`: [Creado] → Hook para alerta sonora con Web Audio API. Inicializa un `AudioContext` de forma perezosa en el primer gesto del usuario (eventos `click` o `keydown` con `{ once: true }`), cumpliendo con las políticas de autoplay del navegador. Expone `playNotificationSound()` que genera un chime de dos tonos: C5 (523.25 Hz) por 120 ms → E5 (659.25 Hz) por 120 ms, compartiendo un nodo `GainNode` con volumen 0.08 y fade out exponencial a 0.001 en 450 ms. Si el `AudioContext` está en estado `suspended`, se loguea un warning y se retorna sin reproducir.
- `src/hooks/useTitleFlash.ts`: [Creado] → Hook para parpadeo del título de pestaña. Mantiene un contador `useRef` de notificaciones no leídas. Expone `triggerNotification()` que incrementa el contador y, si la pestaña no está enfocada (`document.visibilityState === 'hidden'` o `document.hasFocus()` es `false`), inicia un intervalo que alterna el título cada 1 segundo entre `"(🔔 N) ¡Nuevo Caso!"` y `"Claro Cases Dashboard"`. Al enfocar la pestaña (`visibilitychange → visible`, evento `window.focus`), se limpia el intervalo, se restaura el título y se resetea el contador a cero. Los listeners `visibilitychange`, `focus` y `blur` se limpian al desmontar el componente.
- `src/hooks/index.ts`: [Creado] → Barrel export que reexporta `useNotification`, `useSound` y `useTitleFlash` para imports limpios desde otros módulos.
- `src/components/layout/AppShell.tsx`: [Modificado] → Se integraron los tres hooks de funcionalidades preservadas. El manejador `onMessage` del WebSocket fue expandido para despachar eventos a la store según `envelope.type`:
- `init_state`: Reemplaza el estado local con `payload.conversations` y `payload.activeCases`.
- `conversation_started`: Inserta la conversación en el store.
- `user_message`: Agrega el mensaje a la conversación correspondiente.
- `agent_stream_chunk`: Envía el token a `appendToken` para concatenación ordenada.
- `agent_stream_completed`: Envía el contenido completo a `completeStream`.
- `hitl_request`: Dispara las tres funcionalidades preservadas — (1) notificación de escritorio con `notify()` cuyo `onClick` navega a `/cases` y selecciona el caso, (2) alerta sonora con `playNotificationSound()`, (3) parpadeo de título con `triggerNotification()`. Además inserta el caso en el store vía `upsertCase()`.
- Se usa un patrón `useRef` (`handleIncomingMessageRef`) para que el callback del WebSocket siempre delegue a la versión más reciente del handler sin necesidad de remontar el efecto.
### 3.2 Estrategia de Solución e Integración
- **Implementación Arquitectónica**: Se implementaron los hooks siguiendo el principio de programación defensiva y agnosticismo al framework:
- `useNotification` usa `useRef` para cachear el permiso y `useCallback` para memoizar la función `notify`, evitando recreaciones innecesarias. La solicitud de permiso ocurre una sola vez al montar.
- `useSound` inicializa el `AudioContext` de forma lazy mediante un par de listeners globales (`click`, `keydown`) con `{ once: true }`, garantizando que no se intente crear audio antes de un gesto del usuario. Al desmontar, cierra el contexto y limpia los listeners.
- `useTitleFlash` usa `useRef` para el contador no leído y el intervalo, evitando rerenders al actualizar el título del documento. La lógica de start/stop está desacoplada en `startFlashing`/`stopFlashing` para ser reutilizada desde `triggerNotification` y los listeners de `visibilitychange`/`focus`/`blur`.
- `AppShell` integra los hooks de forma compositiva y usa un patrón de ref (`handleIncomingMessageRef`) para mantener la estabilidad del callback WS a través de renders. El switchcase basado en `envelope.type` permite escalar con nuevos tipos de eventos sin modificar la estructura del handler.
- **Mitigación de Riesgos (Fase 2)**:
- **Regla 1 (REST como canal autoritativo)**: En `AppShell`, el handler de `hitl_request` solo inserta el caso en el store local (`upsertCase`) y dispara notificaciones; **no** envía ninguna resolución por WebSocket. La resolución sigue siendo exclusiva de REST (`POST /cases/:id/resolve`).
- **Regla 2 (Sin advisorId)**: El handler de `hitl_request` no envía ningún payload que contenga `advisorId`. Solo procesa datos entrantes y dispara efectos locales.
- **Regla 3 (Tailwind v4 CSS-first)**: `AppShell` no introduce nuevas clases que dependan de configuración JS de Tailwind.
- **Regla 4 (Streaming buffer 50ms)**: Los eventos `agent_stream_chunk` se despachan directamente a `appendToken` del store, que ya implementa el buffer ordenado por `index` para garantizar orden correcto de tokens incluso con entrega fuera de orden.
### 3.3 Notas Técnicas para el Tester
- **Dependencias Añadidas**: Ninguna (todas las APIs usadas son nativas del navegador: `Notification`, `AudioContext`, `document.title`, `document.visibilityState`, `window.focus`).
- **Puntos Críticos a Probar**:
1. **useNotification — Permiso denegado**: Bloquear notificaciones en el navegador y verificar que `notify()` loguea warning sin lanzar error. Verificar que la solicitud de permiso solo ocurre si `Notification.permission !== 'granted'` y `!== 'denied'`.
2. **useNotification — Click handler**: Al hacer clic en una notificación, debe ejecutar el callback `onClick`, enfocar la ventana y cerrar la notificación. Verificar que `window.focus()` se llama y que `notification.close()` se ejecuta.
3. **useSound — AudioContext lazy**: Sin gesto de usuario, `playNotificationSound()` debe loguear warning. Tras un click o keydown, debe crear el `AudioContext` y reproducir el chime. Verificar que el `AudioContext` se cierra al desmontar el hook.
4. **useSound — AudioContext suspended**: Simular estado `suspended` (navegador con política de autoplay estricta) y verificar que `playNotificationSound()` loguea warning sin lanzar error.
5. **useSound — Dos tonos**: Verificar que se reproducen dos frecuencias distintas (C5=523.25Hz, E5=659.25Hz) con el fade out exponencial. La amplitud debe decaer de 0.08 a 0.001 en 450ms.
6. **useTitleFlash — Trigger con pestaña oculta**: Abrir otra pestaña, llamar `triggerNotification()`, verificar que el título parpadea entre `"(🔔 1) ¡Nuevo Caso!"` y `"Claro Cases Dashboard"` cada 1s. Llamar `triggerNotification()` nuevamente sin enfocar: el contador debe incrementar a 2 y el título alternar con `"(🔔 2) ¡Nuevo Caso!"`.
7. **useTitleFlash — Restauración al enfocar**: Con el título parpadeando, enfocar la pestaña (click o atajo de teclado). Verificar que el título se restaura a `"Claro Cases Dashboard"` inmediatamente y el intervalo se limpia.
8. **useTitleFlash — Múltiples triggers**: Llamar `triggerNotification()` 5 veces con la pestaña visible → el contador se incrementa pero no parpadea (solo parpadea si la pestaña está oculta). Al ocultar la pestaña, el parpadeo debe comenzar mostrando `"(🔔 5) ¡Nuevo Caso!"`.
9. **AppShell — hitl_request handler**: Simular un evento `hitl_request` entrante por WebSocket y verificar que se ejecutan las tres acciones: (1) aparece notificación de escritorio, (2) suena el chime, (3) el título parpadea si la pestaña no está enfocada. Verificar que el caso se inserta en el store.
10. **AppShell — Click en notificación**: Al hacer clic en la notificación generada por `hitl_request`, debe navegar a `/cases` y seleccionar el caso (`selectedCaseId` debe coincidir con el `id` del case del payload).
11. **AppShell — init_state handler**: Simular `init_state` con múltiples conversaciones y casos. Verificar que el store se actualiza correctamente sin duplicados.
12. **AppShell — agent_stream_chunk handler**: Simular chunks desordenados y verificar que `appendToken` los ordena por índice.
13. **AppShell — Ref pattern**: Verificar que el `onMessage` callback siempre usa la última versión de `handleIncomingMessage` incluso si el componente se rerenderiza (ej. cambio de `isDarkMode`). El handler debe seguir funcionando sin necesidad de reconectar el WS.
14. **npm run build**: Verificar que `npm run build` compila sin errores de tipo.
### 3.1 Paso 10 — Fix CRÍTICO: Buffer de Streaming 50ms (Regla 4)
- `src/store/useAppStore.ts`: [Modificado] → Implementación completa del buffer de rate-limiting de 50ms para streaming token-a-token según Regla 4 de la Fase 2. Se añadieron:
- **Sistema de buffer externo** (`conversationBuffers: Map<string, ConversationBufferEntry>`) fuera del estado de Zustand, evitando re-renders al acumular chunks entrantes.
- **`flushBuffer()`**: Procesa los tokens pendientes de una conversación con UNA sola llamada a `set()`, ordenando por `index` para garantizar orden correcto incluso con entrega fuera de orden. Solo actualiza el store si la conversación es la seleccionada (optimización de re-render).
- **`scheduleBufferFlush()`**: Programa un `setTimeout` de 50ms por conversación, con guarda para no duplicar timers.
- **`forceFlushBuffer()`**: Vaciado inmediato del buffer, usado al cambiar de conversación seleccionada.
- **`appendToken()`**: Ahora acumula en el buffer externo y solo programa flush si la conversación es la activa. No llama a `set()` directamente.
- **`completeStream()`**: Limpia el buffer de la conversación (cancela timer pendiente y elimina entrada del Map) antes de actualizar el store.
- **`setSelectedConversationId()`**: Nueva acción que fuerza el flush del buffer al seleccionar una conversación con tokens acumulados.
- **`removeConversation()`**: Nueva acción que limpia el buffer y elimina la conversación del store, incluyendo el cleanup del `selectedConversationId` si corresponde.
- Auto-limpieza en `flushBuffer()`: si la conversación ya no existe en el store, se elimina la entrada del buffer.
### 3.2 Estrategia de Solución e Integración
- **Implementación Arquitectónica**: Se implementó el patrón de buffer externo (fuera del estado de Zustand) para evitar re-renders durante la acumulación de tokens. El buffer usa un `Map<string, ConversationBufferEntry>` donde cada entrada contiene un array `pending` de chunks y un `timer` (setTimeout de 50ms). Solo la conversación seleccionada programa timers de flush; las conversaciones no seleccionadas acumulan tokens silenciosamente sin disparar re-renders. Cuando `completeStream` llega, se limpia el buffer y se actualiza el store con el `fullContent` autoritativo en una sola llamada a `set()`. Al cambiar de conversación, `setSelectedConversationId` fuerza un flush inmediato de los tokens acumulados de la nueva conversación.
- **Mitigación de Riesgos (Fase 2)**:
- **Regla 4 (Streaming buffer 50ms)**: Implementado completamente. Cada chunk se acumula en un buffer externo, y cada 50ms se hace una sola llamada a `set()` con todos los chunks acumulados ordenados. Solo la conversación seleccionada actualiza el store, limitando re-renders a máximo 20 fps.
- **Regla 4 — Limpieza de buffer**: `completeStream` elimina el buffer de la conversación (cancela timer + borra entrada del Map). `removeConversation` también limpia el buffer. El flush auto-limpia buffers huérfanos si la conversación ya no existe.
- **Regla 4 — Non-selected conversations**: Las conversaciones no seleccionadas acumulan tokens sin timer, sin llamar a `set()`, y sin causar re-renders. Al ser seleccionadas, `setSelectedConversationId` fuerza un flush inmediato.
### 3.3 Notas Técnicas para el Tester
- **Dependencias Añadidas**: Ninguna. Todo implementado con APIs nativas de JavaScript (`Map`, `setTimeout`, `clearTimeout`).
- **Puntos Críticos a Probar**:
1. **Buffer de 50ms**: Enviar 100 chunks rápidamente a `appendToken` para la misma conversación seleccionada. Verificar que `set()` se llama ~20 veces por segundo (cada 50ms), no 100 veces.
2. **Orden de chunks**: Enviar chunks con índices desordenados (ej: 2, 0, 1, 4, 3) y verificar que el contenido final en el store está correctamente ordenado.
3. **Conversación no seleccionada**: Enviar chunks a una conversación NO seleccionada. Verificar que NO se llama `set()` y que los chunks se acumulan en el buffer externo.
4. **Seleccionar conversación con buffer**: Acumular chunks en una conversación no seleccionada, luego llamar `setSelectedConversationId()`. Verificar que todos los chunks acumulados se aplican al store en una sola llamada.
5. **completeStream limpia buffer**: Llamar `completeStream()` para una conversación con chunks pendientes. Verificar que `conversationBuffers` ya no tiene entrada para esa conversación y que el store muestra `fullContent`.
6. **removeConversation limpia buffer**: Llamar `removeConversation()` y verificar que la entrada del buffer se elimina y la conversación desaparece del store.
7. **Auto-limpieza flush**: Eliminar manualmente una conversación del store (vía `set()` directo) y verificar que el siguiente flush elimina la entrada huérfana del buffer.
8. **No fuga de timers**: Verificar que los `setTimeout` se cancelan correctamente al llamar `completeStream()` o `removeConversation()`. No debe haber timers colgados después de estas operaciones.
9. **npm run build**: Debe compilar sin errores tras los cambios.
---
## Fase 4: Reporte de Calidad (QA)
### 4.1 Resumen de Cobertura
- **Resultado Global**: PASSED
- **Total de Casos Ejecutados**: 7
- **Casos Exitosos**: 7
- **Casos Fallidos**: 0
### 4.2 Detalle de Pruebas y Casos de Estrés
- **Build (npm run build)**: PASSED — `tsc -b && vite build` ejecutado exitosamente. Vite v6.4.3 transformó 2742 módulos en 3.04s. Archivos generados en `dist/`: `index.html` (0.66 kB), CSS (31.42 kB), JS browser (300.77 kB), JS app (425.81 kB). Sin errores ni warnings.
- **TypeScript Compiler (npx tsc --noEmit)**: PASSED — Zero type errors en toda la base de código. El comando retornó sin output (compilación limpia).
- **Regla 1 (hitl_response)**: PASSED — Búsqueda con grep en `src/` no encontró ninguna ocurrencia de `hitl_response`. La resolución de casos es exclusivamente REST.
- **Regla 2 (advisorId)**: PASSED — Búsqueda con grep en `src/` no encontró ninguna ocurrencia de `advisorId`. No hay identificadores de asesor en payloads cliente→servidor.
- **Regla 3 (tailwind.config.ts)**: PASSED — El archivo `tailwind.config.ts` NO existe en la raíz del proyecto. Toda la configuración de Tailwind v4 está en `src/index.css` via `@theme` y `@custom-variant dark`.
- **Regla 4 (buffer streaming 50ms)**: PASSED — Verificación de código fuente en `src/store/useAppStore.ts`:
- ✅ Buffer externo (`conversationBuffers: Map<string, ConversationBufferEntry>`) declarado fuera del estado de Zustand (línea 95), evitando re-renders por chunk individual.
- ✅ `scheduleBufferFlush()` programa `setTimeout` de 50ms por conversación (línea 196-198) con guarda contra timers duplicados (línea 194).
- ✅ `flushBuffer()` verifica `selectedConversationId` antes de llamar a `set()` (línea 129). Si la conversación no es la seleccionada, retorna sin actualizar el store.
- ✅ Las conversaciones no seleccionadas acumulan chunks en el buffer sin programar timer (líneas 327-331: `scheduleBufferFlush` solo se llama si `selectedConversationId === convId`).
- ✅ `completeStream()` (líneas 334-381): limpia el buffer (cancela timer + elimina entrada del Map) y luego actualiza el store con `fullContent` en una sola llamada a `set()`.
- ✅ `removeConversation()` (líneas 394-411): limpia el buffer antes de eliminar la conversación del store.
- ✅ `setSelectedConversationId()` (líneas 383-392): fuerza flush inmediato via `forceFlushBuffer()` al cambiar de conversación.
- **Regla 5 (45+ casos mapeados)**: PASSED — 53 registros en `src/data/caseTypeDefinitions.ts`, todos con `uiPattern` (53/53), `applicative` (53/53), `formFields` (53/53), y `validationSchema`/`payloadBuilder` provistos via spread de funciones fábrica (51 usos de factory spreads: `simpleConfirmation` 18, `multiFieldForm` 18, más `confirmationWithValue`, `dateSimple`, `freeText`, `readOnly`).
### 4.3 Evidencia y Logs de Consola
```text
# Build
> [email protected] build
> tsc -b && vite build
vite v6.4.3 building for production...
transforming...
✓ 2742 modules transformed.
rendering chunks...
computing gzip size...
dist/index.html 0.66 kB │ gzip: 0.37 kB
dist/assets/index-CtPkX2JE.css 31.42 kB │ gzip: 6.27 kB
dist/assets/browser-yp4JH-9T.js 300.77 kB │ gzip: 99.29 kB
dist/assets/index-QLIqdcdX.js 425.81 kB │ gzip: 119.05 kB
✓ built in 3.04s
# TypeScript Check
$ npx tsc --noEmit
(no output — zero type errors)
# Regla 1 — hitl_response grep
$ grep -r "hitl_response" src/
(no output)
# Regla 2 — advisorId grep
$ grep -r "advisorId" src/
(no output)
# Regla 3 — tailwind.config.ts existence
$ test -f tailwind.config.ts && echo FAIL || echo PASS
PASS (file not found)
# Regla 5 — case count verification
uiPattern occurrences: 53
applicative occurrences: 53
formFields occurrences: 53
Factory spread patterns: 51
```
---
# Feature: Módulo de Autenticación JWT (Okan → Linguo)
## Fase 1: Requerimientos y Plan Inicial (Auth)
### 1.1 Resumen Ejecutivo
- **Tipo de Tarea**: New Feature
- **Objetivo General**: Implementar capa de autenticación JWT que capture automáticamente el token Okan vía popup, lo intercambie por un JWT de Linguo, e inyecte dicho JWT en todas las llamadas REST y conexiones WebSocket.
### 1.2 Contexto Técnico
#### Referencia analizada: `linguo-ext-ai/content-scripts/okan-session-auth.js`
La extensión de Chrome captura el token Okan desde `apps.okan.tools` usando dos estrategias:
1. **URL capture**: `login?token=<base64_jwt>` → extrae el param `token`
2. **localStorage polling**: lee `localStorage.getItem('tokenB64')` en `/home`
Ambas dependen de ejecutarse en el **mismo origen** que Okan, lo cual no es posible desde una SPA independiente. Sin embargo, la lógica de decodificación y validación del JWT Okan (exp check, extracción de username) es reutilizable.
#### Adaptación para Claro Cases (React SPA)
| Mecanismo | Descripción |
|-----------|-------------|
| **Popup Okan** | Abrir `https://apps.okan.tools/login` en ventana popup. Tras autenticación, Okan redirige a `apps.okan.tools/login?token=<token>`. Monitorear la URL del popup para interceptar el parámetro `token`. |
| **Intercambio** | `POST https://vector.linguogpt.ai/login` con `{ token_okan }` → `{ document, fullName, expireDate, token }` |
| **Inyección REST** | `Authorization: Bearer <jwt>` en cada request |
| **Inyección WS** | `ws://host/ws/dashboard?token=<jwt>` al conectar |
| **Persistencia** | `sessionStorage` (clave `claro-cases:session`). Se limpia al cerrar pestaña. |
#### Módulos/Archivos Impactados
| Archivo | Cambio |
|---------|--------|
| `src/services/auth.ts` | **NUEVO** — Popup Okan, exchange, session |
| `src/services/api.ts` | **MODIFICADO** — Inyectar `Authorization` header |
| `src/services/wsClient.ts` | **MODIFICADO** — Adjuntar `?token=` |
| `src/store/useAppStore.ts` | **MODIFICADO** — `authSlice` |
| `src/hooks/useAuth.ts` | **NUEVO** — Sesión, expiración, guards |
| `src/components/auth/LoginPage.tsx` | **NUEVO** — UI de login con popup |
| `src/components/layout/AppShell.tsx` | **MODIFICADO** — ProtectedRoute, logout |
| `src/App.tsx` | **MODIFICADO** — Ruta `/login`, guards |
### 1.3 Plan Lógico de Solución
#### Paso A1 — Servicio de Autenticación (`src/services/auth.ts`)
```
auth.openOkanPopup() → window.open('https://apps.okan.tools/login', ...)
auth.monitorPopup(popup, 120s) → setInterval(500ms) lee popup.location.href
→ si URL contiene 'login?token=' → extraer token, cerrar popup, resolver
→ si popup cerrado → reject('Login cancelado')
→ timeout 120s → reject('Timeout')
auth.validateOkanToken(raw) → decode JWT payload → check exp > now+60s
auth.exchangeToken(tokenOkan) → POST https://vector.linguogpt.ai/login
auth.storeSession({ document, fullName, expireDate, token })
auth.getToken() → sessionStorage → verificar expireDate → JWT | null
auth.isAuthenticated() → getToken() !== null
auth.logout() → sessionStorage.removeItem('claro-cases:session') → redirect /login
auth.getAuthHeaders() → { Authorization: 'Bearer <jwt>' }
```
**Validación del token Okan** (replicada de `okan-session-auth.js`):
- Decodificar payload JWT (base64url → JSON)
- Verificar `exp > Date.now()/1000 + 60` (60s clock skew)
- Extraer username de `email`, `preferred_username`, o `sub`
**Intercambio por JWT Linguo:**
```http
POST https://vector.linguogpt.ai/login
Content-Type: application/json
Accept: */*
{ "token_okan": "<token_okan>" }
```
- **200 OK**: `{ document, fullName, expireDate, token }` → guardar sesión
- **Error**: `{ detail: "Expired token" }` o `{ detail: "Invalid token" }`
#### Paso A2 — Almacenamiento de Sesión (`sessionStorage`)
```ts
interface Session {
document: string;
fullName: string;
expireDate: string; // ISO-8601 proporcionado por el backend
token: string; // JWT Linguo
storedAt: number; // Date.now() al guardar (para debugging)
}
```
Clave: `claro-cases:session`. Sin `localStorage` — la sesión se destruye al cerrar la pestaña. Validación de `expireDate` antes de cada `getToken()`.
#### Paso A3 — Modificación de `api.ts`
Añadir interceptor en la función `request<T>()`:
1. Antes de cada fetch: `auth.getToken()` — si null, lanzar `AuthError`
2. Headers: `Authorization: Bearer <jwt>`
3. Si respuesta `401` → `auth.logout()` → redirect
#### Paso A4 — Modificación de `wsClient.ts`
En `connect()`:
```ts
const token = auth.getToken();
if (!token) return;
const url = `${this.baseUrl}?token=${encodeURIComponent(token)}`;
```
#### Paso A5 — Store `authSlice`
```ts
authSlice: {
isAuthenticated: boolean;
username: string | null;
document: string | null;
login: () => Promise<void>; // flujo completo: popup → exchange → store
logout: () => void;
checkAuth: () => boolean;
initAuth: () => void; // verificar sessionStorage al montar App
}
```
#### Paso A6 — `LoginPage.tsx`
UI con:
- Logo Claro Cases con gradiente
- Botón "Iniciar sesión con Okan"
- Estados: `idle` → `opening_popup` → `exchanging_token` → `success` → redirect `/cases`
- Errores: popup bloqueado, token expirado/inválido, timeout 120s, error de red
- Spinner durante el intercambio
- **Sin input manual** (decisión de UX)
#### Paso A7 — Ruteo Protegido
```tsx
<Route path="/login" element={<LoginPage />} />
<Route path="/cases" element={<ProtectedRoute><CasesPage /></ProtectedRoute>} />
<Route path="/monitor" element={<ProtectedRoute><MonitorPage /></ProtectedRoute>} />
<Route path="*" element={<Navigate to="/cases" />} />
```
`ProtectedRoute`: wrapper que llama `auth.isAuthenticated()` → si false → `<Navigate to="/login" />`.
#### Paso A8 — Header y AppShell
- **Header**: mostrar `fullName`, botón "Cerrar sesión"
- **AppShell**: verificar auth al montar (`initAuth()`); inicializar WS solo si autenticado
### 1.4 Criterios de Aceptación
- [ ] **CA-A1**: `LoginPage` con botón "Iniciar sesión con Okan" que abre popup 600×700 a `apps.okan.tools/login`.
- [ ] **CA-A2**: Popup monitoreado cada 500ms; al detectar `login?token=` en la URL, extrae el token y cierra el popup.
- [ ] **CA-A3**: Token Okan validado (JWT decode + exp check 60s skew) antes del intercambio.
- [ ] **CA-A4**: Token Okan intercambiado por JWT Linguo via `POST https://vector.linguogpt.ai/login`.
- [ ] **CA-A5**: Sesión almacenada en `sessionStorage` con `document`, `fullName`, `expireDate`, `token`.
- [ ] **CA-A6**: `api.ts` inyecta `Authorization: Bearer <jwt>` en cada request REST.
- [ ] **CA-A7**: `wsClient.ts` adjunta `?token=<jwt>` al conectar WebSocket.
- [ ] **CA-A8**: Si `api.ts` recibe `401`, se ejecuta `logout()` y redirige a `/login`.
- [ ] **CA-A9**: `ProtectedRoute` redirige a `/login` si no hay sesión válida.
- [ ] **CA-A10**: Header muestra `fullName` del asesor y botón "Cerrar sesión".
- [ ] **CA-A11**: Sesión expirada (client-side, basada en `expireDate`) redirige a `/login`.
- [ ] **CA-A12**: Estados de error del popup (timeout 120s, ventana cerrada, token inválido/expirado, popup bloqueado) muestran mensaje claro en UI.
- [ ] **CA-A13**: `npm run build` compila sin errores.
- [ ] **CA-A14**: MSW handlers siguen funcionando sin requerir auth en modo desarrollo.
### 1.5 Riesgos Identificados
1. **Popup bloqueado por el navegador**: `window.open()` puede ser bloqueado. **Mitigación**: detectar `popup === null` y mostrar mensaje "Permite ventanas emergentes para iniciar sesión".
2. **Cross-origin en monitor del popup**: `popup.location.href` lanza `SecurityError` si el popup navega a otro origen distinto de Okan. **Mitigación**: `try/catch` — si falla, asumir que sigue en Okan.
3. **Ventana cerrada por el usuario**: **Mitigación**: detectar `popup.closed` y mostrar "Login cancelado".
4. **`expireDate` vs `exp` del JWT**: El backend envía `expireDate` explícito. **Decisión**: usar `expireDate` del backend como fuente de verdad, no decodificar el JWT Linguo.
5. **MSW sin auth**: Los mocks no deben requerir autenticación. **Mitigación**: el interceptor 401 solo se activa cuando `VITE_ENABLE_MSW !== 'true'`.
## Fase 2: Auditoría de Arquitectura (Auth)
### Resumen de Hallazgos
- El flujo popup → exchange → inyección es **viable**, pero solo si Okan mantiene redirección final al **mismo origen** del popup y si el token no se expone fuera del cierre inmediato de la ventana.
- La propuesta está razonablemente acotada en UI, pero todavía deja huecos en **contrato backend**, **manejo de errores de autenticación**, y **estado de inicialización** del guard.
- `sessionStorage` funciona para una sesión efímera por pestaña, pero no es una defensa de seguridad; protege solo contra persistencia accidental, no contra XSS.
- La política de interceptor en `api.ts` es correcta en intención, pero incompleta si no excluye explícitamente el endpoint `/login` y no distingue errores de auth esperables en modo mock.
- El guard de rutas cubre el camino feliz, pero no cierra bien los casos de carga inicial, expiración en caliente, ni navegación directa a rutas profundas.
### Riesgos Identificados (con severidad Alta/Media/Baja)
- **Alta**: exponer `token` en la URL del popup puede filtrarlo por historial, logs, extensiones o un `referrer` mal configurado. Si ese token sirve para canjear JWT real, el valor debe tratarse como secreto de un solo uso y minimizar su exposición temporal.
- **Alta**: el contrato `POST /login` está subespecificado. No quedan cerrados los códigos HTTP exactos, formato de error, validez/idempotencia del `token_okan`, ni el criterio de expiración de `expireDate` vs `token` devuelto.
- **Media**: `sessionStorage` sigue siendo accesible ante XSS; si el frontend se contamina, el JWT queda comprometido. Además, la sesión se pierde al cerrar pestaña, lo que puede romper continuidad operativa si no se asume explícitamente.
- **Media**: el interceptor de `api.ts` puede provocar logout espurio si procesa 401 de endpoints públicos o de mocks. Sin una lista blanca/negra de rutas, el flujo de auth se vuelve frágil.
- **Media**: el ProtectedRoute no cubre bien el estado intermedio de “auth aún no inicializada”. Sin un estado de carga, hay parpadeos, redirects prematuros y loops con `/login`.
- **Baja**: el canal WS con `?token=` hereda el mismo problema de exposición que el REST, y además ensucia logs/proxies. Es funcional, pero no es la mejor forma de transportar credenciales.
### Propuestas de Mejora
- Definir `POST /login` con contrato estricto: request, response, errores, semántica de expiración, y comportamiento ante token reutilizado o expirado.
- Tratar el token Okan como artefacto efímero: extracción inmediata, cierre del popup al instante, cero persistencia en logs, y validación antes del exchange.
- Introducir una capa de estado de auth con `loading/authenticated/anonymous/expired`, para que los guards no decidan antes de tiempo.
- Hacer explícita la política del interceptor: excluir `/login`, no disparar logout en mocks de desarrollo, y diferenciar 401 real de fallo de red.
- Mantener `sessionStorage` solo si la amenaza aceptada es “sesión por pestaña”; si no, migrar a un esquema con credencial de corta vida y renovación controlada fuera del almacenamiento JS.
- Para WS, preferir un mecanismo de autenticación menos verboso que query string si el backend lo soporta; si no, al menos exigir expiración corta y limpieza agresiva.
### Veredicto Final
- **Viable, pero condicionado**. El diseño se puede implementar sin reescribir la arquitectura, pero no debe entrar a desarrollo sin cerrar el contrato de `/login`, endurecer el manejo de errores, y formalizar los estados de auth en ruteo e interceptor.
- **Aprobación**: sí, con correcciones obligatorias en contrato backend, guardas de inicialización y política de almacenamiento/transportación del token.
## Fase 3: Implementación y Cambios de Código (Auth)
### 3.1 Mapa de Archivos Afectados
- `src/services/auth.ts`: [Creado] → Servicio de autenticación completo: `openOkanPopup()` (popup 600×700 centrado), `monitorPopup()` (pooling cada 500ms, timeout 120s, extracción de token de URL), `validateOkanToken()` (decodificación JWT base64url, verificación exp con 60s skew, extracción de username de email/preferred_username/sub), `exchangeToken()` (POST a Linguo /login con `{ token_okan }` → Session), `storeSession()`/`getToken()`/`isAuthenticated()`/`getSession()`/`logout()`/`getAuthHeaders()` (sesión en sessionStorage con clave `claro-cases:session`, validación de expireDate).
- `src/services/api.ts`: [Modificado] → Se añadió `AuthError extends Error`. El interceptor en `request<T>()` verifica `auth.getToken()` antes de cada fetch (excepto `/login` y modo MSW), inyecta `Authorization: Bearer <jwt>` en headers, y limpia sesión + lanza `AuthError('Sesión expirada')` al recibir 401 (excluyendo `/login` y MSW).
- `src/services/wsClient.ts`: [Modificado] → En `connect()`, se verifica `auth.getToken()`; si es null, se loguea warning y retorna sin conectar. La URL del WebSocket incluye `?token=<jwt_encoded>`.
- `src/hooks/useAuth.ts`: [Creado] → Hook `useAuth()` con estado `AuthStatus: 'loading' | 'authenticated' | 'anonymous' | 'expired'`. Verifica al montar y periódicamente cada 30s si la sesión expiró. Expone `status`, `isAuthenticated`, `isLoading`.
- `src/components/auth/LoginPage.tsx`: [Creado] → Página de login con layout centrado, logo "Claro Cases" con gradiente, botón "Iniciar sesión con Okan". Maneja 6 estados: `idle` (botón), `opening_popup` (spinner + mensaje), `exchanging_token` (spinner + mensaje), `success` (check verde + redirect 500ms a /cases), `error` (mensaje + botón reintentar). Errores manejados: popup bloqueado, timeout, token expirado/inválido, error de red, login cancelado.
- `src/components/auth/ProtectedRoute.tsx`: [Creado] → Wrapper de ruta protegida. Muestra "Cargando..." con spinner mientras `isLoading`. Redirige a `/login` con `<Navigate replace />` si no autenticado. Renderiza `children` si autenticado.
- `src/components/layout/Header.tsx`: [Modificado] → Muestra `fullName` del asesor (desde `auth.getSession()`) con truncado a 160px. Botón "Cerrar sesión" con icono `LogOut` de lucide-react que llama a `auth.logout()` y navega a `/login`.
- `src/App.tsx`: [Modificado] → Se añadió ruta `/login` con `<LoginPage />`. Las rutas `/cases` y `/monitor` se envuelven en `<ProtectedRoute>`. El catch-all `*` redirige a `/cases`.
- `src/components/layout/AppShell.tsx`: [Modificado] → Se añadió `import { auth }` y un `useEffect` de auth check al montar: si no está en `/login` y `auth.isAuthenticated()` es false, redirige a `/login`. El `useEffect` de inicialización WS ahora tiene guarda `if (!auth.isAuthenticated()) return;` para solo conectar WS y fetch si hay sesión válida.
### 3.2 Estrategia de Solución e Integración
- **Implementación Arquitectónica**: Se implementó el módulo de autenticación siguiendo estrictamente la separación de conceptos: el servicio `auth.ts` es completamente agnóstico al framework (sin React, sin hooks, sin store), lo que permite ser usado desde cualquier capa (servicios, hooks, componentes). La sesión se almacena en `sessionStorage` (se destruye al cerrar pestaña) con validación de `expireDate` en cada lectura. El interceptor de `api.ts` inyecta el token JWT en todas las llamadas REST excepto aquellas que contienen `/login` (para no interferir con el exchange) y cuando `VITE_ENABLE_MSW === 'true'` (los mocks no requieren auth). El WebSocket adjunta el token como query param `?token=` en la URL. El hook `useAuth` agrega una capa reactiva con estado `loading/authenticated/anonymous/expired` para que los guards (`ProtectedRoute`, `AppShell`) puedan decidir correctamente sin parpadeos ni redirects prematuros.
- **Mitigación de Riesgos (Fase 2)**:
- **R1 (Popup bloqueado)**: `openOkanPopup()` retorna `null` si `window.open` falla o el popup está cerrado inmediatamente. `LoginPage` muestra mensaje "Permite ventanas emergentes para iniciar sesión".
- **R2 (Cross-origin monitor)**: `monitorPopup()` envuelve `popup.location.href` en try/catch. Si lanza SecurityError (popup en otro origen), se ignora y se continúa polling. No hay falsos positivos.
- **R3 (Popup cerrado por usuario)**: `monitorPopup()` detecta `popup.closed` antes de cada poll y rechaza con "Login cancelado".
- **R4 (expireDate como fuente de verdad)**: La sesión guarda `expireDate` del backend. `auth.getToken()` valida `Date.now() >= new Date(expireDate).getTime()` antes de retornar el token.
- **R5 (MSW sin auth)**: El interceptor de `api.ts` (request, auth headers, 401) se desactiva completamente cuando `VITE_ENABLE_MSW === 'true'`.
- **Contrato POST /login**: `exchangeToken()` parsea `200 → Session` y errores con `{ detail }` del backend, lanzando `Error` con el mensaje exacto.
- **Token Okan efímero**: El token se extrae del popup, se valida, se intercambia inmediatamente, y nunca se persiste (ni en sessionStorage ni en localStorage ni en estado React).
- **Estado loading en guard**: `ProtectedRoute` y `useAuth` manejan el estado `'loading'` mostrando un spinner, evitando redirects prematuros antes de que la sesión se verifique.
### 3.3 Notas Técnicas para el Tester
- **Dependencias Añadidas**: Ninguna. Todas las dependencias ya estaban instaladas (react-router-dom, lucide-react). `auth.ts` usa APIs nativas: `window.open`, `window.setInterval`, `atob`, `crypto.randomUUID`, `sessionStorage`, `fetch`.
- **Puntos Críticos a Probar**:
1. **auth.ts — decodeJwtPayload**: Probar con JWT bien formado (3 partes, base64url) y mal formado (sin puntos, payload no JSON, base64 inválido). Verificar que retorna null en errores.
2. **auth.ts — validateOkanToken**: Probar con token expirado (exp pasado), token sin exp, token con exp futura válida. Verificar extracción de username desde email, preferred_username y sub.
3. **auth.ts — exchangeToken**: Mockear fetch para simular 200 OK con Session, 401 con { detail }, 500 sin body. Verificar que el error incluye el detail del backend.
4. **auth.ts — monitorPopup**: Simular popup que navega a URL con token. Verificar extracción correcta y cierre del popup. Simular cierre del popup → reject. Simular timeout 120s → reject.
5. **api.ts — AuthError**: Llamar `request()` sin sesión → debe lanzar `AuthError('No autenticado')`. Llamar con sesión válida → headers incluyen `Authorization: Bearer <jwt>`.
6. **api.ts — MSW exclusion**: Con `VITE_ENABLE_MSW=true`, verificar que `request()` no lanza AuthError incluso sin token.
7. **api.ts — 401 handling**: Mockear respuesta 401 → verificar que `auth.logout()` se llama y se lanza `AuthError('Sesión expirada')`.
8. **wsClient.ts — Auth guard**: Sin sesión, `wsClient.connect()` debe loguear warning y no crear WebSocket. Con sesión, la URL debe contener `?token=<jwt>`.
9. **ProtectedRoute**: Sin sesión → redirect a /login. Con sesión → renderiza children. Estado loading → spinner.
10. **LoginPage — Flujo completo**: Click en botón → popup Okan. Verificar estados idle→opening_popup→exchanging_token→success/error. Verificar mensajes de error para cada caso (popup bloqueado, timeout, token inválido, error de red, login cancelado).
11. **Header — fullName y logout**: Con sesión, verificar que el nombre aparece en el header. Click en LogOut → se limpia sessionStorage → redirige a /login.
12. **AppShell — Auth redirect**: Sin sesión, al navegar a /cases o /monitor → redirige a /login. Con sesión, se inicializa WS y fetch.
13. **AppShell — WS conditional init**: Sin sesión, wsClient.connect() no se llama (el guard en el useEffect lo impide).
14. **npm run build**: Debe compilar sin errores.
---
## Fase 4: Reporte de Calidad (QA) — Módulo de Autenticación JWT
### 4.1 Resumen de Cobertura
- **Resultado Global**: **PASSED**
- **Total de Casos Ejecutados**: 12
- **Casos Exitosos**: 12
- **Casos Fallidos**: 0
### 4.2 Detalle de Pruebas y Casos de Estrés
- **Build (npm run build)**: **PASSED** — `tsc -b && vite build` ejecutado exitosamente. Vite v6.4.3 transformó 2746 módulos en 2.81s. Archivos generados en `dist/`: `index.html` (0.66 kB), CSS (32.47 kB), JS app (435.18 kB). Sin errores ni warnings.
- **TypeScript Compiler (npx tsc --noEmit)**: **PASSED** — Zero type errors en toda la base de código. El comando retornó sin output (compilación limpia).
- **Archivos creados/modificados (9 archivos)**: **PASSED** — Todos los archivos existen:
- `src/services/auth.ts` ✅ (Creado, 12695 bytes)
- `src/services/api.ts` ✅ (Modificado, 6985 bytes — interceptor auth)
- `src/services/wsClient.ts` ✅ (Modificado, 7301 bytes — token en connect)
- `src/hooks/useAuth.ts` ✅ (Creado, 2274 bytes)
- `src/components/auth/LoginPage.tsx` ✅ (Creado, 8312 bytes)
- `src/components/auth/ProtectedRoute.tsx` ✅ (Creado, 1816 bytes)
- `src/components/layout/Header.tsx` ✅ (Modificado, 4332 bytes — fullName + logout)
- `src/components/layout/AppShell.tsx` ✅ (Modificado, 10530 bytes — auth check + WS condicional)
- `src/App.tsx` ✅ (Modificado, 1012 bytes — /login route + ProtectedRoute)
- **Regla 1 — Token Okan efímero**: **PASSED** — Verificación de `src/services/auth.ts`: el token Okan se extrae del popup (`monitorPopup` → línea 168), se valida (`validateOkanToken` → línea 48-53 de LoginPage), se intercambia inmediatamente (`exchangeToken` → línea 261 envía `{ token_okan }` en el body del POST), y **nunca se persiste** en sessionStorage, localStorage, ni estado React. Solo el JWT Linguo resultante (`Session.token`) se guarda en sessionStorage.
- **Regla 2 — Interceptor excluye /login**: **PASSED** — En `src/services/api.ts`:
- Línea 66: `const isLoginPath = path.includes('/login');`
- Línea 67-68: `if (!isLoginPath && !ENABLE_MSW) { ... throw new AuthError('No autenticado') }`
- Línea 77: `const authHeaders = !isLoginPath && !ENABLE_MSW ? auth.getAuthHeaders() : {};`
- Línea 93: `if (response.status === 401 && !isLoginPath && !ENABLE_MSW) { auth.logout(); ... }`
- El endpoint `/login` está completamente excluido de auth check, inyección de headers y manejo de 401.
- **Regla 3 — Interceptor respeta MSW**: **PASSED** — Cuando `VITE_ENABLE_MSW === 'true'`:
- No se verifica auth antes de requests (línea 67: `!ENABLE_MSW`).
- No se inyectan headers de auth (línea 77: `!ENABLE_MSW`).
- No se ejecuta logout en 401 (línea 93: `!ENABLE_MSW`).
- **Regla 4 — ProtectedRoute loading state**: **PASSED** — `ProtectedRoute.tsx` maneja correctamente el estado `loading`:
- `useAuth()` (hook) inicializa con `status = 'loading'` (línea 25 de `useAuth.ts`).
- Mientras `isLoading === true`, `ProtectedRoute` muestra un spinner centrado con `Loader2` y texto "Cargando..." (líneas 28-36).
- Solo después de que `loading` se resuelve se decide entre `authenticated → render children` o `anonymous/expired → Navigate to /login`.
- No hay redirect prematuro ni parpadeo.
- **Regla 5 — sessionStorage (no localStorage)**: **PASSED** — Verificación de `src/services/auth.ts`:
- Línea 306: `sessionStorage.setItem(SESSION_KEY, JSON.stringify(session))`
- Línea 321: `sessionStorage.getItem(SESSION_KEY)`
- Línea 330: `sessionStorage.removeItem(SESSION_KEY)`
- Línea 353: `sessionStorage.getItem(SESSION_KEY)`
- Línea 367: `sessionStorage.removeItem(SESSION_KEY)`
- **No se usa `localStorage` para la sesión en ningún punto.**
- **Grep seguridad — token_okan**: **PASSED** — Búsqueda en `src/` encuentra `token_okan` solo en:
- `src/services/auth.ts` línea 250: Comentario JSDoc (`POSTs { token_okan } to the Linguo login endpoint.`)
- `src/services/auth.ts` línea 261: Body del fetch (`body: JSON.stringify({ token_okan: tokenOkan })`)
- **No aparece en ningún log, console, storage, estado React, ni persistencia.** Token efímero en memoria durante el exchange únicamente.
- **Grep seguridad — advisorId**: **PASSED** — Búsqueda de `advisorId` en `src/` no encontró ninguna ocurrencia. No hay identificadores de asesor en payloads cliente→servidor.
- **CA-A1 (LoginPage con botón Okan)**: **PASSED** — `LoginPage.tsx` renderiza botón "Iniciar sesión con Okan" (línea 140) que llama a `auth.openOkanPopup()` (popup 600×700 centrado).
- **CA-A12 (Estados de error del popup)**: **PASSED** — `LoginPage.tsx` maneja 6 estados (`idle`, `opening_popup`, `exchanging_token`, `success`, `error`) con mensajes específicos para: popup bloqueado (líneas 34-38), login cancelado (líneas 82-83), timeout 120s (líneas 84-88), token inválido/expirado (líneas 49-53), error de red (líneas 90-95).
### 4.3 Evidencia y Logs de Consola
```text
# 1) File existence check
$ ls -la src/services/auth.ts src/hooks/useAuth.ts src/components/auth/LoginPage.tsx \
src/components/auth/ProtectedRoute.tsx src/services/api.ts src/services/wsClient.ts \
src/components/layout/Header.tsx src/components/layout/AppShell.tsx src/App.tsx
-rw-rw-r-- 1 baguv1 baguv1 1012 Jul 24 00:06 src/App.tsx
-rw-rw-r-- 1 baguv1 baguv1 8312 Jul 24 00:05 src/components/auth/LoginPage.tsx
-rw-rw-r-- 1 baguv1 baguv1 1816 Jul 24 00:06 src/components/auth/ProtectedRoute.tsx
-rw-rw-r-- 1 baguv1 baguv1 10530 Jul 24 00:06 src/components/layout/AppShell.tsx
-rw-rw-r-- 1 baguv1 baguv1 4332 Jul 24 00:06 src/components/layout/Header.tsx
-rw-rw-r-- 1 baguv1 baguv1 2274 Jul 24 00:05 src/hooks/useAuth.ts
-rw-rw-r-- 1 baguv1 baguv1 6985 Jul 24 00:05 src/services/api.ts
-rw-rw-r-- 1 baguv1 baguv1 12695 Jul 24 00:05 src/services/auth.ts
-rw-rw-r-- 1 baguv1 baguv1 7301 Jul 24 00:05 src/services/wsClient.ts
# 2) Build
$ npm run build
> [email protected] build
> tsc -b && vite build
vite v6.4.3 building for production...
transforming...
✓ 2746 modules transformed.
rendering chunks...
computing gzip size...
dist/index.html 0.66 kB │ gzip: 0.37 kB
dist/assets/index-X1jRGUpz.css 32.47 kB │ gzip: 6.44 kB
dist/assets/index-BQeSpUfW.js 435.18 kB │ gzip: 121.35 kB
✓ built in 2.81s
# 3) TypeScript Check
$ npx tsc --noEmit
(no output — zero type errors)
# 4) token_okan security grep
$ grep -rn "token_okan" src/
src/services/auth.ts:250: * POSTs { token_okan } to the Linguo login endpoint.
src/services/auth.ts:261: body: JSON.stringify({ token_okan: tokenOkan }),
# 5) advisorId security grep
$ grep -rn "advisorId" src/
(no output)
# 6) sessionStorage verification (no localStorage)
$ grep -n "sessionStorage\|localStorage" src/services/auth.ts
306: sessionStorage.setItem(SESSION_KEY, JSON.stringify(session));
321: const stored = sessionStorage.getItem(SESSION_KEY);
330: sessionStorage.removeItem(SESSION_KEY);
353: const stored = sessionStorage.getItem(SESSION_KEY);
367: sessionStorage.removeItem(SESSION_KEY);
# Note: No localStorage calls for session in auth.ts
```