Política de interacción de listings

Overview

Este módulo define la política central de interacción pública para un Listing. Decide si un anuncio puede recibir comentarios, likes, eventos y, de forma reservada, leads futuros. La lógica es determinista, pura y no consulta Mongo; el router ya debe pasar listing y ownerPreferences resueltos antes de llamar al helper.

Se usa como punto común de decisión en rutas de comentarios, likes y eventos. Así evita que cada router reconstruya su propia lógica de bloqueo y sus propios ApiError.

[!NOTE] DEFAULT_OWNER_PREFERENCES es la fuente canónica de los defaults de deletionGracePreferences. El comentario del archivo indica que debe coincidir con services/api/src/models/User.js, y que los datos legacy nunca deben quedar bloqueados por una preferencia ausente.

How it works

flowchart TB
    A[listing y ownerPreferences] --> B[Resolver estado de ciclo y publicacion]
    B --> C[listing ausente]
    C --> D[Denial completo LISTING_UNAVAILABLE]

    B --> E[Estado purged]
    E --> F[Denial completo LISTING_UNAVAILABLE]

    B --> G[Publicacion no published]
    G --> H[Denial completo LISTING_NOT_PUBLISHED]

    B --> I[Publicado y no purged]
    I --> J[Estado normal]
    J --> K[Permitir comentarios likes eventos y leads]

    I --> L[Estado protection o anonymized]
    L --> M[Comentarios y leads dependen de ownerPreferences]
    L --> N[Likes y eventos permitidos]

    O[DEFAULT_OWNER_PREFERENCES] --> M
    P[BLANKET_DENIED_POLICY_FIELDS] --> D
    P --> F
    P --> H

La función primero resuelve el estado del listing. Si el listing no existe, o si está purged, devuelve una denegación global con LISTING_UNAVAILABLE. Si la publicación no es published, devuelve LISTING_NOT_PUBLISHED. Solo cuando el listing está publicado y no purgado evalúa las preferencias del owner.

[!IMPORTANT] La política usa una allowlist explícita. Solo publication.status = published habilita interacciones públicas. Cualquier otro estado bloquea todo, y purged prevalece sobre cualquier otra condición.

Cuando el lifecycle es normal, la función abre todo sin consultar preferencias. Cuando es protection o anonymized, likes y eventos siguen permitidos, pero comentarios y leads dependen de allowComments y allowLeadCapture.

Key components

El módulo expone cuatro funciones y un helper interno. La política principal calcula el contrato de interacción, y los asserts traducen ese contrato en errores concretos para los routers que ya tienen su try catch.

{
    "title": "getListingInteractionPolicy",
    "content": "Funci\u00f3n p\u00fablica [badge:public] que calcula la pol\u00edtica completa de interacci\u00f3n de un Listing. Devuelve permisos, banderas de estado y una denegaci\u00f3n global cuando el anuncio est\u00e1 purgado o no publicado.",
    "type": "function",
    "filePath": "services/api/src/modules/listings/listing-interaction-policy.js",
    "badges": [
        "public"
    ]
}
{
    "title": "assertListingCanReceiveComment",
    "content": "Funci\u00f3n p\u00fablica [badge:public] que valida si el listing puede recibir comentarios. Lanza ApiError 404 para denegaciones globales y ApiError 403 con COMMENTS_DISABLED cuando solo los comentarios est\u00e1n desactivados.",
    "type": "function",
    "filePath": "services/api/src/modules/listings/listing-interaction-policy.js",
    "badges": [
        "public"
    ]
}
{
    "title": "assertListingCanReceiveLike",
    "content": "Funci\u00f3n p\u00fablica [badge:public] que valida si el listing puede recibir likes. Reutiliza la denegaci\u00f3n global y, si aplica, lanza ApiError 403 con LIKES_DISABLED.",
    "type": "function",
    "filePath": "services/api/src/modules/listings/listing-interaction-policy.js",
    "badges": [
        "public"
    ]
}
{
    "title": "assertListingCanReceiveEvent",
    "content": "Funci\u00f3n p\u00fablica [badge:public] que valida si el listing puede recibir eventos. Reutiliza la denegaci\u00f3n global y, si aplica, lanza ApiError 403 con EVENTS_DISABLED.",
    "type": "function",
    "filePath": "services/api/src/modules/listings/listing-interaction-policy.js",
    "badges": [
        "public"
    ]
}
{
    "title": "throwBlanketDenialIfAny",
    "content": "Helper interno que lanza ApiError 404 cuando policy.denialCode existe. Centraliza la denegaci\u00f3n total para los asserts p\u00fablicos.",
    "type": "function",
    "filePath": "services/api/src/modules/listings/listing-interaction-policy.js",
    "badges": []
}

Data & fields

La entrada del helper es pequeña, pero está muy estructurada. listing aporta el estado que gobierna toda la decisión, y ownerPreferences solo participa cuando el listing está en protection o anonymized.

[
    {
        "name": "listing",
        "type": "object",
        "description": "Listing ya resuelto por el router. La función lee lifecycle.status y publication.status.",
        "children": [
            {
                "name": "lifecycle",
                "type": "object",
                "description": "Contenedor del estado de ciclo de vida del listing.",
                "children": [
                    {
                        "name": "status",
                        "type": "string",
                        "description": "Puede ser normal, protection, anonymized o purged. Si falta, se asume normal."
                    }
                ]
            },
            {
                "name": "publication",
                "type": "object",
                "description": "Contenedor del estado de publicación.",
                "children": [
                    {
                        "name": "status",
                        "type": "string",
                        "description": "Solo published habilita interacciones públicas. Cualquier otro valor bloquea todo."
                    }
                ]
            }
        ]
    },
    {
        "name": "ownerPreferences",
        "type": "object",
        "description": "Preferencias del propietario. Se fusionan con DEFAULT_OWNER_PREFERENCES antes de evaluar protection o anonymized.",
        "children": [
            {
                "name": "keepPublishedListingsVisible",
                "type": "boolean",
                "description": "Forma parte del contrato canónico de defaults. Esta función no lo consume."
            },
            {
                "name": "allowLeadCapture",
                "type": "boolean",
                "description": "Controla canReceiveLeads cuando el listing no está en normal."
            },
            {
                "name": "allowComments",
                "type": "boolean",
                "description": "Controla canReceiveComments cuando el listing no está en normal."
            }
        ]
    }
]

DEFAULT_OWNER_PREFERENCES y BLANKET_DENIED_POLICY_FIELDS son objetos de solo lectura. El primero fija defaults permisivos para cuentas legacy, y el segundo predefine el estado de bloqueo total.

  • DEFAULT_OWNER_PREFERENCES [badge:read-only] object — contiene keepPublishedListingsVisible: true, allowLeadCapture: true y allowComments: true.
  • BLANKET_DENIED_POLICY_FIELDS [badge:read-only] object — contiene canReceiveComments: false, canReceiveLikes: false, canReceiveEvents: false y canReceiveLeads: false.
[
    {
        "name": "canReceiveComments",
        "type": "boolean",
        "required": true,
        "description": "Indica si el listing acepta comentarios."
    },
    {
        "name": "canReceiveLikes",
        "type": "boolean",
        "required": true,
        "description": "Indica si el listing acepta likes."
    },
    {
        "name": "canReceiveEvents",
        "type": "boolean",
        "required": true,
        "description": "Indica si el listing acepta eventos."
    },
    {
        "name": "canReceiveLeads",
        "type": "boolean",
        "required": true,
        "description": "Campo reservado para captura pública de leads. Ninguna ruta lo consume todavía."
    },
    {
        "name": "isNormal",
        "type": "boolean",
        "required": true,
        "description": "True cuando lifecycle.status es normal."
    },
    {
        "name": "isProtection",
        "type": "boolean",
        "required": true,
        "description": "True cuando lifecycle.status es protection."
    },
    {
        "name": "isAnonymized",
        "type": "boolean",
        "required": true,
        "description": "True cuando lifecycle.status es anonymized."
    },
    {
        "name": "isPurged",
        "type": "boolean",
        "required": true,
        "description": "True cuando lifecycle.status es purged."
    },
    {
        "name": "isPublished",
        "type": "boolean",
        "required": true,
        "description": "True cuando publication.status es published."
    },
    {
        "name": "denialCode",
        "type": "string | null",
        "required": true,
        "description": "Código de denegación global. Solo se usa para bloqueos de listing completo."
    },
    {
        "name": "denialMessage",
        "type": "string | null",
        "required": true,
        "description": "Mensaje de denegación global asociado a denialCode."
    }
]

Behaviors

  • getListingInteractionPolicy — construye un objeto de política determinista. Devuelve permisos, banderas de estado y un denial global cuando el listing no puede interactuar en absoluto.
  • assertListingCanReceiveComment — lanza ApiError 404 si existe una denegación global. Si no, lanza ApiError 403 con COMMENTS_DISABLED cuando los comentarios están desactivados.
  • assertListingCanReceiveLike — aplica la misma denegación global y, si corresponde, lanza ApiError 403 con LIKES_DISABLED.
  • assertListingCanReceiveEvent — aplica la misma denegación global y, si corresponde, lanza ApiError 403 con EVENTS_DISABLED.
  • throwBlanketDenialIfAny — helper interno que convierte denialCode en un ApiError 404 con el mensaje exacto guardado en la política.

Workflow de evaluación

La decisión sigue un orden fijo. Ese orden está codificado en el archivo y también validado por la matriz de pruebas.

  1. Si listing no existe, bloquear todo con LISTING_UNAVAILABLE.
  2. Leer lifecycle.status y publication.status, con defaults normal y draft.
  3. Si lifecycle.status es purged, bloquear todo con LISTING_UNAVAILABLE.
  4. Si publication.status no es published, bloquear todo con LISTING_NOT_PUBLISHED.
  5. Si lifecycle.status es normal, permitir comentarios, likes, eventos y leads.
  6. Si lifecycle.status es protection o anonymized, permitir likes y eventos, y condicionar comentarios y leads a las preferencias del owner.

Gotchas & edge cases

[!WARNING] denialCode y denialMessage solo representan un bloqueo de listing completo. Un bloqueo puntual, como comentarios desactivados, no usa esos campos. En ese caso, el helper lanza COMMENTS_DISABLED con 403.

[!WARNING] publication.status = draft y publication.status = archived bloquean por igual. El archivo usa una allowlist explícita, así que cualquier valor distinto de published queda denegado.

[!NOTE] Las preferencias legacy ausentes no bloquean accidentalmente. Si ownerPreferences falta, la fusión con DEFAULT_OWNER_PREFERENCES deja allowComments y allowLeadCapture en true.

[!CAUTION] canReceiveLeads está presente en la forma de salida, pero ninguna ruta lo consume todavía. El propio archivo lo reserva para una captura pública futura.

[!IMPORTANT] La función es pura. No muta listing ni ownerPreferences, y el mismo input produce siempre el mismo resultado. Las pruebas lo cubren con Object.freeze y llamadas repetidas.

services/api/src/modules/listing-likes/listing-likes.routes.js usa getListingInteractionPolicy y assertListingCanReceiveLike antes de alternar likes. El flujo evita depender de deletionGracePreferences para likes y eventos.

services/api/src/modules/listings/listing-comments.routes.js usa getListingInteractionPolicy y assertListingCanReceiveComment para lectura y creación de comentarios. También carga contexto del owner antes de aplicar la política.

services/api/src/modules/listings/listing-events.routes.js usa getListingInteractionPolicy y assertListingCanReceiveEvent para registrar eventos. El comentario del flujo confirma que likes y eventos no dependen de deletionGracePreferences.

services/api/test/listing-interaction-policy.test.js valida la matriz completa. Cubre normal, protection, anonymized, purged, draft, archived, preferencias legacy ausentes, pureza y mapeo de errores.

services/api/src/models/User.js aparece como la referencia canónica que debe coincidir con DEFAULT_OWNER_PREFERENCES. Esa relación está declarada en el comentario del archivo, aunque el modelo no está incluido aquí.