Rutas de listings

Overview

Este archivo define la interfaz HTTP de Listing para lectura y escritura. Expone la colección pública, el detalle por identificador y las mutaciones protegidas para crear, editar, cambiar estado y eliminar listings. También calcula likedByCurrentUser en la vista de colección cuando llega un Bearer token válido, y reutiliza ensamblado público compartido para la lista y el detalle .

Las rutas de escritura usan requireAuth y requireVerifiedEmail en POST, PATCH, PATCH /:id/status y DELETE. Las rutas de lectura no exigen verificación de correo, y GET /api/listings sigue siendo público, con mine=true como puerta para la vista autenticada del usuario .

[!NOTE] likedByCurrentUser solo aparece en la respuesta de colección. El detalle por :id usa el ensamblado público, pero no agrega esa marca derivada .

How it works

flowchart TD
    Router[listings routes] --> ListPublic[GET api listings]
    Router --> ListOne[GET api listings id]
    Router --> Create[POST api listings]
    Router --> Update[PATCH api listings id]
    Router --> Status[PATCH api listings id status]
    Router --> Delete[DELETE api listings id]
    Router --> SourceDocs[source documents router]

    ListPublic --> PublicAsm[assemblePublicListingsForViewer]
    ListPublic --> LikesMap[buildLikedByCurrentUserMap]
    ListPublic --> Jwt[verifyAccessToken]
    ListPublic --> ListingModel[Listing model]

    ListOne --> ListingModel
    ListOne --> PublicAsm

    Create --> Auth[requireAuth]
    Create --> Email[requireVerifiedEmail]
    Create --> Sanitize[sanitizeClientListingPayload]
    Create --> Validate[validateListingPayload]
    Create --> Media[resolveTrustedListingMedia]
    Create --> Claim[claimListingMediaFiles]
    Create --> ListingModel
    Create --> Referral[validateRelatedOffer]

    Update --> Auth
    Update --> Email
    Update --> Sanitize
    Update --> Validate
    Update --> Media
    Update --> Claim
    Update --> ListingModel
    Update --> Referral
    Update --> Cleanup[cleanupExpiredReferralRelations]

    Status --> Auth
    Status --> Email
    Status --> ListingModel
    Status --> Limit[checkListingLimit]
    Status --> ReferralSync[syncReferralGraceForOffer]

    Delete --> Auth
    Delete --> Email
    Delete --> ListingModel
    Delete --> FileDelete[deleteListingFilesSafely]

La ruta agrupa lectura, edición, publicación y borrado dentro de un solo express.Router(). El flujo público filtra por estado, paginación y autor; el flujo de escritura valida autenticación, pertenencia y reglas de negocio. Además, las operaciones de medios usan reclamos y liberación controlada para evitar carreras entre requests concurrentes.

Lectura pública

La colección pública aplica filtros por lifecycle.status, publication.status, createdBy, status, userId e intent. Si se envía un Bearer válido, la ruta intenta resolver el usuario actual y marca cada listing con likedByCurrentUser usando ListingLike y buildLikedByCurrentUserMap .

Escrituras protegidas

Las mutaciones usan requireAuth y requireVerifiedEmail, y luego vuelven a comprobar propiedad, estado de ciclo de vida, límites del plan y coherencia del payload. La creación y la edición también pasan por validación del payload y por controles específicos de medios, referrals y ubicación exacta.

Key components

Estas son las piezas internas más importantes del archivo. Todas participan en la decisión de lectura o escritura, o en la preparación de la respuesta.

{
    "title": "resolveIntent",
    "content": "Normaliza el valor de intent. Solo \"requirement\" se conserva como requirement; cualquier otro valor resuelve a offer.",
    "type": "function",
    "filePath": "services/api/src/modules/listings/listings.routes.js",
    "badges": []
}
{
    "title": "checkListingLimit",
    "content": "Lee el plan del usuario y valida cu\u00e1ntos listings activos puede tener para la intenci\u00f3n dada. Devuelve null si no hay problema o un body 403 si se alcanz\u00f3 el l\u00edmite.",
    "type": "function",
    "filePath": "services/api/src/modules/listings/listings.routes.js",
    "badges": []
}
{
    "title": "clearExactLocationIfNotDeclared",
    "content": "Borra los datos de ubicaci\u00f3n exacta cuando location.declaration resuelve a not_declared. Conserva la informaci\u00f3n de \u00e1rea, pero vac\u00eda address, postalCode, latitude, longitude, googleMapsUrl, mapSnapshotUrl y short.",
    "type": "function",
    "filePath": "services/api/src/modules/listings/listings.routes.js",
    "badges": []
}
{
    "title": "respondGuardedWriteMiss",
    "content": "Responde cuando una escritura protegida no encuentra coincidencia. Diferencia entre eliminaci\u00f3n pendiente y ausencia o falta de permisos.",
    "type": "function",
    "filePath": "services/api/src/modules/listings/listings.routes.js",
    "badges": []
}
{
    "title": "respondMediaWriteMiss",
    "content": "Gestiona el fallo de una escritura con media revision. Libera reclamos que ya no siguen referenciados y responde con 409, 404 o el body de conflicto seg\u00fan el estado actual.",
    "type": "function",
    "filePath": "services/api/src/modules/listings/listings.routes.js",
    "badges": []
}
{
    "title": "respondRequirementDeletion",
    "content": "Orquesta la eliminaci\u00f3n segura de requirements. Traduce los resultados de deleteRequirement a respuestas 200, 404, 500 o 502 con reintento.",
    "type": "function",
    "filePath": "services/api/src/modules/listings/listings.routes.js",
    "badges": []
}

Data & fields

Este router trabaja con filtros de colección, payloads de escritura y varios estados internos. Los datos visibles al cliente pasan por allowlists y por reglas de limpieza antes de persistirse.

Constantes de control

Estas constantes guían la respuesta del router y el filtrado de escritura.

  • NOT_PENDING_DELETION — filtro de escritura que exige deletion: null.
  • LISTING_DELETION_PENDING_BODY — body común para conflictos por eliminación pendiente.
  • LISTING_EDIT_CONFLICT_BODY — body común para choques de edición de medios.
  • REQUIREMENT_DELETION_RETRY_BODY — body de reintento para eliminaciones de requirement.
  • PLAN_LIMITS — cuotas por User.plan.
    • free — offerLimit: 5, requirementLimit: null.
    • professional — offerLimit: 15, requirementLimit: null.
    • creator — ilimitado en ambas intenciones.
    • enterprise — ilimitado en ambas intenciones.
  • PLAN_LABELS — nombres legibles usados en mensajes de cuota.
  • NON_EDITABLE_LIFECYCLE — estados que ya no admiten edición: protection, anonymized, purged.
  • EXACT_LOCATION_FIELDS — campos borrados cuando la ubicación se declara como no declarada.

Query params de la colección

El endpoint de colección acepta paginación y filtros de búsqueda. Solo page y limit afectan la paginación; el resto cambia el conjunto devuelto.

[
    {
        "name": "page",
        "type": "number",
        "description": "Número de página. Si no es válido, el router usa 1."
    },
    {
        "name": "limit",
        "type": "number",
        "description": "Cantidad de resultados por página. Si no es válido o supera 50, el router usa 10."
    },
    {
        "name": "mine",
        "type": "string",
        "description": "Cuando vale \"true\", exige Bearer token y limita la consulta al usuario autenticado."
    },
    {
        "name": "status",
        "type": "string",
        "description": "Filtra por publication.status. Si no es published, exige mine=true."
    },
    {
        "name": "userId",
        "type": "string",
        "description": "Filtra por createdBy. Debe ser un ObjectId válido."
    },
    {
        "name": "intent",
        "type": "string",
        "description": "Filtra por intention. requirement exige igualdad exacta; offer incluye los legacy sin campo."
    }
]

Path params

Los endpoints de detalle, edición, cambio de estado y eliminación comparten el identificador de ruta.

[
    {
        "name": "id",
        "type": "string",
        "required": true,
        "description": "Identificador del Listing que se consulta o modifica."
    }
]

Payload de creación y edición

La ruta no acepta el cuerpo crudo completo. Primero lo pasa por sanitizeClientListingPayload, que solo conserva campos escribibles por cliente.

[
    {
        "name": "title",
        "type": "string",
        "description": "Título del listing."
    },
    {
        "name": "intent",
        "type": "string",
        "description": "Intención del listing. Se normaliza con resolveIntent."
    },
    {
        "name": "price",
        "type": "number | object",
        "description": "Dato de precio permitido por la allowlist del payload."
    },
    {
        "name": "location",
        "type": "object",
        "description": "Subdocumento de ubicación. Puede conservar campos hermanos al hacer PATCH parcial."
    },
    {
        "name": "description",
        "type": "string",
        "description": "Descripción del anuncio."
    },
    {
        "name": "contact",
        "type": "object",
        "description": "Bloque de contacto permitido por el sanitizer."
    },
    {
        "name": "media",
        "type": "array",
        "description": "Lista de medios. El router resuelve y verifica los archivos de ImageKit antes de guardar."
    },
    {
        "name": "pdf",
        "type": "object",
        "description": "Metadatos del PDF generado bajo demanda."
    },
    {
        "name": "meta",
        "type": "object",
        "description": "Subdocumento de metadatos del listing."
    },
    {
        "name": "requirement",
        "type": "object",
        "description": "Bloque de requirement y referral cuando el listing pertenece a esa intención."
    },
    {
        "name": "publication",
        "type": "object",
        "description": "Solo conserva los campos publicados por el cliente, como status y role."
    },
    {
        "name": "submissionId",
        "type": "string",
        "description": "Identificador idempotente del intento de publicación."
    }
]

Campos de ubicación exacta

Cuando location.declaration se resuelve como not_declared, el backend borra los datos precisos de ubicación aunque el cliente los haya enviado o aunque vengan heredados de un documento existente.

[
    {
        "name": "address",
        "type": "string",
        "description": "Se vacía si la ubicación se marca como no declarada."
    },
    {
        "name": "postalCode",
        "type": "string",
        "description": "Se vacía si la ubicación se marca como no declarada."
    },
    {
        "name": "latitude",
        "type": "number | null",
        "description": "Se pone en null si la ubicación se marca como no declarada."
    },
    {
        "name": "longitude",
        "type": "number | null",
        "description": "Se pone en null si la ubicación se marca como no declarada."
    },
    {
        "name": "googleMapsUrl",
        "type": "string",
        "description": "Se vacía si la ubicación se marca como no declarada."
    },
    {
        "name": "mapSnapshotUrl",
        "type": "string",
        "description": "Se vacía si la ubicación se marca como no declarada."
    },
    {
        "name": "short",
        "type": "string",
        "description": "Se vacía si la ubicación se marca como no declarada."
    }
]

Reglas de referral

El router también toca campos de referral dentro de requirement.referral. Esa lógica se usa para validar el anuncio relacionado y preservar la gracia cuando el status de un offer cambia.

  • requirement.referral.relatedListingId — debe ser un ObjectId válido cuando existe.
  • requirement.referral.relatedListingUnpublishedAt — se reinicia a null al crear o al cambiar el vínculo.
  • validateRelatedOffer(...) — valida que el anuncio relacionado siga siendo válido.
  • syncReferralGraceForOffer(...) — sincroniza la gracia cuando cambia publication.status.
  • cleanupExpiredReferralRelations() — limpia relaciones expiradas antes de ciertas escrituras.

Behaviors

Estas son las funciones y decisiones de negocio visibles en el archivo. Cada una devuelve una respuesta o prepara una escritura concreta.

  • isDeletionPending(listing) — devuelve true cuando listing.deletion.status es pending.
  • isLifecycleEditable(listing) — devuelve true si el listing no está en protection, anonymized o purged.
  • resolveIntent(rawIntent) — normaliza la intención del payload a requirement u offer.
  • checkListingLimit(userId, intent) — compara el conteo de listings activos del usuario con la cuota del plan y devuelve un body 403 si supera el límite.
  • clearExactLocationIfNotDeclared(location) — elimina los campos exactos cuando la declaración de ubicación no existe.
  • respondGuardedWriteMiss(res, context) — responde 409 si la eliminación quedó pendiente, o 404 si la escritura no puede continuar.
  • respondMediaWriteMiss(res, context) — libera reclamos no referenciados y responde con el estado correcto para conflictos de medios.
  • respondRequirementDeletion(res, context) — traduce el resultado de deleteRequirement a la respuesta HTTP final.
  • router.get("/") — lista listings con filtros, paginación, populate y marca likedByCurrentUser.
  • router.get("/:id") — devuelve un listing público o el propio listing del dueño si no está publicado.
  • router.post("/") — crea un listing autenticado y verificado por correo.
  • router.patch("/:id") — edita un listing propio con validación de payload y control de medios.
  • router.patch("/:id/status") — cambia publication.status entre published, draft y archived.
  • router.delete("/:id") — elimina un listing propio, o delega a la eliminación segura si es requirement.

API

GET /api/listings

Este endpoint devuelve la colección de listings. Es público por defecto, pero puede cambiar el alcance con mine=true o con filtros de estado y autor. Si llega un Bearer válido, añade likedByCurrentUser a cada elemento de la respuesta .

{
    "title": "List listings",
    "description": "Returns paginated listings with public filters, optional owner scope, and likedByCurrentUser when a valid token is present.",
    "method": "GET",
    "baseUrl": "/api",
    "endpoint": "/listings",
    "headers": [
        {
            "key": "Authorization",
            "value": "Bearer <token>",
            "required": false
        }
    ],
    "queryParams": [
        {
            "key": "page",
            "value": "1",
            "required": false
        },
        {
            "key": "limit",
            "value": "10",
            "required": false
        },
        {
            "key": "mine",
            "value": "true",
            "required": false
        },
        {
            "key": "status",
            "value": "published",
            "required": false
        },
        {
            "key": "userId",
            "value": "66c5f2f9e6c1c1d4b3a8f901",
            "required": false
        },
        {
            "key": "intent",
            "value": "offer",
            "required": false
        }
    ],
    "pathParams": [],
    "bodyType": "none",
    "requestBody": "",
    "formData": [],
    "rawBody": "",
    "responses": {
        "200": {
            "description": "Success",
            "body": "{\n  \"success\": true,\n  \"count\": 2,\n  \"total\": 24,\n  \"page\": 1,\n  \"pages\": 12,\n  \"data\": [\n    {\n      \"_id\": \"66c5f2f9e6c1c1d4b3a8f901\",\n      \"likedByCurrentUser\": false\n    }\n  ]\n}"
        },
        "401": {
            "description": "Authentication required for non published status or mine scope",
            "body": "{\n  \"success\": false,\n  \"message\": \"Se requiere autenticaci\u00f3n para consultar anuncios no publicados.\"\n}"
        },
        "400": {
            "description": "Invalid userId",
            "body": "{\n  \"success\": false,\n  \"message\": \"userId inv\u00e1lido.\"\n}"
        },
        "500": {
            "description": "Internal error",
            "body": "{\n  \"success\": false,\n  \"message\": \"No se pudieron obtener los listings.\"\n}"
        }
    }
}

GET /api/listings/:id

Este endpoint devuelve un listing concreto por su identificador. El detalle es público si el listing está publicado. Si el listing no está publicado, solo el dueño autenticado puede verlo; en caso contrario, responde con 404 genérico .

{
    "title": "Get listing by id",
    "description": "Returns one listing by id. Draft or archived listings are only visible to their owner.",
    "method": "GET",
    "baseUrl": "/api",
    "endpoint": "/listings/:id",
    "headers": [
        {
            "key": "Authorization",
            "value": "Bearer <token>",
            "required": false
        }
    ],
    "queryParams": [],
    "pathParams": [
        {
            "key": "id",
            "value": "Listing id",
            "required": true
        }
    ],
    "bodyType": "none",
    "requestBody": "",
    "formData": [],
    "rawBody": "",
    "responses": {
        "200": {
            "description": "Success",
            "body": "{\n  \"success\": true,\n  \"data\": {\n    \"_id\": \"66c5f2f9e6c1c1d4b3a8f901\",\n    \"publication\": {\n      \"status\": \"published\"\n    }\n  }\n}"
        },
        "400": {
            "description": "Invalid id",
            "body": "{\n  \"success\": false,\n  \"message\": \"ID inv\u00e1lido.\"\n}"
        },
        "404": {
            "description": "Not found or not allowed",
            "body": "{\n  \"success\": false,\n  \"message\": \"Listing no encontrado.\"\n}"
        },
        "500": {
            "description": "Internal error",
            "body": "{\n  \"success\": false,\n  \"message\": \"No se pudo obtener el listing.\"\n}"
        }
    }
}

POST /api/listings

Este endpoint crea un listing nuevo para el usuario autenticado. La ruta primero normaliza intent, aplica idempotencia con submissionId, valida la cuota del plan y después procesa medios, referral y payload final antes de persistir el documento.

{
    "title": "Create listing",
    "description": "Creates a new listing for the authenticated and verified user, with plan checks, idempotency, media claims and payload validation.",
    "method": "POST",
    "baseUrl": "/api",
    "endpoint": "/listings",
    "headers": [
        {
            "key": "Authorization",
            "value": "Bearer <token>",
            "required": true
        },
        {
            "key": "Content-Type",
            "value": "application/json",
            "required": true
        }
    ],
    "queryParams": [],
    "pathParams": [],
    "bodyType": "json",
    "requestBody": "{\n  \"submissionId\": \"pub_2026_001\",\n  \"intent\": \"offer\",\n  \"title\": \"Casa en venta\",\n  \"meta\": {\n    \"entityType\": \"property\",\n    \"operationType\": \"sale\",\n    \"propertyType\": \"house\"\n  },\n  \"publication\": {\n    \"status\": \"draft\"\n  }\n}",
    "formData": [],
    "rawBody": "",
    "responses": {
        "201": {
            "description": "Created",
            "body": "{\n  \"success\": true,\n  \"message\": \"Listing creado correctamente.\",\n  \"data\": {\n    \"_id\": \"66c5f2f9e6c1c1d4b3a8f901\"\n  }\n}"
        },
        "200": {
            "description": "Idempotent retry",
            "body": "{\n  \"success\": true,\n  \"message\": \"Listing ya creado para este env\u00edo.\",\n  \"idempotent\": true,\n  \"data\": {\n    \"_id\": \"66c5f2f9e6c1c1d4b3a8f901\"\n  }\n}"
        },
        "400": {
            "description": "Invalid payload or related listing id",
            "body": "{\n  \"success\": false,\n  \"message\": \"Payload inv\u00e1lido.\"\n}"
        },
        "401": {
            "description": "Not authenticated or not verified",
            "body": "{\n  \"success\": false,\n  \"message\": \"Se requiere autenticaci\u00f3n.\"\n}"
        },
        "403": {
            "description": "Plan limit or seeker restriction",
            "body": "{\n  \"success\": false,\n  \"code\": \"PLAN_LIMIT_REACHED\",\n  \"plan\": \"free\",\n  \"intent\": \"offer\",\n  \"limit\": 5,\n  \"message\": \"El plan gratuito permite hasta 5 anuncios activos. Actualiza a Plan Profesional o Plan Creador para publicar m\u00e1s.\"\n}"
        },
        "500": {
            "description": "Internal error",
            "body": "{\n  \"success\": false,\n  \"message\": \"No se pudo crear el listing.\"\n}"
        }
    }
}

PATCH /api/listings/:id

Este endpoint edita un listing propio. Fusiona meta, requirement y location con el documento existente, controla la revisión de medios y vuelve a validar el payload completo antes de escribir.

{
    "title": "Update listing",
    "description": "Updates an owned listing with partial payloads, media revision checks and referral validation.",
    "method": "PATCH",
    "baseUrl": "/api",
    "endpoint": "/listings/:id",
    "headers": [
        {
            "key": "Authorization",
            "value": "Bearer <token>",
            "required": true
        },
        {
            "key": "Content-Type",
            "value": "application/json",
            "required": true
        }
    ],
    "queryParams": [],
    "pathParams": [
        {
            "key": "id",
            "value": "Listing id",
            "required": true
        }
    ],
    "bodyType": "json",
    "requestBody": "{\n  \"title\": \"Casa en venta actualizada\",\n  \"location\": {\n    \"declaration\": \"not_declared\"\n  }\n}",
    "formData": [],
    "rawBody": "",
    "responses": {
        "200": {
            "description": "Success",
            "body": "{\n  \"success\": true,\n  \"message\": \"Listing actualizado correctamente.\",\n  \"data\": {\n    \"_id\": \"66c5f2f9e6c1c1d4b3a8f901\"\n  }\n}"
        },
        "400": {
            "description": "Invalid payload or immutable intent",
            "body": "{\n  \"success\": false,\n  \"code\": \"INTENT_IMMUTABLE\",\n  \"message\": \"No puedes cambiar si una publicaci\u00f3n es oferta o requerimiento despu\u00e9s de crearla.\"\n}"
        },
        "401": {
            "description": "Not authenticated or not verified",
            "body": "{\n  \"success\": false,\n  \"message\": \"Se requiere autenticaci\u00f3n.\"\n}"
        },
        "403": {
            "description": "Seeker cannot edit offer",
            "body": "{\n  \"success\": false,\n  \"code\": \"OFFER_NOT_ALLOWED_FOR_SEEKER\",\n  \"message\": \"Tu cuenta est\u00e1 configurada para buscar, no para publicar ofertas.\"\n}"
        },
        "404": {
            "description": "Not found or no permission",
            "body": "{\n  \"success\": false,\n  \"message\": \"Listing no encontrado o sin permisos.\"\n}"
        },
        "409": {
            "description": "Deletion pending or edit conflict",
            "body": "{\n  \"success\": false,\n  \"code\": \"LISTING_EDIT_CONFLICT\",\n  \"message\": \"Este anuncio cambi\u00f3 mientras guardabas. Recarga e intenta de nuevo.\"\n}"
        },
        "500": {
            "description": "Internal error",
            "body": "{\n  \"success\": false,\n  \"message\": \"No se pudo actualizar el listing.\"\n}"
        }
    }
}

PATCH /api/listings/:id/status

Este endpoint cambia solo publication.status. Acepta published, draft y archived. Si el listing sale de archived, la ruta vuelve a revisar la cuota del plan, porque esa transición puede volver a activar el conteo de anuncios activos.

{
    "title": "Change listing status",
    "description": "Changes publication.status for an owned listing and rechecks plan limits when leaving archived.",
    "method": "PATCH",
    "baseUrl": "/api",
    "endpoint": "/listings/:id/status",
    "headers": [
        {
            "key": "Authorization",
            "value": "Bearer <token>",
            "required": true
        },
        {
            "key": "Content-Type",
            "value": "application/json",
            "required": true
        }
    ],
    "queryParams": [],
    "pathParams": [
        {
            "key": "id",
            "value": "Listing id",
            "required": true
        }
    ],
    "bodyType": "json",
    "requestBody": "{\n  \"status\": \"draft\"\n}",
    "formData": [],
    "rawBody": "",
    "responses": {
        "200": {
            "description": "Success",
            "body": "{\n  \"success\": true,\n  \"message\": \"Listing draft.\",\n  \"data\": {\n    \"_id\": \"66c5f2f9e6c1c1d4b3a8f901\"\n  }\n}"
        },
        "400": {
            "description": "Invalid status",
            "body": "{\n  \"success\": false,\n  \"message\": \"Estado inv\u00e1lido. Usa: published, draft, archived.\"\n}"
        },
        "403": {
            "description": "Plan limit reached",
            "body": "{\n  \"success\": false,\n  \"code\": \"PLAN_LIMIT_REACHED\",\n  \"plan\": \"free\",\n  \"intent\": \"offer\",\n  \"limit\": 5,\n  \"message\": \"El plan gratuito permite hasta 5 anuncios activos. Actualiza a Plan Profesional o Plan Creador para publicar m\u00e1s.\"\n}"
        },
        "404": {
            "description": "Not found or no permission",
            "body": "{\n  \"success\": false,\n  \"message\": \"Listing no encontrado o sin permisos.\"\n}"
        },
        "409": {
            "description": "Deletion pending",
            "body": "{\n  \"success\": false,\n  \"code\": \"LISTING_DELETION_PENDING\",\n  \"message\": \"Esta publicaci\u00f3n se est\u00e1 eliminando y ya no se puede modificar.\"\n}"
        },
        "500": {
            "description": "Internal error",
            "body": "{\n  \"success\": false,\n  \"message\": \"No se pudo actualizar el estado.\"\n}"
        }
    }
}

DELETE /api/listings/:id

Este endpoint elimina un listing propio. Si el listing es requirement, delega en deleteRequirement; si es offer, primero borra los assets de ImageKit que ya no quedan referenciados y luego elimina el documento.

{
    "title": "Delete listing",
    "description": "Deletes an owned listing. Requirements use the safe deletion orchestrator and offers remove their media first.",
    "method": "DELETE",
    "baseUrl": "/api",
    "endpoint": "/listings/:id",
    "headers": [
        {
            "key": "Authorization",
            "value": "Bearer <token>",
            "required": true
        }
    ],
    "queryParams": [],
    "pathParams": [
        {
            "key": "id",
            "value": "Listing id",
            "required": true
        }
    ],
    "bodyType": "none",
    "requestBody": "",
    "formData": [],
    "rawBody": "",
    "responses": {
        "200": {
            "description": "Deleted",
            "body": "{\n  \"success\": true,\n  \"message\": \"Listing eliminado correctamente.\"\n}"
        },
        "404": {
            "description": "Not found or no permission",
            "body": "{\n  \"success\": false,\n  \"message\": \"Listing no encontrado o sin permisos.\"\n}"
        },
        "502": {
            "description": "Partial media deletion failure or retryable requirement deletion failure",
            "body": "{\n  \"success\": false,\n  \"code\": \"LISTING_DELETION_PENDING\",\n  \"retryable\": true,\n  \"message\": \"La eliminaci\u00f3n qued\u00f3 pendiente. Vuelve a intentarlo.\"\n}"
        },
        "500": {
            "description": "Internal error",
            "body": "{\n  \"success\": false,\n  \"message\": \"No se pudo eliminar el listing.\"\n}"
        }
    }
}

Workflows

La ruta tiene dos flujos principales: lectura pública y escritura protegida. La creación concentra la mayoría de dependencias, porque mezcla validación, cuotas, medios y referral.

sequenceDiagram
    participant C as Client
    participant R as listings routes
    participant A as requireAuth
    participant V as validateListingPayload
    participant M as Listing
    participant P as media claims service
    participant F as referral service

    C->>R: POST api listings
    R->>A: verify auth and verified email
    R->>R: sanitize payload and resolve intent
    R->>R: check plan limit
    R->>V: validate payload
    R->>P: resolve media and claim files
    R->>F: validate related offer
    R->>M: create listing
    M-->>R: created document
    R-->>C: 201 response

El flujo de lectura es más corto. La colección consulta listings, compone el resultado público y añade la marca de like. El detalle consulta un solo documento, comprueba visibilidad pública u ownership, y devuelve el ensamblado público final.

Gotchas & edge cases

[!WARNING] mine=true exige un encabezado Authorization con formato Bearer. Si falta, la ruta responde 401.

[!WARNING] GET /api/listings solo admite limit hasta 50. Si el valor no es válido, usa 10.

[!WARNING] GET /api/listings responde 401 si se pide un status distinto de published sin mine=true.

[!WARNING] GET /api/listings/:id oculta draft y archived a usuarios que no son el dueño. También devuelve 404 para listings purgados.

[!WARNING] POST /api/listings aplica cuotas por plan antes de crear. El límite depende de User.plan y de intent.

[!CAUTION] PATCH /api/listings/:id no permite cambiar intent después de creado. Si el payload intenta modificarlo, responde 400 con INTENT_IMMUTABLE.

[!IMPORTANT] location.declaration = not_declared borra los datos exactos de ubicación. La regla vive en el backend, incluso si el cliente manda esos campos o los hereda por merge.

[!WARNING] Las escrituras fallidas con media pueden devolver 409 LISTING_EDIT_CONFLICT. Cuando eso ocurre, la ruta libera solo los reclamos que ya no quedan referenciados por el listing vigente.

[!NOTE] LISTING_DELETION_PENDING se usa como respuesta común cuando un listing ya entró en eliminación pendiente. En ese estado, el documento no debe modificarse.

[!IMPORTANT] Un seeker puede crear o editar requirements, pero no offers. La ruta lo bloquea antes de persistir cambios.

[!CAUTION] deleteRequirement puede devolver una eliminación reintentable. El router traduce ese caso a retryable: true para que el cliente sepa que puede volver a intentar.

Este router se apoya en módulos cercanos del backend y en varios consumidores del frontend. La documentación previa del proyecto ya lo ubica como la superficie de consultas públicas y mutaciones protegidas de listings .

  • services/api/src/models/Listing.js — modelo principal leído y escrito por este router.
  • services/api/src/models/User.js — fuente del plan usado en checkListingLimit.
  • services/api/src/modules/listings/listings.validators.js — valida el payload antes de crear o editar.
  • services/api/src/modules/auth/auth.middleware.js — aporta requireAuth y requireVerifiedEmail.
  • services/api/src/modules/media/media.service.js — resuelve medios confiables antes de persistir.
  • services/api/src/modules/listings/listing-write-sanitizer.js — filtra el payload del cliente.
  • services/api/src/modules/listings/listing-public-assembly.js — construye la forma pública del listing.
  • services/api/src/modules/listings/listing-public-view.js — elimina campos internos de la respuesta.
  • services/api/src/modules/listings/listing-referral.js — valida y sincroniza referrals.
  • services/api/src/modules/listings/requirement-deletion.service.js — orquesta la eliminación segura de requirements.
  • services/api/src/modules/media/media-claims.service.js — reclama, libera y borra archivos de ImageKit.
  • services/api/src/modules/listings/source-documents.routes.js — subrouter montado en /:id/source-documents.

[!NOTE] El archivo también reutiliza el patrón de ensamblado público compartido para la lista y el detalle. Esa decisión mantiene la forma pública coherente entre GET /api/listings y GET /api/listings/:id .