services/api/src/modules/media/media.service.js

Overview

Este módulo concentra la lógica de media para la API de Somos Plano. Su foco principal es ImageKit: subida, normalización, verificación de propiedad y borrado de assets asociados a usuarios y listings. También expone utilidades pequeñas para construir rutas de carpeta y resolver URLs de imagen, thumbnail y PDF.

El archivo funciona como una capa de servicio determinista sobre ImageKit y el modelo Listing. Esto permite que el backend controle qué archivos se aceptan, dónde se guardan y cuáles pueden eliminarse al reconciliar media de un anuncio.

[!IMPORTANT] La verificación de propiedad no confía en el cliente. Cuando llega un fileId nuevo, el servidor consulta ImageKit y valida la carpeta real del archivo.

How it works

flowchart TD
    Upload[uploadAssetToImageKit] --> Infer[inferMediaType]
    Upload --> Folder[buildFolder]
    Upload --> Name[sanitizeFileName]
    Upload --> Client[getImageKitClient]
    Upload --> Normalize[normalizeUploadResponse]

    Delete[deleteAsset] --> Client2[getImageKitClient]
    Delete --> NotFound[NotFoundError]

    Trusted[resolveTrustedListingMedia] --> Existing[existingMedia]
    Trusted --> Assert[assertOwnedListingMediaFile]
    Trusted --> Resolve[known refs and fileId]

    ListingDelete[deleteListingMediaAssets] --> ListingModel[Listing.findById]
    ListingDelete --> AssetDelete[deleteFileIdsIndependently]
    SourceDelete[deleteListingSourceDocumentAssets] --> SourceIds[collectSourceDocumentFileIds]
    SourceDelete --> AssetDelete

El flujo general empieza con la subida o la reconciliación de media. Para subir, el servicio calcula el tipo de archivo, valida límites y construye una carpeta segura. Después usa el cliente de ImageKit y devuelve una respuesta normalizada para el resto del sistema.

Para borrado, el módulo reúne fileId de media y documentos fuente, y elimina cada asset de forma independiente. Si un asset ya no existe en ImageKit, el servicio lo trata como éxito para mantener idempotencia parcial.

Key components

{
    "title": "sanitizeFileName",
    "content": "Funci\u00f3n utilitaria [badge:public] que limpia nombres de archivo y extiende una base segura a partir del nombre original.",
    "type": "function",
    "filePath": "services/api/src/modules/media/media.service.js",
    "badges": [
        "public"
    ]
}
{
    "title": "uploadAssetToImageKit",
    "content": "Sube un archivo a ImageKit, valida tipo y tama\u00f1o, construye la carpeta destino y devuelve una respuesta normalizada.",
    "type": "function",
    "filePath": "services/api/src/modules/media/media.service.js",
    "badges": [
        "public"
    ]
}
{
    "title": "resolveTrustedListingMedia",
    "content": "Reconcilia media entrante con media ya guardada y conserva la identidad persistida cuando el servidor ya conoce el archivo.",
    "type": "function",
    "filePath": "services/api/src/modules/media/media.service.js",
    "badges": [
        "public"
    ]
}
{
    "title": "assertOwnedListingMediaFile",
    "content": "Verifica en ImageKit que un `fileId` pertenece a una carpeta v\u00e1lida del usuario o listing.",
    "type": "function",
    "filePath": "services/api/src/modules/media/media.service.js",
    "badges": [
        "public"
    ]
}
{
    "title": "deleteListingMediaAssets",
    "content": "Elimina assets de ImageKit asociados a un listing, incluyendo media del anuncio y documentos fuente.",
    "type": "function",
    "filePath": "services/api/src/modules/media/media.service.js",
    "badges": [
        "public"
    ]
}
{
    "title": "deleteListingSourceDocumentAssets",
    "content": "Elimina \u00fanicamente los `fileId` de `sourceDocuments` de un listing.",
    "type": "function",
    "filePath": "services/api/src/modules/media/media.service.js",
    "badges": [
        "public"
    ]
}

Data & fields

[
    {
        "name": "IMAGEKIT_ROOT",
        "type": "string",
        "required": true,
        "description": "Raíz fija usada para construir carpetas de ImageKit. Su valor es `/plano`."
    },
    {
        "name": "MEDIA_CONFIG.maxImageSizeBytes",
        "type": "number",
        "required": true,
        "description": "Límite máximo de tamaño para imágenes. Se usa para bloquear subidas que exceden el límite."
    },
    {
        "name": "MEDIA_CONFIG.maxImageSizeMB",
        "type": "number",
        "required": true,
        "description": "Valor en megabytes usado en el mensaje de error cuando una imagen supera el límite."
    }
]

La función normalizeUploadResponse transforma la respuesta de ImageKit en una estructura homogénea. Ese objeto incluye:

[
    {
        "name": "provider",
        "type": "string",
        "required": true,
        "description": "Proveedor de media. Siempre se fija en `imagekit`."
    },
    {
        "name": "fileId",
        "type": "string",
        "required": true,
        "description": "Identificador del archivo en ImageKit."
    },
    {
        "name": "name",
        "type": "string",
        "required": true,
        "description": "Nombre visible del archivo."
    },
    {
        "name": "fileName",
        "type": "string",
        "required": true,
        "description": "Nombre de archivo persistido o derivado del original."
    },
    {
        "name": "filePath",
        "type": "string",
        "required": true,
        "description": "Ruta del archivo dentro de ImageKit."
    },
    {
        "name": "url",
        "type": "string",
        "required": true,
        "description": "URL pública devuelta por ImageKit."
    },
    {
        "name": "thumbnailUrl",
        "type": "string",
        "required": true,
        "description": "URL de thumbnail devuelta por ImageKit, si existe."
    },
    {
        "name": "width",
        "type": "number|null",
        "required": true,
        "description": "Ancho reportado por ImageKit."
    },
    {
        "name": "height",
        "type": "number|null",
        "required": true,
        "description": "Alto reportado por ImageKit."
    },
    {
        "name": "size",
        "type": "number|null",
        "required": true,
        "description": "Tamaño del archivo."
    },
    {
        "name": "mime",
        "type": "string",
        "required": true,
        "description": "Tipo MIME reportado."
    },
    {
        "name": "mimeType",
        "type": "string",
        "required": true,
        "description": "Alias del tipo MIME."
    },
    {
        "name": "status",
        "type": "string",
        "required": true,
        "description": "Estado fijo del asset. Se establece como `active`."
    },
    {
        "name": "uploadedAt",
        "type": "string",
        "required": true,
        "description": "Marca temporal ISO de la subida."
    }
]

resolveTrustedListingMedia devuelve la colección entrante, pero reescribe campos cuando el servidor puede confiar en una referencia previa.

[
    {
        "name": "incomingMedia",
        "type": "array",
        "required": true,
        "description": "Media enviada por el cliente o por el flujo de edición."
    },
    {
        "name": "existingMedia",
        "type": "array",
        "description": "Media ya guardada en el listing."
    },
    {
        "name": "userId",
        "type": "string",
        "required": true,
        "description": "Identificador del usuario propietario o editor."
    },
    {
        "name": "listingId",
        "type": "string",
        "description": "Identificador del listing para validar carpetas y ownership."
    }
]

Behaviors

  • sanitizeSegment(value = "") — Normaliza texto, elimina acentos, convierte caracteres no seguros a guiones y produce un segmento en minúsculas.
  • sanitizeFileName(filename = "archivo") — Limpia nombre y extensión, y devuelve un nombre de archivo seguro.
  • buildFolder({ requestedFolder, userId, listingId }) — Construye la carpeta destino de ImageKit según el tipo de media solicitado.
  • inferMediaType(mime = "") — Clasifica el archivo como image, video, pdf o file.
  • normalizeUploadResponse(response = {}, file = {}) — Convierte la respuesta de ImageKit en una estructura estable para persistencia.
  • uploadAssetToImageKit(...) — Valida, sube y normaliza un archivo.
  • getImageKitSrc(media = {}) — Resuelve la URL principal de media desde filePath, src o url.
  • getCardImageUrl(media = {}) — Devuelve la URL usada para imagen de tarjeta.
  • getThumbnailUrl(media = {}) — Devuelve thumbnailUrl o la URL principal.
  • getPdfImageUrl(media = {}) — Devuelve la URL principal para representación visual de PDF.
  • deleteAsset(fileId) — Borra un asset individual o lo trata como éxito si ya no existe.
  • deleteImageKitAsset(fileId) — Alias público de deleteAsset.
  • mediaError(statusCode, code, message) — Fabrica errores con statusCode y code.
  • allowedListingMediaPrefixes({ userId, listingId }) — Construye prefijos válidos de carpeta para media de listing.
  • assertOwnedListingMediaFile({ fileId, userId, listingId }) — Comprueba ownership real contra ImageKit.
  • resolveTrustedListingMedia({ incomingMedia, existingMedia, userId, listingId }) — Reconcilia media del cliente con media persistida.
  • collectSourceDocumentFileIds(listing) — Extrae fileId desde listing.sourceDocuments.
  • deleteFileIdsIndependently(fileIds) — Borra varios assets uno por uno y agrega fallos al final.
  • deleteListingSourceDocumentAssets(listing) — Borra solo documentos fuente del listing.
  • deleteListingMediaAssets(listingOrId) — Borra media y documentos fuente asociados a un listing.

How the media service decides where files go

flowchart TD
    Input[requestedFolder userId listingId] --> FolderCheck{folder includes}
    FolderCheck -->|avatar| AvatarPath[plano users userId avatar]
    FolderCheck -->|banner or cover| BannerPath[plano users userId banner]
    FolderCheck -->|pdf| PdfPath[plano listings listingId or tmp userId pdfs]
    FolderCheck -->|video| VideoPath[plano listings listingId or tmp userId videos]
    FolderCheck -->|other| ListingPath[plano listings listingId or tmp userId]

El módulo convierte la intención de carpeta en una ruta predecible dentro de IMAGEKIT_ROOT. Esa lógica separa avatares, banners, PDFs, videos y media general de listings.

buildFolder usa sanitizeSegment para evitar caracteres problemáticos en userId y listingId. Si no hay listingId, el servicio cae en una carpeta temporal con prefijo tmp-.

Subida de assets

1. Validar entrada | `uploadAssetToImageKit` exige un archivo y rechaza la ausencia con error 400.
2. Detectar tipo | `inferMediaType` clasifica el MIME en imagen, video, PDF o archivo genérico.
3. Aplicar reglas | Las imágenes se limitan por `MEDIA_CONFIG.maxImageSizeBytes`.
4. Elegir carpeta | `buildFolder` calcula la ruta destino según `requestedFolder`, `userId` y `listingId`.
5. Limpiar nombre | `sanitizeFileName` genera un nombre seguro para ImageKit.
6. Subir a ImageKit | El cliente creado por `getImageKitClient` envía el archivo con etiquetas normalizadas.
7. Normalizar salida | `normalizeUploadResponse` devuelve un contrato estable para el resto de la API.

[!NOTE] La subida acepta solo image, video y pdf como tipos permitidos. Cualquier otro tipo devuelve error 400.

Verificación de ownership de media

assertOwnedListingMediaFile no acepta la versión pública del cliente como fuente de verdad. Primero consulta ImageKit con fileId. Luego verifica que filePath empiece por uno de los prefijos permitidos para ese usuario o listing.

Si ImageKit responde con NotFoundError, el servicio devuelve un error MEDIA_NOT_OWNED. Si falla la consulta por otro motivo, devuelve MEDIA_VERIFICATION_FAILED con estado 502.

[!WARNING] Un fileId de ImageKit solo es aceptado si pertenece a una carpeta válida del usuario o del listing.

Reconciliación de media de listing

resolveTrustedListingMedia protege el flujo de edición y evita que el cliente reescriba identidad de media ya conocida. El servicio crea dos índices internos: uno por referencias públicas src y url, y otro por fileId.

Cuando un ítem entrante coincide con un elemento ya guardado, el servicio reutiliza provider, fileId y filePath persistidos. Si el ítem trae un fileId nuevo de ImageKit, el servidor lo verifica y usa el filePath devuelto por ImageKit.

[!IMPORTANT] Un item histórico sin provider: "imagekit" no se “promueve” automáticamente. Si trae un fileId, ese identificador se trata como nuevo y se verifica otra vez.

Borrado de assets

deleteAsset envuelve el borrado individual de ImageKit. Si el asset ya no existe, la función lo considera un éxito con alreadyMissing: true. Eso hace seguro repetir operaciones parciales.

deleteListingMediaAssets y deleteListingSourceDocumentAssets reutilizan deleteFileIdsIndependently. Esa función intenta cada borrado por separado y solo lanza un error agregado al final si hubo fallos.

flowchart TD
    ListingInput[listing or listingId] --> Load[Listing.findById]
    Load --> MediaIds[media imagekit fileId]
    Load --> SourceIds[sourceDocuments fileId]
    MediaIds --> Batch[deleteFileIdsIndependently]
    SourceIds --> Batch
    Batch --> PerFile[deleteAsset]
    PerFile --> IK[ImageKit delete]
    PerFile --> Missing[NotFoundError treated as success]
    Batch --> Aggregate[aggregate error if failures]

Gotchas & edge cases

[!WARNING] deleteFileIdsIndependently no corta el proceso al primer error. Intenta borrar todos los archivos antes de lanzar un error agregado.

[!CAUTION] Si existingMedia contiene el mismo fileId con rutas distintas, resolveTrustedListingMedia vacía filePath para ese fileId por considerarlo no confiable.

[!NOTE] getThumbnailUrl, getCardImageUrl y getPdfImageUrl son resolutores simples. Si no hay valor específico, todas caen en getImageKitSrc.

[!WARNING] uploadAssetToImageKit solo permite subidas cuando existe file.buffer. El servicio trabaja con archivos recibidos en memoria.

[!IMPORTANT] deleteListingMediaAssets incluye media del listing y sourceDocuments. El borrado no se limita a la colección media.

Este módulo se conecta con componentes ya visibles en el código proporcionado:

  • Listing desde ../../models/Listing.js, usado para cargar listings antes del borrado.
  • getImageKitClient desde ../../config/imagekit.js, que crea el cliente de ImageKit.
  • MEDIA_CONFIG desde ../../config/media.js, que aporta límites de tamaño.
  • toFile y NotFoundError desde @imagekit/nodejs, usados en subida y manejo de borrado.
  • IMAGEKIT_ROOT como raíz operativa de carpetas dentro de ImageKit.
  • Listing aporta la persistencia del anuncio.
  • getImageKitClient concentra la conexión con ImageKit.
  • MEDIA_CONFIG gobierna la validación de imágenes.
  • ImageKit actúa como fuente de verdad para fileId y filePath.
{
    "title": "ImageKit integration",
    "content": "Este m\u00f3dulo implementa la integraci\u00f3n operativa con ImageKit para subida, consulta y borrado de media.",
    "type": "class",
    "filePath": "services/api/src/modules/media/media.service.js",
    "badges": [
        "public"
    ]
}