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 tablachat_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ó unZendeskAttachmentProxyController
(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 endpointPOST .../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 enchat_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
zendeskContentUrly añadecloudinaryPublicId, 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-compresslas 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 componenteTicketAttachments (frontend) muestra una galería en el detalle
del ticket:
- Drag & drop, pegar (⌘/Ctrl+V) o botón Subir (acepta
image/*,video/*yapplication/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.
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 (mismoACCEPT y límite de 50 MB).
Detalles de comportamiento (hook usePasteFiles):
- Solo actúa sobre archivos del portapapeles (
clipboardDataconkind === '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) acaptura-<yyyyMMdd-HHmmss>.<ext>para que elfilenamemostrado 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 JSONBchat_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.