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 |
Un índice único parcial sobre zendesk_attachment_id garantiza que un adjunto de Zendesk se re-aloje una sola vez.
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:
- Se sube el archivo original (no el WebP optimizado) a Zendesk vía
uploadAttachment, que devuelve un token.
- 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:
- Valida que la URL sea
https y del host de Zendesk configurado (anti-SSRF).
- Descarga con nuestra Basic auth vía
ZendeskProvider.
- 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.
- 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.
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.