# Implementation Plan: Asistente de Chat Integrado (n8n)

**Feature Branch**: `011-asistente-chat-n8n`

**Created**: 2026-07-06

**Status**: Draft

**Input**: `specs/011-asistente-chat-n8n/spec.md`

> **Constitution (II. Skinny Controllers, IV. Inertia.js Frontera)**: Este plan define CÓMO
> se implementará la funcionalidad en Laravel + React + Inertia.js.

---

## Resumen técnico

Integrar un asistente de chat global en el backoffice autenticado:

1. **Frontend**: botón fijo inferior + panel incrustado (25 % ancho) o flotante, visible solo en escritorio (`≥992px`).
2. **Estado**: React Context a nivel `app.tsx` (no en `Layout`) para sobrevivir remontajes de Inertia al navegar entre páginas.
3. **Backend**: proxy Laravel autenticado que reenvía `{ action, sessionId, chatInput }` al webhook n8n vía `Http::`.
4. **Sin BD**: historial UI efímero en memoria de pestaña; n8n gestiona contexto por `sessionId`.

### Diagrama de flujo

```mermaid
sequenceDiagram
    participant U as Usuario (React)
    participant P as ChatAssistantProvider
    participant L as Laravel Proxy
    participant N as n8n Webhook

    U->>P: Escribe y envía mensaje
    P->>P: Añade mensaje usuario al hilo
    P->>L: POST /api/v1/chat/messages (Sanctum session)
    L->>L: Valida payload (FormRequest)
    L->>N: POST webhook (Http::timeout)
    N-->>L: JSON { status, message, data, error }
    L-->>P: JSON normalizado
    P->>P: Renderiza respuesta (text/table/card)
    P-->>U: Actualiza hilo
```

---

## User Review Required

> [!IMPORTANT]
> **URL del webhook en `.env`**: La URL `http://172.233.98.190:8680/webhook/...` debe configurarse
> como variable de entorno (`ENEON_N8N_CHAT_WEBHOOK_URL`). Confirmar que el servidor de aplicación
> (dev/staging/prod) tiene conectividad de red hacia esa IP/puerto.

> [!WARNING]
> **Provider global vs Layout**: El `Layout` se remonta en cada navegación Inertia porque cada
> página define `Page.layout = (page) => <Layout>{page}</Layout>`. Por ello el estado del chat
> **no puede** vivir solo en `Layout`; se propone `ChatAssistantProvider` en `app.tsx`.
> Aprobar este enfoque antes de implementar.

> [!NOTE]
> **Breakpoint escritorio**: Se usará el breakpoint Bootstrap **lg = 992px** (`min-width: 992px`).
> Por debajo, botón y panel ocultos. Fallback automático a modo flotante en viewports
> **992px–1279px**; panel incrustado por defecto en **≥1280px**.

---

## Open Questions

- Ninguna bloqueante tras las clarificaciones del spec. El contrato de respuesta n8n ya está definido.

---

## Proposed Changes

### Configuración

#### [MODIFY] `config/services.php`

Añadir sección `n8n`:

```php
'n8n' => [
    'chat_webhook_url' => env('ENEON_N8N_CHAT_WEBHOOK_URL'),
    'chat_timeout'     => (int) env('ENEON_N8N_CHAT_TIMEOUT', 30),
],
```

#### [MODIFY] `.env` / documentación de despliegue

Variables requeridas:

```env
ENEON_N8N_CHAT_WEBHOOK_URL=http://172.233.98.190:8680/webhook/4093f79d-6fcd-4fe4-b147-019ee1ede065/chat
ENEON_N8N_CHAT_TIMEOUT=30
```

---

### Database & Models

**Sin cambios.** No se persisten mensajes ni sesiones en BD (FR-020).

---

### Backend — DTOs, Service, Request, Controller

#### [NEW] `app/DTOs/N8n/SendChatMessageDto.php`

```php
declare(strict_types=1);

final readonly class SendChatMessageDto
{
    public function __construct(
        public string $action,      // siempre "sendMessage"
        public string $sessionId,
        public string $chatInput,
    ) {}

    public function toArray(): array { ... }
}
```

#### [NEW] `app/Http/Requests/SendChatMessageRequest.php`

Validación estricta — **solo** los tres campos permitidos:

| Campo | Reglas |
|-------|--------|
| `action` | `required\|string\|in:sendMessage` |
| `sessionId` | `required\|string\|max:64` |
| `chatInput` | `required\|string\|min:1\|max:4000` |

Método `toDto(): SendChatMessageDto`.

#### [NEW] `app/Services/N8n/N8nChatService.php`

Responsabilidades:

- Leer URL y timeout desde `config('services.n8n')`.
- `sendMessage(SendChatMessageDto $dto): array` — POST al webhook con `Http::timeout()->acceptJson()->post(...)`.
- Validar que la respuesta JSON cumple el esquema (`status`, `message`, `data`, `error`).
- Si HTTP falla, timeout o JSON inválido → lanzar excepción de dominio o retornar envelope de error normalizado.
- Log estructurado con `Log::warning/error` (sin loguear `chatInput` completo en producción si contiene PII).

Patrón de referencia: `app/Services/Adx/AdxHttpClient.php` + proxy en `SipsController::consumirServicioSips`.

#### [NEW] `app/Http/Controllers/ChatAssistantController.php`

Controlador delgado (< 15 líneas por acción):

```php
public function sendMessage(SendChatMessageRequest $request, N8nChatService $service): JsonResponse
{
    return response()->json(
        $service->sendMessage($request->toDto())
    );
}
```

Manejo de excepciones vía `Handler` o try/catch mínimo retornando `{ status: "error", error: { code: 502, message: "..." } }`.

#### [MODIFY] `routes/web.php`

Dentro del grupo `middleware(['auth', 'user.active'])`:

```php
Route::post('/api/v1/chat/messages', [ChatAssistantController::class, 'sendMessage'])
    ->name('chat.send-message');
```

Ruta autenticada por sesión Sanctum/web (mismo patrón que `sips.consumo`).

---

### API Contracts & Types (TypeScript)

#### [NEW] `resources/js/types/chatAssistant.ts`

Interfaces alineadas con el contrato n8n:

```typescript
export type ChatMessageType = 'text' | 'table' | 'card';
export type ChatDisplayMode = 'embedded' | 'floating';

export interface ChatSendPayload {
  action: 'sendMessage';
  sessionId: string;
  chatInput: string;
}

export interface ChatWebhookResponse {
  status: 'success' | 'error';
  meta: { intent: string };
  message: { text: string; type: ChatMessageType };
  data: Record<string, unknown>[];
  error: null | { code: number; message: string };
}

export interface ChatUiMessage {
  id: string;
  role: 'user' | 'assistant' | 'error';
  text: string;
  type?: ChatMessageType;
  data?: Record<string, unknown>[];
  timestamp: number;
  status?: 'sent' | 'pending' | 'failed';
}
```

#### [MODIFY] `resources/js/interfaces.ts`

Re-export opcional de tipos de chat o import directo desde `types/chatAssistant.ts` (preferir archivo dedicado para SRP).

---

### Frontend — Hooks & Context

#### [NEW] `resources/js/Hooks/useIsDesktopViewport.ts`

Hook con `matchMedia('(min-width: 992px)')` + listener `change`.

Retorna `{ isDesktop: boolean, isWideDesktop: boolean }` donde `isWideDesktop` = `≥1280px`.

#### [NEW] `resources/js/Hooks/useChatAssistant.ts`

Lógica de dominio del chat (sin JSX):

| Responsabilidad | Detalle |
|-----------------|---------|
| `sessionId` | `useRef<string>` inicializado con `crypto.randomUUID()` por montaje del Provider (una instancia = una pestaña) |
| `messages` | `useState<ChatUiMessage[]>` — solo memoria, sin `localStorage` |
| `isOpen` | `useState<boolean>` |
| `displayMode` | `useState<'embedded'\|'floating'>` — preferencia de sesión de pestaña |
| `isSending` | `useState<boolean>` |
| `sendMessage(text)` | Validar trim, añadir mensaje usuario, POST `axios.post(route('chat.send-message'), payload)`, parsear respuesta |
| `startNewConversation()` | Nuevo `sessionId`, limpiar mensajes |
| `toggleOpen()` / `close()` | Control panel |
| `setDisplayMode(mode)` | Cambio explícito usuario |
| Fallback automático | Si `!isWideDesktop && isDesktop` → forzar `floating`; si `!isDesktop` → ocultar |

Retry: conservar texto del último mensaje fallido y exponer `retryLastMessage()`.

#### [NEW] `resources/js/Contexts/ChatAssistantContext.tsx`

- Envuelve `useChatAssistant` en Context (patrón `LoadingContext.tsx`).
- Exporta `ChatAssistantProvider` + `useChatAssistantContext()`.

---

### Frontend — Componentes UI

Estructura bajo `resources/js/Components/ChatAssistant/`:

| Archivo | Responsabilidad |
|---------|-----------------|
| `ChatAssistantShell.tsx` | Orquestador: consume context + viewport; no renderiza nada si `!auth.user` o `!isDesktop` |
| `ChatAssistantToggle.tsx` | FAB inferior derecho (`position: fixed; bottom: 1.5rem; right: 1.5rem; z-index: 1050`) con `aria-label`, `id="chat-assistant-toggle"` |
| `ChatAssistantPanel.tsx` | Panel incrustado: `width: 25vw; height: 100vh; position: fixed; top: 0; right: 0` |
| `ChatAssistantFloatingPanel.tsx` | Panel flotante fijo (~400×600px, esquina inferior derecha, **no draggable**) |
| `ChatMessageList.tsx` | Área scrollable del hilo, `role="log"`, `aria-live="polite"` |
| `ChatMessageBubble.tsx` | Burbuja usuario/asistente/error |
| `ChatMessageTable.tsx` | Render de `message.type === 'table'` + `data[]` dinámico (columnas desde keys del primer objeto) |
| `ChatMessageCard.tsx` | Render de `message.type === 'card'` — tarjetas compactas por registro |
| `ChatInput.tsx` | Textarea + botón enviar; Enter envía, Shift+Enter nueva línea; deshabilitado mientras `isSending` |
| `ChatAssistantHeader.tsx` | Título, selector modo (incrustado/flotante), botón nueva conversación, cerrar |
| `ChatAssistant.module.css` | Estilos módulo; variables CSS `--chat-panel-width: 25vw` |

**Lazy loading** (como `RightSidebar`):

```tsx
const ChatAssistantShell = React.lazy(() => import('../Components/ChatAssistant/ChatAssistantShell'));
```

Montado en `app.tsx` dentro de `Suspense fallback={null}`.

#### [MODIFY] `resources/js/app.tsx`

```tsx
<LoadingProvider>
  <ChatAssistantProvider>
    <Suspense fallback={null}>
      <ChatAssistantShell />
    </Suspense>
    <InertiaCursor />
    <Suspense fallback={<LoadingSpinner ... />}>
      <App {...props} />
    </Suspense>
  </ChatAssistantProvider>
</LoadingProvider>
```

#### [MODIFY] `resources/js/Layouts/index.tsx`

Añadir clase condicional al `#layout-wrapper` cuando el chat esté abierto en modo incrustado para desplazar `.main-content`:

```tsx
// Consumir useChatAssistantContext (solo clase CSS, sin duplicar estado)
className={`${layoutStyles.layoutWrapper} ${isChatEmbeddedOpen ? layoutStyles.layoutWithChat : ''}`}
```

Alternativa si el acoplamiento Layout↔Context se considera excesivo: el panel incrustado usa `position: fixed` superpuesto sin empujar contenido (spec SC-006 sigue cumpliéndose porque el 75% restante es interactivo). **Recomendación**: empujar con `margin-right: 25vw` en `.main-content` vía clase en `layout-wrapper` para UX tipo Copilot.

#### [MODIFY] `resources/js/Layouts/Layout.module.css`

```css
.layoutWithChat .main-content {
  margin-right: 25vw;
  transition: margin-right 0.2s ease;
}
```

---

### Accesibilidad (FR-018)

- Toggle: `aria-expanded`, `aria-controls="chat-assistant-panel"`.
- Panel: `role="dialog"`, `aria-modal="false"` (no bloquea contenido principal).
- Foco: al abrir, foco al input; al cerrar, devolver foco al toggle.
- Contraste y foco visible reutilizando tokens del tema Velzon/Eneon existente.

---

## Orden de implementación sugerido

| Fase | Entregable | Prioridad spec |
|------|------------|----------------|
| 1 | Config + `N8nChatService` + Controller + ruta + test Feature con `Http::fake` | P1 |
| 2 | Types + `useChatAssistant` + Context | P1 |
| 3 | `ChatInput` + `ChatMessageList` + envío/recepción básica (type `text`) | P1 |
| 4 | `ChatAssistantToggle` + panel incrustado + integración `app.tsx` | P1/P2 |
| 5 | Render `table` / `card` + manejo errores + retry | P2 |
| 6 | Modo flotante + selector + fallback automático | P3 |
| 7 | Accesibilidad, ocultación móvil, pulido CSS | P2/P3 |

---

## Verification Plan

### Automated Tests

#### [NEW] `tests/Unit/Services/N8nChatServiceTest.php`

- `Http::fake` retorna JSON válido → servicio retorna array normalizado.
- Respuesta HTTP 500 → excepción o envelope de error.
- JSON sin campo `status` → error de integración.
- Timeout configurado respetado.

#### [NEW] `tests/Feature/ChatAssistantProxyTest.php`

- Usuario no autenticado → `POST /api/v1/chat/messages` → 401/302.
- Usuario autenticado + payload válido → 200 + JSON espejo del webhook (fake).
- Payload con campos extra rechazados o ignorados por validación (solo 3 campos).
- `chatInput` vacío → 422.

Comando:

```bash
php artisan test --filter=ChatAssistant
```

### Manual Verification

1. **Escritorio ≥1280px**: login → ver FAB inferior → abrir → panel 25% derecha → enviar «necesito 5 clientes de GUADALAJARA» → ver respuesta.
2. **Navegación**: con chat abierto, ir a Contratos → Clientes → historial y estado abierto persisten.
3. **Nueva conversación**: pulsar control → hilo vacío → nuevo `sessionId` (verificar en DevTools Network).
4. **Modo flotante**: cambiar manualmente → panel superpuesto, contenido no se desplaza.
5. **Fallback**: redimensionar a ~1100px → modo flotante automático.
6. **Móvil <992px**: FAB y panel no visibles.
7. **Error**: detener n8n o URL incorrecta → mensaje error + reintentar.
8. **Dos pestañas**: enviar en cada una → `sessionId` distintos en Network.
9. **Cerrar pestaña y reabrir**: hilo vacío, nuevo `sessionId`.
10. **Respuesta `table`/`card`**: verificar render con datos de prueba (mock en dev o respuesta real n8n).

### Verificación proxy (SC-009)

En DevTools → Network: las peticiones del chat deben ir a `/api/v1/chat/messages` del dominio de la app, **nunca** a `172.233.98.190:8680`.

---

## Archivos — resumen

| Acción | Ruta |
|--------|------|
| NEW | `app/DTOs/N8n/SendChatMessageDto.php` |
| NEW | `app/Http/Requests/SendChatMessageRequest.php` |
| NEW | `app/Services/N8n/N8nChatService.php` |
| NEW | `app/Http/Controllers/ChatAssistantController.php` |
| NEW | `tests/Unit/Services/N8nChatServiceTest.php` |
| NEW | `tests/Feature/ChatAssistantProxyTest.php` |
| NEW | `resources/js/types/chatAssistant.ts` |
| NEW | `resources/js/Hooks/useIsDesktopViewport.ts` |
| NEW | `resources/js/Hooks/useChatAssistant.ts` |
| NEW | `resources/js/Contexts/ChatAssistantContext.tsx` |
| NEW | `resources/js/Components/ChatAssistant/*.tsx` (10 archivos) |
| NEW | `resources/js/Components/ChatAssistant/ChatAssistant.module.css` |
| MODIFY | `config/services.php` |
| MODIFY | `routes/web.php` |
| MODIFY | `resources/js/app.tsx` |
| MODIFY | `resources/js/Layouts/index.tsx` |
| MODIFY | `resources/js/Layouts/Layout.module.css` |

**Sin migraciones. Sin cambios en modelos Eloquent.**

---

## Riesgos y mitigaciones

| Riesgo | Mitigación |
|--------|------------|
| Servidor app sin ruta a IP n8n | Verificar conectividad en despliegue; timeout + mensaje amigable |
| Latencia alta del workflow n8n | Timeout 30s configurable; spinner en UI |
| Layout remonta y pierde estado | Provider global en `app.tsx` (decisión clave de este plan) |
| Respuestas `data` con esquema variable | Tabla dinámica por keys; fallback a JSON pretty-print en dev |
