# Plan — Chat web + Vale Admin (revisar antes de construir)

**Estado:** propuesta, sin construir. Decisiones marcadas con 👉 son mi recomendación; reaccioná antes de codear.

**Contexto:** el spine de Sprint 1 (WhatsApp → Gemini → inbox + `agent_logs`) ya anda y está endurecido. Esto agrega **dos superficies nuevas sobre el mismo cerebro**, en paralelo. NO incluye Function Calling ni APIs de Google (eso es Sprint 2 propio, va por debajo y se enciende en todos los canales a la vez).

---

## Track 0 — Refactor del cerebro (bloqueante, va primero)

Hoy la lógica "resolver contexto → llamar Gemini → persistir mensaje + log" vive inline en `app/controllers/agent.php`. Hay que extraerla para no duplicarla en el canal web.

**Nuevo:** `app/services/agent_service.php`

```
AgentService::handleMessage(PDO $db, array $tenant, int $conversationId, string $incomingBody): array
  → arma historial (últimos 10 + actual)
  → construye system instruction (persona + greeting + services del tenant)
  → llama GeminiService
  → persiste el mensaje outbound + agent_log (gemini_call=1, tokens)
  → devuelve ['text' => ..., 'tokens' => ...]
```

Después:
- `agent.php` (Twilio): resuelve tenant por número → busca/crea contact+conversation → persiste inbound → `AgentService::handleMessage()` → responde **TwiML**.
- `agent_web.php` (web): resuelve tenant por clave de embed → busca/crea contact+conversation → persiste inbound → `AgentService::handleMessage()` → responde **JSON**.

> El "busca/crea contact + conversation + persiste inbound" también es común — lo extraigo a un helper compartido para que el único delta entre canales sea cómo se resuelve el tenant y cómo se responde.

---

## Track A — Chat web tipo ChatGPT (`POST /agent/web`)

El schema ya soporta `channel='web'`. Lo nuevo son 4 decisiones que en WhatsApp resolvía Twilio:

### A.1 — Resolución de tenant 👉 clave pública de embed
- Nueva columna `tenants.web_public_key CHAR(32) UNIQUE NULL` (random, **no secreta**).
- Va en el snippet del widget: `<script src=".../widget.js" data-vale-key="abc123..."></script>`.
- El endpoint la recibe y resuelve el tenant. Reemplaza al "número receptor".

### A.2 — Identidad del visitante 👉 token opaco de visitante
- Nueva columna `contacts.web_visitor_id VARCHAR(64) NULL` + `UNIQUE (tenant_id, web_visitor_id)`.
- El widget guarda un `visitor_token` (UUID) en **cookie** (no localStorage, por convención) en el primer mensaje; el server mapea a un `contact` con `phone=NULL`, `channel='web'`.
- Visitante recurrente → misma conversación abierta.

### A.3 — CORS 👉 gate por clave, origen reflejado
- El widget corre en el dominio del SMB y pega a nuestro endpoint.
- Para el demo: la `web_public_key` es el gate; respondemos `Access-Control-Allow-Origin` reflejando el `Origin` de la request. (Endurecer a allow-list por tenant cuando haya clientes reales.)

### A.4 — Rate-limit 👉 endpoint público sin firma de Twilio
- Sin la barrera de firma, un troll quema la cuota de Gemini.
- Chequeo simple por sliding window: contar `messages` inbound de ese `(tenant_id, contact_id)` en los últimos 60s; si supera N (ej. 10), responder 429 sin llamar a Gemini.
- (Cloud Run es multi-instancia → el contador va en DB, no en memoria.)

### A.5 — Respuesta y frontend
- Response: `{ "reply": "...", "conversationId": 123 }` (JSON, no TwiML).
- Widget: vanilla JS, camelCase, sin localStorage. Burbujas estilo chat, Vale siempre se identifica como asistente.

---

## Track B — Vale Admin lean (super-admin)

Tres vistas. Carril de auth **separado** de los owners (es lo que cruza tenants).

### B.1 — Auth 👉 tabla `admins` aparte, NO un rol dentro de `users`
- `users.tenant_id` es `NOT NULL` y todo el modelo asume "un user = un tenant". El super-admin cruza tenants → contaminaría el scoping.
- Nueva tabla `admins (id, email, password_hash, name, created_at)`.
- Login en `/admin/login`, sesión en `$_SESSION['admin_id']` (clave distinta de `user_id`).
- `/admin/*` gateado por sesión de admin; jamás alcanzable desde sesión de owner.

### B.2 — Vistas
- **`/admin/tenants`** — listar negocios + alta (crea `tenant` + `vale_config` + primer `users` owner). Reemplaza el SQL a mano del README.
- **`/admin/logs`** — visor de `agent_logs` global, filtrable por tipo / tenant / fecha; muestra `gemini_call`, tokens, payload. **Es la evidencia para los jueces.**
- **`/admin/impersonate?tenant_id=N`** — setea sesión de owner de ese tenant + flag `$_SESSION['impersonated_by_admin']` (muestra banner + botón "salir"). Para soporte.

### B.3 — Seguridad
- Las queries cross-tenant existen **solo** en controllers `/admin/*`, explícitas y revisadas. El resto del sistema mantiene `tenant_id` en toda query, sin excepción.

---

## Delta de schema (migración incremental)

Archivo nuevo `migrations/002_web_admin.sql` (no toco `SCHEMA.sql` de Sprint 1, queda prístino):

```sql
ALTER TABLE tenants  ADD COLUMN web_public_key CHAR(32) NULL,
                     ADD UNIQUE KEY uq_web_public_key (web_public_key);
ALTER TABLE contacts ADD COLUMN web_visitor_id VARCHAR(64) NULL,
                     ADD UNIQUE KEY uq_contacts_tenant_visitor (tenant_id, web_visitor_id);
CREATE TABLE admins ( ... );
```

---

## Orden de ataque

1. **Track 0** (refactor `AgentService`) — bloqueante, primero. Sin esto, los dos tracks duplican cerebro.
2. **Track A** y **Track B** en paralelo (ya independientes una vez hecho el refactor).
3. Verificar: WhatsApp sigue andando igual (el refactor no debe romper Sprint 1).

## Explícitamente FUERA de este plan

- Function Calling y APIs de Google (Calendar/Drive/Business Profile/landing) → Sprint 2 propio, va por debajo después.
- Billing (Stripe), follow-ups, SMS, modo demo público.
- El config-chat del dueño (mismo widget, distinta audiencia) → reusa Track A, se arma cuando toque.
