source-documents.routes.js

Overview

Este archivo define el router de Express para administrar los documentos fuente de un listing. Está montado en services/api/src/modules/listings/listings.routes.js bajo /api/listings/:id/source-documents, y usa mergeParams: true para leer el :id del listing padre.

El router cubre dos operaciones: adjuntar un PDF y eliminarlo. La escritura de Listing.sourceDocuments pasa por el servidor, que sube el archivo a ImageKit y guarda solo los metadatos devueltos por el servicio.

[!IMPORTANT] Esta es la única vía de escritura de Listing.sourceDocuments. El cliente nunca envía un fileId; el servidor lo genera tras subir el archivo.

How it works

El flujo de subida valida primero la identidad, luego la verificación de correo, y después el plan y la propiedad del listing. Solo entonces recibe el archivo, lo valida como PDF, lo sube a ImageKit y persiste el nuevo subdocumento en MongoDB.

El flujo de borrado también exige autenticación. Busca el listing del dueño, localiza el subdocumento por docId, elimina primero el asset remoto y después retira la referencia en MongoDB.

flowchart TB
    subgraph UploadFlow [Upload flow]
        UClient[Client]
        UAuth[requireAuth]
        UEmail[requireVerifiedEmail]
        UAttach[authorizeAttach]
        UMulter[receiveFile]
        UValidate[validateSourceDocumentFile]
        UUpload[uploadAssetToImageKit]
        UPersist[Listing findOneAndUpdate]
        UResponse[201 response]

        UClient --> UAuth
        UAuth --> UEmail
        UEmail --> UAttach
        UAttach --> UMulter
        UMulter --> UValidate
        UValidate --> UUpload
        UUpload --> UPersist
        UPersist --> UResponse
    end

    subgraph DeleteFlow [Delete flow]
        DClient[Client]
        DAuth[requireAuth]
        DLookup[Listing findOne]
        DDelete[deleteAsset]
        DRemove[Listing findOneAndUpdate]
        DResponse[200 response]

        DClient --> DAuth
        DAuth --> DLookup
        DLookup --> DDelete
        DDelete --> DRemove
        DRemove --> DResponse
    end

El orden de authorizeAttach antes de multer evita bufferizar archivos de usuarios sin permiso. En la eliminación, el asset remoto se borra antes que el subdocumento, para no dejar archivos públicos huérfanos.

Key components

Estos son los bloques principales del archivo y su responsabilidad en el flujo.

{
    "title": "authorizeAttach",
    "content": "Middleware as\u00edncrono que valida el ObjectId del listing, el plan del usuario, la propiedad del anuncio, el tipo de listing y el cupo disponible antes de leer el archivo. Devuelve errores HTTP espec\u00edficos y corta el flujo cuando no cumple.",
    "type": "function",
    "filePath": "services/api/src/modules/listings/source-documents.routes.js",
    "badges": [
        "public"
    ]
}
{
    "title": "receiveFile",
    "content": "Wrapper de multer que recibe un \u00fanico archivo en el campo file, usa memoria en lugar de disco y traduce los errores de multer a respuestas JSON consistentes.",
    "type": "function",
    "filePath": "services/api/src/modules/listings/source-documents.routes.js",
    "badges": [
        "public"
    ]
}
{
    "title": "fail",
    "content": "Utilidad local para responder errores con success false, code y message. Centraliza el formato de error del router.",
    "type": "function",
    "filePath": "services/api/src/modules/listings/source-documents.routes.js",
    "badges": [
        "public"
    ]
}
{
    "title": "ownedListingFilter",
    "content": "Construye el filtro de MongoDB que limita las consultas al listing cuyo _id coincide con la ruta y cuyo createdBy coincide con el usuario autenticado.",
    "type": "function",
    "filePath": "services/api/src/modules/listings/source-documents.routes.js",
    "badges": [
        "public"
    ]
}

Data & fields

Este router trabaja con tres conjuntos de datos. Primero, lee parámetros de ruta. Segundo, consume el archivo enviado por multipart form-data. Tercero, persiste un subdocumento en Listing.sourceDocuments.

Route parameters and upload input

[
    {
        "name": "id",
        "type": "string",
        "required": true,
        "description": "ID del listing recibido desde la ruta padre."
    },
    {
        "name": "docId",
        "type": "string",
        "required": true,
        "description": "ID del subdocumento que se desea eliminar."
    },
    {
        "name": "file",
        "type": "multer file",
        "required": true,
        "description": "Archivo PDF enviado en el campo file de multipart form-data.",
        "children": [
            {
                "name": "originalname",
                "type": "string",
                "required": true,
                "description": "Nombre original enviado por el navegador."
            },
            {
                "name": "size",
                "type": "number",
                "required": true,
                "description": "Tamaño del archivo en bytes."
            }
        ]
    }
]

Stored source document entry

[
    {
        "name": "_id",
        "type": "string",
        "required": true,
        "description": "Identificador del subdocumento dentro de MongoDB."
    },
    {
        "name": "fileId",
        "type": "string",
        "required": true,
        "description": "Identificador del asset devuelto por ImageKit."
    },
    {
        "name": "url",
        "type": "string",
        "required": true,
        "description": "URL pública del documento almacenado."
    },
    {
        "name": "name",
        "type": "string",
        "required": true,
        "description": "Nombre sanitizado del archivo visible para el usuario."
    },
    {
        "name": "mimeType",
        "type": "string",
        "required": true,
        "description": "Valor fijo definido por SOURCE_DOCUMENT_MIME_TYPE."
    },
    {
        "name": "sizeBytes",
        "type": "number",
        "required": true,
        "description": "Tamaño del archivo en bytes."
    },
    {
        "name": "uploadedAt",
        "type": "Date",
        "required": true,
        "description": "Marca temporal de la carga."
    }
]

Storage and limit settings used by the router

  • MEDIA_CONFIG.maxDocumentSizeBytes [badge:read-only] — Límite duro aplicado por multer al archivo recibido.
  • MEDIA_CONFIG.maxDocumentSizeMB [badge:read-only] — Tamaño visible en el mensaje de error por exceso de peso.
  • MEDIA_CONFIG.maxDocumentsPerListing [badge:read-only] — Cantidad máxima de documentos permitida por listing.

Behaviors

El archivo combina middleware de seguridad, validación y persistencia. Cada pieza tiene una responsabilidad concreta.

  • requireAuth — Protege ambas rutas y garantiza req.auth.userId.
  • requireVerifiedEmail — Protege la ruta de subida y exige correo verificado.
  • authorizeAttach — Rechaza IDs inválidos, planes no permitidos, listings ajenos, listings de tipo requirement y exceso de documentos.
  • receiveFile — Ejecuta multer.single("file") con memoria y limita el tamaño y la cantidad de archivos.
  • validateSourceDocumentFile — Verifica que el archivo recibido sea un PDF válido y devuelve un código de error cuando no lo es.
  • uploadAssetToImageKit — Sube el archivo al almacenamiento externo y devuelve fileId y url.
  • Listing.findOneAndUpdate en POST — Agrega el nuevo subdocumento de forma atómica.
  • deleteAsset — Borra el archivo remoto antes de eliminar la referencia en MongoDB.
  • serializeSourceDocument y serializeSourceDocuments — Preparan la respuesta JSON con el documento nuevo y la colección completa.

API

El router expone dos endpoints. Ambos viven bajo /api/listings/:id/source-documents.

POST /api/listings/:id/source-documents

Este endpoint adjunta un documento fuente al listing. Requiere autenticación, correo verificado y un plan que permita adjuntar documentos.

{
    "title": "Adjuntar documento fuente",
    "description": "Sube un PDF del anunciante, lo almacena en ImageKit y agrega su metadata a Listing.sourceDocuments.",
    "method": "POST",
    "baseUrl": "/api/listings/:id",
    "endpoint": "/source-documents",
    "headers": [
        {
            "key": "Authorization",
            "value": "Bearer <token>",
            "required": true
        },
        {
            "key": "Content-Type",
            "value": "multipart/form-data",
            "required": true
        }
    ],
    "queryParams": [],
    "pathParams": [
        {
            "key": "id",
            "value": "ID del listing",
            "required": true
        }
    ],
    "bodyType": "form",
    "requestBody": "",
    "formData": [
        {
            "key": "file",
            "value": "Archivo PDF a adjuntar",
            "required": true
        }
    ],
    "rawBody": "",
    "responses": {
        "201": {
            "description": "Documento adjuntado correctamente",
            "body": "{\n  \"success\": true,\n  \"message\": \"Documento adjuntado.\",\n  \"data\": {\n    \"_id\": \"66f1f2a0c8e9f1a00123abcd\",\n    \"fileId\": \"imagekit-file-id\",\n    \"url\": \"https://ik.imagekit.io/example/listings/pdfs/documento.pdf\",\n    \"name\": \"Ficha tecnica.pdf\",\n    \"mimeType\": \"application/pdf\",\n    \"sizeBytes\": 245760,\n    \"uploadedAt\": \"2026-11-02T12:00:00.000Z\"\n  },\n  \"sourceDocuments\": [\n    {\n      \"_id\": \"66f1f2a0c8e9f1a00123abcd\",\n      \"fileId\": \"imagekit-file-id\",\n      \"url\": \"https://ik.imagekit.io/example/listings/pdfs/documento.pdf\",\n      \"name\": \"Ficha tecnica.pdf\",\n      \"mimeType\": \"application/pdf\",\n      \"sizeBytes\": 245760,\n      \"uploadedAt\": \"2026-11-02T12:00:00.000Z\"\n    }\n  ]\n}"
        },
        "400": {
            "description": "Validaci\u00f3n fallida del archivo, del listing o del input",
            "body": "{\n  \"success\": false,\n  \"code\": \"SOURCE_DOCUMENT_INVALID_TYPE\",\n  \"message\": \"Solo se aceptan archivos PDF (.pdf).\"\n}"
        },
        "403": {
            "description": "El plan no permite adjuntar documentos fuente",
            "body": "{\n  \"success\": false,\n  \"code\": \"SOURCE_DOCUMENT_PLAN_REQUIRED\",\n  \"message\": \"Adjuntar una ficha t\u00e9cnica o brochure est\u00e1 disponible en planes Creator.\"\n}"
        },
        "404": {
            "description": "Listing no encontrado o sin permisos",
            "body": "{\n  \"success\": false,\n  \"code\": \"LISTING_NOT_FOUND\",\n  \"message\": \"Listing no encontrado o sin permisos.\"\n}"
        },
        "409": {
            "description": "Se alcanz\u00f3 el m\u00e1ximo de documentos permitidos",
            "body": "{\n  \"success\": false,\n  \"code\": \"SOURCE_DOCUMENT_LIMIT_REACHED\",\n  \"message\": \"Cada anuncio permite 1 documento. Elimina el actual para subir otro.\"\n}"
        },
        "413": {
            "description": "El archivo supera el tama\u00f1o permitido",
            "body": "{\n  \"success\": false,\n  \"code\": \"SOURCE_DOCUMENT_TOO_LARGE\",\n  \"message\": \"El PDF excede el l\u00edmite de 15 MB.\"\n}"
        },
        "502": {
            "description": "Fall\u00f3 la subida al almacenamiento externo",
            "body": "{\n  \"success\": false,\n  \"code\": \"SOURCE_DOCUMENT_UPLOAD_FAILED\",\n  \"message\": \"No se pudo subir el documento. Intenta de nuevo.\"\n}"
        },
        "503": {
            "description": "El almacenamiento no est\u00e1 disponible en este entorno",
            "body": "{\n  \"success\": false,\n  \"code\": \"SOURCE_DOCUMENT_STORAGE_UNAVAILABLE\",\n  \"message\": \"El almacenamiento de archivos no est\u00e1 disponible en este entorno.\"\n}"
        }
    }
}

DELETE /api/listings/:id/source-documents/:docId

Este endpoint elimina un documento fuente existente. Solo exige autenticación y propiedad del listing. El comentario del archivo indica que sigue permitido aunque el usuario haya degradado su plan.

{
    "title": "Eliminar documento fuente",
    "description": "Borra primero el asset en ImageKit y luego elimina el subdocumento correspondiente de Listing.sourceDocuments.",
    "method": "DELETE",
    "baseUrl": "/api/listings/:id",
    "endpoint": "/source-documents/:docId",
    "headers": [
        {
            "key": "Authorization",
            "value": "Bearer <token>",
            "required": true
        }
    ],
    "queryParams": [],
    "pathParams": [
        {
            "key": "id",
            "value": "ID del listing",
            "required": true
        },
        {
            "key": "docId",
            "value": "ID del documento fuente",
            "required": true
        }
    ],
    "bodyType": "none",
    "requestBody": "",
    "formData": [],
    "rawBody": "",
    "responses": {
        "200": {
            "description": "Documento eliminado correctamente",
            "body": "{\n  \"success\": true,\n  \"message\": \"Documento eliminado.\",\n  \"sourceDocuments\": []\n}"
        },
        "400": {
            "description": "ID inv\u00e1lido",
            "body": "{\n  \"success\": false,\n  \"code\": \"INVALID_ID\",\n  \"message\": \"ID inv\u00e1lido.\"\n}"
        },
        "404": {
            "description": "Listing no encontrado, sin permisos o documento inexistente",
            "body": "{\n  \"success\": false,\n  \"code\": \"SOURCE_DOCUMENT_NOT_FOUND\",\n  \"message\": \"Documento no encontrado.\"\n}"
        },
        "502": {
            "description": "Fall\u00f3 la eliminaci\u00f3n en el almacenamiento externo",
            "body": "{\n  \"success\": false,\n  \"code\": \"SOURCE_DOCUMENT_DELETE_FAILED\",\n  \"message\": \"No se pudo eliminar el documento. Intenta de nuevo.\"\n}"
        },
        "500": {
            "description": "Error inesperado al ejecutar el flujo",
            "body": "{\n  \"success\": false,\n  \"code\": \"SOURCE_DOCUMENT_DELETE_FAILED\",\n  \"message\": \"No se pudo eliminar el documento.\"\n}"
        }
    }
}

Workflows

Los flujos de este router están pensados para ser deterministas. El estado del listing cambia solo después de pasar por validación, autorización y persistencia.

Subida de documento

El flujo de subida usa la ruta protegida por autenticación y verificación de correo. La validación de plan y propiedad ocurre antes de recibir el archivo.

sequenceDiagram
    participant C as Client
    participant R as Router
    participant A as requireAuth
    participant V as requireVerifiedEmail
    participant T as authorizeAttach
    participant M as receiveFile
    participant S as validateSourceDocumentFile
    participant U as uploadAssetToImageKit
    participant L as Listing

    C->>R: POST source document
    R->>A: authenticate request
    A-->>R: auth context
    R->>V: verify email
    V-->>R: allowed
    R->>T: check plan and ownership
    T->>L: find listing
    L-->>T: listing data
    T-->>R: allowed
    R->>M: parse file field
    M-->>R: file buffer
    R->>S: validate file
    S-->>R: valid
    R->>U: upload file
    U-->>R: fileId and url
    R->>L: append source document
    L-->>R: updated listing
    R-->>C: 201 document attached

El archivo se sube solo después de pasar la validación local. Si la persistencia falla después de subir, el router intenta borrar el asset remoto.

Eliminación de documento

El flujo de borrado comprueba propiedad y existencia del subdocumento. Después borra el asset remoto y solo luego elimina la referencia en MongoDB.

sequenceDiagram
    participant C as Client
    participant R as Router
    participant A as requireAuth
    participant L as Listing
    participant M as deleteAsset

    C->>R: DELETE source document
    R->>A: authenticate request
    A-->>R: auth context
    R->>L: find owned listing
    L-->>R: listing and source documents
    R->>M: delete asset
    M-->>R: deletion result
    R->>L: remove subdocument
    L-->>R: updated listing
    R-->>C: 200 document deleted

Si deleteAsset falla, el subdocumento permanece adjunto. Eso permite reintentar sin perder la referencia.

Gotchas & edge cases

[!WARNING] El router usa multer.memoryStorage(). El archivo entra en memoria antes de persistirse. Aun así, authorizeAttach corre antes y bloquea usuarios sin permiso.

[!CAUTION] La subida es atómica en MongoDB. Si dos POST compiten, el filtro de findOneAndUpdate deja pasar solo uno. El otro flujo borra el archivo subido en ImageKit para evitar huérfanos.

[!NOTE] defParamCharset: "utf8" evita que multer corrompa nombres con acentos. El comentario del archivo menciona este problema explícitamente.

[!IMPORTANT] router.delete("/:docId") no exige el plan Creator. La eliminación sigue disponible aunque el usuario haya degradado su cuenta.

  • La ruta de subida devuelve 403 cuando canAttachSourceDocument(user) rechaza el plan.
  • La ruta de subida devuelve 400 cuando listing.intent === "requirement".
  • La subida devuelve 409 cuando el listado ya alcanzó MEDIA_CONFIG.maxDocumentsPerListing.
  • La eliminación responde 404 si el docId no existe dentro de sourceDocuments.
  • Si uploadAssetToImageKit devuelve IMAGEKIT_NOT_CONFIGURED, la respuesta es 503.
  • Si deleteAsset falla, el subdocumento no se elimina de MongoDB.

Este archivo trabaja con varios módulos del área de listings, auth y media.

  • services/api/src/modules/listings/source-documents.js — Centraliza SOURCE_DOCUMENT_MIME_TYPE, canAttachSourceDocument, sanitizeSourceDocumentName, validateSourceDocumentFile, serializeSourceDocument y serializeSourceDocuments.
  • services/api/src/modules/media/media.service.js — Provee uploadAssetToImageKit y deleteAsset.
  • services/api/src/models/Listing.js — Contiene intent, createdBy y sourceDocuments.
  • services/api/src/models/User.js — Provee plan y accountRole para la autorización.
  • services/api/src/modules/auth/auth.middleware.js — Aporta requireAuth y requireVerifiedEmail.
  • services/api/src/config/media.js — Define maxDocumentSizeBytes, maxDocumentSizeMB y maxDocumentsPerListing.
  • services/api/src/modules/listings/listings.routes.js — Comentado como punto de montaje del router en /api/listings/:id/source-documents.