# Tickets de soporte

Abre tickets de soporte en nombre de tus usuarios, añade mensajes con adjuntos, ciérralos o reábrelos y recibe los cambios en tiempo real.

El servicio de tickets conecta a las personas de tu aplicación con el equipo de soporte de tu organización en UTILIA OS. Desde tu aplicación abren un ticket, conversan con el equipo y lo cierran; el equipo lo atiende desde [Tickets de soporte](https://os.utilia.ai/dashboard/support-tickets) y, si hace falta, lo convierte en una tarea de Gestión de proyectos. Funciona con la clave de tu aplicación externa, siempre desde tu servidor.

**Vida de un ticket entre tu aplicación y el equipo**

Participantes: Tu aplicación, UTILIA OS y Equipo de soporte.

1. Tu aplicación → UTILIA OS: `POST /api/external/v1/tickets`. tickets.create abre el ticket en nombre de la persona.
2. UTILIA OS → Tu aplicación (respuesta): id y ticketKey, por ejemplo APP-0001
3. Tu aplicación → UTILIA OS: `GET /api/external/v1/tickets/events`. streamUpdates abre la conexión de eventos.
4. Equipo de soporte → UTILIA OS: Responde en Tickets de soporte
5. UTILIA OS → Tu aplicación: Evento comment-added. Por la conexión abierta, con ticketId y ticketKey.
6. Tu aplicación → UTILIA OS: `GET /api/external/v1/tickets/:id`. tickets.get trae los mensajes; los del equipo llevan author.type INTERNAL.
7. Tu aplicación → UTILIA OS: `POST /api/external/v1/tickets/:id/messages`. addMessage con la respuesta de la persona.
8. Tu aplicación → UTILIA OS: `PATCH /api/external/v1/tickets/:id/close`. close cuando el problema está resuelto.

_Las rutas son las que llama el SDK; userId es siempre el externalId de la persona._

## Antes de empezar

| JavaScript | Python | Para qué | Scope |
| - | - | - | - |
| `tickets.create(datos)` | `tickets.create(datos)` | Abrir un ticket | `support:tickets:write` |
| `tickets.list(userId, filtros?)` | `tickets.list(user_id, filtros)` | Listar los tickets de un usuario | `support:tickets:read` |
| `tickets.get(ticketId, userId)` | `tickets.get(ticket_id, user_id)` | Ver un ticket con sus mensajes | `support:tickets:read` |
| `tickets.addMessage(ticketId, userId, datos)` | `tickets.add_message(ticket_id, user_id, datos)` | Añadir un mensaje | `support:tickets:write` |
| `tickets.close(ticketId, userId)` | `tickets.close(ticket_id, user_id)` | Cerrar un ticket | `support:tickets:write` |
| `tickets.reopen(ticketId, userId)` | `tickets.reopen(ticket_id, user_id)` | Reabrir un ticket cerrado | `support:tickets:write` |
| `tickets.getUnreadCount(userId)` | `tickets.get_unread_count(user_id)` | Mensajes sin leer del usuario | `support:tickets:read` |
| `tickets.streamUpdates(userId?, opciones?)` | `tickets.stream_updates(user_id)` | Cambios en tiempo real | `support:tickets:read` |
| `tickets.createStreamToken(datos)` | `tickets.create_stream_token(route, ...)` | Pase para abrir la conexión en tiempo real por tu cuenta (4.2.0) | `support:tickets:read` (`support:tickets:ai` en las conexiones de IA) |

Sin el scope, la respuesta es un 403 con `errorCode` `INSUFFICIENT_SCOPE`. Antes se comprueba el plan: hacen falta el aplicativo Tickets de soporte y su capacidad «Tickets propios»; si faltan, el 403 es un [bloqueo de plan](https://os.utilia.ai/docs/developers/sdk/plan-errors). En todos los métodos, `userId` es el `externalId` con el que [identificaste](https://os.utilia.ai/docs/developers/sdk/users) a la persona: el servidor comprueba que el ticket es suyo.

## Abrir un ticket

| JavaScript | Python | Obligatorio | Qué es |
| - | - | - | - |
| `user` | `user` | Sí | Quién lo abre: `externalId` obligatorio y, si quieres, `email`, `name` y `metadata`. |
| `title` | `title` | Sí | Título, de 5 a 500 caracteres. |
| `description` | `description` | Sí | Descripción, 10 caracteres como mínimo. |
| `category` | `category` | No | `BUG` (algo no funciona), `CONSULTA` o `SUGERENCIA`. Si no la mandas, el servidor pone `CONSULTA`. |
| `priority` | `priority` | No | `BAJA`, `MEDIA`, `ALTA` o `CRITICA`. |
| `context` | `context` | No | Dónde ocurrió: `url`, `appVersion` (`app_version`), `browserInfo` (`browser_info`) y `customData` (`custom_data`). |
| `attachmentIds` | `attachment_ids` | No | Archivos ya subidos con el [servicio de archivos](https://os.utilia.ai/docs/developers/sdk/files). |

La categoría antigua `PROBLEMA` se sigue aceptando por compatibilidad y el SDK la envía como `BUG`; en Python avisa además con un `DeprecationWarning`. Usa `BUG` directamente.

### JavaScript

```typescript
// Primero, los adjuntos (opcional)
const screenshot = await sdk.files.upload(screenshotFile);

const ticket = await sdk.tickets.create({
  user: { externalId: 'usr-4821', email: 'laura@ejemplo.com', name: 'Laura García' },
  title: 'Error al generar el informe mensual',
  description: 'Al pulsar «Generar informe» aparece un error y no se descarga el archivo.',
  category: 'BUG',
  priority: 'ALTA',
  context: {
    url: 'https://mi-app.com/informes',
    appVersion: '2.4.1',
    customData: { reportType: 'monthly' },
  },
  attachmentIds: [screenshot.id],
});

console.log(ticket.ticketKey); // por ejemplo, APP-0001
```

### Python

```python
from utilia_sdk import CreateTicketInput, CreateTicketUser, TicketContext

# Primero, los adjuntos (opcional)
screenshot = await sdk.files.upload("/ruta/captura.png")

ticket = await sdk.tickets.create(
    CreateTicketInput(
        user=CreateTicketUser(
            external_id="usr-4821", email="laura@ejemplo.com", name="Laura García"
        ),
        title="Error al generar el informe mensual",
        description="Al pulsar «Generar informe» aparece un error y no se descarga el archivo.",
        category="BUG",
        priority="ALTA",
        context=TicketContext(
            url="https://mi-app.com/informes",
            app_version="2.4.1",
            custom_data={"reportType": "monthly"},
        ),
        attachment_ids=[screenshot.id],
    )
)

print(ticket.ticket_key)  # por ejemplo, APP-0001
```

La respuesta trae `id`, `ticketKey` (`ticket_key`), la clave legible que verá la persona, `title`, `status` y `createdAt`.

## Consultar tickets

`list` devuelve los tickets de un usuario por páginas (20 por defecto, 100 como máximo) y admite filtrar por `status`. Cada elemento trae `unreadByExternal` (`unread_by_external`): los mensajes del equipo que la persona aún no ha leído. `get` devuelve el ticket completo con `messages`, `attachments`, `context` y `reporter`. En cada mensaje, `author.type` vale `EXTERNAL` si lo escribió la persona e `INTERNAL` si lo escribió el equipo de soporte.

Estados posibles: `OPEN` (abierto), `IN_REVIEW` (en revisión), `CONVERTED` (convertido en una tarea de Gestión de proyectos), `RESOLVED` (resuelto) y `CLOSED` (cerrado).

### JavaScript

```typescript
const result = await sdk.tickets.list('usr-4821', { status: 'OPEN', limit: 10 });
for (const item of result.data) {
  const unread = item.unreadByExternal > 0 ? ' (respuestas nuevas)' : '';
  console.log(`[${item.ticketKey}] ${item.title}${unread}`);
}

const detail = await sdk.tickets.get(result.data[0].id, 'usr-4821');
for (const msg of detail.messages) {
  const who = msg.author.type === 'INTERNAL' ? 'Soporte' : 'Usuario';
  console.log(`${who} (${msg.author.name}): ${msg.content}`);
}
```

### Python

```python
from utilia_sdk import TicketFilters

result = await sdk.tickets.list("usr-4821", TicketFilters(status="OPEN", limit=10))
for item in result.data:
    unread = " (respuestas nuevas)" if item.unread_by_external > 0 else ""
    print(f"[{item.ticket_key}] {item.title}{unread}")

detail = await sdk.tickets.get(result.data[0].id, "usr-4821")
for msg in detail.messages:
    who = "Soporte" if msg.author.type == "INTERNAL" else "Usuario"
    print(f"{who} ({msg.author.name}): {msg.content}")
```

## Responder, cerrar y reabrir

Un mensaje lleva `content` (de 1 a 5000 caracteres) y, si quieres, `attachmentIds`. `close` y `reopen` no devuelven nada; los puede hacer el usuario que abrió el ticket. `getUnreadCount` devuelve `{ count }` con los mensajes sin leer de todos sus tickets.

### JavaScript

```typescript
await sdk.tickets.addMessage(ticket.id, 'usr-4821', {
  content: 'He seguido los pasos y ya funciona. Gracias.',
});
await sdk.tickets.close(ticket.id, 'usr-4821');

// Si vuelve a pasar
await sdk.tickets.reopen(ticket.id, 'usr-4821');

const { count } = await sdk.tickets.getUnreadCount('usr-4821');
```

### Python

```python
from utilia_sdk import AddMessageInput

await sdk.tickets.add_message(
    ticket.id, "usr-4821", AddMessageInput(content="He seguido los pasos y ya funciona. Gracias.")
)
await sdk.tickets.close(ticket.id, "usr-4821")

# Si vuelve a pasar
await sdk.tickets.reopen(ticket.id, "usr-4821")

unread = await sdk.tickets.get_unread_count("usr-4821")
print(unread.count)
```

## Cambios en tiempo real

En lugar de preguntar cada poco por los mensajes sin leer, puedes abrir una conexión de eventos (Server-Sent Events) y enterarte en el momento en que el equipo responde o cambia el estado. Si indicas un usuario, solo recibes sus tickets; sin usuario, los de toda tu aplicación.

Cada evento trae `ticketId`, `ticketKey`, `type` y `externalUserId`. `type` solo toma dos valores, con guion medio: `comment-added` (hay un mensaje nuevo) y `status-changed` (ha cambiado el estado).

- **JavaScript** devuelve un objeto con `close()` y `keyVersion`, y avisa por las funciones `onTicketUpdated` y `onError`. Necesita `EventSource`: en Node.js, instala un polyfill como `eventsource` y asígnalo a `globalThis.EventSource`. Desde la 4.2.0 abre cada conexión con un pase de un solo uso y reconecta por sí mismo si la conexión se corta: pide un pase nuevo y espera 1, 2, 4… hasta 30 segundos entre intentos. Si el servidor rechaza el pase con un error 4xx (clave retirada o caducada, aplicación desactivada, plan sin Tickets de soporte, falta el usuario, límite de peticiones), llama a `onError` y deja de intentarlo; sin respuesta o con un 5xx, vuelve a intentarlo.
- **Python** devuelve un generador: recibes los eventos iterando con `async for` (o `for` en la versión síncrona) y la conexión se cierra al salir del bucle. También acepta la función `on_ticket_updated`. La clave viaja en la cabecera `X-Api-Key`, nunca en la dirección. No reconecta solo: si la red se corta, vuelve a llamar a `stream_updates`.

En las aplicaciones con widget, indica siempre el usuario: sin él, la conexión se rechaza con 400 `STREAM_TOKEN_USER_REQUIRED`.

### JavaScript

```typescript
const stream = sdk.tickets.streamUpdates('usr-4821', {
  onTicketUpdated: async (event) => {
    if (event.type === 'comment-added') {
      const detail = await sdk.tickets.get(event.ticketId, 'usr-4821');
      // Actualiza tu interfaz con detail.messages
    }
  },
  onError: (error) => console.error('Conexión de eventos:', error.message),
});

// Cuando ya no la necesites
stream.close();
```

### Python

```python
async for event in sdk.tickets.stream_updates("usr-4821"):
    if event["type"] == "comment-added":
        detail = await sdk.tickets.get(event["ticketId"], "usr-4821")
        # Actualiza tu interfaz con detail.messages
    if stop_listening:
        break  # cierra la conexión
```

La clave de tu aplicación no viaja nunca en la dirección de la conexión, pero sigue siendo un secreto de servidor: no la pongas en el navegador de tus usuarios.

### Abrir la conexión por tu cuenta

Si quieres abrir la conexión desde otro sitio, por ejemplo desde el navegador de una persona, tu servidor pide un **pase de conexión** con `createStreamToken` (`create_stream_token` en Python) y entrega solo el pase, nunca la clave. La respuesta trae `streamToken`, `route`, `expiresAt`, `expiresIn` (300 segundos), `keyVersion`, la generación de la clave con la que se emitió, y `url`, la dirección completa de la conexión con `?streamToken=…`.

- El pase vale cinco minutos y **una sola conexión**: para reconectar, pide otro.
- Va atado a la conexión (`route`), al usuario o al trabajo de IA con el que lo pediste. `ticket-events` admite `userId` (obligatorio en las aplicaciones con widget); `ai-suggest` y `ai-transcribe` exigen `jobId`.
- Si no se puede emitir en ese momento, la respuesta es 503 `STREAM_TOKEN_UNAVAILABLE` y puedes reintentar a los pocos segundos. Un pase usado o caducado se rechaza con 401 `STREAM_TOKEN_INVALID`.

#### JavaScript

```typescript
// En tu servidor
const pase = await sdk.tickets.createStreamToken({
  route: 'ticket-events',
  userId: 'usr-4821',
});
// Entrega pase.url al navegador; allí basta con:
// const conexion = new EventSource(pase.url);
```

#### Python

```python
# En tu servidor
pase = await sdk.tickets.create_stream_token("ticket-events", user_id="usr-4821")
# Entrega pase.url al navegador, que abre allí la conexión con new EventSource(url)
```

La ruta del servidor y sus errores están en [Endpoints de tickets](https://os.utilia.ai/docs/developers/api/tickets#pase-de-conexión-sin-la-clave-en-la-dirección).

## Siguiente paso

- [Sugerencias con IA](https://os.utilia.ai/docs/developers/sdk/ai-suggestions): Antes de crear el ticket, propone soluciones a partir del texto, el audio o las imágenes.
- [Archivos adjuntos](https://os.utilia.ai/docs/developers/sdk/files): Sube capturas y registros para adjuntarlos al ticket o a sus mensajes.
