# hub-engine

Hub multi-tenant sobre Postgres ("Actividades" en las UIs en español):
tableros de cards (leads, deals, tareas y los kinds propios de cada tenant),
su historial, contactos con sus identidades por canal, empresas y
responsables. API REST pura, sin frontend. Express + `pg` (SQL crudo, sin
ORM), módulo por dominio (`models.ts`/`service.ts`/`router.ts`),
validación con Zod.

Cumple el **contrato común de engines v1** de nicetry.

## Rutas

- `GET /health` (`{status, engine, version, db}`; 503 si la base no
  responde), `GET /` (puntos de entrada), `/openapi.json`, `/docs`,
  `/llms.txt`: sin auth.
- `/admin/tenants…`: provisión de tenants, con `x-admin-api-key`.
- `/{tenant}/…`: todo lo del tenant, con su `X-Engine-Key`. Las rutas de
  este documento (`/cards`, `/contacts`…) van siempre bajo ese prefijo:
  `GET /acme/cards`.
- `POST /{tenant}/mcp`: el MCP del tenant (ver abajo).

Las rutas viejas sin tenant (`/cards`, `/mcp`…, con el tenant implícito en la
key) siguen funcionando mientras migran los consumidores: responden con
`Deprecation: true` y un `Link` a la nueva, y conservan su comportamiento
de antes (sin `limit`, los listados devuelven todo).

## Autenticación

Dos secretos separados e independientes entre sí:

- `X-Engine-Key: <api_key>` — en todo `/{tenant}/…`. Cada tenant tiene su
  propio `api_key` (192 bits, único), resuelto contra la tabla `tenants`.
  El slug del path tiene que ser el de la key. Sin key válida: 401, exista o
  no el tenant; con la key de otro tenant: 403; con una key válida y un
  tenant que no existe: 404. Un tenant solo ve/opera sus propios datos;
  todas las queries filtran por `tenant_id` y las rutas anidadas validan
  ownership del recurso padre antes de listar hijos. Un tenant con
  `status='disabled'` deja de poder autenticarse.
- `x-admin-api-key: <ADMIN_API_KEY>` — solo para `/admin/tenants` (crear/listar/
  deshabilitar/borrar tenants y rotar su key); es el mismo header que en el
  resto de los engines de nicetry. `X-Admin-Key` sigue aceptado, deprecado
  (`Deprecation: true`), hasta que el panel deje de mandarlo. No sirve para
  ninguna otra ruta, y `X-Engine-Key` no sirve para `/admin/tenants`.
  Comparación timing-safe. Las rutas `/admin/tenants/:id` aceptan el id o
  el slug.

Las `api_key` se guardan hasheadas (sha256): el valor en claro se devuelve
una sola vez (`api_key` y `apiKey`), en el `POST /admin/tenants` que crea el
tenant (`created: true`; sobre un slug existente responde 200 con
`apiKey: null` y `created: false`) o en `POST /admin/tenants/:id/rotate-key`.
`GET /admin/tenants` devuelve filas (id, slug, name, status, fechas), nunca
keys. El slug es minúsculas, números y guiones, y no puede ser un nombre de
ruta (`cards`, `admin`…). `DELETE /admin/tenants/:id` da 409 si el tenant
tiene cards, contactos, empresas o citas, salvo que el cuerpo traiga
`{"confirm": "<slug>"}`: ahí borra todo. La key de hub en el bus por tenant
se carga con `PUT /admin/tenants/:slug/bus {"apiKey"}` (se prueba contra el
bus antes de guardar: 422 si no anda), se consulta con `GET` del mismo path
(`{configured, keyFingerprint, secretConfigured: false, paused, pausedAt}`,
nunca la key; `paused` si el bus rechazó la key y la cola quedó pausada) y se
borra con `DELETE`. `GET /admin/tenants` suma `bus: true|false`.

Rate limit por minuto (429 `rate_limited` + `Retry-After`): 600 por api_key,
120 por IP sin credencial, 30 por IP en `/admin/tenants`.

## Actor y request id

- `X-Actor: <system>:<id>` en toda escritura (`panel:ana@acme.com`,
  `agent:<rutina>`, `loops:<slug>`, `user:<id>`): queda en `created_by` /
  `updated_by` y en el historial de cambios. Hoy, en adopción: uno con otro
  formato se registra tal cual (con un warning en el log) y uno que falta
  queda como `unknown:` en el historial. Con `ACTOR_STRICT=1` en el entorno
  del engine, cualquiera de los dos da 400.
- `X-Request-Id`: si viene (hasta 128 caracteres `[A-Za-z0-9._:-]`) se
  respeta; si no, el engine genera uno. Vuelve en la respuesta, en el cuerpo
  de los errores, en cada línea de log y en las entregas de eventos que
  dispara ese request.

## Errores

Una sola forma: `{"error": "<mensaje>", "code": "<snake_case>", "details"?: [...], "requestId": "..."}`.
`code` es lo que se compara; `error` es para una persona.

- 400 `validation_error` (body o query inválido; `details` con los issues
  de Zod cuando los hay; también ids o fechas mal formados y un
  `limit`/`cursor` inválido), 400 `invalid_json` (el cuerpo no es JSON).
- 401 `unauthorized`, 403 `forbidden`, 404 `not_found` (también para una
  ruta que no existe, siempre en JSON).
- 409 `conflict` (borrar algo que todavía tiene datos asociados, una
  identidad que ya es de otro contact, un slug explícito que ya existe),
  409 `idempotency_conflict` / `idempotency_in_progress`.
- 413 `payload_too_large` (más de 5 MB), 415 `unsupported_media_type` (un
  cuerpo que no es `application/json`), 429 `rate_limited`.
- 503 `unavailable` si la base no responde; 500 `internal` con un mensaje
  genérico (el detalle va al log con el `requestId`).

Cada escritura, su entrada en el historial y su evento (entregas directas y
bus) se confirman en una sola transacción: o queda todo o no queda nada. Si
no se puede registrar el evento, la escritura no se hace y el cliente recibe
el error, así que reintentar es seguro.

## Listados y paginación

Todo listado acepta `?limit` (1 a 500; por defecto 100) y `?cursor`. El
cuerpo es el array de siempre; si hay más, la respuesta trae el header
`X-Next-Cursor`, que se pasa como `cursor` en la próxima llamada (ausente en
la última página). Cursor opaco. Un `limit` fuera de rango o un cursor
inválido dan 400. En las rutas viejas sin tenant, sin `limit` se devuelve
todo (`/changes`: 200 por defecto y hasta 1000; `/appointments`: 500 y
hasta 1000), como antes.

## Idempotencia

Todo `POST` acepta `Idempotency-Key` (1 a 255 caracteres ASCII). Con la
misma key, el mismo tenant y el mismo cuerpo dentro de 24 h devuelve la
respuesta original (status y cuerpo, con `Idempotent-Replayed: true`) sin
volver a crear; con otro cuerpo, 409 `idempotency_conflict`; si el primero
todavía está corriendo, 409 `idempotency_in_progress`. Una respuesta 5xx no
se guarda: se puede reintentar con la misma key.

## MCP

`POST /{tenant}/mcp` (Streamable HTTP, sin estado) con la misma
`X-Engine-Key`. Tools: `help` (este documento) y `api` (`method`, `path`
sin el tenant, `query`, `body`): llama a la API REST del tenant con el
mismo X-Actor y devuelve `<status>`, `next-cursor: <c>` si hay más, y el
cuerpo. Por el `/mcp` viejo sin tenant, `api` usa las rutas viejas (sin
`limit`, los listados vienen completos). `/admin` y `/mcp` no están disponibles por `api` (se chequea sobre
el path normalizado).

## Modelo de datos

La **card** es el elemento central: la unidad de trabajo de un tablero. Qué
es una card lo dice el **kind** de su pipeline. Todo lo demás cuelga de ella
(su historial, sus movimientos) o la describe (quién la lleva, sobre quién
es).

- `kinds` (`GET /kinds`): qué puede ser una card, **por tenant**. Cada kind
  tiene `slug` (singular: una card es un <kind>), `label` ({singular,
  plural, gender}), `core_fields` ({required, optional}: qué columnas
  propias de la card usa) y `field_schema` (campos a medida: [{key, label,
  type text|number|date|datetime|enum|bool, required, options}], cuyos
  valores van en `cards.fields`) y `field_options` (listas de valores
  {value, label} de los campos propios que las usan, hoy
  `lost_reason_code`: cada tenant mantiene la suya con PATCH /kinds; quitar
  un valor en uso da 409). Tres vienen incorporados en todo tenant y no se
  borran:
  - `sale` — un lead de venta: title y contact_id obligatorios, más
    amount/currency y temperature.
  - `task` — una tarea: sólo title obligatorio, contact_id opcional, más
    start_date, due_date y priority (low/medium/high/urgent).
  - `deal` — un potencial cliente, nacido o no de una conversación: title
    y contact_id obligatorios, más amount/currency, `source` (whatsapp|
    instagram|web|manual|referido), `qualification` ({result calificado|
    no_calificado|pendiente, criteria, at, by}), `lost_reason`,
    `interest`, `assigned_at`, `last_contact_at`, `temperature`
    (frio|tibio|caliente: un atributo, independiente de la stage),
    `expected_close_date` (cuándo se espera cerrar; con amount, la
    proyección) y `lost_reason_code` (motivo de pérdida categorizado, de
    la lista del kind: precio, timing, competencia, sin_respuesta,
    no_califica, otro por default; `lost_reason` queda como detalle libre).
  Crear un kind propio (`POST /kinds`) es una operación normal. Las
  columnas propias disponibles: title, contact_id, owner_id, company_id,
  tags, notes, amount, currency, start_date, due_date, priority, source, qualification,
  lost_reason, interest, assigned_at, last_contact_at, temperature,
  expected_close_date, lost_reason_code.
- `pipelines` son tableros; sus `stages` son las columnas, ordenadas por
  `position`. Ambos tienen un `slug` autogenerado desde `name` al crear
  (inmutable). Cada pipeline tiene un `kind` y devuelve `card_fields`
  ({required, optional, enums, options, custom}) y `card_label`: la definición que
  un consumidor usa para armar formularios sin hardcodear nada. Mandar un
  campo que el kind no tiene da 400; `fields` se valida contra `custom`.
- `cards` se mueven entre stages con `POST /cards/:id/move` (`PATCH` no
  cambia stage ni pipeline). No tienen status propio: su estado es la stage
  en la que están. `notes` es un texto libre único, no un log. Cambiar
  `owner_id` actualiza `assigned_at` (ahora, o null si se quita). Cada vez
  que una card se crea o cambia de stage queda un registro en
  `card_stage_transitions` (`card_id`, `stage_id`, `stage_name`,
  `entered_at`), vía `GET /cards/:id/stage-history` o
  `GET /cards/stage-transitions` (todo el tenant, sin agregación).
  Dos cards del mismo tenant se pueden **vincular** (ej. una tarea sobre un
  deal): `POST /cards/:id/links {card_id}`, `GET /cards/:id/links` (la
  otra card con título, pipeline, stage y kind), `DELETE
  /cards/:id/links/:linkId`. Tipo `relates_to`, simétrico: aparece desde
  las dos cards; borrar una card borra sus vínculos.
  `GET /cards` filtra por pipeline, stage, owner, contact, company, kind,
  source, tag (repetible: todas), título (`q`) y ventana de tiempo
  (`from`/`to`, ISO con zona: cards cuyo intervalo
  [start_date, due_date] se superpone con [from, to); una sola de las dos
  fechas cuenta como un punto, sin ninguna no entra); con `limit` pagina
  por cursor (header `X-Next-Cursor`). `start_date` (cuándo arranca el
  trabajo) no puede ser posterior a `due_date`: 400, también en un PATCH
  que manda sólo una de las dos. `GET /cards/by-identity` devuelve las
  cards del contact que tiene una identidad dada (ver contacts).
- `entries` son el **historial**: cosas que pasaron (`call`, `meeting`,
  `note`) ligadas a una card y/o un contact (al menos uno), con
  `occurred_at` (cuándo pasó; por default, ahora). No tienen estado ni
  vencimiento: algo por hacer es una card en un pipeline `task`, no una
  entry. Registrar una entry en una card actualiza su `last_contact_at`.
  `GET /cards/:id/entries`, `GET /contacts/:id/entries`.
- `contacts` (personas; lead y contacto unificados) y `companies` son el
  directorio externo: entidades propias que una card referencia
  (`contact_id`, `company_id`), no atributos de la card. Si se omite
  company_id al crear una card, hereda la del contact. El teléfono se
  normaliza a E.164 cuando se puede leer como número (sin "+", se asume
  Argentina). Cada contact tiene **identidades** por canal
  (`identities`: [{channel, account_id, external_id, verified}]): para
  WhatsApp, `channel=whatsapp`, `account_id=<number_id>`,
  `external_id=<wa_id>`. Una identidad pertenece a un solo contact del
  tenant (409 si se repite). `GET /contacts/by-identity?channel&account_id&
  external_id` es cómo otro sistema (el engine de WhatsApp, loops) encuentra
  al contact de una conversación sin comparar teléfonos; 404 significa
  "crear el contact con esa identidad".
- `users` es el directorio propio del Hub de responsables (`owner_id`):
  personas o agentes, sin distinción, sin login propio (la API ya autentica
  por tenant). Se dan de alta sólo por esta API. Cada usuario puede tener
  `external_refs` ([{system, external_id}], uno por sistema) que lo
  vinculan a su identidad en otro sistema (ej. `ops`); el Hub no llama a
  esos sistemas, es sólo un dato. Búsqueda:
  `GET /users?external_system=ops&external_id=...`.

Cards, entries, contacts y companies tienen `tags` (array libre).

## Citas, fases de stage y plantillas

- `appointments` (citas): un compromiso futuro **siempre sobre una card**
  (visita, reunion, llamada, videollamada) con `scheduled_at`, owner y
  estado `programada → reprogramada | realizada | no_asistio | cancelada`.
  `POST /appointments/:id/complete {outcome: {result calificado|
  no_calificado|seguimiento, rating 1-5, notes}}` la marca realizada, deja
  una entry `meeting` en el historial de la card y actualiza su
  `last_contact_at`; **no mueve la card de stage** (eso lo decide quien
  escucha el evento `appointment.completed`). `GET /appointments?since&
  until&owner_id&status` es la agenda; `GET /appointments/stats` el
  embudo (agendadas / realizadas / no asistió / canceladas, por resultado).
- `stages.phase`: qué representa cada stage en el recorrido del tablero,
  independiente del nombre. Embudo comercial: `entrada`, `contacto`,
  `reunion`, `oportunidad`; cierres comerciales: `ganado`, `perdido`,
  `frio`, `tibio`; cierres de tableros que no son de ventas: `hecho`,
  `descartado`. `stages.outcome` (lo calcula el engine) dice lo que
  implica: `open`, `won` (ganado, hecho) o `lost` (perdido,
  descartado, frio, tibio). Los reportes y loops leen phase/outcome, no el
  nombre.
- Plantillas: `GET /pipelines/templates` y `POST /pipelines/from-template
  {template: inmobiliaria|servicios|generico|tareas}` crean un pipeline con
  sus stages y phases. `POST /admin/tenants {slug, name, template?}` siembra
  una al crear el tenant.

## Quién hizo cada cambio, y avisar a otros

- **Actor.** Toda escritura puede llevar el header `X-Actor: <sistema>:<id>`
  (`panel:ana@acme.com`, `agent:atencion-a-traves-de-whatsapp`,
  `loops:crear-deal`, `user:<id>`). Queda en `created_by` / `updated_by`
  de cards, entries, contacts y companies, y en `actor` de cada movimiento
  entre stages. Sin header, null (y `unknown:` en el historial); ver
  "Actor y request id" arriba. Los agentes deberían mandarlo siempre.
- **Changes.** `GET /changes?entity&entity_id&actor&since&until&limit&cursor`:
  log append-only de cada escritura con los campos que cambiaron
  (`diff: {campo: {from, to}}`), la acción (create|update|move|delete) y
  el actor. Un PATCH que no cambia nada no genera entrada.
- **Suscripciones.** `POST /subscriptions {url, event_types[], headers{}}`
  devuelve, una sola vez, un `secret` (se guarda cifrado; ninguna lectura
  lo vuelve a mostrar) con el que se firma cada entrega
  (`X-Signature-256: sha256=<hmac-sha256(secret, body)>`). La `url` tiene
  que ser https y no resolver a loopback, redes privadas, link-local ni
  metadata de la nube (400 si no; se vuelve a chequear en cada entrega, con
  la IP resuelta fijada). El engine hace un POST sin contenido —sólo
  `{type, entityType, entityId, tenantSlug, eventId, occurredAt}`— por cada
  evento: `card.created|updated|moved|deleted`,
  `entry.created|updated|deleted`, `contact.created|updated|deleted`,
  `company.*`, `appointment.*`, `card_link.created|deleted`.
  `event_types` vacío = todos. Cada entrega queda en un outbox y viaja con
  `X-Event-Id` (un ULID, el mismo en todos los reintentos y el mismo `id`
  con que el evento se publica en el bus: deduplicar por él) y
  `X-Event-Timestamp` (unix, segundos), más el `X-Request-Id` del request
  que lo disparó. Si el suscriptor no responde 2xx
  se reintenta a los 10 s, 1 min, 5 min, 30 min y 2 h; después queda
  descartada. El suscriptor vuelve a leer la entidad con la API.
- **Bus.** Si el engine tiene `BUS_URL` y el tenant tiene key del bus
  (`PUT /admin/tenants/:slug/bus`), cada evento se publica además en el bus
  (`POST {BUS_URL}/{tenant}/events`) con el envelope del bus: `id` (el
  mismo ULID), `type` `hub.<entidad>.<verbo>` con guiones
  (`hub.card.moved`, `hub.card-link.created`, `hub.appointment.no-show`),
  `subject` `<entidad>:<id>`, `actor`, `requestId` y `data`
  `{entityType, entityId}` (`card.moved` suma `fromStageId`, `toStageId`
  y `pipelineId`). La cola del bus es aparte: si el bus no responde se
  reintenta con el mismo backoff, en orden por tenant, y las entregas
  directas siguen igual. Si el bus rechaza la key del tenant (401/403), la
  cola del bus de ese tenant se pausa hasta que se cargue una key nueva con
  `PUT /admin/tenants/:slug/bus`, que solo acepta la key de hub en ese
  tenant (se verifica con `whoami` del bus).

Borrar un contact no borra las cards que lo referencian (quedan sin contact),
salvo que alguna esté en un pipeline cuyo kind exige contact (409); sus
entries sin card y sus identidades se borran con él. Borrar una stage o un
pipeline no borra el historial de movimientos: esas filas quedan con
`stage_id` null y conservan `stage_name`. Borrar una card borra sus entries
y sus movimientos. Borrar un kind: 409 si es incorporado o tiene pipelines.

`pipeline_id` es requerido al crear una card (`POST /cards`, no hay
pipeline default); si se omite `stage_id`, usa la primera stage. Un tenant
nuevo arranca con los tres kinds incorporados y sin pipelines.

## Recursos

Spec completo en `/openapi.json` / Swagger UI en `/docs`. Todas las rutas
tienen `operationId` y descripción; las respuestas de kinds, pipelines, stages, cards,
contacts e identities y users están tipadas en el spec.
