Skip to main content

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: con Zendesk ya desconectado (agosto 2026), los archivos siguen siendo nuestros.

Modelo

Los adjuntos se almacenan en la tabla chat_ticket_attachments y los binarios en Cloudinary, dentro del folder chat/tickets/{nº} (adjuntos a nivel ticket) o chat/tickets/comments/{ticketId} (adjuntos de un comentario del hilo, ver abajo). El destino de un adjunto — ticket completo o un comentario concreto — lo marca comment_id (mig 213): NULL es adjunto a nivel ticket (comportamiento original); poblado es un adjunto que se subió al redactar un comentario y se renderiza inline en su burbuja además de aparecer aquí. Una sola fuente de verdad: no se duplica en el JSONB chat_ticket_comments.attachments (ese campo sigue existiendo solo para los adjuntos legacy importados de Zendesk, ver más abajo). 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.
Un índice único parcial sobre zendesk_attachment_id garantiza que un adjunto de Zendesk se re-aloje una sola vez. Un índice parcial sobre comment_id (WHERE comment_id IS NOT NULL) acelera la carga de adjuntos por comentario al pintar el hilo.

Orígenes (source)

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

Todos requieren Auth0 + tool-chat (rol viewer). Ambos POST aceptan hasta 10 archivos de 50 MB cada uno (mismo FilesInterceptor). El borrado elimina primero el asset de Cloudinary (best-effort, según resource_type) y luego la fila.

Write-back a Zendesk (retirado)

Al desconectar Zendesk (agosto 2026) se retiró el write-back: los adjuntos que se suben desde Tools viven solo en Cloudinary, que es donde ya estaba el 100% de esta tabla. zendesk_upload_token queda null en las filas nuevas; las viejas conservan el suyo. Antes se re-subía el archivo original a Zendesk (uploadAttachment) y se creaba una única nota interna con todos los tokens del lote; ese camino ya no existe.

Proxy de adjuntos legacy de Zendesk (retirado)

Existió un ZendeskAttachmentProxyController (GET /chat/zendesk-attachments/proxy?url=…) que servía inline los adjuntos alojados en Zendesk: resolvía la Basic auth que exigían esas URLs y transcodificaba a JPEG los HEIC/HEIF que el navegador no pinta. Se borró en la PR #855 junto al resto del cluster de Zendesk. La cuenta está cerrada, así que no hay nada que proxear: las URLs vivlahelp.zendesk.com devuelven 403. Un adjunto legacy que siga apuntando a Zendesk se muestra hoy como un enlace con su nombre y una nota no disponible.

Adjuntos de comentario (mig 213)

Desde la web de Tools, redactar un comentario en el hilo de un ticket admite adjuntar uno o varios ficheros (imagen o documento), por el mismo camino que los adjuntos a nivel ticket: Cloudinary, no URLs de Zendesk. El flujo es: crear el comentario → subir los ficheros al endpoint POST .../comments/:commentId/attachments con el commentId devuelto → el resultado (filas con comment_id poblado) se renderiza inline en la burbuja del comentario y aparece además en la pestaña de Adjuntos del ticket, con una marca “desde comentario” que enlaza de vuelta a esa burbuja. TicketCommentsService.findByTicketId carga esas filas (una query por comment_id IN (...)) y las expone en un campo nuevo del response, uploadedAttachments, separado del legacy attachments (JSONB, ver abajo) — no se duplica nada, es la misma tabla de siempre con una columna más.
El body del comentario sigue siendo obligatorio: un comentario “solo adjunto” (sin texto) se filtraría al leer el hilo (mismo filtro anti-eco/vacío de siempre, que mira el JSONB legado, no uploadedAttachments). Encaja con el caso de uso — “escribo qué se hizo y adjunto la foto” — pero si algún día se quiere soportar comentario-solo-imagen, ese filtro es el sitio a tocar.

Legacy: adjuntos de comentarios importados de Zendesk

Los adjuntos históricos del hilo de comentarios (importados antes de mig 213, o que siguen entrando por el import/webhook de Zendesk) no viven en chat_ticket_attachments: se guardan como fichas dentro del JSONB chat_ticket_comments.attachments (nombre, tamaño, tipo), pero el fichero en sí es una URL con token de Zendesk. La cuenta ya se canceló y esas URLs murieron. La migración de julio-agosto de 2026 re-alojó 1.309 de esos ficheros en Cloudinary, dejando el original en zendeskContentUrl; 2 resultaron irrecuperables. Los adjuntos nuevos de comentario (arriba) ya nacen en Cloudinary + la tabla, así que no necesitan este paso. El CLI migrate-comment-attachments (borrado también en la PR #855; recuperable del historial) recorría los comentarios con adjuntos JSONB, descargaba cada fichero de Zendesk con auth (vía ZendeskProvider, que validaba el host configurado), lo subía a nuestro Cloudinary y reescribía contentUrl en el JSONB.
  • Idempotente y reversible: guarda el original en zendeskContentUrl y añade cloudinaryPublicId, que es además la marca de “ya migrado” (esos adjuntos se saltan).
  • Escribe una vez por comentario, con todos sus adjuntos ya reescritos, para no dejar el JSONB a medias si algo falla a mitad. Un fallo suelto se cuenta y no aborta la migración.
  • Imágenes: por defecto se optimizan (WebP q80, máx 2560px), como los adjuntos nuevos. Los vídeos no se tocan. --no-compress las sube tal cual para conservar el EXIF (fecha/GPS), útil si la foto puede acabar en una reclamación.
A propósito no usa TicketAttachmentsService.importFromUrl: ése escribe en chat_ticket_attachments (nivel ticket) y el hilo se renderiza desde el JSONB del comentario, así que no arreglaría lo que hay que arreglar.

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.” El componente TicketCommentsThread (el hilo de comentarios) tiene su propio selector de ficheros (botón de clip junto al switch de “Nota interna”, mismo ACCEPT/límite de 50 MB) — los ficheros elegidos se muestran como chips removibles antes de enviar y se suben tras crear el comentario. En la burbuja, los adjuntos nuevos (uploadedAttachments, Cloudinary) se renderizan directos por <img>/enlace de descarga; los legacy (attachments, JSONB de Zendesk) siguen pasando por ProxiedZendeskImage sin cambios.

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 en el modal de alta, la sección de adjuntos del detalle y el composer del hilo de comentarios, 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.

Imágenes pegadas dentro del texto (histórico, en su mayoría perdidas)

Ojo: además de los adjuntos formales, la gente pegaba capturas dentro del cuerpo del comentario. Zendesk las guardaba por un canal distinto — no aparecen en el JSONB chat_ticket_comments.attachments, así que el CLI de migración, que iteraba ese array, nunca las tocó. Medido contra producción el 7-sep-2026: 1.551 URLs inline en 751 comentarios (739 de ellos notas internas), de 2025 y 2026. Cuando la cuenta de Zendesk se cerró, esos ficheros se perdieron: el host devuelve 403 y la API 404. Solo 44 tenían copia identificable en Cloudinary y se reescribieron en la migración 245_rewrite_inline_zendesk_images.sql. El resto no se puede recuperar ni adivinar: 996 casos usan nombres genéricos del tipo Captura de pantalla ….png con varios candidatos distintos, y colocar la captura de otro ticket en una nota interna es peor que no mostrar nada. Cómo se renderizan hoy (parse-comment-body.ts + CommentBubble): El nombre sale del parámetro ?name=, decodificado; si falta, se cae al último segmento de la ruta y, en último término, a una etiqueta genérica. Nunca se pinta la URL cruda como texto.
El hilo se renderiza siempre desde body, con nodos de texto de React. El campo html_body guarda HTML crudo de Zendesk y no se renderiza: no hay sanitizador en el repo, así que pintarlo con dangerouslySetInnerHTML sería un agujero de XSS.