- 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
112 KiB
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). Tablarequestscon 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
/monitore/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 porindex.htmlde 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 ensrc/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
- 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 subdirectoriolegacy/. - Conservar en la raíz:
.git/,.opencode/,SPECIFICATION.md,Consulta de aplicativos - Claro - Facturación.csv,README.md.
- Mover todo el backend legacy (
- Inicializar proyecto con
npm create vite@latest . -- --template react-tsen el directorio raíz. - Instalar dependencias core:
react-router-dom,zustand,zod,date-fns,lucide-react. - Instalar dependencias de desarrollo:
msw(Mock Service Worker para desacoplar frontend del backend),@testing-library/react,vitest. - Configurar Tailwind CSS v4 con enfoque CSS-first (sin
tailwind.config.ts):- Definir design tokens en
src/index.cssmediante la directiva@theme:@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.darktoggleada manualmente. - Animaciones definidas como
@keyframesen el mismo archivo CSS.
- Definir design tokens en
- 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
-
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?.CaseUITypeenum:SIMPLE_CONFIRMATION,CONFIRMATION_WITH_VALUE,MULTI_FIELD_FORM,DATE_SIMPLE,FREE_TEXT,READ_ONLY.CaseStatus:PENDING,IN_PROGRESS,RESOLVED,FAILED.AgentStatus:ONLINE,BUSY,OFFLINE.
-
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 }(sinadvisorId; backend deriva identidad)
- Eventos entrantes (backend → frontend):
-
src/data/caseTypeDefinitions.ts: Mapeo completo de los 45 tipos del CSV aCaseTypeDefinition: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):{ 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 }) }
- Ejemplo concreto —
Paso 2 — Capa de Servicios y Store (arquitectura híbrida: REST autoritativo + WS difusión)
-
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 derivaadvisorIddel 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.
-
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_statepara 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(sinadvisorId; el backend deriva la identidad). - Indicador de estado de conexión en el store (
connected|disconnected|reconnecting). - No se emite
hitl_responsepor WebSocket; la resolución de casos es exclusiva de REST.
- Conexión a
-
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
useRefpara intervalos (evitar re-renders);localStoragesolo como caché de UI, no como fuente de verdad parahandling_time(el backend calcula constartedAt/resolvedAt).
- casesSlice:
Paso 3 — Componentes Compartidos
StatusBadge: Badge de estado con colores por estado (pending/in_progress/resolved/failed).SearchBar: Input de búsqueda con debounce.TabsBar: Pestañas de filtro (Todos/Pendientes/Finalizados).ApplicativeFilter: Dropdown/chips para filtrar por aplicativo (AC+, ASCARD, RR, etc.).Timer: Cronómetro independiente por caso con persistencia enlocalStorage(migrado del JS actual).Modal: Diálogo de confirmación genérico.EmptyState: Estado vacío para paneles sin selección.
Paso 4 — Módulo de Gestión de Casos HITL (/cases)
CasesPage.tsx: Layout maestro: sidebar izquierda (lista de casos) + panel derecho (detalle/acciones).CaseCard.tsx: Tarjeta de caso en la lista con título, status badge, timer (si activo), tipo de solicitud, aplicativo, fecha.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únuiPattern:SIMPLE_CONFIRMATION→ Botones "Sí" / "No".CONFIRMATION_WITH_VALUE→ Radio group (Sí/No) + campo numérico con prefijo$.MULTI_FIELD_FORM→ Formulario con campos definidos enformFields[](text, number, select, date).DATE_SIMPLE→ Date picker con formatodd-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.
- 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 difundehitl_resolvedpor WS a todos los asesores. - Caso pasa a estado
resolvedy timer se detiene.
Paso 5 — Módulo de Monitoreo (/monitor)
MonitorPage.tsx: Layout de dos columnas: lista de conversaciones (izquierda estrecha) + feed de chat (derecha amplia).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.
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.
MessageBubble.tsx: Burbuja de mensaje individual con timestamp y rol.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).
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
App.tsx: Router con dos rutas:/→ redirect a/cases./cases→CasesPage./monitor→MonitorPage.
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 delucide-react.- Inicializa WebSocket y fetch inicial al montar.
Paso 7 — Migración de Estilos (Preservar línea gráfica)
- Extraer todos los design tokens del
style.cssactual a bloques@themeensrc/index.css(ver Paso 0 para la configuración completa). - 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
- Preservar animaciones (
slideIn,fadeIn,pulse-op) como keyframes en Tailwind config. - Scrollbar styling → utilities de Tailwind o CSS global.
- Modo oscuro: conservar lógica de toggle con
classstrategy de Tailwind + persistencia enlocalStorage.
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:
{
"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/resolveno recibeadvisorId. 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
advisorIddel 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
- Backoff exponencial: 1s → 2s → 4s → 8s → 16s → 30s (máx).
- Al reconectar exitosamente, el servidor envía
init_statecon el estado completo actual. - El frontend reemplaza
conversationsyactiveCasescon los datos deinit_state. - 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 conisStreaming: true.agent_stream_chunk: concatenar token al contenido del mensaje usando elindexpara garantizar orden (no asumir orden de llegada de red).agent_stream_completed: marcar mensaje conisStreaming: false, reemplazar contenido confullContentpara 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 devsobre Vite + React + TypeScript, sirviendo enlocalhost:5173. - CA-2: Ruteo funcional:
/redirige a/cases; navegación entre/casesy/monitorvía sidebar con iconoslucide-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
uiPatterndel tipo de caso, con validación Zod antes de enviar. - CA-5: El formulario
MULTI_FIELD_FORMrenderiza campos específicos (ej. paraPlan_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
localStoragecomo cache de UI; elhandling_timeoficial lo calcula el backend constartedAt/resolvedAt. - CA-7: Resolución de caso se envía exclusivamente por REST (
POST /cases/:id/resolve). El backend difundehitl_resolvedpor 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_notepor WebSocket (sinadvisorIden 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 darkde 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@themeen CSS. - CA-14: Los 45 tipos de caso del CSV están mapeados en
src/data/caseTypeDefinitions.tsconuiPattern,formFields,validationSchema(Zod) ypayloadBuilderpara 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_statey se reemplaza el estado local completo.
Paso 9 — Capa de Mocks (MSW) y Funcionalidades Preservadas
- 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
uiPatterny múltiples aplicativos. - 3-5 conversaciones simuladas con mensajes de diferentes roles.
- El MSW se activa solo en modo desarrollo (
VITE_ENABLE_MSW=true).
- Handlers REST que simulan
- Funcionalidades preservadas del sistema actual:
- Notificaciones de escritorio HTML5: Hook
useNotificationque emitenew Notification()al recibirhitl_request; click en notificación navega a/casescon el caso seleccionado. - Alerta sonora: Hook
useSoundcon 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/blurpara controlar notificaciones.
- Notificaciones de escritorio HTML5: Hook
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)
- Streaming token-a-token: La semántica de concatenación depende de que el backend envíe tokens con un
conversationIdconsistente. Si hay mensajes simultaneous, el orden de tokens debe estar garantizado. - Persistencia de timers: Actualmente en
localStorage. En React, el estado del timer debe sincronizarse entre el store ylocalStoragesin causar re-renders excesivos (usar refs para el intervalo). - Tailwind + CSS variables: La migración de CSS puro a Tailwind requiere mapear cada utilidad. Los gradientes (
linear-gradient) y-webkit-background-clipnecesitan configuración adicional en Tailwind. - CSV parsing: Los 45 registros deben clasificarse manualmente en los 6
uiPattern. Algunos casos (ej.Unificar_Factura_EFque usa ASCARD + Paradigma) requieren lógica multi-aplicativo. - 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 atailwind.config.tspurgada. - ✅ R2 (Doble canal): Resuelto — REST como único canal autoritativo;
hitl_responseeliminado 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=. - 🟡 R6–R10: 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). Sintailwind.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) ypayloadBuilderdefinidos 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 tablarequests.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 atsconfig.app.jsonytsconfig.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 parasrc/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] → Ignoranode_modules/,dist/,.env,database.sqlite, entre otros.src/vite-env.d.ts: [Creado] → Declaraciones de tipos paraimport.meta.envcon 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, animacionesslideIn/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 6uiPattern), 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 paralayout/.src/components/cases/.gitkeep: [Creado] → Marcador de directorio paracases/.src/components/monitor/.gitkeep: [Creado] → Marcador de directorio paramonitor/.src/components/shared/.gitkeep: [Creado] → Marcador de directorio parashared/.src/hooks/.gitkeep: [Creado] → Marcador de directorio parahooks/.src/services/.gitkeep: [Creado] → Marcador de directorio paraservices/.src/store/.gitkeep: [Creado] → Marcador de directorio parastore/.src/types/.gitkeep: [Creado] → Marcador de directorio paratypes/.src/pages/.gitkeep: [Creado] → Marcador de directorio parapages/.src/data/.gitkeep: [Creado] → Marcador de directorio paradata/.
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 (sintailwind.config.ts), usando la directiva@themepara definir los design tokens y@custom-variant darkpara 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/resolvecomo endpoint REST exclusivo para resolución de casos. No se incluye WebSocket para escritura. - Regla 2 (Sin advisorId): Los handlers MSW no requieren
advisorIden 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á ensrc/index.cssmediante@themey@custom-variant. - Regla 4 (Streaming buffer 50ms): Se documentó en la spec; la implementación del buffer se realizará en el hook
useWebSocketen 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.
- Regla 1 (REST como canal autoritativo): Los handlers de MSW simulan
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
- Core:
-
Puntos Críticos a Probar:
- Restauración manual necesaria: Los archivos
node_modules/,package-lock.json,database.sqlitey el directoriopublic/(antiguo) aún existen en la raíz y deben moverse manualmente alegacy/o eliminarse. Ejecutar:rm -rf node_modules/ public/ package-lock.json database.sqlite mv server.js db.js schema.sql legacy/ 2>/dev/null; true - MSW no inicializado: El archivo
public/mockServiceWorker.jsdebe generarse ejecutandonpx msw init public/ --save. - Verificar que el alias
@/funciona: Eltsconfig.app.jsondefinepathscon@/*→src/*. Confirmar que Vite resuelva los imports correctamente. - Modo oscuro: El
@custom-variant darkusa la clase.darken un contenedor padre. Verificar que al agregarclass="dark"al<html>se activen los colores oscuros. - MSW handlers: Verificar que
VITE_ENABLE_MSW=trueactiva la interceptación en desarrollo y que los endpoints mock responden correctamente (ej.curl http://localhost:5173/api/v1/cases).
- Restauración manual necesaria: Los archivos
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 conz.ZodTypepara 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 helpercreateWSEnvelope(),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 objetosCaseTypeDefinitioncon:- Clasificación de
uiPatternsegún las 6 familias visuales (SIMPLE_CONFIRMATION, CONFIRMATION_WITH_VALUE, MULTI_FIELD_FORM, DATE_SIMPLE, FREE_TEXT, READ_ONLY). formFieldsderivados delresponseFormaty casos especiales documentados (Escalar_Pagos_No_Abonados con 9 campos, Validar_OTT_1/2 con 6 y 8 campos respectivamente, etc.).validationSchemaZod para cada entrada, con validaciones de tipo (número, moneda, toggle, fecha en formato dd-mm-aaaa, select con enum).payloadBuilderpara serializar el formulario al payload del backend.- Mapas helper
caseTypeByToolNameycaseTypesByApplicativepara búsqueda rápida. - Helpers de fábrica (
simpleConfirmation,confirmationWithValue,dateSimple,freeText,readOnly,multiFieldForm) para reducir repetición de código.
- Clasificación de
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) ensrc/types/index.tsdesacopladas 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.tsno existe ningún eventohitl_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 payloadinternal_notesolo contieneconversationIdycontent. No se incluyeadvisorIden 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
validationSchemaZod ypayloadBuildercompletamente implementados, sin placeholders ni TODOs.
- Regla 1 (REST como canal autoritativo): En
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:
- Tipos estrictos: Verificar que
tsc --noEmit(onpm run lint) no produce errores de tipo. Archivos clave:src/types/index.ts,src/types/wsProtocol.ts,src/data/caseTypeDefinitions.ts. - Validación Zod de eventos WS: Probar que
validateServerEvent('init_state', payload)rechaza payloads mal formados (ej. faltaconversationsoactiveCases). ProbarvalidateClientEvent('internal_note', { conversationId: '', content: '' })debe fallar porquecontentrequieremin(1). - Cobertura de 53 registros: Verificar que
caseTypeDefinitions.lengthes 53 y que ningún registro tienevalidationSchemaopayloadBuildercomo undefined. - Mapas auxiliares:
caseTypeByToolNamedebe contener todas las toolNames (las duplicadas prevalece la última).caseTypesByApplicativedebe tener entradas para "AC+", "ASCARD", "DiMe", "Formatos SGCS", "Mi asistencia 360", "Paradigma", "RR", "Phone Protect". - FormFields vs ValidationSchema: Para cada
MULTI_FIELD_FORM, verificar que los campos enformFieldscoinciden uno a uno con las claves delvalidationSchema. Ejemplo:Plan_De_Pagos_EFdebe tener 4 campos (numero_cuotas, valor_cuota, dia_corte, dia_limite_pago) tanto en formFields como en validationSchema. - PayloadBuilder fidelidad: Para
Validar_OTT_1, verificar quepayloadBuilder({ 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.
- Tipos estrictos: Verificar que
3.1 Paso 2 — Capa de Servicios (api.ts, wsClient.ts) y Store Zustand (useAppStore.ts)
src/services/api.ts: [Creado] → Cliente REST confetchnativo. ImplementagetCases,getCaseById,resolveCase,getActiveConversations,getConversation. DefinePaginatedResponse<T>,CaseFilters, yApiErrorpara manejo de errores HTTP. La URL base se configura viaVITE_API_BASE_URLcon fallback ahttp://localhost:5503/api/v1. Incluye helperbuildQuery()para construir query string con filtros (status, applicative, search, offset, limit) y helper internorequest<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 claseWsClientcon patrón singleton exportado comowsClient. Implementa reconexión con backoff exponencial (1s → 2s → 4s → 8s → 16s → máx 30s, factor 2x). Exponeconnect(),disconnect(),send(type, payload)que genera automáticamenteeventId(crypto.randomUUID) yoccurredAt(ISO-8601) en el envelope estándar,onMessagecallback setter/getter, ygetStatus()retornando'connected' | 'disconnected' | 'reconnecting'. Maneja cierre graceful con flagdestroyFlagpara 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 aapi.getCasesy actualiza estado),upsertCase(c)(reemplaza si existe o agrega al inicio),resolveCase(id, data)(llama aapi.resolveCasey actualiza el caso en el array local). - conversationsSlice:
conversations[],selectedConversationId,fetchConversations()(llama aapi.getActiveConversations),upsertConversation(c),addMessage(convId, msg),appendToken(convId, msgId, token, index)(bufferiza chunks enmetadata._chunksordenados porindexpara manejar entrega fuera de orden, actualizacontentconcatenando chunks ordenados),completeStream(convId, msgId, fullContent)(limpia_chunksde metadata, establececontent = fullContent, marcaisStreaming = false). - uiSlice:
sidebarTab('all'|'pending'|'resolved'),searchQuery,applicativeFilter,isDarkMode(persistido enlocalStoragevia claveclaro-cases:darkMode),wsStatus. Setters:setSidebarTab,setSearchQuery,setApplicativeFilter,toggleDarkMode(persiste y actualiza),setWsStatus. - La persistencia de
isDarkModese implementa con helperreadDarkMode()que leelocalStorageal inicializar el store ypersistDarkMode()que escribe en cada toggle.
- casesSlice:
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 porCaseStatus. Usa mapasSTATUS_LABELS(Pendiente/En Progreso/Finalizado/Fallido) ySTATUS_STYLEScon 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 íconoSearchdelucide-react. Implementa debounce de 300ms usandouseRefpara el timer yuseEffectpara sincronizar con el store. Almacena el valor local enuseStatey solo escribe al store tras el debounce. Estilo: fondobg-elevated, bordeborder, focofocus: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 leesidebarTabdel store y llama asetSidebarTab. 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. Renderizaicon(ReactNode, ej. emoji),title(14px font-semibold),description(12px text-secondary). Ícono context-[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ónfadeIn. 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 parachildren. Footer opcionalactions. Props:isOpen,onClose,title,children,actions?.src/components/shared/Timer.tsx: [Creado] → Cronómetro individual por caso con persistencia enlocalStorage(clavetimer_case_{caseId}). Implementado conforwardRefyuseImperativeHandleexponiendostart(),stop(),getElapsed(). UsauseRefpara el intervalo (setInterval1s) y contadores acumulados.useStatesolo para el display (MM:SS). Al montar, restaura estado desdelocalStorage. 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.tses un cliente REST puro sin dependencias de React ni del store, permitiendo ser usado desde hooks o desde MSW.wsClient.tses una clase singleton agnóstica al framework que expone callbacks, permitiendo queuseWebSocket(hook futuro) se suscriba sin acoplamiento. El store Zustand usaapipara 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):
resolveCaseen el store llama exclusivamente aapi.resolveCase()(POST REST). No existe ninguna función de resolución por WebSocket. - Regla 2 (Sin advisorId): El cliente WebSocket
send()no incluyeadvisorIden ningún payload. El método genérico solo recibetypeypayload. Los helpers de validación Zod delwsProtocol.tsya garantizan queinternal_notesolo tengaconversationIdycontent. - 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):
appendTokenen el store usametadata._chunksordenados porindexpara 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 hookuseWebSocket(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).
- Regla 1 (REST como canal autoritativo):
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:
- api.ts — Error handling: Verificar que
ApiErrorse lanza correctamente para códigos HTTP 4xx/5xx. Probar con MSW simulando errores 404 y 500. Verificar quebuildQueryomite parámetros undefined/null. - api.ts — Paginación: Llamar
getCases({ offset: 0, limit: 5 })y verificar query string?offset=0&limit=5. Llamar congetCases({})y verificar que no se añade?en la URL. - 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. - wsClient.ts — Envelope: Verificar que
send('internal_note', { conversationId: 'c1', content: 'nota' })produce un mensaje JSON contype,eventId(UUID),occurredAt(ISO string) ypayload. - 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
completeStreamreemplaza el contenido confullContenty limpiametadata._chunks. - useAppStore.ts — Dark mode persistence: Llamar
toggleDarkMode(), recargar el store, verificar queisDarkModepersiste. Verificar quelocalStoragecontieneclaro-cases:darkMode=true. - StatusBadge.tsx — Renderizado condicional: Renderizar con cada
CaseStatusy verificar clases de color correctas y texto en español. - SearchBar.tsx — Debounce: Escribir texto rápidamente y verificar que solo se actualiza el store tras 300ms de inactividad. Verificar que el ícono
Searchestá presente. - TabsBar.tsx — Estado activo: Hacer clic en "Pendientes" y verificar que
sidebarTaben el store cambia a'pending'y la pestaña visualmente activa tiene las clasesbg-accent-orange/8 text-accent-orange border-accent-orange. - Timer.tsx — Persistencia y control: Llamar
start()y esperar 5s. Verificar quelocalStoragetiene el timer guardado. Llamarstop()y verificar display se congela. LlamargetElapsed()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ó. - 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.
- EmptyState.tsx — Renderizado: Verificar que
iconrenderiza como elemento (puede ser string emoji o componente React),titleen 14px semibold,descriptionen 12px secondary, centrado vertical/horizontalmente.
- api.ts — Error handling: Verificar que
3.1 Paso 4 — Módulo de Gestión de Casos HITL (/cases)
src/components/cases/TypeBadge.tsx: [Creado] → Badge pequeño que muestra eltipoSolicitudcon estilobg-accent-orange/10 text-accent-orange border-accent-orange/25. Trunca el texto a 140px contitlepara tooltip.src/components/cases/CaseCard.tsx: [Creado] → Tarjeta de caso en la sidebar. Propscase: CaseRequest,isActive,onClick. Renderiza: (1) Header con título,StatusBadgey timer formateado (solo sistatus === IN_PROGRESSyhandlingTime > 0); (2) Descripción truncada a 2 líneas conline-clamp-2; (3) Footer con ID externo en monospace,TypeBadgecontipoSolicitud, y fecha formateada condate-fns. Estilo basebg-elevated border rounded-md p-3 cursor-pointer transition hover:bg-hover, activobg-accent-orange/4 border-accent-orange. Animaciónanimate-[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). UsaapplicativeFilterysetApplicativeFilterdel store. Al hacer clic en un chip activo, lo deselecciona (pasa anull). Incluye botón "✕ Limpiar" que solo aparece cuando hay un filtro activo. Estilo: chip activobg-accent-orange/10 text-accent-orange border-accent-orange/30, inactivobg-elevated text-text-muted border-border.src/components/cases/FormRenderer.tsx: [Creado] → Componente crítico que renderiza formularios dinámicos segúnCaseUIType. 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 localformValues/formErrors, transformación de fechas yyyy-mm-dd ↔ dd-mm-aaaa, validación Zod inline, soporteconditionalOn, 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.CaseCardyTypeBadgeson puramente presentacionales.FormRendererencapsula toda la complejidad de renderizado condicional, transformación de fechas, y validación Zod inline.CaseDetailorquesta la integración entre metadata, formulario y timer.CasesPageactúa como orquestador de layout y filtros. -
Mitigación de Riesgos (Fase 2):
- Regla 1 (REST como canal autoritativo):
CaseDetail.handleFormSubmitllama aresolveCasedel store (POST REST).FormRenderersolo recolecta datos y llama aonSubmit. - Regla 2 (Sin advisorId): Ningún componente envía
advisorId. El payload contiene soloactionypayload. - Regla 3 (Tailwind v4 CSS-first): Todos los componentes usan exclusivamente tokens
@themesin configuración JS de Tailwind. - Regla 5 (45 tipos de caso con Zod):
FormRendererusavalidationSchema.safeParse()antes de llamar aonSubmit.
- Regla 1 (REST como canal autoritativo):
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:
- CaseCard — Renderizado condicional de timer: Solo aparece cuando
status === IN_PROGRESSyhandlingTime > 0. Formato MM:SS. - CaseCard — Animación slideIn al montar.
- ApplicativeFilter — Toggle: Chip activo ↔
applicativeFilteren store. Botón ✕ solo visible con filtro activo. - FormRenderer — SIMPLE_CONFIRMATION: Botones Sí/No llaman
onSubmit({ confirmacion: true/false }). - FormRenderer — CONFIRMATION_WITH_VALUE: Radio Sí→ campo $ visible, Radio No→ oculto. Validación valor negativo.
- FormRenderer — MULTI_FIELD_FORM: Renderiza types correctos, min/max, toggle switch, conditionalOn, errores inline.
- FormRenderer — Transformación fecha: Date picker → valor enviado en dd-mm-aaaa.
- FormRenderer — READ_ONLY: Botón "Marcar como revisado" llama
onSubmit({}). - CaseDetail — Timer: Inicia automático en IN_PROGRESS, se detiene al resolver.
- CaseDetail — Acordeón: Pasos colapsables con ChevronDown/ChevronUp.
- CasesPage — Filtros combinados: Búsqueda + tab + aplicativo se combinan correctamente. Footer "X de Y casos".
- CasesPage — Empty states: Sin casos → EmptyState en sidebar. Sin selección → EmptyState en panel derecho.
- CasesPage — Fetch on mount: Se llama
fetchCases()al montar. - FormRenderer — Validación Zod: Datos inválidos → errores inline, no se llama
onSubmit.
- CaseCard — Renderizado condicional de timer: Solo aparece cuando
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. SiisStreaming, muestra cursor parpadeante (barra animada).src/components/monitor/InternalNotesGroup.tsx: [Creado] → Acordeón expandible que agrupa mensajesinternalconsecutivos. Cabecera "🔄 Notas internas (N)" colapsable. Al expandir, muestra contenido y timestamp de cada nota. Implementa filtro de seguridad para solo renderizar mensajes conrole === 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 handleronScroll; si está cerca, hace scroll automático al llegar nuevo mensaje o token. Agrupa mensajesinternalconsecutivos enInternalNotesGroupmediante buffer de acumulación intercalado conflushInternal(). Muestra indicador "Escribiendo..." con spinner cuando el último mensaje del agente tieneisStreaming: 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) spinnerLoader2animado 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 awsClient.send('internal_note', { conversationId, content })sinadvisorId. 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 deConversationCards (con encabezado y contador), derecha flex-1 conChatFeed+InternalNoteBannersi hay conversación seleccionada, oEmptyStatesi no. Al montar, llama afetchConversations()del store. Conecta conselectedConversationIdy setea medianteuseAppStore.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.
MessageBubblees puramente presentacional (solo recibeMessagepor props).InternalNotesGroupencapsula la lógica de agrupación y colapso.ChatFeedorquesta la integración entre burbujas, agrupación de notas internas y auto-scroll.ConversationCardes presentacional con helpers de extracción de último mensaje y detección de streaming.InternalNoteBannerse conecta directamente conwsClient(singleton) para enviar notas internas, sin pasar por el store.MonitorPageactúa como orquestador de layout y conexión con el store. -
Mitigación de Riesgos (Fase 2):
- Regla 1 (REST como canal autoritativo):
InternalNoteBannerenvía por WebSocket exclusivamente notas internas (eventointernal_note), nunca resolución de casos. - Regla 2 (Sin advisorId):
wsClient.send('internal_note', { conversationId, content })no incluyeadvisorIden 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
@themesin configuración JS de Tailwind. - Regla 4 (Streaming buffer 50ms):
ChatFeedreacciona a cambios enmessages[messages.length-1]?.contentpara auto-scroll durante streaming, respetando posición manual del usuario mediante refisNearBottomRef.
- Regla 1 (REST como canal autoritativo):
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:
- 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 🔒).
- MessageBubble — Streaming cursor: Cuando
isStreaming: true, debe mostrar barra parpadeante al final del contenido. - 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.
- ChatFeed — Agrupación de notas internas: 2+ mensajes
internalconsecutivos deben agruparse en un acordeón. Un mensaje internal seguido de user/agent debe renderizarse individualmente. - ChatFeed — Indicador "Escribiendo...": Cuando el último mensaje del agente tiene
isStreaming: true, debe mostrar texto "Escribiendo..." con spinner. - InternalNotesGroup — Expandir/colapsar: Hacer clic en cabecera y verificar que se expanden/colapsan las notas. Verificar contador "Notas internas (N)".
- ConversationCard — Último mensaje truncado: Mensaje > 80 caracteres debe truncarse con "...".
- ConversationCard — Spinner streaming: Debe mostrar
Loader2animado cuando el último mensaje tieneisStreaming: true. - InternalNoteBanner — Envío sin advisorId: Verificar que
wsClient.sendrecibe payload sin campoadvisorId. Verificar feedback "Enviado ✓" post-envío. - InternalNoteBanner — Enter vs Shift+Enter: Enter envía, Shift+Enter inserta nueva línea.
- MonitorPage — Layout: 280px sidebar izquierda + flex-1 derecha. EmptyState cuando no hay conversación seleccionada.
- MonitorPage — Fetch on mount: Se llama
fetchConversations()al montar. AlmacenarselectedConversationIdconuseAppStore.setState.
3.1 Paso 6 — App Shell y Ruteo
src/services/wsClient.ts: [Modificado] → Se añadió callbackonStatusChange(getter/setter) y tipoStatusChangeCallbackpara notificar cambios de estado de conexión al store. El método privadosetStatus()ahora invocaonStatusChangeCallback?.(status)en cada transición, permitiendo queAppShellsincronice el indicador WS en el Header.src/components/layout/Header.tsx: [Creado] → Barra superior de 50px. Logo: emoji 🔴 + "Claro Cases" con gradientebg-gradient-to-r from-accent-orange to-accent-yellow bg-clip-text text-transparent. Badge "En vivo" con estilobg-accent-green/10 text-accent-green. Indicador de conexión WS: punto circular coloreado (verde/amarillo/rojo segúnwsStatusdel store) + texto (Conectado/Reconectando.../Desconectado), conanimate-pulseen estado reconnecting. Toggle tema oscuro/claro con iconos Sun/Moon delucide-react.src/components/layout/Sidebar.tsx: [Creado] → Navegación lateral fija de 50px de ancho. UsaNavLinkde react-router-dom con dos rutas: Casos (iconoLayoutList) →/cases, Monitor (iconoMonitor) →/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. RenderizaHeaderarriba,Sidebara la izquierda (50px), ychildren(contenido de la ruta) a la derecha. Al montar: (1) sincroniza clase.darken<html>segúnisDarkModedel store, (2) inicializa conexión WebSocket viawsClient.connect()y registraonStatusChange→setWsStatus, (3) registraonMessagehandler (placeholder para integración futura de eventos WS), (4) llamafetchCases()ofetchConversations()según la ruta actual. Cleanup: desconecta WS y limpia callbacks al desmontar.src/App.tsx: [Reemplazado] → Router conBrowserRouterenvolviendoAppShellcomo 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:
AppShelles el layout contenedor que orquesta la inicialización de infraestructura (WS, tema oscuro, fetch inicial) y renderizaHeader+Sidebar+ contenido. El ruteo está desacoplado enApp.tsxusando react-router-dom estándar.HeaderySidebarson componentes puramente presentacionales que se conectan al store para estado de UI (wsStatus, isDarkMode). La modificación awsClient.tses mínima y no rompe la interfaz existente. -
Mitigación de Riesgos (Fase 2):
- Regla 2 (Sin advisorId):
AppShellno 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
@themesin configuración JS de Tailwind. El toggle dark mode usaclassstrategy con@custom-variant dark. - Regla 4 (Streaming buffer 50ms):
AppShellregistra unonMessagehandler 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.
- Regla 2 (Sin advisorId):
3.3 Notas Técnicas para el Tester
- Dependencias Añadidas: Ninguna nueva.
- Puntos Críticos a Probar:
- Header — Gradiente logo: Verificar que el texto "Claro Cases" tiene gradiente
accent-orange → accent-yellowconbg-clip-text text-transparent. - Header — Indicador WS: Verificar punto verde + "Conectado" cuando
wsStatus = 'connected', amarillo + "Reconectando..." cuando'reconnecting', rojo + "Desconectado" cuando'disconnected'. Estado reconnecting debe teneranimate-pulse. - Header — Toggle tema: Hacer clic en icono sol/luna y verificar que
isDarkModecambia en el store y se agrega/remueve clase.darken<html>. - 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. - AppShell — Inicialización WS: Al montar, verificar que
wsClient.connect()se llama y quewsClient.onStatusChangeactualizawsStatusen el store. - AppShell — Dark mode sync: Con
isDarkMode = true, verificar que<html>tiene clase.dark. Confalse, que no la tiene. - AppShell — Fetch inicial: Al navegar a /cases, verificar que se llama
fetchCases(). Al navegar a /monitor, verificar que se llamafetchConversations(). NOTA: El fetch inicial solo ocurre al montarAppShell; cambios de ruta posteriores son manejados por los pages. - App.tsx — Ruteo: Verificar que
/redirige a/cases. Verificar que/casesrenderizaCasesPage. Verificar que/monitorrenderizaMonitorPage. Verificar que ruta desconocida redirige a/cases. - App.tsx — AppShell wrapping: Verificar que todas las rutas están envueltas en
AppShelly que Header + Sidebar son visibles en todas las vistas.
- Header — Gradiente logo: Verificar que el texto "Claro Cases" tiene gradiente
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 permisoNotification.requestPermission()al montar si no está en estadogranted. Exponenotify(title, body, onClick?)que crea unanew Notification()con icono/favicon.icoy auto‑cierre a los 6 segundos. Al hacer clic en la notificación se ejecuta el callbackonClick, 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 unAudioContextde forma perezosa en el primer gesto del usuario (eventosclickokeydowncon{ once: true }), cumpliendo con las políticas de autoplay del navegador. ExponeplayNotificationSound()que genera un chime de dos tonos: C5 (523.25 Hz) por 120 ms → E5 (659.25 Hz) por 120 ms, compartiendo un nodoGainNodecon volumen 0.08 y fade out exponencial a 0.001 en 450 ms. Si elAudioContextestá en estadosuspended, 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 contadoruseRefde notificaciones no leídas. ExponetriggerNotification()que incrementa el contador y, si la pestaña no está enfocada (document.visibilityState === 'hidden'odocument.hasFocus()esfalse), 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, eventowindow.focus), se limpia el intervalo, se restaura el título y se resetea el contador a cero. Los listenersvisibilitychange,focusyblurse limpian al desmontar el componente.src/hooks/index.ts: [Creado] → Barrel export que re‑exportauseNotification,useSoundyuseTitleFlashpara imports limpios desde otros módulos.src/components/layout/AppShell.tsx: [Modificado] → Se integraron los tres hooks de funcionalidades preservadas. El manejadoronMessagedel WebSocket fue expandido para despachar eventos a la store segúnenvelope.type:init_state: Reemplaza el estado local conpayload.conversationsypayload.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 aappendTokenpara concatenación ordenada.agent_stream_completed: Envía el contenido completo acompleteStream.hitl_request: Dispara las tres funcionalidades preservadas — (1) notificación de escritorio connotify()cuyoonClicknavega a/casesy selecciona el caso, (2) alerta sonora conplayNotificationSound(), (3) parpadeo de título contriggerNotification(). Además inserta el caso en el store víaupsertCase().- 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 re‑montar 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:
useNotificationusauseRefpara cachear el permiso yuseCallbackpara memoizar la funciónnotify, evitando re‑creaciones innecesarias. La solicitud de permiso ocurre una sola vez al montar.useSoundinicializa elAudioContextde 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.useTitleFlashusauseRefpara el contador no leído y el intervalo, evitando re‑renders al actualizar el título del documento. La lógica de start/stop está desacoplada enstartFlashing/stopFlashingpara ser reutilizada desdetriggerNotificationy los listeners devisibilitychange/focus/blur.AppShellintegra 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 switch‑case basado enenvelope.typepermite 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 dehitl_requestsolo 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_requestno envía ningún payload que contengaadvisorId. Solo procesa datos entrantes y dispara efectos locales. - Regla 3 (Tailwind v4 CSS-first):
AppShellno introduce nuevas clases que dependan de configuración JS de Tailwind. - Regla 4 (Streaming buffer 50ms): Los eventos
agent_stream_chunkse despachan directamente aappendTokendel store, que ya implementa el buffer ordenado porindexpara garantizar orden correcto de tokens incluso con entrega fuera de orden.
- Regla 1 (REST como canal autoritativo): En
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:
- 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 siNotification.permission !== 'granted'y!== 'denied'. - useNotification — Click handler: Al hacer clic en una notificación, debe ejecutar el callback
onClick, enfocar la ventana y cerrar la notificación. Verificar quewindow.focus()se llama y quenotification.close()se ejecuta. - useSound — AudioContext lazy: Sin gesto de usuario,
playNotificationSound()debe loguear warning. Tras un click o keydown, debe crear elAudioContexty reproducir el chime. Verificar que elAudioContextse cierra al desmontar el hook. - useSound — AudioContext suspended: Simular estado
suspended(navegador con política de autoplay estricta) y verificar queplayNotificationSound()loguea warning sin lanzar error. - 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.
- 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. LlamartriggerNotification()nuevamente sin enfocar: el contador debe incrementar a 2 y el título alternar con"(🔔 2) ¡Nuevo Caso!". - 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. - 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!". - AppShell — hitl_request handler: Simular un evento
hitl_requestentrante 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. - AppShell — Click en notificación: Al hacer clic en la notificación generada por
hitl_request, debe navegar a/casesy seleccionar el caso (selectedCaseIddebe coincidir con eliddel case del payload). - AppShell — init_state handler: Simular
init_statecon múltiples conversaciones y casos. Verificar que el store se actualiza correctamente sin duplicados. - AppShell — agent_stream_chunk handler: Simular chunks desordenados y verificar que
appendTokenlos ordena por índice. - AppShell — Ref pattern: Verificar que el
onMessagecallback siempre usa la última versión dehandleIncomingMessageincluso si el componente se re‑renderiza (ej. cambio deisDarkMode). El handler debe seguir funcionando sin necesidad de re‑conectar el WS. - npm run build: Verificar que
npm run buildcompila sin errores de tipo.
- useNotification — Permiso denegado: Bloquear notificaciones en el navegador y verificar que
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 aset(), ordenando porindexpara 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 unsetTimeoutde 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 aset()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 delselectedConversationIdsi corresponde.- Auto-limpieza en
flushBuffer(): si la conversación ya no existe en el store, se elimina la entrada del buffer.
- Sistema de buffer externo (
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 arraypendingde chunks y untimer(setTimeout de 50ms). Solo la conversación seleccionada programa timers de flush; las conversaciones no seleccionadas acumulan tokens silenciosamente sin disparar re-renders. CuandocompleteStreamllega, se limpia el buffer y se actualiza el store con elfullContentautoritativo en una sola llamada aset(). Al cambiar de conversación,setSelectedConversationIdfuerza 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:
completeStreamelimina el buffer de la conversación (cancela timer + borra entrada del Map).removeConversationtambié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,setSelectedConversationIdfuerza un flush inmediato.
- Regla 4 (Streaming buffer 50ms): Implementado completamente. Cada chunk se acumula en un buffer externo, y cada 50ms se hace una sola llamada a
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:
- Buffer de 50ms: Enviar 100 chunks rápidamente a
appendTokenpara la misma conversación seleccionada. Verificar queset()se llama ~20 veces por segundo (cada 50ms), no 100 veces. - 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.
- 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. - 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. - completeStream limpia buffer: Llamar
completeStream()para una conversación con chunks pendientes. Verificar queconversationBuffersya no tiene entrada para esa conversación y que el store muestrafullContent. - removeConversation limpia buffer: Llamar
removeConversation()y verificar que la entrada del buffer se elimina y la conversación desaparece del store. - 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. - No fuga de timers: Verificar que los
setTimeoutse cancelan correctamente al llamarcompleteStream()oremoveConversation(). No debe haber timers colgados después de estas operaciones. - npm run build: Debe compilar sin errores tras los cambios.
- Buffer de 50ms: Enviar 100 chunks rápidamente a
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 buildejecutado exitosamente. Vite v6.4.3 transformó 2742 módulos en 3.04s. Archivos generados endist/: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 dehitl_response. La resolución de casos es exclusivamente REST. - Regla 2 (advisorId): PASSED — Búsqueda con grep en
src/no encontró ninguna ocurrencia deadvisorId. No hay identificadores de asesor en payloads cliente→servidor. - Regla 3 (tailwind.config.ts): PASSED — El archivo
tailwind.config.tsNO existe en la raíz del proyecto. Toda la configuración de Tailwind v4 está ensrc/index.cssvia@themey@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()programasetTimeoutde 50ms por conversación (línea 196-198) con guarda contra timers duplicados (línea 194). - ✅
flushBuffer()verificaselectedConversationIdantes de llamar aset()(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:
scheduleBufferFlushsolo se llama siselectedConversationId === convId). - ✅
completeStream()(líneas 334-381): limpia el buffer (cancela timer + elimina entrada del Map) y luego actualiza el store confullContenten una sola llamada aset(). - ✅
removeConversation()(líneas 394-411): limpia el buffer antes de eliminar la conversación del store. - ✅
setSelectedConversationId()(líneas 383-392): fuerza flush inmediato viaforceFlushBuffer()al cambiar de conversación.
- ✅ Buffer externo (
- Regla 5 (45+ casos mapeados): PASSED — 53 registros en
src/data/caseTypeDefinitions.ts, todos conuiPattern(53/53),applicative(53/53),formFields(53/53), yvalidationSchema/payloadBuilderprovistos via spread de funciones fábrica (51 usos de factory spreads:simpleConfirmation18,multiFieldForm18, másconfirmationWithValue,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:
- URL capture:
login?token=<base64_jwt>→ extrae el paramtoken - 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, osub
Intercambio por JWT Linguo:
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)
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>():
- Antes de cada fetch:
auth.getToken()— si null, lanzarAuthError - Headers:
Authorization: Bearer <jwt> - Si respuesta
401→auth.logout()→ redirect
Paso A4 — Modificación de wsClient.ts
En connect():
const token = auth.getToken();
if (!token) return;
const url = `${this.baseUrl}?token=${encodeURIComponent(token)}`;
Paso A5 — Store authSlice
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
<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:
LoginPagecon botón "Iniciar sesión con Okan" que abre popup 600×700 aapps.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
sessionStoragecondocument,fullName,expireDate,token. - CA-A6:
api.tsinyectaAuthorization: Bearer <jwt>en cada request REST. - CA-A7:
wsClient.tsadjunta?token=<jwt>al conectar WebSocket. - CA-A8: Si
api.tsrecibe401, se ejecutalogout()y redirige a/login. - CA-A9:
ProtectedRouteredirige a/loginsi no hay sesión válida. - CA-A10: Header muestra
fullNamedel 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 buildcompila sin errores. - CA-A14: MSW handlers siguen funcionando sin requerir auth en modo desarrollo.
1.5 Riesgos Identificados
- Popup bloqueado por el navegador:
window.open()puede ser bloqueado. Mitigación: detectarpopup === nully mostrar mensaje "Permite ventanas emergentes para iniciar sesión". - Cross-origin en monitor del popup:
popup.location.hreflanzaSecurityErrorsi el popup navega a otro origen distinto de Okan. Mitigación:try/catch— si falla, asumir que sigue en Okan. - Ventana cerrada por el usuario: Mitigación: detectar
popup.closedy mostrar "Login cancelado". expireDatevsexpdel JWT: El backend envíaexpireDateexplícito. Decisión: usarexpireDatedel backend como fuente de verdad, no decodificar el JWT Linguo.- 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.
sessionStoragefunciona 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.tses correcta en intención, pero incompleta si no excluye explícitamente el endpoint/loginy 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
tokenen la URL del popup puede filtrarlo por historial, logs, extensiones o unreferrermal 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 /loginestá subespecificado. No quedan cerrados los códigos HTTP exactos, formato de error, validez/idempotencia deltoken_okan, ni el criterio de expiración deexpireDatevstokendevuelto. - Media:
sessionStoragesigue 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.tspuede 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 /logincon 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
sessionStoragesolo 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 claveclaro-cases:session, validación de expireDate).src/services/api.ts: [Modificado] → Se añadióAuthError extends Error. El interceptor enrequest<T>()verificaauth.getToken()antes de cada fetch (excepto/loginy modo MSW), inyectaAuthorization: Bearer <jwt>en headers, y limpia sesión + lanzaAuthError('Sesión expirada')al recibir 401 (excluyendo/loginy MSW).src/services/wsClient.ts: [Modificado] → Enconnect(), se verificaauth.getToken(); si es null, se loguea warning y retorna sin conectar. La URL del WebSocket incluye?token=<jwt_encoded>.src/hooks/useAuth.ts: [Creado] → HookuseAuth()con estadoAuthStatus: 'loading' | 'authenticated' | 'anonymous' | 'expired'. Verifica al montar y periódicamente cada 30s si la sesión expiró. Exponestatus,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 mientrasisLoading. Redirige a/logincon<Navigate replace />si no autenticado. Renderizachildrensi autenticado.src/components/layout/Header.tsx: [Modificado] → MuestrafullNamedel asesor (desdeauth.getSession()) con truncado a 160px. Botón "Cerrar sesión" con iconoLogOutde lucide-react que llama aauth.logout()y navega a/login.src/App.tsx: [Modificado] → Se añadió ruta/logincon<LoginPage />. Las rutas/casesy/monitorse envuelven en<ProtectedRoute>. El catch-all*redirige a/cases.src/components/layout/AppShell.tsx: [Modificado] → Se añadióimport { auth }y unuseEffectde auth check al montar: si no está en/loginyauth.isAuthenticated()es false, redirige a/login. EluseEffectde inicialización WS ahora tiene guardaif (!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.tses 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 ensessionStorage(se destruye al cerrar pestaña) con validación deexpireDateen cada lectura. El interceptor deapi.tsinyecta el token JWT en todas las llamadas REST excepto aquellas que contienen/login(para no interferir con el exchange) y cuandoVITE_ENABLE_MSW === 'true'(los mocks no requieren auth). El WebSocket adjunta el token como query param?token=en la URL. El hookuseAuthagrega una capa reactiva con estadoloading/authenticated/anonymous/expiredpara que los guards (ProtectedRoute,AppShell) puedan decidir correctamente sin parpadeos ni redirects prematuros. -
Mitigación de Riesgos (Fase 2):
- R1 (Popup bloqueado):
openOkanPopup()retornanullsiwindow.openfalla o el popup está cerrado inmediatamente.LoginPagemuestra mensaje "Permite ventanas emergentes para iniciar sesión". - R2 (Cross-origin monitor):
monitorPopup()envuelvepopup.location.hrefen 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()detectapopup.closedantes de cada poll y rechaza con "Login cancelado". - R4 (expireDate como fuente de verdad): La sesión guarda
expireDatedel backend.auth.getToken()validaDate.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 cuandoVITE_ENABLE_MSW === 'true'. - Contrato POST /login:
exchangeToken()parsea200 → Sessiony errores con{ detail }del backend, lanzandoErrorcon 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:
ProtectedRouteyuseAuthmanejan el estado'loading'mostrando un spinner, evitando redirects prematuros antes de que la sesión se verifique.
- R1 (Popup bloqueado):
3.3 Notas Técnicas para el Tester
- Dependencias Añadidas: Ninguna. Todas las dependencias ya estaban instaladas (react-router-dom, lucide-react).
auth.tsusa APIs nativas:window.open,window.setInterval,atob,crypto.randomUUID,sessionStorage,fetch. - Puntos Críticos a Probar:
- 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.
- 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.
- 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.
- 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.
- api.ts — AuthError: Llamar
request()sin sesión → debe lanzarAuthError('No autenticado'). Llamar con sesión válida → headers incluyenAuthorization: Bearer <jwt>. - api.ts — MSW exclusion: Con
VITE_ENABLE_MSW=true, verificar querequest()no lanza AuthError incluso sin token. - api.ts — 401 handling: Mockear respuesta 401 → verificar que
auth.logout()se llama y se lanzaAuthError('Sesión expirada'). - 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>. - ProtectedRoute: Sin sesión → redirect a /login. Con sesión → renderiza children. Estado loading → spinner.
- 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).
- Header — fullName y logout: Con sesión, verificar que el nombre aparece en el header. Click en LogOut → se limpia sessionStorage → redirige a /login.
- AppShell — Auth redirect: Sin sesión, al navegar a /cases o /monitor → redirige a /login. Con sesión, se inicializa WS y fetch.
- AppShell — WS conditional init: Sin sesión, wsClient.connect() no se llama (el guard en el useEffect lo impide).
- 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 buildejecutado exitosamente. Vite v6.4.3 transformó 2746 módulos en 2.81s. Archivos generados endist/: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
/loginestá completamente excluido de auth check, inyección de headers y manejo de 401.
- Línea 66:
-
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).
- No se verifica auth antes de requests (línea 67:
-
Regla 4 — ProtectedRoute loading state: PASSED —
ProtectedRoute.tsxmaneja correctamente el estadoloading:useAuth()(hook) inicializa constatus = 'loading'(línea 25 deuseAuth.ts).- Mientras
isLoading === true,ProtectedRoutemuestra un spinner centrado conLoader2y texto "Cargando..." (líneas 28-36). - Solo después de que
loadingse resuelve se decide entreauthenticated → render childrenoanonymous/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
localStoragepara la sesión en ningún punto.
- Línea 306:
-
Grep seguridad — token_okan: PASSED — Búsqueda en
src/encuentratoken_okansolo en:src/services/auth.tslínea 250: Comentario JSDoc (POSTs { token_okan } to the Linguo login endpoint.)src/services/auth.tslí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
advisorIdensrc/no encontró ninguna ocurrencia. No hay identificadores de asesor en payloads cliente→servidor. -
CA-A1 (LoginPage con botón Okan): PASSED —
LoginPage.tsxrenderiza botón "Iniciar sesión con Okan" (línea 140) que llama aauth.openOkanPopup()(popup 600×700 centrado). -
CA-A12 (Estados de error del popup): PASSED —
LoginPage.tsxmaneja 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
```