services/api/src/modules/listings/listing-public-view.js

Overview

Este módulo centraliza la serialización pública de un Listing. Su función principal es construir una forma segura para respuestas HTTP, combinando el enmascarado del anunciante con el saneamiento de contact, location, media, sourceDocuments y requirement.searchZones. El repositorio lo describe como el punto de entrada único usado por GET /api/listings y GET /api/listings/:id, para que el feed y el detalle no diverjan.

El módulo trabaja solo sobre objetos de respuesta. No persiste cambios en Mongo, y sus comentarios lo recalcan varias veces. Para lifecycle.status = anonymized o purged, reduce más campos; para normal y protection, conserva la política pública actual.

[!NOTE] viewerUserId solo sirve para decidir si el visitante es el dueño del Listing. El comentario del archivo indica que esa identidad ya debe venir verificada por la ruta, normalmente a partir de un JWT decodificado.

How it works

flowchart TD
    A[Listing de entrada] --> B[stripInternalListingFields]
    B --> C[maskListingOwner]
    C --> D{intent requirement y searchZones}
    D -->|si| E[sanitizeSearchZonesForPublicView]
    D -->|no| F[conservar requirement]
    E --> G[stripMediaFileIds]
    F --> G
    G --> H[serializeSourceDocuments]
    H --> I{lifecycle status anonymized o purged}
    I -->|no| J[Devolver listing publico]
    I -->|si| K[sanitizeLocation]
    I -->|si| L[sanitizeMedia]
    I -->|si| M[Vaciar contact y sourceDocuments]
    K --> N[Devolver listing enmascarado]
    L --> N
    M --> N

La función sanitizeListingForPublicView ejecuta el pipeline completo en un orden fijo. Primero quita campos internos, luego enmascara al anunciante, después limita searchZones para visitantes que no son dueños, y por último limpia media y sourceDocuments. Solo al final decide si debe vaciar contact, reducir location y filtrar media tipo map.

Key components

{
    "title": "sanitizeListingForPublicView",
    "content": "[badge:public] Punto de entrada \u00fanico para serializar un Listing hacia la forma p\u00fablica final. Compone el enmascarado del anunciante con el saneamiento de contacto, ubicaci\u00f3n, media, searchZones y sourceDocuments.",
    "type": "function",
    "filePath": "services/api/src/modules/listings/listing-public-view.js",
    "badges": [
        "public"
    ]
}
{
    "title": "stripInternalListingFields",
    "content": "[badge:public] Elimina campos internos del Listing y agrega deletionPending para el due\u00f1o cuando la eliminaci\u00f3n est\u00e1 pendiente. No muta el objeto recibido.",
    "type": "function",
    "filePath": "services/api/src/modules/listings/listing-public-view.js",
    "badges": [
        "public"
    ]
}
{
    "title": "maskListingOwner",
    "content": "[badge:public] Funci\u00f3n importada desde listing-owner-view.js. Sustituye createdBy por una identidad neutral cuando lifecycle.status es anonymized o purged.",
    "type": "function",
    "filePath": "services/api/src/modules/listings/listing-owner-view.js",
    "badges": [
        "public"
    ]
}
{
    "title": "serializeSourceDocuments",
    "content": "[badge:public] Funci\u00f3n importada desde source-documents.js. Convierte sourceDocuments a la forma p\u00fablica que este m\u00f3dulo vuelve a usar en la respuesta.",
    "type": "function",
    "filePath": "services/api/src/modules/listings/source-documents.js",
    "badges": [
        "public"
    ]
}

Data & fields

[
    {
        "name": "listing",
        "type": "object",
        "required": true,
        "description": "Listing recibido por el serializador. Puede ser lean o un documento hidratado.",
        "children": [
            {
                "name": "_id",
                "type": "string | object",
                "description": "Identificador del Listing. El módulo no lo modifica."
            },
            {
                "name": "createdBy",
                "type": "object | string | null",
                "description": "Autor poblado o referencia al autor. Puede ser reemplazado por una identidad neutral en estados anonymized o purged."
            },
            {
                "name": "intent",
                "type": "string",
                "description": "Si vale requirement, el módulo también sanea requirement.searchZones."
            },
            {
                "name": "lifecycle",
                "type": "object",
                "description": "Objeto que controla la política pública del Listing.",
                "children": [
                    {
                        "name": "status",
                        "type": "string",
                        "description": "Si vale anonymized o purged, contact, location, media y sourceDocuments reciben saneamiento adicional."
                    },
                    {
                        "name": "publicOwnerLabel",
                        "type": "string",
                        "description": "Etiqueta pública usada por maskListingOwner cuando el anunciante queda anonimizado."
                    }
                ]
            },
            {
                "name": "contact",
                "type": "object",
                "description": "Datos de contacto visibles en la respuesta pública antes del saneamiento de estados protegidos.",
                "children": [
                    {
                        "name": "name",
                        "type": "string",
                        "description": "Nombre público del contacto."
                    },
                    {
                        "name": "phone",
                        "type": "string",
                        "description": "Teléfono del contacto."
                    },
                    {
                        "name": "method",
                        "type": "string",
                        "description": "Método principal de contacto."
                    },
                    {
                        "name": "link",
                        "type": "string",
                        "description": "Enlace externo asociado al contacto."
                    },
                    {
                        "name": "telegramUsername",
                        "type": "string",
                        "description": "Usuario de Telegram."
                    },
                    {
                        "name": "message",
                        "type": "string",
                        "description": "Mensaje asociado al contacto."
                    }
                ]
            },
            {
                "name": "location",
                "type": "object",
                "description": "Ubicación del Listing.",
                "children": [
                    {
                        "name": "country",
                        "type": "string",
                        "description": "País conservado incluso en la vista reducida."
                    },
                    {
                        "name": "state",
                        "type": "string",
                        "description": "Estado conservado incluso en la vista reducida."
                    },
                    {
                        "name": "city",
                        "type": "string",
                        "description": "Ciudad conservada incluso en la vista reducida."
                    },
                    {
                        "name": "neighborhood",
                        "type": "string",
                        "description": "Colonia o zona. El archivo conserva este campo aunque documenta un riesgo residual de localización."
                    },
                    {
                        "name": "address",
                        "type": "string",
                        "description": "Se vacía en estados anonymized o purged."
                    },
                    {
                        "name": "postalCode",
                        "type": "string",
                        "description": "Se vacía en estados anonymized o purged."
                    },
                    {
                        "name": "latitude",
                        "type": "number | null",
                        "description": "Se nulifica en estados anonymized o purged."
                    },
                    {
                        "name": "longitude",
                        "type": "number | null",
                        "description": "Se nulifica en estados anonymized o purged."
                    },
                    {
                        "name": "googleMapsUrl",
                        "type": "string",
                        "description": "Se vacía en estados anonymized o purged."
                    },
                    {
                        "name": "mapSnapshotUrl",
                        "type": "string",
                        "description": "Se vacía en estados anonymized o purged."
                    }
                ]
            },
            {
                "name": "requirement.searchZones",
                "type": "array",
                "description": "Zonas de búsqueda del requirement. Para visitantes que no son dueños, el centro exacto no se expone.",
                "children": [
                    {
                        "name": "label",
                        "type": "string",
                        "description": "Etiqueta visible de la zona."
                    },
                    {
                        "name": "country",
                        "type": "string",
                        "description": "País de la zona."
                    },
                    {
                        "name": "state",
                        "type": "string",
                        "description": "Estado de la zona."
                    },
                    {
                        "name": "city",
                        "type": "string",
                        "description": "Ciudad de la zona."
                    },
                    {
                        "name": "neighborhood",
                        "type": "string",
                        "description": "Colonia de la zona."
                    },
                    {
                        "name": "radiusKm",
                        "type": "number | null",
                        "description": "Radio público de búsqueda."
                    },
                    {
                        "name": "center",
                        "type": "object | null",
                        "description": "Se conserva completo solo para el dueño; para otros visitantes se reemplaza por null."
                    }
                ]
            },
            {
                "name": "media",
                "type": "array",
                "description": "Lista de medios adjuntos al Listing.",
                "children": [
                    {
                        "name": "type",
                        "type": "string",
                        "description": "Cuando vale map, el elemento se elimina en estados anonymized o purged."
                    },
                    {
                        "name": "fileId",
                        "type": "string",
                        "description": "Identificador interno que siempre se retira de la respuesta pública."
                    }
                ]
            },
            {
                "name": "sourceDocuments",
                "type": "array",
                "description": "Documentos fuente del Listing. Se serializan primero y luego se vacían por completo en estados anonymized o purged."
            },
            {
                "name": "deletion",
                "type": "object",
                "description": "Campo interno que nunca sale en la respuesta pública.",
                "children": [
                    {
                        "name": "status",
                        "type": "string",
                        "description": "Si vale pending y el visitante es el dueño, el serializer expone deletionPending."
                    }
                ]
            },
            {
                "name": "conversationReservations",
                "type": "array",
                "description": "Campo interno eliminado siempre."
            },
            {
                "name": "mediaRevision",
                "type": "string | number | object",
                "description": "Campo interno eliminado siempre."
            },
            {
                "name": "deletionPending",
                "type": "boolean",
                "description": "Indicador derivado que solo aparece para el dueño cuando deletion.status es pending."
            }
        ]
    },
    {
        "name": "viewerUserId",
        "type": "string | null",
        "description": "Identidad verificada del visitante. Solo decide si searchZones.center se conserva completo."
    }
]

El archivo define varios campos de salida con política fija. CONTACT_AND_LOCATION_MASKED_STATUSES contiene anonymized y purged. EMPTY_CONTACT es un objeto congelado con campos vacíos. INTERNAL_LISTING_FIELDS elimina deletion, conversationReservations y mediaRevision de cualquier respuesta pública.

Behaviors

  • sanitizeLocation(location) — devuelve una ubicación reducida con country, state, city y neighborhood, y vacía address, postalCode, latitude, longitude, googleMapsUrl y mapSnapshotUrl.
  • sanitizeSearchZonesForPublicView(searchZones, { isOwner }) — conserva searchZones completas para el dueño y, para otros visitantes, mantiene la geografía visible pero reemplaza center por null.
  • sanitizeMedia(media) — elimina cualquier item cuyo type sea map.
  • stripMediaFileIds(media) — recorre la lista de media y elimina fileId de cada item.
  • stripInternalListingFields(listing, { isOwner = false } = {}) — clona el documento, quita campos internos y agrega deletionPending: true solo si el dueño ve un Listing con deletion.status = pending.
  • sanitizeListingForPublicView(listing, { viewerUserId } = {}) — aplica el pipeline completo de serialización pública y nunca muta el Listing original. Para normal y protection, devuelve la forma pública sin vaciar contact ni reducir location.

Workflows

sequenceDiagram
    participant Caller
    participant SLPV as sanitizeListingForPublicView
    participant Strip as stripInternalListingFields
    participant Mask as maskListingOwner
    participant Zones as sanitizeSearchZonesForPublicView
    participant Media as stripMediaFileIds
    participant Docs as serializeSourceDocuments
    participant Loc as sanitizeLocation
    participant Filter as sanitizeMedia

    Caller->>SLPV: listing and viewerUserId
    SLPV->>Strip: remove internal fields
    Strip-->>SLPV: listing without internals
    SLPV->>Mask: mask owner by lifecycle status
    Mask-->>SLPV: owner masked or unchanged
    SLPV->>Zones: sanitize requirement search zones when present
    Zones-->>SLPV: zones for owner or public
    SLPV->>Media: strip media fileId
    Media-->>SLPV: media without fileId
    SLPV->>Docs: serialize source documents
    Docs-->>SLPV: public source documents
    alt lifecycle status is anonymized or purged
        SLPV->>Loc: reduce location
        Loc-->>SLPV: reduced location
        SLPV->>Filter: remove map media
        Filter-->>SLPV: media without map items
        SLPV-->>Caller: masked public listing
    else lifecycle status is normal or protection
        SLPV-->>Caller: public listing
    end

El flujo es deliberadamente determinista. El módulo primero prepara una copia pública estable y solo después aplica el bloqueo extra por estado de ciclo de vida. Eso permite que la misma función sirva para feed, detalle y otros consumidores públicos.

Gotchas & edge cases

[!WARNING] viewerUserId no debe tomarse de un valor sin verificar. El archivo aclara que la ruta debe decodificar y validar la identidad antes de pasársela a este serializer.

[!CAUTION] location.neighborhood permanece visible en la versión reducida. El propio código documenta un riesgo residual de reidentificación en barrios pequeños o poco densos.

[!IMPORTANT] media se limpia en dos niveles distintos. Primero se elimina fileId siempre, y luego se expulsan los elementos type = map cuando el Listing está anonymized o purged.

[!NOTE] stripInternalListingFields usa toJSON() cuando existe. El comentario explica que eso replica exactamente la forma que res.json produciría para un documento hidratado.

[!WARNING] sourceDocuments se serializa antes del bloqueo por estado, pero en anonymized y purged el resultado final se reemplaza por un arreglo vacío.

  • services/api/src/modules/listings/listing-owner-view.js aporta maskListingOwner, que sustituye createdBy por una identidad neutral en anonymized y purged.
  • services/api/src/modules/listings/source-documents.js aporta serializeSourceDocuments, que este módulo usa antes del saneamiento final de sourceDocuments.
  • services/api/src/modules/listings/listing-public-assembly.js reutiliza sanitizeListingForPublicView para compartir la misma forma pública entre GET /api/listings, GET /api/listings/:id y el consumidor de Match.
  • services/api/test/listing-public-view.test.js y services/api/test/listing-public-assembly.test.js validan el comportamiento de esta serialización compartida.

[!NOTE] Este archivo no define endpoints HTTP. Su responsabilidad es exclusivamente transformar objetos Listing antes de que la capa de rutas los entregue al cliente.