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

112 KiB
Raw Permalink Blame History

BITÁCORA DE DESARROLLO Y ESPECIFICACIONES

CONTROL DE ESTADO

  • Último Agente Modificador: qa-tester
  • Estado del Ciclo: [STATUS: PASSED] - Listo para Producción / Git
  • Feature Activa: Módulo de Autenticación JWT (Okan → Linguo)

Fase 1: Requerimientos y Plan Inicial

1.1 Resumen Ejecutivo

  • Tipo de Tarea: Migración y expansión (New Feature + Rewrite)
  • Objetivo General: Reescribir el dashboard Claro Cases de vanilla HTML/CSS/JS a React + TypeScript + Vite, expandiéndolo con dos módulos: (1) Gestión de Casos HITL con formularios dinámicos por tipología y (2) Monitoreo completo de conversaciones en tiempo real con capacidad de intervención mediante notas internas, utilizando comunicación híbrida REST + WebSocket.

1.2 Contexto Técnico y Hallazgos

Estado Actual (Proyecto Claro Cases existente)

  • Backend: Node.js + Express + SQLite (better-sqlite3). Monolítico, acoplado al frontend.
  • Frontend: SPA vanilla HTML/CSS/JS. Sidebar de casos + panel de detalle.
  • Comunicación: REST (CRUD) + SSE unidireccional para notificaciones.
  • Persistencia: SQLite local (database.sqlite). Tabla requests con campos: id, title, description, status, external_id, cedula, tipo_solicitud, payload (JSON), handling_time, created_at.
  • Lógica actual: Dos flujos de resolución (validación Sí/No y texto libre). Cronómetros individuales con persistencia en localStorage. Notificaciones de escritorio + sonido Web Audio + parpadeo de título.
  • Estilos: Sistema de diseño con CSS custom properties. Paleta orange/red/yellow/green. Tipografía Inter. Modo oscuro/claro. Sin framework CSS.

Proyecto de Referencia (Linguo Nexus)

  • Stack: React 19 + TypeScript + Vite + Tailwind CSS v4.
  • Estado: Zustand store centralizado.
  • Ruteo: React Router con /monitor e /intervention.
  • Comunicación: REST (/api/v1/conversations/active, /api/v1/tickets/pending) + WebSocket (/ws/monitor) con eventos tipados (init_state, conversation_started, user_message, agent_stream, hitl_required, hitl_resolved, CLIENT_TOOL_REQUEST).
  • Validación: Zod para payloads WebSocket y edge tool calling.
  • UI: Kanban drag&drop (@dnd-kit), streaming token-a-token con auto-scroll, renderizado Markdown (marked-react), leader election (navigator.locks).

Tipos de Caso (CSV: 45 registros)

  • Aplicativos origen: AC+, ASCARD, DiMe, Formatos SGCS, Mi asistencia 360, Paradigma, RR, Phone Protect.
  • Taxonomía de interacción aprobada: Confirmación simple, Confirmación + valor, Formulario multi-campo, Fecha simple, Texto libre, Solo lectura.
  • Jerarquía secundaria: Filtro por aplicativo (columna A del CSV).

Módulos/Archivos Impactados

  • public/index.html: Reemplazado por index.html de Vite + React root.
  • public/style.css: Migrado a Tailwind config + CSS custom properties preservados.
  • public/app.js: Reescrito en componentes React + Zustand store.
  • server.js: Backend actual se reemplazará por backend Python (fuera del scope de esta migración frontend).
  • db.js, schema.sql, database.sqlite: Reemplazados por backend Python.
  • Consulta de aplicativos - Claro - Facturación.csv: Parseado e incrustado como datos estáticos en src/data/caseTypeDefinitions.ts.

1.3 Plan Lógico de Solución (Paso a Paso)

Paso 0 — Bootstrap del proyecto React + TypeScript + Vite y Reestructuración del Repositorio

  1. Reorganización del repositorio (previa al bootstrap):
    • Mover todo el backend legacy (server.js, db.js, schema.sql, database.sqlite, node_modules/, public/, package.json, package-lock.json, .env, .env.example) a un subdirectorio legacy/.
    • Conservar en la raíz: .git/, .opencode/, SPECIFICATION.md, Consulta de aplicativos - Claro - Facturación.csv, README.md.
  2. Inicializar proyecto con npm create vite@latest . -- --template react-ts en el directorio raíz.
  3. Instalar dependencias core: react-router-dom, zustand, zod, date-fns, lucide-react.
  4. Instalar dependencias de desarrollo: msw (Mock Service Worker para desacoplar frontend del backend), @testing-library/react, vitest.
  5. Configurar Tailwind CSS v4 con enfoque CSS-first (sin tailwind.config.ts):
    • Definir design tokens en src/index.css mediante la directiva @theme:
      @import "tailwindcss";
      @theme {
        --color-accent-orange: #ff4e00;
        --color-accent-yellow: #ffa600;
        --color-accent-red: #f80018;
        --color-accent-green: #10b981;
        --color-bg-base: #f0f2f5;
        --color-bg-surface: #ffffff;
        --color-bg-elevated: #f8fafc;
        --color-bg-hover: #e2e8f0;
        --color-text-primary: #1e293b;
        --color-text-secondary: #475569;
        --color-text-muted: #94a3b8;
        --color-border: rgba(0, 0, 0, 0.08);
        --color-border-accent: rgba(255, 78, 0, 0.25);
        --radius-sm: 4px; --radius-md: 8px; --radius-lg: 12px; --radius-xl: 16px;
        --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.05);
        --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
        --shadow-lg: 0 12px 24px rgba(0, 0, 0, 0.12);
        --font-family-sans: 'Inter', system-ui, sans-serif;
        --transition-default: 0.18s cubic-bezier(0.4, 0, 0.2, 1);
      }
      
    • Modo oscuro mediante @custom-variant dark (&:where(.dark, .dark *)) con overrides de variables en bloque @media (prefers-color-scheme: dark) y clase .dark toggleada manualmente.
    • Animaciones definidas como @keyframes en el mismo archivo CSS.
  6. Estructura de carpetas:
    src/
      components/
        layout/     (AppShell, Sidebar, Header, StatusBar)
        cases/      (CaseCard, CaseDetail, FormRenderer, Timer, TypeBadge, ApplicativeFilter)
        monitor/    (ConversationCard, ChatFeed, MessageBubble, InternalNoteBanner, InterventionPanel)
        shared/     (StatusBadge, SearchBar, TabsBar, Modal, EmptyState)
      hooks/        (useWebSocket, useTimer, useNotification)
      services/     (api.ts, wsClient.ts)
      store/        (useAppStore.ts — slices: cases, conversations, ui)
      types/        (index.ts, wsProtocol.ts, caseTypes.ts)
      pages/        (CasesPage.tsx, MonitorPage.tsx)
      data/         (caseTypeDefinitions.ts — parsed from CSV)
    App.tsx
    main.tsx
    

Paso 1 — Sistema de Tipos y Contratos

  1. src/types/index.ts: Interfaces base:

    • CaseRequest: id, title, description, status, externalId, cedula, tipoSolicitud, payload, handlingTime, createdAt, applicative, uiPattern.
    • Conversation: id, clientId, agentId, status, messages[], createdAt.
    • Message: id, conversationId, role (user/agent/system/internal), content, timestamp, metadata?.
    • CaseUIType enum: SIMPLE_CONFIRMATION, CONFIRMATION_WITH_VALUE, MULTI_FIELD_FORM, DATE_SIMPLE, FREE_TEXT, READ_ONLY.
    • CaseStatus: PENDING, IN_PROGRESS, RESOLVED, FAILED.
    • AgentStatus: ONLINE, BUSY, OFFLINE.
  2. src/types/wsProtocol.ts: Contratos WebSocket tipados (Zod):

    • Eventos entrantes (backend → frontend):
      • init_state: { conversations: Conversation[], activeCases: CaseRequest[] }
      • conversation_started: { conversation: Conversation }
      • conversation_update: { conversationId: string, message: Message }
      • agent_stream: { conversationId: string, token: string }
      • agent_status_update: { agentId: string, status: AgentStatus }
      • hitl_request: { case: CaseRequest, conversationId: string }
      • hitl_resolved: { caseId: string, resolution: object }
    • Eventos salientes (frontend → backend):
      • internal_note: { conversationId: string, content: string } (sin advisorId; backend deriva identidad)
  3. src/data/caseTypeDefinitions.ts: Mapeo completo de los 45 tipos del CSV a CaseTypeDefinition:

    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 concretoPlan_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
        })
      }
      

Paso 2 — Capa de Servicios y Store (arquitectura híbrida: REST autoritativo + WS difusión)

  1. src/services/api.ts: Cliente REST (canal autoritativo de escritura):

    • GET /api/v1/cases?status=&applicative=&search=&offset=&limit={ items: CaseRequest[], total: number } (filtrable, paginado).
    • GET /api/v1/cases/:idCaseRequest (detalle de caso).
    • POST /api/v1/cases/:id/resolveCaseRequest (canal único de resolución; el backend deriva advisorId del token de sesión).
    • GET /api/v1/conversations/activeConversation[].
    • Base URL configurable via variable de entorno (VITE_API_BASE_URL).
    • Se implementará una capa de MSW (Mock Service Worker) con handlers que simulen estas respuestas para desarrollo sin backend.
  2. src/hooks/useWebSocket.ts: Hook de conexión WebSocket (solo difusión/streaming, sin escritura de negocio):

    • Conexión a ws://<host>/ws/dashboard.
    • Reconexión automática con backoff exponencial (inicio 1s, máx 30s, factor 2x).
    • Al reconectar, el backend envía init_state para resincronizar; el frontend reemplaza el estado local completo.
    • Parseo con Zod de cada mensaje entrante usando el envelope estándar (ver Paso 8).
    • Dispatch a acciones del store según payload.type.
    • Envío de eventos salientes solo para internal_note (sin advisorId; el backend deriva la identidad).
    • Indicador de estado de conexión en el store (connected | disconnected | reconnecting).
    • No se emite hitl_response por WebSocket; la resolución de casos es exclusiva de REST.
  3. src/store/useAppStore.ts: Store centralizado Zustand con slices:

    • casesSlice: cases[], selectedCaseId, totalCases, fetchCases(filters), upsertCase(), resolveCase(), deleteCase().
    • conversationsSlice: conversations[], selectedConversationId, fetchConversations(), upsertConversation(), addMessage(), appendToken().
    • uiSlice: sidebarTab, searchQuery, applicativeFilter, isDarkMode, wsStatus.
    • timerSlice: Timers gestionados con useRef para intervalos (evitar re-renders); localStorage solo como caché de UI, no como fuente de verdad para handling_time (el backend calcula con startedAt/resolvedAt).

Paso 3 — Componentes Compartidos

  1. StatusBadge: Badge de estado con colores por estado (pending/in_progress/resolved/failed).
  2. SearchBar: Input de búsqueda con debounce.
  3. TabsBar: Pestañas de filtro (Todos/Pendientes/Finalizados).
  4. ApplicativeFilter: Dropdown/chips para filtrar por aplicativo (AC+, ASCARD, RR, etc.).
  5. Timer: Cronómetro independiente por caso con persistencia en localStorage (migrado del JS actual).
  6. Modal: Diálogo de confirmación genérico.
  7. EmptyState: Estado vacío para paneles sin selección.

Paso 4 — Módulo de Gestión de Casos HITL (/cases)

  1. CasesPage.tsx: Layout maestro: sidebar izquierda (lista de casos) + panel derecho (detalle/acciones).
  2. CaseCard.tsx: Tarjeta de caso en la lista con título, status badge, timer (si activo), tipo de solicitud, aplicativo, fecha.
  3. CaseDetail.tsx: Vista detallada del caso seleccionado con:
    • Metadata grid (ID, cédula, tipo solicitud, aplicativo).
    • Descripción del caso.
    • Payload de datos entrantes.
    • FormRenderer.tsx: Componente dinámico que renderiza el formulario adecuado según uiPattern:
      • SIMPLE_CONFIRMATION → Botones "Sí" / "No".
      • CONFIRMATION_WITH_VALUE → Radio group (Sí/No) + campo numérico con prefijo $.
      • MULTI_FIELD_FORM → Formulario con campos definidos en formFields[] (text, number, select, date).
      • DATE_SIMPLE → Date picker con formato dd-mm-aaaa.
      • FREE_TEXT → Textarea con placeholder contextual.
      • READ_ONLY → Panel informativo sin campos editables, solo botón "Marcar como revisado".
    • Panel de operación con timer y botones de acción.
    • Instrucciones paso a paso del aplicativo (del CSV) colapsables en acordeón.
  4. Flujo de resolución:
    • Asesor abre caso → timer inicia automáticamente.
    • Completa formulario dinámico → botón "Enviar resolución".
    • Se envía POST /api/v1/cases/:id/resolve (REST, canal autoritativo) con payload estructurado. El backend difunde hitl_resolved por WS a todos los asesores.
    • Caso pasa a estado resolved y timer se detiene.

Paso 5 — Módulo de Monitoreo (/monitor)

  1. MonitorPage.tsx: Layout de dos columnas: lista de conversaciones (izquierda estrecha) + feed de chat (derecha amplia).
  2. ConversationCard.tsx: Tarjeta de conversación activa mostrando:
    • ID/Nombre del cliente.
    • Último mensaje (truncado).
    • Indicador de streaming activo (spinner).
    • Badge de HITL pendiente.
    • Estado del agente asignado.
  3. ChatFeed.tsx: Feed de mensajes con:
    • Auto-scroll inteligente (respeta scroll manual del usuario, reanuda al llegar al fondo).
    • Renderizado de mensajes con diferenciación visual por rol (cliente, agente, sistema).
    • Streaming token-a-token: Concatenación progresiva de tokens en el último mensaje del agente.
  4. MessageBubble.tsx: Burbuja de mensaje individual con timestamp y rol.
  5. InternalNoteBanner.tsx: Banner de intervención que permite al asesor:
    • Escribir nota interna en un textarea.
    • Previsualizar cómo se verá en la conversación (etiquetada como "Nota interna").
    • Enviar vía WebSocket (internal_note).
  6. InterventionPanel.tsx: Panel lateral o modal para cuando se detecta un caso HITL asociado a la conversación activa.

Paso 6 — Ruteo y Shell de Aplicación

  1. App.tsx: Router con dos rutas:
    • / → redirect a /cases.
    • /casesCasesPage.
    • /monitorMonitorPage.
  2. AppShell.tsx: Layout global:
    • Header: Logo Claro Cases, badge "En vivo", indicador de conexión WebSocket, toggle tema oscuro.
    • Sidebar: Navegación entre módulos (Casos, Monitor) con iconos de lucide-react.
    • Inicializa WebSocket y fetch inicial al montar.

Paso 7 — Migración de Estilos (Preservar línea gráfica)

  1. Extraer todos los design tokens del style.css actual a bloques @theme en src/index.css (ver Paso 0 para la configuración completa).
  2. Mapear cada clase CSS a utilidades Tailwind equivalentes:
    • .app-headerflex items-center justify-between h-[50px] px-4 border-b bg-surface shadow-sm
    • .case-cardbg-elevated border border-border rounded-md p-3 cursor-pointer transition
    • .btn-primarybg-accent-orange text-white px-4 py-2 rounded-md font-semibold
  3. Preservar animaciones (slideIn, fadeIn, pulse-op) como keyframes en Tailwind config.
  4. Scrollbar styling → utilities de Tailwind o CSS global.
  5. Modo oscuro: conservar lógica de toggle con class strategy de Tailwind + persistencia en localStorage.

Paso 8 — Contratos de Comunicación Completos (para el equipo Python)

8.1 Envelope WebSocket Estándar

Todo mensaje WebSocket (en ambas direcciones) usa el siguiente envelope JSON:

{
  "type": "string",        // Tipo de evento (ej. "agent_stream")
  "eventId": "uuid",       // ID único del evento para deduplicación
  "occurredAt": "ISO-8601",// Timestamp UTC del lado emisor
  "payload": { }           // Carga específica del evento
}
8.2 REST Endpoints (canal autoritativo)
Método Ruta Query Params Body Respuesta
GET /api/v1/cases status, applicative, search, offset, limit { items: CaseRequest[], total: number }
GET /api/v1/cases/:id CaseRequest
POST /api/v1/cases/:id/resolve { action, payload, note? } CaseRequest (updated)
GET /api/v1/conversations/active Conversation[]
GET /api/v1/conversations/:id Conversation (con mensajes)

Nota para backend: POST /cases/:id/resolve no recibe advisorId. El backend debe derivar la identidad del asesor desde el token de autenticación de la sesión HTTP (Bearer token o cookie).

8.3 WebSocket Events (servidor → cliente)
Evento type Payload Trigger
init_state { conversations: Conversation[], activeCases: CaseRequest[] } Al conectar o reconectar
conversation_started { conversation: Conversation } Nueva conversación
conversation_ended { conversationId: string, endedAt: ISO-8601 } Conversación finalizada
user_message { conversationId: string, message: Message } Mensaje completo de usuario
agent_stream_started { conversationId: string, messageId: string } Inicio de streaming del agente
agent_stream_chunk { conversationId: string, messageId: string, token: string, index: number } Token individual con índice de orden
agent_stream_completed { conversationId: string, messageId: string, fullContent: string } Cierre de streaming; fullContent es el texto completo para verificación
agent_status_update { agentId: string, status: AgentStatus } Cambio de estado del agente
hitl_request { case: CaseRequest, conversationId: string } Se requiere intervención humana
hitl_resolved { caseId: string, resolution: object } Caso resuelto (broadcast a todos los asesores)
error { code: string, message: string, details?: object } Error del servidor notificable al frontend
8.4 WebSocket Events (cliente → servidor)
Evento type Payload Trigger
internal_note { conversationId: string, content: string } Asesor inyecta nota interna

Nota: El backend deriva advisorId del contexto de la conexión WebSocket autenticada. El cliente no envía identificadores de asesor en ningún payload.

8.5 Estrategia de Reconexión
  1. Backoff exponencial: 1s → 2s → 4s → 8s → 16s → 30s (máx).
  2. Al reconectar exitosamente, el servidor envía init_state con el estado completo actual.
  3. El frontend reemplaza conversations y activeCases con los datos de init_state.
  4. Durante la desconexión, el frontend muestra indicador "Reconectando..." y deshabilita acciones de escritura (resolución de casos e inyección de notas).
8.6 Estrategia de Streaming (lado frontend)
  • agent_stream_started: crear mensaje placeholder en la conversación con isStreaming: true.
  • agent_stream_chunk: concatenar token al contenido del mensaje usando el index para garantizar orden (no asumir orden de llegada de red).
  • agent_stream_completed: marcar mensaje con isStreaming: false, reemplazar contenido con fullContent para verificación de integridad.
  • Las actualizaciones al store se bufferizan cada 50ms (máximo 20 actualizaciones/segundo) para evitar re-renders excesivos. Solo la conversación activa/seleccionada dispara re-renders de UI; las demás acumulan tokens en el store sin re-render hasta ser seleccionadas.

1.4 Criterios de Aceptación

  • CA-1: Proyecto arranca con npm run dev sobre Vite + React + TypeScript, sirviendo en localhost:5173.
  • CA-2: Ruteo funcional: / redirige a /cases; navegación entre /cases y /monitor vía sidebar con iconos lucide-react.
  • CA-3: Sidebar de casos muestra lista con búsqueda textual (debounced 300ms), pestañas (Todos/Pendientes/Finalizados) y filtro secundario por aplicativo (chips/dropdown con los 8 aplicativos del CSV).
  • CA-4: Al seleccionar un caso, el panel de detalle renderiza el formulario dinámico correcto según el uiPattern del tipo de caso, con validación Zod antes de enviar.
  • CA-5: El formulario MULTI_FIELD_FORM renderiza campos específicos (ej. para Plan_De_Pagos_EF: número de cuotas, valor cuota, día corte, día límite) con validación por tipo (número, moneda, rango) y mensajes de error inline.
  • CA-6: Timer independiente por caso con persistencia en localStorage como cache de UI; el handling_time oficial lo calcula el backend con startedAt/resolvedAt.
  • CA-7: Resolución de caso se envía exclusivamente por REST (POST /cases/:id/resolve). El backend difunde hitl_resolved por WS a todos los asesores conectados.
  • CA-8: Módulo de monitoreo muestra lista de conversaciones activas con streaming token-a-token usando eventos agent_stream_started/agent_stream_chunk/agent_stream_completed, con buffer de 50ms para limitar re-renders a 20 fps.
  • CA-9: Chat feed con auto-scroll inteligente y diferenciación visual de 4 roles: cliente, agente, sistema, nota interna (esta última con badge "Interno" y fondo distintivo).
  • CA-10: Asesor puede inyectar nota interna desde el monitor; se emite internal_note por WebSocket (sin advisorId en el payload).
  • CA-11: Indicador visual de estado de conexión WebSocket en el header: 🟢 Conectado / 🟡 Reconectando... / 🔴 Desconectado. Durante desconexión, se deshabilitan acciones de escritura.
  • CA-12: Modo oscuro funcional con toggle (ícono sol/luna) y persistencia en localStorage; implementado con @custom-variant dark de Tailwind v4.
  • CA-13: Paleta de colores, tipografía Inter, sombras, radios, transiciones y animaciones (slideIn, fadeIn, pulse-op) preservados del diseño original mediante tokens @theme en CSS.
  • CA-14: Los 45 tipos de caso del CSV están mapeados en src/data/caseTypeDefinitions.ts con uiPattern, formFields, validationSchema (Zod) y payloadBuilder para cada uno.
  • CA-15: Backend Python puede implementarse siguiendo los contratos REST + WebSocket documentados en la sección 1.3 Paso 8 sin ambigüedades.
  • CA-16: Capa MSW operativa con handlers para todos los endpoints REST y simulación de eventos WebSocket, permitiendo desarrollo full-stack del frontend sin backend real.
  • CA-17: Paridad funcional con el sistema actual: notificaciones de escritorio HTML5, alerta sonora (Web Audio API) y parpadeo de título al recibir nuevos casos (hitl_request).
  • CA-18: Reconexión WebSocket con backoff exponencial; al reconectar se recibe init_state y se reemplaza el estado local completo.

Paso 9 — Capa de Mocks (MSW) y Funcionalidades Preservadas

  1. MSW (Mock Service Worker) para desarrollo desacoplado:
    • Handlers REST que simulan /api/v1/cases, /api/v1/cases/:id, /api/v1/cases/:id/resolve, /api/v1/conversations/active.
    • Datos de prueba: 10-15 casos de ejemplo cubriendo los 6 uiPattern y múltiples aplicativos.
    • 3-5 conversaciones simuladas con mensajes de diferentes roles.
    • El MSW se activa solo en modo desarrollo (VITE_ENABLE_MSW=true).
  2. Funcionalidades preservadas del sistema actual:
    • Notificaciones de escritorio HTML5: Hook useNotification que emite new Notification() al recibir hitl_request; click en notificación navega a /cases con el caso seleccionado.
    • Alerta sonora: Hook useSound con Web Audio API (chime de dos tonos C5→E5, volumen 0.08), activado solo tras primer gesto del usuario (política de autoplay).
    • Parpadeo de título: Efecto de título alternante cuando la pestaña no está enfocada y llegan nuevos casos; se limpia al enfocar.
    • Detección de foco de pestaña: document.visibilitychange + window.focus/blur para controlar notificaciones.

1.5 Jerarquía de Aplicativos (para filtro secundario)

Aplicativo Descripción N° de Tipos
AC+ Atención al Cliente (móvil) 13
ASCARD Equipos financiados 9
DiMe Ajustes online 8
Formatos SGCS Cambios de ciclo 2
Mi asistencia 360 Escalamientos de pago 2
Paradigma Facturación hogar/móvil 2
RR Recepción y Radicación (hogar) 12
Phone Protect Desbloqueo IMEI 1

1.6 Riesgos Identificados (preliminar, para debate)

  1. Streaming token-a-token: La semántica de concatenación depende de que el backend envíe tokens con un conversationId consistente. Si hay mensajes simultaneous, el orden de tokens debe estar garantizado.
  2. Persistencia de timers: Actualmente en localStorage. En React, el estado del timer debe sincronizarse entre el store y localStorage sin causar re-renders excesivos (usar refs para el intervalo).
  3. Tailwind + CSS variables: La migración de CSS puro a Tailwind requiere mapear cada utilidad. Los gradientes (linear-gradient) y -webkit-background-clip necesitan configuración adicional en Tailwind.
  4. CSV parsing: Los 45 registros deben clasificarse manualmente en los 6 uiPattern. Algunos casos (ej. Unificar_Factura_EF que usa ASCARD + Paradigma) requieren lógica multi-aplicativo.
  5. WebSocket reconnection: La lógica de reconexión debe preservar el estado local y re-sincronizar al reconectar (recibir init_state).

Fase 2: Auditoría de Arquitectura y Debate Técnico (v3 — Aprobada)

2.1 Resumen de Hallazgos

La Fase 1 pasó por dos ciclos de auditoría. En la primera iteración se identificaron 10 riesgos (5 bloqueantes). Tras las correcciones del usuario, la segunda auditoría detectó 3 inconsistencias residuales de redacción: referencias a hitl_response como canal WS, mención de tailwind.config.ts en el Paso 7, y advisorId persistente en una definición de tipo. Las tres fueron corregidas. El plan es ahora consistente, blindado y viable sin bloqueantes.

2.2 Riesgos Resueltos (todos)

  • R1 (Tailwind v4): Resuelto — @theme + @custom-variant dark; toda referencia a tailwind.config.ts purgada.
  • R2 (Doble canal): Resuelto — REST como único canal autoritativo; hitl_response eliminado de tipos, Paso 4 y contratos WS.
  • R3 (Contratos WS): Resuelto — Envelope estándar, eventos de streaming explícitos, error, reconexión documentada.
  • R4 (advisorId): Resuelto — Eliminado de todos los payloads cliente→servidor y tipos; consistente en REST y WS.
  • R5 (REST filtrable): Resuelto — GET /api/v1/cases?status=&applicative=&search=&offset=&limit=.
  • 🟡 R6R10: Mitigados con acciones documentadas en el plan (buffer streaming, MSW, reestructuración repo, funcionalidades preservadas, taxonomía ampliada con Zod).

2.3 Directrices para el Desarrollador

  • Regla 1: La resolución de casos es exclusivamente REST (POST /cases/:id/resolve). WebSocket solo difunde y streamea.
  • Regla 2: Ningún payload cliente→servidor contiene identificadores de asesor. El backend deriva la identidad.
  • Regla 3: Tailwind v4 se configura exclusivamente vía CSS (@theme, @custom-variant dark). Sin tailwind.config.ts.
  • Regla 4: El streaming usa el buffer de 50ms y solo re-renderiza la conversación seleccionada.
  • Regla 5: Los 45 tipos de caso deben tener validationSchema (Zod) y payloadBuilder definidos antes de declarar completo el mapeo.

2.4 Veredicto Final

  • Estado del plan: En Implementación.
  • El plan es internamente consistente, los contratos REST/WS están completamente especificados, y el frontend puede desarrollarse de forma desacoplada mediante MSW. No hay bloqueantes residuales.

Fase 3: Registro de Implementación

3.1 Paso 0 — Bootstrap y Setup

  • legacy/server.js: [Creado] → Copia del backend Express legacy.
  • legacy/db.js: [Creado] → Copia del módulo de base de datos SQLite (better-sqlite3).
  • legacy/schema.sql: [Creado] → Copia del esquema SQL de la tabla requests.
  • legacy/package.json: [Creado] → Copia del manifiesto de dependencias del backend legacy.
  • legacy/.env: [Creado] → Copia de variables de entorno del backend legacy.
  • legacy/.env.example: [Creado] → Copia con comentarios del backend legacy.
  • legacy/public/index.html: [Creado] → Copia del HTML del frontend vanilla legacy.
  • legacy/public/style.css: [Creado] → Copia de los estilos CSS del frontend vanilla legacy.
  • legacy/public/app.js: [Creado] → Copia de la lógica JS del frontend vanilla legacy.
  • package.json: [Modificado] → Reemplazado por el manifiesto del nuevo proyecto Vite + React + TypeScript con todas las dependencias core y de desarrollo.
  • vite.config.ts: [Creado] → Configuración de Vite con plugin React y Tailwind CSS v4, proxy para API REST y WebSocket.
  • tsconfig.json: [Creado] → Configuración raíz de TypeScript con referencias a tsconfig.app.json y tsconfig.node.json.
  • tsconfig.app.json: [Creado] → Configuración TS para la aplicación React (ES2020, JSX react-jsx, paths con alias @/).
  • tsconfig.node.json: [Creado] → Configuración TS para Vite y herramientas de Node.
  • index.html: [Creado] → Entry point de Vite con fuente Inter de Google Fonts, módulo ES para src/main.tsx.
  • .env: [Modificado] → Nuevas variables de entorno para frontend (VITE_API_BASE_URL, VITE_WS_URL, VITE_ENABLE_MSW).
  • .env.example: [Creado] → Template de variables de entorno del frontend.
  • .gitignore: [Creado] → Ignora node_modules/, dist/, .env, database.sqlite, entre otros.
  • src/vite-env.d.ts: [Creado] → Declaraciones de tipos para import.meta.env con tipado estricto.
  • src/main.tsx: [Creado] → Punto de entrada React con inicialización condicional de MSW (VITE_ENABLE_MSW=true).
  • src/App.tsx: [Creado] → Componente raíz con React Router (/, /cases, /monitor), redirect a /cases.
  • src/index.css: [Creado] → Estilos globales con Tailwind CSS v4, design tokens @theme, modo oscuro con @custom-variant dark, animaciones slideIn/fadeIn/pulse-op, scrollbar personalizado.
  • src/mocks/browser.ts: [Creado] → Setup de MSW Worker para interceptar peticiones REST en desarrollo.
  • src/mocks/handlers.ts: [Creado] → Handlers MSW para endpoints REST mock: 10 casos de prueba (cubriendo los 6 uiPattern), 3 conversaciones simuladas, handlers para /api/v1/cases, /api/v1/cases/:id, /api/v1/cases/:id/resolve, /api/v1/conversations/active, /api/v1/conversations/:id.
  • src/components/layout/.gitkeep: [Creado] → Marcador de directorio para layout/.
  • src/components/cases/.gitkeep: [Creado] → Marcador de directorio para cases/.
  • src/components/monitor/.gitkeep: [Creado] → Marcador de directorio para monitor/.
  • src/components/shared/.gitkeep: [Creado] → Marcador de directorio para shared/.
  • src/hooks/.gitkeep: [Creado] → Marcador de directorio para hooks/.
  • src/services/.gitkeep: [Creado] → Marcador de directorio para services/.
  • src/store/.gitkeep: [Creado] → Marcador de directorio para store/.
  • src/types/.gitkeep: [Creado] → Marcador de directorio para types/.
  • src/pages/.gitkeep: [Creado] → Marcador de directorio para pages/.
  • src/data/.gitkeep: [Creado] → Marcador de directorio para data/.

3.2 Estrategia de Solución e Integración

  • Implementación Arquitectónica: Se estructuró el proyecto siguiendo el principio de agnosticismo y separación de conceptos. El backend legacy se aisló completamente en legacy/, dejando la raíz del proyecto limpia para el nuevo frontend Vite + React + TypeScript. La configuración de Tailwind v4 es CSS-first (sin tailwind.config.ts), usando la directiva @theme para definir los design tokens y @custom-variant dark para el modo oscuro. Se implementó MSW como capa de mockeo REST para desarrollo desacoplado del backend.

  • Mitigación de Riesgos (Fase 2):

    • Regla 1 (REST como canal autoritativo): Los handlers de MSW simulan POST /cases/:id/resolve como endpoint REST exclusivo para resolución de casos. No se incluye WebSocket para escritura.
    • Regla 2 (Sin advisorId): Los handlers MSW no requieren advisorId en los payloads, en línea con los contratos especificados.
    • Regla 3 (Tailwind v4 CSS-first): No existe tailwind.config.ts. Toda la configuración está en src/index.css mediante @theme y @custom-variant.
    • Regla 4 (Streaming buffer 50ms): Se documentó en la spec; la implementación del buffer se realizará en el hook useWebSocket en fases posteriores.
    • Regla 5 (45 tipos de caso con Zod): Los mock data en handlers incluyen 10 casos de ejemplo cubriendo los 6 uiPattern; la implementación completa de los 45 tipos se hará en Paso 1.

3.3 Notas Técnicas para el Tester

  • Dependencias Añadidas:

    • Core: react, react-dom, react-router-dom, zustand, zod, date-fns, lucide-react
    • Dev: typescript, vite, @vitejs/plugin-react, tailwindcss, @tailwindcss/vite, msw, @testing-library/react, @testing-library/jest-dom, vitest, @types/react, @types/react-dom
  • Puntos Críticos a Probar:

    1. Restauración manual necesaria: Los archivos node_modules/, package-lock.json, database.sqlite y el directorio public/ (antiguo) aún existen en la raíz y deben moverse manualmente a legacy/ o eliminarse. Ejecutar:
      rm -rf node_modules/ public/ package-lock.json database.sqlite
      mv server.js db.js schema.sql legacy/ 2>/dev/null; true
      
    2. MSW no inicializado: El archivo public/mockServiceWorker.js debe generarse ejecutando npx msw init public/ --save.
    3. Verificar que el alias @/ funciona: El tsconfig.app.json define paths con @/*src/*. Confirmar que Vite resuelva los imports correctamente.
    4. Modo oscuro: El @custom-variant dark usa la clase .dark en un contenedor padre. Verificar que al agregar class="dark" al <html> se activen los colores oscuros.
    5. MSW handlers: Verificar que VITE_ENABLE_MSW=true activa la interceptación en desarrollo y que los endpoints mock responden correctamente (ej. curl http://localhost:5173/api/v1/cases).

3.1 Paso 1 — Sistema de Tipos, Contratos WebSocket y Mapeo de 53 Casos del CSV

  • src/types/index.ts: [Creado] → Define las interfaces base del sistema (CaseRequest, Conversation, Message, FormField, CaseTypeDefinition) y los enums (CaseUIType, CaseStatus, AgentStatus, MessageRole). Utiliza tipado estático estricto con z.ZodType para los campos de validación de esquemas en CaseTypeDefinition.
  • src/types/wsProtocol.ts: [Creado] → Implementa el envelope WebSocket estándar con Zod (WSEnvelopeSchema), más los 11 schemas de eventos servidor→cliente (init_state, conversation_started, conversation_ended, user_message, agent_stream_started, agent_stream_chunk, agent_stream_completed, agent_status_update, hitl_request, hitl_resolved, error) y 1 schema cliente→servidor (internal_note). Incluye funciones helper createWSEnvelope(), validateServerEvent(), validateClientEvent() con mapas discriminadores por tipo de evento para validación dinámica en el hook useWebSocket.
  • src/data/caseTypeDefinitions.ts: [Creado] → Mapeo completo de los 53 registros del CSV a objetos CaseTypeDefinition con:
    • Clasificación de uiPattern según las 6 familias visuales (SIMPLE_CONFIRMATION, CONFIRMATION_WITH_VALUE, MULTI_FIELD_FORM, DATE_SIMPLE, FREE_TEXT, READ_ONLY).
    • formFields derivados del responseFormat y casos especiales documentados (Escalar_Pagos_No_Abonados con 9 campos, Validar_OTT_1/2 con 6 y 8 campos respectivamente, etc.).
    • validationSchema Zod para cada entrada, con validaciones de tipo (número, moneda, toggle, fecha en formato dd-mm-aaaa, select con enum).
    • payloadBuilder para serializar el formulario al payload del backend.
    • Mapas helper caseTypeByToolName y caseTypesByApplicative para búsqueda rápida.
    • Helpers de fábrica (simpleConfirmation, confirmationWithValue, dateSimple, freeText, readOnly, multiFieldForm) para reducir repetición de código.

3.2 Estrategia de Solución e Integración

  • Implementación Arquitectónica: Se respetó el principio de separación de conceptos manteniendo las interfaces de dominio (CaseRequest, Conversation, Message) en src/types/index.ts desacopladas de los contratos de comunicación (wsProtocol.ts) y de los datos estáticos (caseTypeDefinitions.ts). Los helpers de fábrica en caseTypeDefinitions.ts permiten definir esquemas Zod y builders de payload de forma declarativa y consistente, eliminando la duplicación masiva de código.

  • Mitigación de Riesgos (Fase 2):

    • Regla 1 (REST como canal autoritativo): En wsProtocol.ts no existe ningún evento hitl_response; la resolución de casos se realiza exclusivamente vía REST. El protocolo WS solo define eventos de difusión/streaming.
    • Regla 2 (Sin advisorId): En wsProtocol.ts, el payload internal_note solo contiene conversationId y content. No se incluye advisorId en ningún payload cliente→servidor. El backend debe derivar la identidad del contexto de conexión.
    • Regla 5 (45 tipos de caso con Zod): Se implementaron 53 registros del CSV (la diferencia con la cifra "45" se debe a que algunos toolName se repiten con diferentes especialistas/objetivos). Cada registro tiene su validationSchema Zod y payloadBuilder completamente implementados, sin placeholders ni TODOs.

3.3 Notas Técnicas para el Tester

  • Dependencias Añadidas: Ninguna nueva (zod ya estaba incluida en Paso 0).
  • Puntos Críticos a Probar:
    1. Tipos estrictos: Verificar que tsc --noEmit (o npm run lint) no produce errores de tipo. Archivos clave: src/types/index.ts, src/types/wsProtocol.ts, src/data/caseTypeDefinitions.ts.
    2. Validación Zod de eventos WS: Probar que validateServerEvent('init_state', payload) rechaza payloads mal formados (ej. falta conversations o activeCases). Probar validateClientEvent('internal_note', { conversationId: '', content: '' }) debe fallar porque content requiere min(1).
    3. Cobertura de 53 registros: Verificar que caseTypeDefinitions.length es 53 y que ningún registro tiene validationSchema o payloadBuilder como undefined.
    4. Mapas auxiliares: caseTypeByToolName debe contener todas las toolNames (las duplicadas prevalece la última). caseTypesByApplicative debe tener entradas para "AC+", "ASCARD", "DiMe", "Formatos SGCS", "Mi asistencia 360", "Paradigma", "RR", "Phone Protect".
    5. FormFields vs ValidationSchema: Para cada MULTI_FIELD_FORM, verificar que los campos en formFields coinciden uno a uno con las claves del validationSchema. Ejemplo: Plan_De_Pagos_EF debe tener 4 campos (numero_cuotas, valor_cuota, dia_corte, dia_limite_pago) tanto en formFields como en validationSchema.
    6. PayloadBuilder fidelidad: Para Validar_OTT_1, verificar que payloadBuilder({ reinstalacion: true, valor_reinstalacion: 50000, fecha_adquisicion_reinstalacion: '01-01-2024', deco_adicional: false, valor_deco: 0, fecha_adquisicion_deco: '01-01-2024' }) devuelve un objeto con exactamente esas 6 claves y mismos valores.

3.1 Paso 2 — Capa de Servicios (api.ts, wsClient.ts) y Store Zustand (useAppStore.ts)

  • src/services/api.ts: [Creado] → Cliente REST con fetch nativo. Implementa getCases, getCaseById, resolveCase, getActiveConversations, getConversation. Define PaginatedResponse<T>, CaseFilters, y ApiError para manejo de errores HTTP. La URL base se configura via VITE_API_BASE_URL con fallback a http://localhost:5503/api/v1. Incluye helper buildQuery() para construir query string con filtros (status, applicative, search, offset, limit) y helper interno request<T>() para centralizar la lógica de fetch, headers JSON, y validación de código HTTP. Tipos importados de @/types.
  • src/services/wsClient.ts: [Creado] → Cliente WebSocket en clase WsClient con patrón singleton exportado como wsClient. Implementa reconexión con backoff exponencial (1s → 2s → 4s → 8s → 16s → máx 30s, factor 2x). Expone connect(), disconnect(), send(type, payload) que genera automáticamente eventId (crypto.randomUUID) y occurredAt (ISO-8601) en el envelope estándar, onMessage callback setter/getter, y getStatus() retornando 'connected' | 'disconnected' | 'reconnecting'. Maneja cierre graceful con flag destroyFlag para evitar reconexión en desconexión intencional. Ignora mensajes malformados silenciosamente. Tipos importados de @/types/wsProtocol.
  • src/store/useAppStore.ts: [Creado] → Store centralizado Zustand con tres slices:
    • casesSlice: cases[], selectedCaseId, totalCases, fetchCases(filters) (llama a api.getCases y actualiza estado), upsertCase(c) (reemplaza si existe o agrega al inicio), resolveCase(id, data) (llama a api.resolveCase y actualiza el caso en el array local).
    • conversationsSlice: conversations[], selectedConversationId, fetchConversations() (llama a api.getActiveConversations), upsertConversation(c), addMessage(convId, msg), appendToken(convId, msgId, token, index) (bufferiza chunks en metadata._chunks ordenados por index para manejar entrega fuera de orden, actualiza content concatenando chunks ordenados), completeStream(convId, msgId, fullContent) (limpia _chunks de metadata, establece content = fullContent, marca isStreaming = false).
    • uiSlice: sidebarTab ('all'|'pending'|'resolved'), searchQuery, applicativeFilter, isDarkMode (persistido en localStorage via clave claro-cases:darkMode), wsStatus. Setters: setSidebarTab, setSearchQuery, setApplicativeFilter, toggleDarkMode (persiste y actualiza), setWsStatus.
    • La persistencia de isDarkMode se implementa con helper readDarkMode() que lee localStorage al inicializar el store y persistDarkMode() que escribe en cada toggle.

3.2 Paso 3 — Componentes Compartidos (StatusBadge, SearchBar, TabsBar, EmptyState, Modal, Timer)

  • src/components/shared/StatusBadge.tsx: [Creado] → Renderiza un badge de estado con colores por CaseStatus. Usa mapas STATUS_LABELS (Pendiente/En Progreso/Finalizado/Fallido) y STATUS_STYLES con clases Tailwind según los tokens del tema (accent-yellow, accent-orange, accent-green, accent-red). Estilo: text-[9px] px-1.5 py-0.5 rounded-[10px] font-semibold uppercase border. Props: status: CaseStatus.
  • src/components/shared/SearchBar.tsx: [Creado] → Input de búsqueda con ícono Search de lucide-react. Implementa debounce de 300ms usando useRef para el timer y useEffect para sincronizar con el store. Almacena el valor local en useState y solo escribe al store tras el debounce. Estilo: fondo bg-elevated, borde border, foco focus:border-accent-orange. Props: ninguna (lee/escribe del store directamente).
  • src/components/shared/TabsBar.tsx: [Creado] → Barra de tres pestañas (Todos/Pendientes/Finalizados) que lee sidebarTab del store y llama a setSidebarTab. Pestaña activa: bg-accent-orange/8 text-accent-orange border-accent-orange. Inactiva: text-text-muted border-transparent. Estilo: text-[11px] font-semibold uppercase tracking-wider. Props: ninguna.
  • src/components/shared/EmptyState.tsx: [Creado] → Estado vacío centrado vertical/horizontalmente. Renderiza icon (ReactNode, ej. emoji), title (14px font-semibold), description (12px text-secondary). Ícono con text-[3rem] opacity-40 leading-none. Props: icon: ReactNode, title: string, description: string.
  • src/components/shared/Modal.tsx: [Creado] → Overlay modal con backdrop blur (bg-black/40 backdrop-blur-sm), contenido centrado con animación fadeIn. Cierra con Escape (event listener) y al hacer click en backdrop. Contenido: bg-surface border border-border rounded-lg shadow-lg. Header con título y botón ✕. Body para children. Footer opcional actions. Props: isOpen, onClose, title, children, actions?.
  • src/components/shared/Timer.tsx: [Creado] → Cronómetro individual por caso con persistencia en localStorage (clave timer_case_{caseId}). Implementado con forwardRef y useImperativeHandle exponiendo start(), stop(), getElapsed(). Usa useRef para el intervalo (setInterval 1s) y contadores acumulados. useState solo para el display (MM:SS). Al montar, restaura estado desde localStorage. Al desmontar, limpia el intervalo. Display: font-mono text-xl font-bold tabular-nums text-text-primary. Props: caseId: string | number.

3.2 Estrategia de Solución e Integración

  • Implementación Arquitectónica: Se respetó el principio de agnosticismo separando la capa de servicios (REST y WebSocket) del store y de los componentes. api.ts es un cliente REST puro sin dependencias de React ni del store, permitiendo ser usado desde hooks o desde MSW. wsClient.ts es una clase singleton agnóstica al framework que expone callbacks, permitiendo que useWebSocket (hook futuro) se suscriba sin acoplamiento. El store Zustand usa api para las operaciones de escritura (fetchCases, resolveCase, fetchConversations), manteniendo la lógica de negocio desacoplada del mecanismo de transporte. Los componentes compartidos son puramente presentacionales (StatusBadge, EmptyState, Modal) o se conectan al store de forma mínima (SearchBar, TabsBar), sin depender de servicios directamente.

  • Mitigación de Riesgos (Fase 2):

    • Regla 1 (REST como canal autoritativo): resolveCase en el store llama exclusivamente a api.resolveCase() (POST REST). No existe ninguna función de resolución por WebSocket.
    • Regla 2 (Sin advisorId): El cliente WebSocket send() no incluye advisorId en ningún payload. El método genérico solo recibe type y payload. Los helpers de validación Zod del wsProtocol.ts ya garantizan que internal_note solo tenga conversationId y content.
    • Regla 3 (Tailwind v4 CSS-first): Todos los componentes usan clases Tailwind directamente con los tokens CSS definidos en @theme (bg-surface, text-primary, border-accent-orange, etc.). No hay configuración JS de Tailwind.
    • Regla 4 (Streaming buffer 50ms): appendToken en el store usa metadata._chunks ordenados por index para garantizar orden correcto de tokens incluso si llegan fuera de orden de red. El buffer se implementa a nivel de store, preparado para que el hook useWebSocket (futuro) pueda rate-limit las actualizaciones a 20fps.
    • Regla 5 (45 tipos de caso con Zod): No aplica en este paso (implementado en Paso 1).

3.3 Notas Técnicas para el Tester

  • Dependencias Añadidas: zustand (ya instalada en Paso 0), lucide-react (ya instalada en Paso 0). No se añadieron nuevas dependencias.
  • Puntos Críticos a Probar:
    1. api.ts — Error handling: Verificar que ApiError se lanza correctamente para códigos HTTP 4xx/5xx. Probar con MSW simulando errores 404 y 500. Verificar que buildQuery omite parámetros undefined/null.
    2. api.ts — Paginación: Llamar getCases({ offset: 0, limit: 5 }) y verificar query string ?offset=0&limit=5. Llamar con getCases({}) y verificar que no se añade ? en la URL.
    3. wsClient.ts — Reconexión: Verificar backoff exponencial: tras cerrar WebSocket, debe reconectar con delays crecientes (1s, 2s, 4s, 8s...). Probar que disconnect() detiene la reconexión inmediatamente.
    4. wsClient.ts — Envelope: Verificar que send('internal_note', { conversationId: 'c1', content: 'nota' }) produce un mensaje JSON con type, eventId (UUID), occurredAt (ISO string) y payload.
    5. useAppStore.ts — appendToken: Enviar tokens fuera de orden (index 2, 0, 1) y verificar que el contenido final es la concatenación ordenada. Verificar que completeStream reemplaza el contenido con fullContent y limpia metadata._chunks.
    6. useAppStore.ts — Dark mode persistence: Llamar toggleDarkMode(), recargar el store, verificar que isDarkMode persiste. Verificar que localStorage contiene claro-cases:darkMode=true.
    7. StatusBadge.tsx — Renderizado condicional: Renderizar con cada CaseStatus y verificar clases de color correctas y texto en español.
    8. SearchBar.tsx — Debounce: Escribir texto rápidamente y verificar que solo se actualiza el store tras 300ms de inactividad. Verificar que el ícono Search está presente.
    9. TabsBar.tsx — Estado activo: Hacer clic en "Pendientes" y verificar que sidebarTab en el store cambia a 'pending' y la pestaña visualmente activa tiene las clases bg-accent-orange/8 text-accent-orange border-accent-orange.
    10. Timer.tsx — Persistencia y control: Llamar start() y esperar 5s. Verificar que localStorage tiene el timer guardado. Llamar stop() y verificar display se congela. Llamar getElapsed() y verificar que devuelve los segundos exactos. Recargar el componente y verificar que el tiempo acumulado se restaura. Iniciar de nuevo y confirmar que continúa desde donde quedó.
    11. Modal.tsx — Accesibilidad: Verificar que el modal se cierra con tecla Escape. Verificar que el click en backdrop cierra el modal. Verificar que el click dentro del contenido no lo cierra.
    12. EmptyState.tsx — Renderizado: Verificar que icon renderiza como elemento (puede ser string emoji o componente React), title en 14px semibold, description en 12px secondary, centrado vertical/horizontalmente.

3.1 Paso 4 — Módulo de Gestión de Casos HITL (/cases)

  • src/components/cases/TypeBadge.tsx: [Creado] → Badge pequeño que muestra el tipoSolicitud con estilo bg-accent-orange/10 text-accent-orange border-accent-orange/25. Trunca el texto a 140px con title para tooltip.
  • src/components/cases/CaseCard.tsx: [Creado] → Tarjeta de caso en la sidebar. Props case: CaseRequest, isActive, onClick. Renderiza: (1) Header con título, StatusBadge y timer formateado (solo si status === IN_PROGRESS y handlingTime > 0); (2) Descripción truncada a 2 líneas con line-clamp-2; (3) Footer con ID externo en monospace, TypeBadge con tipoSolicitud, y fecha formateada con date-fns. Estilo base bg-elevated border rounded-md p-3 cursor-pointer transition hover:bg-hover, activo bg-accent-orange/4 border-accent-orange. Animación animate-[slideIn_0.2s_ease-out].
  • src/components/cases/ApplicativeFilter.tsx: [Creado] → Filtro de aplicativos mediante chips/badges clickeables. Lista fija de los 8 aplicativos (AC+, ASCARD, DiMe, Formatos SGCS, Mi asistencia 360, Paradigma, RR, Phone Protect). Usa applicativeFilter y setApplicativeFilter del store. Al hacer clic en un chip activo, lo deselecciona (pasa a null). Incluye botón "✕ Limpiar" que solo aparece cuando hay un filtro activo. Estilo: chip activo bg-accent-orange/10 text-accent-orange border-accent-orange/30, inactivo bg-elevated text-text-muted border-border.
  • src/components/cases/FormRenderer.tsx: [Creado] → Componente crítico que renderiza formularios dinámicos según CaseUIType. Props: caseType: CaseTypeDefinition, onSubmit: (data) => void. Implementa los 6 patrones de UI (SIMPLE_CONFIRMATION, CONFIRMATION_WITH_VALUE, MULTI_FIELD_FORM, DATE_SIMPLE, FREE_TEXT, READ_ONLY) con estado local formValues/formErrors, transformación de fechas yyyy-mm-dd ↔ dd-mm-aaaa, validación Zod inline, soporte conditionalOn, y FieldInput interno para renderizar cada tipo de campo (text, number, currency con $, date, select, textarea, toggle switch).
  • src/components/cases/CaseDetail.tsx: [Creado] → Panel derecho de detalle con metadata grid (ID, cédula, tipo, aplicativo), descripción, payload entrante, FormRenderer dinámico, acordeón de pasos colapsable, y panel de operación sticky con Timer + fecha.
  • src/pages/CasesPage.tsx: [Creado] → Layout maestro: sidebar 320px (SearchBar + TabsBar + ApplicativeFilter + lista CaseCards scrolleable + footer conteo) y panel derecho (CaseDetail / EmptyState). Conecta store para casos filtrados por tab/search/applicative. filterCases() interno con lógica de filtrado combinado.

3.2 Estrategia de Solución e Integración

  • Implementación Arquitectónica: Se respetó la separación de conceptos manteniendo los componentes de UI (CaseCard, CaseDetail, TypeBadge, ApplicativeFilter) desacoplados de la lógica de formularios dinámicos (FormRenderer) y del store. CaseCard y TypeBadge son puramente presentacionales. FormRenderer encapsula toda la complejidad de renderizado condicional, transformación de fechas, y validación Zod inline. CaseDetail orquesta la integración entre metadata, formulario y timer. CasesPage actúa como orquestador de layout y filtros.

  • Mitigación de Riesgos (Fase 2):

    • Regla 1 (REST como canal autoritativo): CaseDetail.handleFormSubmit llama a resolveCase del store (POST REST). FormRenderer solo recolecta datos y llama a onSubmit.
    • Regla 2 (Sin advisorId): Ningún componente envía advisorId. El payload contiene solo action y payload.
    • Regla 3 (Tailwind v4 CSS-first): Todos los componentes usan exclusivamente tokens @theme sin configuración JS de Tailwind.
    • Regla 5 (45 tipos de caso con Zod): FormRenderer usa validationSchema.safeParse() antes de llamar a onSubmit.

3.3 Notas Técnicas para el Tester

  • Dependencias Añadidas: date-fns (ya instalada en Paso 0). lucide-react (ya instalada en Paso 0). No se añadieron nuevas dependencias.
  • Puntos Críticos a Probar:
    1. CaseCard — Renderizado condicional de timer: Solo aparece cuando status === IN_PROGRESS y handlingTime > 0. Formato MM:SS.
    2. CaseCard — Animación slideIn al montar.
    3. ApplicativeFilter — Toggle: Chip activo ↔ applicativeFilter en store. Botón ✕ solo visible con filtro activo.
    4. FormRenderer — SIMPLE_CONFIRMATION: Botones Sí/No llaman onSubmit({ confirmacion: true/false }).
    5. FormRenderer — CONFIRMATION_WITH_VALUE: Radio Sí→ campo $ visible, Radio No→ oculto. Validación valor negativo.
    6. FormRenderer — MULTI_FIELD_FORM: Renderiza types correctos, min/max, toggle switch, conditionalOn, errores inline.
    7. FormRenderer — Transformación fecha: Date picker → valor enviado en dd-mm-aaaa.
    8. FormRenderer — READ_ONLY: Botón "Marcar como revisado" llama onSubmit({}).
    9. CaseDetail — Timer: Inicia automático en IN_PROGRESS, se detiene al resolver.
    10. CaseDetail — Acordeón: Pasos colapsables con ChevronDown/ChevronUp.
    11. CasesPage — Filtros combinados: Búsqueda + tab + aplicativo se combinan correctamente. Footer "X de Y casos".
    12. CasesPage — Empty states: Sin casos → EmptyState en sidebar. Sin selección → EmptyState en panel derecho.
    13. CasesPage — Fetch on mount: Se llama fetchCases() al montar.
    14. FormRenderer — Validación Zod: Datos inválidos → errores inline, no se llama onSubmit.

3.1 Paso 5 — Módulo de Monitoreo (/monitor)

  • src/components/monitor/MessageBubble.tsx: [Creado] → Burbuja de mensaje individual con diferenciación visual por rol (user → derecha/accent-orange, agent → izquierda/elevated, system → centrado/base/italic, internal → izquierda/accent-yellow con badge 🔒). Muestra timestamp HH:mm. Si isStreaming, muestra cursor parpadeante (barra animada).
  • src/components/monitor/InternalNotesGroup.tsx: [Creado] → Acordeón expandible que agrupa mensajes internal consecutivos. Cabecera "🔄 Notas internas (N)" colapsable. Al expandir, muestra contenido y timestamp de cada nota. Implementa filtro de seguridad para solo renderizar mensajes con role === INTERNAL.
  • src/components/monitor/ChatFeed.tsx: [Creado] → Feed de mensajes con auto-scroll inteligente. Detecta si el usuario está cerca del fondo (≤ 100px) mediante ref y handler onScroll; si está cerca, hace scroll automático al llegar nuevo mensaje o token. Agrupa mensajes internal consecutivos en InternalNotesGroup mediante buffer de acumulación intercalado con flushInternal(). Muestra indicador "Escribiendo..." con spinner cuando el último mensaje del agente tiene isStreaming: true.
  • src/components/monitor/ConversationCard.tsx: [Creado] → Tarjeta de conversación en lista lateral. Muestra: (1) ID/nombre del cliente con icono User, (2) último mensaje truncado a 80 caracteres, (3) spinner Loader2 animado si el último mensaje está en streaming, (4) estado del agente con color verde para activa, (5) badge de estado de conversación (Activa/En pausa/Finalizada). Sin badge HITL en esta iteración (requiere mapeo conversationId → caseId que se integrará con eventos WS).
  • src/components/monitor/InternalNoteBanner.tsx: [Creado] → Banner inferior para inyección de notas internas. Textarea de 2 líneas con placeholder, botón "Enviar" con icono Send. Al enviar, llama a wsClient.send('internal_note', { conversationId, content }) sin advisorId. Soporte Enter para enviar, Shift+Enter para nueva línea. Feedback visual "Enviado ✓" por 2 segundos tras envío exitoso. Hint con atajos de teclado.
  • src/pages/MonitorPage.tsx: [Creado] → Layout de dos columnas: izquierda 280px con lista scrolleable de ConversationCards (con encabezado y contador), derecha flex-1 con ChatFeed + InternalNoteBanner si hay conversación seleccionada, o EmptyState si no. Al montar, llama a fetchConversations() del store. Conecta con selectedConversationId y setea mediante useAppStore.setState.

3.2 Estrategia de Solución e Integración

  • Implementación Arquitectónica: Se respetó la separación de conceptos manteniendo los componentes de monitoreo desacoplados del store y servicios. MessageBubble es puramente presentacional (solo recibe Message por props). InternalNotesGroup encapsula la lógica de agrupación y colapso. ChatFeed orquesta la integración entre burbujas, agrupación de notas internas y auto-scroll. ConversationCard es presentacional con helpers de extracción de último mensaje y detección de streaming. InternalNoteBanner se conecta directamente con wsClient (singleton) para enviar notas internas, sin pasar por el store. MonitorPage actúa como orquestador de layout y conexión con el store.

  • Mitigación de Riesgos (Fase 2):

    • Regla 1 (REST como canal autoritativo): InternalNoteBanner envía por WebSocket exclusivamente notas internas (evento internal_note), nunca resolución de casos.
    • Regla 2 (Sin advisorId): wsClient.send('internal_note', { conversationId, content }) no incluye advisorId en el payload. El backend deriva la identidad del contexto de conexión WS.
    • Regla 3 (Tailwind v4 CSS-first): Todos los componentes usan exclusivamente tokens @theme sin configuración JS de Tailwind.
    • Regla 4 (Streaming buffer 50ms): ChatFeed reacciona a cambios en messages[messages.length-1]?.content para auto-scroll durante streaming, respetando posición manual del usuario mediante ref isNearBottomRef.

3.3 Notas Técnicas para el Tester

  • Dependencias Añadidas: Ninguna nueva (todas las dependencias ya estaban instaladas en Pasos previos).
  • Puntos Críticos a Probar:
    1. MessageBubble — 4 roles visuales: Verificar alineación y fondo correctos para user (derecha/accent-orange/10), agent (izquierda/elevated), system (centrado/base/italic), internal (izquierda/accent-yellow/10 con badge 🔒).
    2. MessageBubble — Streaming cursor: Cuando isStreaming: true, debe mostrar barra parpadeante al final del contenido.
    3. ChatFeed — Auto-scroll: Con varias burbujas visibles, scrollear manualmente hacia arriba y verificar que al llegar un nuevo mensaje NO se hace auto-scroll. Scrollear al fondo y verificar que al llegar un nuevo mensaje SÍ se hace auto-scroll al fondo.
    4. ChatFeed — Agrupación de notas internas: 2+ mensajes internal consecutivos deben agruparse en un acordeón. Un mensaje internal seguido de user/agent debe renderizarse individualmente.
    5. ChatFeed — Indicador "Escribiendo...": Cuando el último mensaje del agente tiene isStreaming: true, debe mostrar texto "Escribiendo..." con spinner.
    6. InternalNotesGroup — Expandir/colapsar: Hacer clic en cabecera y verificar que se expanden/colapsan las notas. Verificar contador "Notas internas (N)".
    7. ConversationCard — Último mensaje truncado: Mensaje > 80 caracteres debe truncarse con "...".
    8. ConversationCard — Spinner streaming: Debe mostrar Loader2 animado cuando el último mensaje tiene isStreaming: true.
    9. InternalNoteBanner — Envío sin advisorId: Verificar que wsClient.send recibe payload sin campo advisorId. Verificar feedback "Enviado ✓" post-envío.
    10. InternalNoteBanner — Enter vs Shift+Enter: Enter envía, Shift+Enter inserta nueva línea.
    11. MonitorPage — Layout: 280px sidebar izquierda + flex-1 derecha. EmptyState cuando no hay conversación seleccionada.
    12. MonitorPage — Fetch on mount: Se llama fetchConversations() al montar. Almacenar selectedConversationId con useAppStore.setState.

3.1 Paso 6 — App Shell y Ruteo

  • src/services/wsClient.ts: [Modificado] → Se añadió callback onStatusChange (getter/setter) y tipo StatusChangeCallback para notificar cambios de estado de conexión al store. El método privado setStatus() ahora invoca onStatusChangeCallback?.(status) en cada transición, permitiendo que AppShell sincronice el indicador WS en el Header.
  • src/components/layout/Header.tsx: [Creado] → Barra superior de 50px. Logo: emoji 🔴 + "Claro Cases" con gradiente bg-gradient-to-r from-accent-orange to-accent-yellow bg-clip-text text-transparent. Badge "En vivo" con estilo bg-accent-green/10 text-accent-green. Indicador de conexión WS: punto circular coloreado (verde/amarillo/rojo según wsStatus del store) + texto (Conectado/Reconectando.../Desconectado), con animate-pulse en estado reconnecting. Toggle tema oscuro/claro con iconos Sun/Moon de lucide-react.
  • src/components/layout/Sidebar.tsx: [Creado] → Navegación lateral fija de 50px de ancho. Usa NavLink de react-router-dom con dos rutas: Casos (icono LayoutList) → /cases, Monitor (icono Monitor) → /monitor. Link activo: bg-accent-orange/8 text-accent-orange. Link inactivo: text-text-muted hover:text-text-primary hover:bg-hover. Layout vertical centrado con icono + label en 10px.
  • src/components/layout/AppShell.tsx: [Creado] → Layout global que envuelve todo el contenido. Renderiza Header arriba, Sidebar a la izquierda (50px), y children (contenido de la ruta) a la derecha. Al montar: (1) sincroniza clase .dark en <html> según isDarkMode del store, (2) inicializa conexión WebSocket via wsClient.connect() y registra onStatusChangesetWsStatus, (3) registra onMessage handler (placeholder para integración futura de eventos WS), (4) llama fetchCases() o fetchConversations() según la ruta actual. Cleanup: desconecta WS y limpia callbacks al desmontar.
  • src/App.tsx: [Reemplazado] → Router con BrowserRouter envolviendo AppShell como layout global. Tres rutas: / → redirect a /cases, /casesCasesPage, /monitorMonitorPage. Catch-all * → redirect a /cases.

3.2 Estrategia de Solución e Integración

  • Implementación Arquitectónica: Se implementó el shell de aplicación siguiendo el principio de composición: AppShell es el layout contenedor que orquesta la inicialización de infraestructura (WS, tema oscuro, fetch inicial) y renderiza Header + Sidebar + contenido. El ruteo está desacoplado en App.tsx usando react-router-dom estándar. Header y Sidebar son componentes puramente presentacionales que se conectan al store para estado de UI (wsStatus, isDarkMode). La modificación a wsClient.ts es mínima y no rompe la interfaz existente.

  • Mitigación de Riesgos (Fase 2):

    • Regla 2 (Sin advisorId): AppShell no envía ningún identificador de asesor; solo establece la conexión WS y el handler de mensajes.
    • Regla 3 (Tailwind v4 CSS-first): Todos los componentes de layout usan exclusivamente tokens @theme sin configuración JS de Tailwind. El toggle dark mode usa class strategy con @custom-variant dark.
    • Regla 4 (Streaming buffer 50ms): AppShell registra un onMessage handler placeholder que será expandido en fases posteriores para implementar el buffer de 50ms.
    • Regla 5 (45 tipos de caso con Zod): No aplica en este paso.

3.3 Notas Técnicas para el Tester

  • Dependencias Añadidas: Ninguna nueva.
  • Puntos Críticos a Probar:
    1. Header — Gradiente logo: Verificar que el texto "Claro Cases" tiene gradiente accent-orange → accent-yellow con bg-clip-text text-transparent.
    2. Header — Indicador WS: Verificar punto verde + "Conectado" cuando wsStatus = 'connected', amarillo + "Reconectando..." cuando 'reconnecting', rojo + "Desconectado" cuando 'disconnected'. Estado reconnecting debe tener animate-pulse.
    3. Header — Toggle tema: Hacer clic en icono sol/luna y verificar que isDarkMode cambia en el store y se agrega/remueve clase .dark en <html>.
    4. Sidebar — Navegación: Verificar que NavLink activo tiene clase bg-accent-orange/8 text-accent-orange. Navegar entre /cases y /monitor y verificar cambio visual.
    5. AppShell — Inicialización WS: Al montar, verificar que wsClient.connect() se llama y que wsClient.onStatusChange actualiza wsStatus en el store.
    6. AppShell — Dark mode sync: Con isDarkMode = true, verificar que <html> tiene clase .dark. Con false, que no la tiene.
    7. AppShell — Fetch inicial: Al navegar a /cases, verificar que se llama fetchCases(). Al navegar a /monitor, verificar que se llama fetchConversations(). NOTA: El fetch inicial solo ocurre al montar AppShell; cambios de ruta posteriores son manejados por los pages.
    8. App.tsx — Ruteo: Verificar que / redirige a /cases. Verificar que /cases renderiza CasesPage. Verificar que /monitor renderiza MonitorPage. Verificar que ruta desconocida redirige a /cases.
    9. App.tsx — AppShell wrapping: Verificar que todas las rutas están envueltas en AppShell y que Header + Sidebar son visibles en todas las vistas.

3.1 Paso 9 — Funcionalidades Preservadas: Notificaciones, Sonido y Parpadeo de Título

  • src/hooks/useNotification.ts: [Creado] → Hook para notificaciones de escritorio HTML5. Solicita permiso Notification.requestPermission() al montar si no está en estado granted. Expone notify(title, body, onClick?) que crea una new Notification() con icono /favicon.ico y autocierre a los 6 segundos. Al hacer clic en la notificación se ejecuta el callback onClick, se enfoca la ventana (window.focus()) y se cierra la notificación. Si el navegador no soporta Notifications o el permiso fue denegado, se loguea un warning y la llamada es silenciosamente ignorada.
  • src/hooks/useSound.ts: [Creado] → Hook para alerta sonora con Web Audio API. Inicializa un AudioContext de forma perezosa en el primer gesto del usuario (eventos click o keydown con { once: true }), cumpliendo con las políticas de autoplay del navegador. Expone playNotificationSound() que genera un chime de dos tonos: C5 (523.25 Hz) por 120 ms → E5 (659.25 Hz) por 120 ms, compartiendo un nodo GainNode con volumen 0.08 y fade out exponencial a 0.001 en 450 ms. Si el AudioContext está en estado suspended, se loguea un warning y se retorna sin reproducir.
  • src/hooks/useTitleFlash.ts: [Creado] → Hook para parpadeo del título de pestaña. Mantiene un contador useRef de notificaciones no leídas. Expone triggerNotification() que incrementa el contador y, si la pestaña no está enfocada (document.visibilityState === 'hidden' o document.hasFocus() es false), inicia un intervalo que alterna el título cada 1 segundo entre "(🔔 N) ¡Nuevo Caso!" y "Claro Cases Dashboard". Al enfocar la pestaña (visibilitychange → visible, evento window.focus), se limpia el intervalo, se restaura el título y se resetea el contador a cero. Los listeners visibilitychange, focus y blur se limpian al desmontar el componente.
  • src/hooks/index.ts: [Creado] → Barrel export que reexporta useNotification, useSound y useTitleFlash para imports limpios desde otros módulos.
  • src/components/layout/AppShell.tsx: [Modificado] → Se integraron los tres hooks de funcionalidades preservadas. El manejador onMessage del WebSocket fue expandido para despachar eventos a la store según envelope.type:
    • init_state: Reemplaza el estado local con payload.conversations y payload.activeCases.
    • conversation_started: Inserta la conversación en el store.
    • user_message: Agrega el mensaje a la conversación correspondiente.
    • agent_stream_chunk: Envía el token a appendToken para concatenación ordenada.
    • agent_stream_completed: Envía el contenido completo a completeStream.
    • hitl_request: Dispara las tres funcionalidades preservadas — (1) notificación de escritorio con notify() cuyo onClick navega a /cases y selecciona el caso, (2) alerta sonora con playNotificationSound(), (3) parpadeo de título con triggerNotification(). Además inserta el caso en el store vía upsertCase().
    • Se usa un patrón useRef (handleIncomingMessageRef) para que el callback del WebSocket siempre delegue a la versión más reciente del handler sin necesidad de remontar el efecto.

3.2 Estrategia de Solución e Integración

  • Implementación Arquitectónica: Se implementaron los hooks siguiendo el principio de programación defensiva y agnosticismo al framework:

    • useNotification usa useRef para cachear el permiso y useCallback para memoizar la función notify, evitando recreaciones innecesarias. La solicitud de permiso ocurre una sola vez al montar.
    • useSound inicializa el AudioContext de forma lazy mediante un par de listeners globales (click, keydown) con { once: true }, garantizando que no se intente crear audio antes de un gesto del usuario. Al desmontar, cierra el contexto y limpia los listeners.
    • useTitleFlash usa useRef para el contador no leído y el intervalo, evitando rerenders al actualizar el título del documento. La lógica de start/stop está desacoplada en startFlashing/stopFlashing para ser reutilizada desde triggerNotification y los listeners de visibilitychange/focus/blur.
    • AppShell integra los hooks de forma compositiva y usa un patrón de ref (handleIncomingMessageRef) para mantener la estabilidad del callback WS a través de renders. El switchcase basado en envelope.type permite escalar con nuevos tipos de eventos sin modificar la estructura del handler.
  • Mitigación de Riesgos (Fase 2):

    • Regla 1 (REST como canal autoritativo): En AppShell, el handler de hitl_request solo inserta el caso en el store local (upsertCase) y dispara notificaciones; no envía ninguna resolución por WebSocket. La resolución sigue siendo exclusiva de REST (POST /cases/:id/resolve).
    • Regla 2 (Sin advisorId): El handler de hitl_request no envía ningún payload que contenga advisorId. Solo procesa datos entrantes y dispara efectos locales.
    • Regla 3 (Tailwind v4 CSS-first): AppShell no introduce nuevas clases que dependan de configuración JS de Tailwind.
    • Regla 4 (Streaming buffer 50ms): Los eventos agent_stream_chunk se despachan directamente a appendToken del store, que ya implementa el buffer ordenado por index para garantizar orden correcto de tokens incluso con entrega fuera de orden.

3.3 Notas Técnicas para el Tester

  • Dependencias Añadidas: Ninguna (todas las APIs usadas son nativas del navegador: Notification, AudioContext, document.title, document.visibilityState, window.focus).

  • Puntos Críticos a Probar:

    1. useNotification — Permiso denegado: Bloquear notificaciones en el navegador y verificar que notify() loguea warning sin lanzar error. Verificar que la solicitud de permiso solo ocurre si Notification.permission !== 'granted' y !== 'denied'.
    2. useNotification — Click handler: Al hacer clic en una notificación, debe ejecutar el callback onClick, enfocar la ventana y cerrar la notificación. Verificar que window.focus() se llama y que notification.close() se ejecuta.
    3. useSound — AudioContext lazy: Sin gesto de usuario, playNotificationSound() debe loguear warning. Tras un click o keydown, debe crear el AudioContext y reproducir el chime. Verificar que el AudioContext se cierra al desmontar el hook.
    4. useSound — AudioContext suspended: Simular estado suspended (navegador con política de autoplay estricta) y verificar que playNotificationSound() loguea warning sin lanzar error.
    5. useSound — Dos tonos: Verificar que se reproducen dos frecuencias distintas (C5=523.25Hz, E5=659.25Hz) con el fade out exponencial. La amplitud debe decaer de 0.08 a 0.001 en 450ms.
    6. useTitleFlash — Trigger con pestaña oculta: Abrir otra pestaña, llamar triggerNotification(), verificar que el título parpadea entre "(🔔 1) ¡Nuevo Caso!" y "Claro Cases Dashboard" cada 1s. Llamar triggerNotification() nuevamente sin enfocar: el contador debe incrementar a 2 y el título alternar con "(🔔 2) ¡Nuevo Caso!".
    7. useTitleFlash — Restauración al enfocar: Con el título parpadeando, enfocar la pestaña (click o atajo de teclado). Verificar que el título se restaura a "Claro Cases Dashboard" inmediatamente y el intervalo se limpia.
    8. useTitleFlash — Múltiples triggers: Llamar triggerNotification() 5 veces con la pestaña visible → el contador se incrementa pero no parpadea (solo parpadea si la pestaña está oculta). Al ocultar la pestaña, el parpadeo debe comenzar mostrando "(🔔 5) ¡Nuevo Caso!".
    9. AppShell — hitl_request handler: Simular un evento hitl_request entrante por WebSocket y verificar que se ejecutan las tres acciones: (1) aparece notificación de escritorio, (2) suena el chime, (3) el título parpadea si la pestaña no está enfocada. Verificar que el caso se inserta en el store.
    10. AppShell — Click en notificación: Al hacer clic en la notificación generada por hitl_request, debe navegar a /cases y seleccionar el caso (selectedCaseId debe coincidir con el id del case del payload).
    11. AppShell — init_state handler: Simular init_state con múltiples conversaciones y casos. Verificar que el store se actualiza correctamente sin duplicados.
    12. AppShell — agent_stream_chunk handler: Simular chunks desordenados y verificar que appendToken los ordena por índice.
    13. AppShell — Ref pattern: Verificar que el onMessage callback siempre usa la última versión de handleIncomingMessage incluso si el componente se rerenderiza (ej. cambio de isDarkMode). El handler debe seguir funcionando sin necesidad de reconectar el WS.
    14. npm run build: Verificar que npm run build compila sin errores de tipo.

3.1 Paso 10 — Fix CRÍTICO: Buffer de Streaming 50ms (Regla 4)

  • src/store/useAppStore.ts: [Modificado] → Implementación completa del buffer de rate-limiting de 50ms para streaming token-a-token según Regla 4 de la Fase 2. Se añadieron:

    • Sistema de buffer externo (conversationBuffers: Map<string, ConversationBufferEntry>) fuera del estado de Zustand, evitando re-renders al acumular chunks entrantes.
    • flushBuffer(): Procesa los tokens pendientes de una conversación con UNA sola llamada a set(), ordenando por index para garantizar orden correcto incluso con entrega fuera de orden. Solo actualiza el store si la conversación es la seleccionada (optimización de re-render).
    • scheduleBufferFlush(): Programa un setTimeout de 50ms por conversación, con guarda para no duplicar timers.
    • forceFlushBuffer(): Vaciado inmediato del buffer, usado al cambiar de conversación seleccionada.
    • appendToken(): Ahora acumula en el buffer externo y solo programa flush si la conversación es la activa. No llama a set() directamente.
    • completeStream(): Limpia el buffer de la conversación (cancela timer pendiente y elimina entrada del Map) antes de actualizar el store.
    • setSelectedConversationId(): Nueva acción que fuerza el flush del buffer al seleccionar una conversación con tokens acumulados.
    • removeConversation(): Nueva acción que limpia el buffer y elimina la conversación del store, incluyendo el cleanup del selectedConversationId si corresponde.
    • Auto-limpieza en flushBuffer(): si la conversación ya no existe en el store, se elimina la entrada del buffer.

3.2 Estrategia de Solución e Integración

  • Implementación Arquitectónica: Se implementó el patrón de buffer externo (fuera del estado de Zustand) para evitar re-renders durante la acumulación de tokens. El buffer usa un Map<string, ConversationBufferEntry> donde cada entrada contiene un array pending de chunks y un timer (setTimeout de 50ms). Solo la conversación seleccionada programa timers de flush; las conversaciones no seleccionadas acumulan tokens silenciosamente sin disparar re-renders. Cuando completeStream llega, se limpia el buffer y se actualiza el store con el fullContent autoritativo en una sola llamada a set(). Al cambiar de conversación, setSelectedConversationId fuerza un flush inmediato de los tokens acumulados de la nueva conversación.

  • Mitigación de Riesgos (Fase 2):

    • Regla 4 (Streaming buffer 50ms): Implementado completamente. Cada chunk se acumula en un buffer externo, y cada 50ms se hace una sola llamada a set() con todos los chunks acumulados ordenados. Solo la conversación seleccionada actualiza el store, limitando re-renders a máximo 20 fps.
    • Regla 4 — Limpieza de buffer: completeStream elimina el buffer de la conversación (cancela timer + borra entrada del Map). removeConversation también limpia el buffer. El flush auto-limpia buffers huérfanos si la conversación ya no existe.
    • Regla 4 — Non-selected conversations: Las conversaciones no seleccionadas acumulan tokens sin timer, sin llamar a set(), y sin causar re-renders. Al ser seleccionadas, setSelectedConversationId fuerza un flush inmediato.

3.3 Notas Técnicas para el Tester

  • Dependencias Añadidas: Ninguna. Todo implementado con APIs nativas de JavaScript (Map, setTimeout, clearTimeout).
  • Puntos Críticos a Probar:
    1. Buffer de 50ms: Enviar 100 chunks rápidamente a appendToken para la misma conversación seleccionada. Verificar que set() se llama ~20 veces por segundo (cada 50ms), no 100 veces.
    2. Orden de chunks: Enviar chunks con índices desordenados (ej: 2, 0, 1, 4, 3) y verificar que el contenido final en el store está correctamente ordenado.
    3. Conversación no seleccionada: Enviar chunks a una conversación NO seleccionada. Verificar que NO se llama set() y que los chunks se acumulan en el buffer externo.
    4. Seleccionar conversación con buffer: Acumular chunks en una conversación no seleccionada, luego llamar setSelectedConversationId(). Verificar que todos los chunks acumulados se aplican al store en una sola llamada.
    5. completeStream limpia buffer: Llamar completeStream() para una conversación con chunks pendientes. Verificar que conversationBuffers ya no tiene entrada para esa conversación y que el store muestra fullContent.
    6. removeConversation limpia buffer: Llamar removeConversation() y verificar que la entrada del buffer se elimina y la conversación desaparece del store.
    7. Auto-limpieza flush: Eliminar manualmente una conversación del store (vía set() directo) y verificar que el siguiente flush elimina la entrada huérfana del buffer.
    8. No fuga de timers: Verificar que los setTimeout se cancelan correctamente al llamar completeStream() o removeConversation(). No debe haber timers colgados después de estas operaciones.
    9. npm run build: Debe compilar sin errores tras los cambios.

Fase 4: Reporte de Calidad (QA)

4.1 Resumen de Cobertura

  • Resultado Global: PASSED
  • Total de Casos Ejecutados: 7
  • Casos Exitosos: 7
  • Casos Fallidos: 0

4.2 Detalle de Pruebas y Casos de Estrés

  • Build (npm run build): PASSED — tsc -b && vite build ejecutado exitosamente. Vite v6.4.3 transformó 2742 módulos en 3.04s. Archivos generados en dist/: index.html (0.66 kB), CSS (31.42 kB), JS browser (300.77 kB), JS app (425.81 kB). Sin errores ni warnings.
  • TypeScript Compiler (npx tsc --noEmit): PASSED — Zero type errors en toda la base de código. El comando retornó sin output (compilación limpia).
  • Regla 1 (hitl_response): PASSED — Búsqueda con grep en src/ no encontró ninguna ocurrencia de hitl_response. La resolución de casos es exclusivamente REST.
  • Regla 2 (advisorId): PASSED — Búsqueda con grep en src/ no encontró ninguna ocurrencia de advisorId. No hay identificadores de asesor en payloads cliente→servidor.
  • Regla 3 (tailwind.config.ts): PASSED — El archivo tailwind.config.ts NO existe en la raíz del proyecto. Toda la configuración de Tailwind v4 está en src/index.css via @theme y @custom-variant dark.
  • Regla 4 (buffer streaming 50ms): PASSED — Verificación de código fuente en src/store/useAppStore.ts:
    • Buffer externo (conversationBuffers: Map<string, ConversationBufferEntry>) declarado fuera del estado de Zustand (línea 95), evitando re-renders por chunk individual.
    • scheduleBufferFlush() programa setTimeout de 50ms por conversación (línea 196-198) con guarda contra timers duplicados (línea 194).
    • flushBuffer() verifica selectedConversationId antes de llamar a set() (línea 129). Si la conversación no es la seleccionada, retorna sin actualizar el store.
    • Las conversaciones no seleccionadas acumulan chunks en el buffer sin programar timer (líneas 327-331: scheduleBufferFlush solo se llama si selectedConversationId === convId).
    • completeStream() (líneas 334-381): limpia el buffer (cancela timer + elimina entrada del Map) y luego actualiza el store con fullContent en una sola llamada a set().
    • removeConversation() (líneas 394-411): limpia el buffer antes de eliminar la conversación del store.
    • setSelectedConversationId() (líneas 383-392): fuerza flush inmediato via forceFlushBuffer() al cambiar de conversación.
  • Regla 5 (45+ casos mapeados): PASSED — 53 registros en src/data/caseTypeDefinitions.ts, todos con uiPattern (53/53), applicative (53/53), formFields (53/53), y validationSchema/payloadBuilder provistos via spread de funciones fábrica (51 usos de factory spreads: simpleConfirmation 18, multiFieldForm 18, más confirmationWithValue, dateSimple, freeText, readOnly).

4.3 Evidencia y Logs de Consola

```text
# Build
> [email protected] build
> tsc -b && vite build

vite v6.4.3 building for production...
transforming...
✓ 2742 modules transformed.
rendering chunks...
computing gzip size...
dist/index.html                    0.66 kB │ gzip:   0.37 kB
dist/assets/index-CtPkX2JE.css    31.42 kB │ gzip:   6.27 kB
dist/assets/browser-yp4JH-9T.js  300.77 kB │ gzip:  99.29 kB
dist/assets/index-QLIqdcdX.js    425.81 kB │ gzip: 119.05 kB
✓ built in 3.04s

# TypeScript Check
$ npx tsc --noEmit
(no output — zero type errors)

# Regla 1 — hitl_response grep
$ grep -r "hitl_response" src/
(no output)

# Regla 2 — advisorId grep
$ grep -r "advisorId" src/
(no output)

# Regla 3 — tailwind.config.ts existence
$ test -f tailwind.config.ts && echo FAIL || echo PASS
PASS (file not found)

# Regla 5 — case count verification
uiPattern occurrences: 53
applicative occurrences: 53
formFields occurrences: 53
Factory spread patterns: 51
```

Feature: Módulo de Autenticación JWT (Okan → Linguo)

Fase 1: Requerimientos y Plan Inicial (Auth)

1.1 Resumen Ejecutivo

  • Tipo de Tarea: New Feature
  • Objetivo General: Implementar capa de autenticación JWT que capture automáticamente el token Okan vía popup, lo intercambie por un JWT de Linguo, e inyecte dicho JWT en todas las llamadas REST y conexiones WebSocket.

1.2 Contexto Técnico

Referencia analizada: linguo-ext-ai/content-scripts/okan-session-auth.js

La extensión de Chrome captura el token Okan desde apps.okan.tools usando dos estrategias:

  1. URL capture: login?token=<base64_jwt> → extrae el param token
  2. localStorage polling: lee localStorage.getItem('tokenB64') en /home

Ambas dependen de ejecutarse en el mismo origen que Okan, lo cual no es posible desde una SPA independiente. Sin embargo, la lógica de decodificación y validación del JWT Okan (exp check, extracción de username) es reutilizable.

Adaptación para Claro Cases (React SPA)

Mecanismo Descripción
Popup Okan Abrir https://apps.okan.tools/login en ventana popup. Tras autenticación, Okan redirige a apps.okan.tools/login?token=<token>. Monitorear la URL del popup para interceptar el parámetro token.
Intercambio POST https://vector.linguogpt.ai/login con { token_okan }{ document, fullName, expireDate, token }
Inyección REST Authorization: Bearer <jwt> en cada request
Inyección WS ws://host/ws/dashboard?token=<jwt> al conectar
Persistencia sessionStorage (clave claro-cases:session). Se limpia al cerrar pestaña.

Módulos/Archivos Impactados

Archivo Cambio
src/services/auth.ts NUEVO — Popup Okan, exchange, session
src/services/api.ts MODIFICADO — Inyectar Authorization header
src/services/wsClient.ts MODIFICADO — Adjuntar ?token=
src/store/useAppStore.ts MODIFICADOauthSlice
src/hooks/useAuth.ts NUEVO — Sesión, expiración, guards
src/components/auth/LoginPage.tsx NUEVO — UI de login con popup
src/components/layout/AppShell.tsx MODIFICADO — ProtectedRoute, logout
src/App.tsx MODIFICADO — Ruta /login, guards

1.3 Plan Lógico de Solución

Paso A1 — Servicio de Autenticación (src/services/auth.ts)

auth.openOkanPopup() → window.open('https://apps.okan.tools/login', ...)
auth.monitorPopup(popup, 120s) → setInterval(500ms) lee popup.location.href
  → si URL contiene 'login?token=' → extraer token, cerrar popup, resolver
  → si popup cerrado → reject('Login cancelado')
  → timeout 120s → reject('Timeout')
auth.validateOkanToken(raw) → decode JWT payload → check exp > now+60s
auth.exchangeToken(tokenOkan) → POST https://vector.linguogpt.ai/login
auth.storeSession({ document, fullName, expireDate, token })
auth.getToken() → sessionStorage → verificar expireDate → JWT | null
auth.isAuthenticated() → getToken() !== null
auth.logout() → sessionStorage.removeItem('claro-cases:session') → redirect /login
auth.getAuthHeaders() → { Authorization: 'Bearer <jwt>' }

Validación del token Okan (replicada de okan-session-auth.js):

  • Decodificar payload JWT (base64url → JSON)
  • Verificar exp > Date.now()/1000 + 60 (60s clock skew)
  • Extraer username de email, preferred_username, o sub

Intercambio por JWT Linguo:

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>():

  1. Antes de cada fetch: auth.getToken() — si null, lanzar AuthError
  2. Headers: Authorization: Bearer <jwt>
  3. Si respuesta 401auth.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: idleopening_popupexchanging_tokensuccess → 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: LoginPage con botón "Iniciar sesión con Okan" que abre popup 600×700 a apps.okan.tools/login.
  • CA-A2: Popup monitoreado cada 500ms; al detectar login?token= en la URL, extrae el token y cierra el popup.
  • CA-A3: Token Okan validado (JWT decode + exp check 60s skew) antes del intercambio.
  • CA-A4: Token Okan intercambiado por JWT Linguo via POST https://vector.linguogpt.ai/login.
  • CA-A5: Sesión almacenada en sessionStorage con document, fullName, expireDate, token.
  • CA-A6: api.ts inyecta Authorization: Bearer <jwt> en cada request REST.
  • CA-A7: wsClient.ts adjunta ?token=<jwt> al conectar WebSocket.
  • CA-A8: Si api.ts recibe 401, se ejecuta logout() y redirige a /login.
  • CA-A9: ProtectedRoute redirige a /login si no hay sesión válida.
  • CA-A10: Header muestra fullName del asesor y botón "Cerrar sesión".
  • CA-A11: Sesión expirada (client-side, basada en expireDate) redirige a /login.
  • CA-A12: Estados de error del popup (timeout 120s, ventana cerrada, token inválido/expirado, popup bloqueado) muestran mensaje claro en UI.
  • CA-A13: npm run build compila sin errores.
  • CA-A14: MSW handlers siguen funcionando sin requerir auth en modo desarrollo.

1.5 Riesgos Identificados

  1. Popup bloqueado por el navegador: window.open() puede ser bloqueado. Mitigación: detectar popup === null y mostrar mensaje "Permite ventanas emergentes para iniciar sesión".
  2. Cross-origin en monitor del popup: popup.location.href lanza SecurityError si el popup navega a otro origen distinto de Okan. Mitigación: try/catch — si falla, asumir que sigue en Okan.
  3. Ventana cerrada por el usuario: Mitigación: detectar popup.closed y mostrar "Login cancelado".
  4. expireDate vs exp del JWT: El backend envía expireDate explícito. Decisión: usar expireDate del backend como fuente de verdad, no decodificar el JWT Linguo.
  5. MSW sin auth: Los mocks no deben requerir autenticación. Mitigación: el interceptor 401 solo se activa cuando VITE_ENABLE_MSW !== 'true'.

Fase 2: Auditoría de Arquitectura (Auth)

Resumen de Hallazgos

  • El flujo popup → exchange → inyección es viable, pero solo si Okan mantiene redirección final al mismo origen del popup y si el token no se expone fuera del cierre inmediato de la ventana.
  • La propuesta está razonablemente acotada en UI, pero todavía deja huecos en contrato backend, manejo de errores de autenticación, y estado de inicialización del guard.
  • sessionStorage funciona para una sesión efímera por pestaña, pero no es una defensa de seguridad; protege solo contra persistencia accidental, no contra XSS.
  • La política de interceptor en api.ts es correcta en intención, pero incompleta si no excluye explícitamente el endpoint /login y no distingue errores de auth esperables en modo mock.
  • El guard de rutas cubre el camino feliz, pero no cierra bien los casos de carga inicial, expiración en caliente, ni navegación directa a rutas profundas.

Riesgos Identificados (con severidad Alta/Media/Baja)

  • Alta: exponer token en la URL del popup puede filtrarlo por historial, logs, extensiones o un referrer mal configurado. Si ese token sirve para canjear JWT real, el valor debe tratarse como secreto de un solo uso y minimizar su exposición temporal.
  • Alta: el contrato POST /login está subespecificado. No quedan cerrados los códigos HTTP exactos, formato de error, validez/idempotencia del token_okan, ni el criterio de expiración de expireDate vs token devuelto.
  • Media: sessionStorage sigue siendo accesible ante XSS; si el frontend se contamina, el JWT queda comprometido. Además, la sesión se pierde al cerrar pestaña, lo que puede romper continuidad operativa si no se asume explícitamente.
  • Media: el interceptor de api.ts puede provocar logout espurio si procesa 401 de endpoints públicos o de mocks. Sin una lista blanca/negra de rutas, el flujo de auth se vuelve frágil.
  • Media: el ProtectedRoute no cubre bien el estado intermedio de “auth aún no inicializada”. Sin un estado de carga, hay parpadeos, redirects prematuros y loops con /login.
  • Baja: el canal WS con ?token= hereda el mismo problema de exposición que el REST, y además ensucia logs/proxies. Es funcional, pero no es la mejor forma de transportar credenciales.

Propuestas de Mejora

  • Definir POST /login con contrato estricto: request, response, errores, semántica de expiración, y comportamiento ante token reutilizado o expirado.
  • Tratar el token Okan como artefacto efímero: extracción inmediata, cierre del popup al instante, cero persistencia en logs, y validación antes del exchange.
  • Introducir una capa de estado de auth con loading/authenticated/anonymous/expired, para que los guards no decidan antes de tiempo.
  • Hacer explícita la política del interceptor: excluir /login, no disparar logout en mocks de desarrollo, y diferenciar 401 real de fallo de red.
  • Mantener sessionStorage solo si la amenaza aceptada es “sesión por pestaña”; si no, migrar a un esquema con credencial de corta vida y renovación controlada fuera del almacenamiento JS.
  • Para WS, preferir un mecanismo de autenticación menos verboso que query string si el backend lo soporta; si no, al menos exigir expiración corta y limpieza agresiva.

Veredicto Final

  • Viable, pero condicionado. El diseño se puede implementar sin reescribir la arquitectura, pero no debe entrar a desarrollo sin cerrar el contrato de /login, endurecer el manejo de errores, y formalizar los estados de auth en ruteo e interceptor.
  • Aprobación: sí, con correcciones obligatorias en contrato backend, guardas de inicialización y política de almacenamiento/transportación del token.

Fase 3: Implementación y Cambios de Código (Auth)

3.1 Mapa de Archivos Afectados

  • src/services/auth.ts: [Creado] → Servicio de autenticación completo: openOkanPopup() (popup 600×700 centrado), monitorPopup() (pooling cada 500ms, timeout 120s, extracción de token de URL), validateOkanToken() (decodificación JWT base64url, verificación exp con 60s skew, extracción de username de email/preferred_username/sub), exchangeToken() (POST a Linguo /login con { token_okan } → Session), storeSession()/getToken()/isAuthenticated()/getSession()/logout()/getAuthHeaders() (sesión en sessionStorage con clave claro-cases:session, validación de expireDate).
  • src/services/api.ts: [Modificado] → Se añadió AuthError extends Error. El interceptor en request<T>() verifica auth.getToken() antes de cada fetch (excepto /login y modo MSW), inyecta Authorization: Bearer <jwt> en headers, y limpia sesión + lanza AuthError('Sesión expirada') al recibir 401 (excluyendo /login y MSW).
  • src/services/wsClient.ts: [Modificado] → En connect(), se verifica auth.getToken(); si es null, se loguea warning y retorna sin conectar. La URL del WebSocket incluye ?token=<jwt_encoded>.
  • src/hooks/useAuth.ts: [Creado] → Hook useAuth() con estado AuthStatus: 'loading' | 'authenticated' | 'anonymous' | 'expired'. Verifica al montar y periódicamente cada 30s si la sesión expiró. Expone status, isAuthenticated, isLoading.
  • src/components/auth/LoginPage.tsx: [Creado] → Página de login con layout centrado, logo "Claro Cases" con gradiente, botón "Iniciar sesión con Okan". Maneja 6 estados: idle (botón), opening_popup (spinner + mensaje), exchanging_token (spinner + mensaje), success (check verde + redirect 500ms a /cases), error (mensaje + botón reintentar). Errores manejados: popup bloqueado, timeout, token expirado/inválido, error de red, login cancelado.
  • src/components/auth/ProtectedRoute.tsx: [Creado] → Wrapper de ruta protegida. Muestra "Cargando..." con spinner mientras isLoading. Redirige a /login con <Navigate replace /> si no autenticado. Renderiza children si autenticado.
  • src/components/layout/Header.tsx: [Modificado] → Muestra fullName del asesor (desde auth.getSession()) con truncado a 160px. Botón "Cerrar sesión" con icono LogOut de lucide-react que llama a auth.logout() y navega a /login.
  • src/App.tsx: [Modificado] → Se añadió ruta /login con <LoginPage />. Las rutas /cases y /monitor se envuelven en <ProtectedRoute>. El catch-all * redirige a /cases.
  • src/components/layout/AppShell.tsx: [Modificado] → Se añadió import { auth } y un useEffect de auth check al montar: si no está en /login y auth.isAuthenticated() es false, redirige a /login. El useEffect de inicialización WS ahora tiene guarda if (!auth.isAuthenticated()) return; para solo conectar WS y fetch si hay sesión válida.

3.2 Estrategia de Solución e Integración

  • Implementación Arquitectónica: Se implementó el módulo de autenticación siguiendo estrictamente la separación de conceptos: el servicio auth.ts es completamente agnóstico al framework (sin React, sin hooks, sin store), lo que permite ser usado desde cualquier capa (servicios, hooks, componentes). La sesión se almacena en sessionStorage (se destruye al cerrar pestaña) con validación de expireDate en cada lectura. El interceptor de api.ts inyecta el token JWT en todas las llamadas REST excepto aquellas que contienen /login (para no interferir con el exchange) y cuando VITE_ENABLE_MSW === 'true' (los mocks no requieren auth). El WebSocket adjunta el token como query param ?token= en la URL. El hook useAuth agrega una capa reactiva con estado loading/authenticated/anonymous/expired para que los guards (ProtectedRoute, AppShell) puedan decidir correctamente sin parpadeos ni redirects prematuros.

  • Mitigación de Riesgos (Fase 2):

    • R1 (Popup bloqueado): openOkanPopup() retorna null si window.open falla o el popup está cerrado inmediatamente. LoginPage muestra mensaje "Permite ventanas emergentes para iniciar sesión".
    • R2 (Cross-origin monitor): monitorPopup() envuelve popup.location.href en try/catch. Si lanza SecurityError (popup en otro origen), se ignora y se continúa polling. No hay falsos positivos.
    • R3 (Popup cerrado por usuario): monitorPopup() detecta popup.closed antes de cada poll y rechaza con "Login cancelado".
    • R4 (expireDate como fuente de verdad): La sesión guarda expireDate del backend. auth.getToken() valida Date.now() >= new Date(expireDate).getTime() antes de retornar el token.
    • R5 (MSW sin auth): El interceptor de api.ts (request, auth headers, 401) se desactiva completamente cuando VITE_ENABLE_MSW === 'true'.
    • Contrato POST /login: exchangeToken() parsea 200 → Session y errores con { detail } del backend, lanzando Error con el mensaje exacto.
    • Token Okan efímero: El token se extrae del popup, se valida, se intercambia inmediatamente, y nunca se persiste (ni en sessionStorage ni en localStorage ni en estado React).
    • Estado loading en guard: ProtectedRoute y useAuth manejan el estado 'loading' mostrando un spinner, evitando redirects prematuros antes de que la sesión se verifique.

3.3 Notas Técnicas para el Tester

  • Dependencias Añadidas: Ninguna. Todas las dependencias ya estaban instaladas (react-router-dom, lucide-react). auth.ts usa APIs nativas: window.open, window.setInterval, atob, crypto.randomUUID, sessionStorage, fetch.
  • Puntos Críticos a Probar:
    1. auth.ts — decodeJwtPayload: Probar con JWT bien formado (3 partes, base64url) y mal formado (sin puntos, payload no JSON, base64 inválido). Verificar que retorna null en errores.
    2. auth.ts — validateOkanToken: Probar con token expirado (exp pasado), token sin exp, token con exp futura válida. Verificar extracción de username desde email, preferred_username y sub.
    3. auth.ts — exchangeToken: Mockear fetch para simular 200 OK con Session, 401 con { detail }, 500 sin body. Verificar que el error incluye el detail del backend.
    4. auth.ts — monitorPopup: Simular popup que navega a URL con token. Verificar extracción correcta y cierre del popup. Simular cierre del popup → reject. Simular timeout 120s → reject.
    5. api.ts — AuthError: Llamar request() sin sesión → debe lanzar AuthError('No autenticado'). Llamar con sesión válida → headers incluyen Authorization: Bearer <jwt>.
    6. api.ts — MSW exclusion: Con VITE_ENABLE_MSW=true, verificar que request() no lanza AuthError incluso sin token.
    7. api.ts — 401 handling: Mockear respuesta 401 → verificar que auth.logout() se llama y se lanza AuthError('Sesión expirada').
    8. wsClient.ts — Auth guard: Sin sesión, wsClient.connect() debe loguear warning y no crear WebSocket. Con sesión, la URL debe contener ?token=<jwt>.
    9. ProtectedRoute: Sin sesión → redirect a /login. Con sesión → renderiza children. Estado loading → spinner.
    10. LoginPage — Flujo completo: Click en botón → popup Okan. Verificar estados idle→opening_popup→exchanging_token→success/error. Verificar mensajes de error para cada caso (popup bloqueado, timeout, token inválido, error de red, login cancelado).
    11. Header — fullName y logout: Con sesión, verificar que el nombre aparece en el header. Click en LogOut → se limpia sessionStorage → redirige a /login.
    12. AppShell — Auth redirect: Sin sesión, al navegar a /cases o /monitor → redirige a /login. Con sesión, se inicializa WS y fetch.
    13. AppShell — WS conditional init: Sin sesión, wsClient.connect() no se llama (el guard en el useEffect lo impide).
    14. npm run build: Debe compilar sin errores.

Fase 4: Reporte de Calidad (QA) — Módulo de Autenticación JWT

4.1 Resumen de Cobertura

  • Resultado Global: PASSED
  • Total de Casos Ejecutados: 12
  • Casos Exitosos: 12
  • Casos Fallidos: 0

4.2 Detalle de Pruebas y Casos de Estrés

  • Build (npm run build): PASSEDtsc -b && vite build ejecutado exitosamente. Vite v6.4.3 transformó 2746 módulos en 2.81s. Archivos generados en dist/: index.html (0.66 kB), CSS (32.47 kB), JS app (435.18 kB). Sin errores ni warnings.

  • TypeScript Compiler (npx tsc --noEmit): PASSED — Zero type errors en toda la base de código. El comando retornó sin output (compilación limpia).

  • Archivos creados/modificados (9 archivos): PASSED — Todos los archivos existen:

    • src/services/auth.ts (Creado, 12695 bytes)
    • src/services/api.ts (Modificado, 6985 bytes — interceptor auth)
    • src/services/wsClient.ts (Modificado, 7301 bytes — token en connect)
    • src/hooks/useAuth.ts (Creado, 2274 bytes)
    • src/components/auth/LoginPage.tsx (Creado, 8312 bytes)
    • src/components/auth/ProtectedRoute.tsx (Creado, 1816 bytes)
    • src/components/layout/Header.tsx (Modificado, 4332 bytes — fullName + logout)
    • src/components/layout/AppShell.tsx (Modificado, 10530 bytes — auth check + WS condicional)
    • src/App.tsx (Modificado, 1012 bytes — /login route + ProtectedRoute)
  • Regla 1 — Token Okan efímero: PASSED — Verificación de src/services/auth.ts: el token Okan se extrae del popup (monitorPopup → línea 168), se valida (validateOkanToken → línea 48-53 de LoginPage), se intercambia inmediatamente (exchangeToken → línea 261 envía { token_okan } en el body del POST), y nunca se persiste en sessionStorage, localStorage, ni estado React. Solo el JWT Linguo resultante (Session.token) se guarda en sessionStorage.

  • Regla 2 — Interceptor excluye /login: PASSED — En src/services/api.ts:

    • Línea 66: const isLoginPath = path.includes('/login');
    • Línea 67-68: if (!isLoginPath && !ENABLE_MSW) { ... throw new AuthError('No autenticado') }
    • Línea 77: const authHeaders = !isLoginPath && !ENABLE_MSW ? auth.getAuthHeaders() : {};
    • Línea 93: if (response.status === 401 && !isLoginPath && !ENABLE_MSW) { auth.logout(); ... }
    • El endpoint /login está completamente excluido de auth check, inyección de headers y manejo de 401.
  • Regla 3 — Interceptor respeta MSW: PASSED — Cuando VITE_ENABLE_MSW === 'true':

    • No se verifica auth antes de requests (línea 67: !ENABLE_MSW).
    • No se inyectan headers de auth (línea 77: !ENABLE_MSW).
    • No se ejecuta logout en 401 (línea 93: !ENABLE_MSW).
  • Regla 4 — ProtectedRoute loading state: PASSEDProtectedRoute.tsx maneja correctamente el estado loading:

    • useAuth() (hook) inicializa con status = 'loading' (línea 25 de useAuth.ts).
    • Mientras isLoading === true, ProtectedRoute muestra un spinner centrado con Loader2 y texto "Cargando..." (líneas 28-36).
    • Solo después de que loading se resuelve se decide entre authenticated → render children o anonymous/expired → Navigate to /login.
    • No hay redirect prematuro ni parpadeo.
  • Regla 5 — sessionStorage (no localStorage): PASSED — Verificación de src/services/auth.ts:

    • Línea 306: sessionStorage.setItem(SESSION_KEY, JSON.stringify(session))
    • Línea 321: sessionStorage.getItem(SESSION_KEY)
    • Línea 330: sessionStorage.removeItem(SESSION_KEY)
    • Línea 353: sessionStorage.getItem(SESSION_KEY)
    • Línea 367: sessionStorage.removeItem(SESSION_KEY)
    • No se usa localStorage para la sesión en ningún punto.
  • Grep seguridad — token_okan: PASSED — Búsqueda en src/ encuentra token_okan solo en:

    • src/services/auth.ts línea 250: Comentario JSDoc (POSTs { token_okan } to the Linguo login endpoint.)
    • src/services/auth.ts línea 261: Body del fetch (body: JSON.stringify({ token_okan: tokenOkan }))
    • No aparece en ningún log, console, storage, estado React, ni persistencia. Token efímero en memoria durante el exchange únicamente.
  • Grep seguridad — advisorId: PASSED — Búsqueda de advisorId en src/ no encontró ninguna ocurrencia. No hay identificadores de asesor en payloads cliente→servidor.

  • CA-A1 (LoginPage con botón Okan): PASSEDLoginPage.tsx renderiza botón "Iniciar sesión con Okan" (línea 140) que llama a auth.openOkanPopup() (popup 600×700 centrado).

  • CA-A12 (Estados de error del popup): PASSEDLoginPage.tsx maneja 6 estados (idle, opening_popup, exchanging_token, success, error) con mensajes específicos para: popup bloqueado (líneas 34-38), login cancelado (líneas 82-83), timeout 120s (líneas 84-88), token inválido/expirado (líneas 49-53), error de red (líneas 90-95).

4.3 Evidencia y Logs de Consola

```text
# 1) File existence check
$ ls -la src/services/auth.ts src/hooks/useAuth.ts src/components/auth/LoginPage.tsx \
         src/components/auth/ProtectedRoute.tsx src/services/api.ts src/services/wsClient.ts \
         src/components/layout/Header.tsx src/components/layout/AppShell.tsx src/App.tsx
-rw-rw-r-- 1 baguv1 baguv1  1012 Jul 24 00:06 src/App.tsx
-rw-rw-r-- 1 baguv1 baguv1  8312 Jul 24 00:05 src/components/auth/LoginPage.tsx
-rw-rw-r-- 1 baguv1 baguv1  1816 Jul 24 00:06 src/components/auth/ProtectedRoute.tsx
-rw-rw-r-- 1 baguv1 baguv1 10530 Jul 24 00:06 src/components/layout/AppShell.tsx
-rw-rw-r-- 1 baguv1 baguv1  4332 Jul 24 00:06 src/components/layout/Header.tsx
-rw-rw-r-- 1 baguv1 baguv1  2274 Jul 24 00:05 src/hooks/useAuth.ts
-rw-rw-r-- 1 baguv1 baguv1  6985 Jul 24 00:05 src/services/api.ts
-rw-rw-r-- 1 baguv1 baguv1 12695 Jul 24 00:05 src/services/auth.ts
-rw-rw-r-- 1 baguv1 baguv1  7301 Jul 24 00:05 src/services/wsClient.ts

# 2) Build
$ npm run build
> [email protected] build
> tsc -b && vite build

vite v6.4.3 building for production...
transforming...
✓ 2746 modules transformed.
rendering chunks...
computing gzip size...
dist/index.html                    0.66 kB │ gzip:   0.37 kB
dist/assets/index-X1jRGUpz.css    32.47 kB │ gzip:   6.44 kB
dist/assets/index-BQeSpUfW.js    435.18 kB │ gzip: 121.35 kB
✓ built in 2.81s

# 3) TypeScript Check
$ npx tsc --noEmit
(no output — zero type errors)

# 4) token_okan security grep
$ grep -rn "token_okan" src/
src/services/auth.ts:250: * POSTs { token_okan } to the Linguo login endpoint.
src/services/auth.ts:261:     body: JSON.stringify({ token_okan: tokenOkan }),

# 5) advisorId security grep
$ grep -rn "advisorId" src/
(no output)

# 6) sessionStorage verification (no localStorage)
$ grep -n "sessionStorage\|localStorage" src/services/auth.ts
306:    sessionStorage.setItem(SESSION_KEY, JSON.stringify(session));
321:    const stored = sessionStorage.getItem(SESSION_KEY);
330:      sessionStorage.removeItem(SESSION_KEY);
353:    const stored = sessionStorage.getItem(SESSION_KEY);
367:    sessionStorage.removeItem(SESSION_KEY);
# Note: No localStorage calls for session in auth.ts
```