> ## Documentation Index
> Fetch the complete documentation index at: https://wiki.vivla.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Adjuntos de tickets

> Archivos a nivel ticket alojados en nuestro Cloudinary, con write-back bidireccional a Zendesk

# Adjuntos de tickets

Los tickets de soporte pueden llevar archivos adjuntos (fotos de incidencias, facturas y presupuestos en PDF, etc.). A diferencia de los adjuntos de Zendesk, estos viven en **nuestro Cloudinary** y son la **fuente de verdad propia**: cuando apaguemos Zendesk, los archivos siguen siendo nuestros.

## Modelo

Los adjuntos se asocian al **ticket completo**, no a un comentario concreto. Se almacenan en la tabla `chat_ticket_attachments` y los binarios en Cloudinary, dentro del folder `chat/tickets/{nº}` (usa el número de Zendesk cuando existe; si no, el `id` del ticket).

Acepta imágenes, vídeos y otros tipos de archivo (PDF, documentos…). Las imágenes se re-codifican a WebP optimizado antes de subirse a Cloudinary; el resto de archivos (vídeo incluido) se suben tal cual — Cloudinary usa `resource_type='auto'` y los clasifica como `video`/`raw` según corresponda.

| Columna                     | Descripción                                                    |
| --------------------------- | -------------------------------------------------------------- |
| `id`                        | UUID, PK                                                       |
| `ticket_id`                 | FK a `chat_tickets` (`ON DELETE CASCADE`)                      |
| `cloudinary_public_id`      | ID del asset en Cloudinary (para borrarlo después)             |
| `resource_type`             | `image` \| `raw` \| `video` — namespace de Cloudinary          |
| `url`                       | `secure_url` de Cloudinary                                     |
| `filename`                  | Nombre original del archivo                                    |
| `content_type`              | MIME type                                                      |
| `size` / `width` / `height` | Metadatos del archivo                                          |
| `source`                    | Origen del adjunto (ver abajo)                                 |
| `uploaded_by`               | FK a `users` — quién lo subió (solo en `upload`)               |
| `zendesk_attachment_id`     | ID del adjunto de Zendesk re-alojado (idempotencia del import) |
| `zendesk_upload_token`      | Token devuelto por el write-back a Zendesk (trazabilidad)      |
| `metadata`                  | JSONB libre                                                    |
| `created_at`                | Timestamp de creación                                          |

<Note>
  Un índice único parcial sobre `zendesk_attachment_id` garantiza que un adjunto de Zendesk se re-aloje **una sola vez**.
</Note>

## Orígenes (`source`)

| `source`         | Cómo llega                                                                  |
| ---------------- | --------------------------------------------------------------------------- |
| `upload`         | Subida manual desde Vivla Tools (al crear el ticket o desde el detalle)     |
| `chat`           | Imagen de un mensaje de chat adjuntada al ticket y re-alojada en Cloudinary |
| `zendesk_import` | Re-alojado desde un adjunto de Zendesk                                      |

Los orígenes `chat` y `zendesk_import` entran por el método `importFromUrl()`: descarga el archivo desde una URL externa (con `Authorization` si hace falta), lo sube a nuestro Cloudinary y crea la fila. Es **best-effort** e **idempotente** por `zendesk_attachment_id` (no rompe el import/sync si falla).

## Endpoints

Bajo `chat/tickets/:id/attachments`. Todos requieren Auth0 + `tool-chat` (rol `viewer`).

| Método   | Ruta                                          | Descripción                                                |
| -------- | --------------------------------------------- | ---------------------------------------------------------- |
| `GET`    | `/chat/tickets/:id/attachments`               | Lista los adjuntos del ticket (orden por `created_at` asc) |
| `POST`   | `/chat/tickets/:id/attachments`               | Sube uno o varios archivos (campo `files`, multipart)      |
| `DELETE` | `/chat/tickets/:id/attachments/:attachmentId` | Borra un adjunto (Cloudinary + fila)                       |

El `POST` acepta **hasta 10 archivos** de **50 MB** cada uno. El borrado elimina primero el asset de Cloudinary (best-effort, según `resource_type`) y luego la fila.

## Bidireccional con Zendesk

Al subir desde Tools, además de alojarlo en Cloudinary se hace **write-back** a Zendesk para que el archivo no quede huérfano durante la transición:

1. Se sube **el archivo original** (no el WebP optimizado) a Zendesk vía `uploadAttachment`, que devuelve un `token`.
2. Tras procesar todo el lote, se crea **una única nota interna** (no pública) en el ticket de Zendesk con todos los tokens del lote:

```
📎 N archivo(s) adjuntado(s) desde Vivla Tools
```

El write-back solo ocurre si el ticket tiene `zendesk_ticket_id`, y es best-effort: si Zendesk falla, el adjunto se guarda igual en Cloudinary (solo se loguea el warning).

## Proxy de adjuntos legacy de Zendesk

Los adjuntos viejos que viven en comentarios de Zendesk apuntan a URLs del tipo `https://vivlahelp.zendesk.com/attachments/token/.../`, que tienen dos problemas para mostrarlos en el navegador:

* Requieren **Basic auth**: puestas directas en un `<img>` devuelven 401.
* Las subidas de iOS llegan en **HEIC/HEIF**, que Chrome/Firefox no renderizan inline.

El controlador `ZendeskAttachmentProxyController` (`GET /chat/zendesk-attachments/proxy?url=…`) resuelve ambos:

1. Valida que la URL sea `https` y del host de Zendesk configurado (anti-SSRF).
2. Descarga con nuestra Basic auth vía `ZendeskProvider`.
3. Si el archivo es HEIC/HEIF, lo **transcodifica a JPEG** con `sharp` (q85). Si `libheif` no está disponible en el servidor, devuelve `415` para que el frontend caiga a un enlace de descarga.
4. Devuelve los bytes con `Cache-Control: private, max-age=3600`.

Requiere la misma auth que el resto de endpoints de tickets (Auth0 + `tool-chat` viewer). **No es un proxy público.**

## UI

El componente `TicketAttachments` (frontend) muestra una galería en el detalle del ticket:

* **Drag & drop**, **pegar** (⌘/Ctrl+V) o botón **Subir** (acepta `image/*`, `video/*` y `application/pdf`, máx. 50 MB).
* Preview en miniatura para imágenes; **chip con icono** y nombre de archivo para PDFs.
* Botón de **borrar** por adjunto (visible al hover).
* Cada tile enlaza al archivo en una pestaña nueva y muestra su tamaño.

También hay una caja de subida en el formulario de **alta** del ticket. El copy lo deja claro: *"Se guardan en el ticket y se sincronizan con Zendesk."*

### Pegar desde el portapapeles

Como en Zendesk, se puede **pegar** una captura o imagen del portapapeles con ⌘/Ctrl+V y se adjunta directamente, sin pasar por el selector de archivos. Funciona tanto en el **modal de alta** como en la **sección de adjuntos del detalle**, y comparte la misma validación de tipo/tamaño que el drag & drop (mismo `ACCEPT` y límite de 50 MB).

Detalles de comportamiento (hook `usePasteFiles`):

* Solo actúa sobre archivos del portapapeles (`clipboardData` con `kind === 'file'`); un pegado de **solo texto** sigue su curso normal.
* Filtra por mimetype (`image/*`, `video/*`, `application/pdf`) y por los 50 MB; descarta en silencio lo que no encaje.
* Renombra las capturas con nombre genérico del portapapeles (`image.png`) a `captura-<yyyyMMdd-HHmmss>.<ext>` para que el `filename` mostrado en Zendesk/DB sea reconocible. Un archivo con nombre propio se respeta.
* En el **modal**, con el diálogo abierto pegar desde cualquier campo adjunta la imagen. En el **detalle** (página completa con otros campos de texto) el pegado se ignora si el foco está en un `input`/`textarea`/`contenteditable`, para no subir un adjunto por sorpresa mientras se escribe.

## Follow-ups

* **Re-hosting de adjuntos del import de Zendesk** (`source='zendesk_import'`): gateado a comentarios nuevos que entran vía webhook. Pendiente.
* **Backfill** de los adjuntos viejos ya existentes en Zendesk. Pendiente.
