services/api/src/modules/listing-comments/listing-comments.routes.js

Overview

Este archivo define la frontera HTTP de comentarios de listings. Expone una lectura pública de comentarios visibles y una creación autenticada de comentarios o respuestas, con moderación textual, actualización de métricas y emisión de eventos secundarios. La ruta forma parte del flujo de feed y conversación del anuncio, y el consumo del frontend reconstruye el árbol con parentCommentId a partir de una respuesta plana.

La lógica no depende de IA ni de un clasificador externo. La moderación se decide con listas locales de palabras y reglas deterministas, mientras que la autorización y la elegibilidad del listing se resuelven con requireAuth y getListingInteractionPolicy.

[!NOTE] La lectura devuelve un arreglo plano. El frontend arma el árbol con parentCommentId, y replyToUserId no se puebla aquí. Viaja como ObjectId crudo.

How it works

flowchart TD
    req[HTTP request] --> method{Method}

    method -->|GET| getRoute[GET listing comments]
    getRoute --> validateId[Validate listingId]
    validateId --> listing[Load Listing]
    listing --> policy[Check interaction policy]
    policy -->|Denied| denied[Return 404]
    policy -->|Allowed| comments[Find visible ListingComment]
    comments --> populate[Populate authorId]
    populate --> mask[Mask author identity]
    mask --> responseGet[Return data]

    method -->|POST| postRoute[POST create comment]
    postRoute --> auth[requireAuth]
    auth --> validateBody[Validate listingId text parentCommentId]
    validateBody --> listing2[Load Listing]
    listing2 --> ownerCtx[Load owner context when needed]
    ownerCtx --> policy2[Check interaction policy]
    policy2 --> allowPost[Assert comment can be created]
    allowPost --> moderate[moderateText]
    moderate --> create[Create ListingComment]

    create --> visible{Status visible}
    visible -->|Yes| metrics[Update Listing metrics]
    metrics --> dominant[Recompute sentiment dominant]
    dominant --> eventOwner[Create owner ListingEvent]
    eventOwner --> eventReply[Create reply target ListingEvent]
    visible -->|No| responseModerated[Return moderated true]

    create --> populate2[Populate authorId]
    populate2 --> mask2[Mask author identity]
    mask2 --> responsePost[Return data]

La ruta GET valida listingId, carga el anuncio, verifica la política de interacción y devuelve solo comentarios con status: "visible". La ruta POST exige autenticación, valida entrada, aplica moderación, crea el comentario y, si quedó visible, actualiza métricas y genera eventos de actividad.

[!IMPORTANT] La política del listing puede bloquear la lectura pública del hilo. Si el policy check niega acceso, el handler responde con 404 y un mensaje de denegación.

Key components

{
    "title": "loadOwnerContextForPolicy",
    "content": "Funci\u00f3n interna que carga solo `deletionGracePreferences` y `accountStatus` del owner cuando el listing est\u00e1 en lifecycle `protection` o `anonymized`.",
    "type": "function",
    "filePath": "services/api/src/modules/listing-comments/listing-comments.routes.js",
    "badges": []
}
{
    "title": "moderateText",
    "content": "Funci\u00f3n local que normaliza el texto y devuelve `status`, `sentiment` y `moderationReason` seg\u00fan listas de palabras bloqueadas, positivas y negativas.",
    "type": "function",
    "filePath": "services/api/src/modules/listing-comments/listing-comments.routes.js",
    "badges": []
}
{
    "title": "maskCommentAuthor",
    "content": "Funci\u00f3n local que serializa el comentario y enmascara `authorId` con `maskAnonymizedUserIdentity` cuando el autor fue purgado.",
    "type": "function",
    "filePath": "services/api/src/modules/listing-comments/listing-comments.routes.js",
    "badges": []
}

Data & fields

Consulta de lectura

[
    {
        "name": "listingId",
        "type": "string",
        "required": true,
        "description": "Identificador del anuncio consultado en la ruta GET. Se valida como ObjectId."
    }
]

Cuerpo de creación

[
    {
        "name": "listingId",
        "type": "string",
        "required": true,
        "description": "Identificador del anuncio al que pertenece el comentario."
    },
    {
        "name": "text",
        "type": "string",
        "required": true,
        "description": "Texto del comentario. Se recorta con `trim()` antes de moderarlo y guardarlo."
    },
    {
        "name": "parentCommentId",
        "type": "string | null",
        "description": "Identificador del comentario padre para respuestas. Si existe, se valida como ObjectId."
    },
    {
        "name": "replyToUserId",
        "type": "string | null",
        "description": "Usuario destinatario de la respuesta. No bloquea la creación si es inválido; solo afecta la rama de notificación."
    },
    {
        "name": "actorName",
        "type": "string",
        "description": "Nombre usado en `ListingEvent.metadata`. Si falta, el código usa `req.auth.email` o una cadena vacía."
    }
]

Respuesta de lectura y escritura

[
    {
        "name": "data",
        "type": "ListingComment[] | ListingComment",
        "required": true,
        "description": "Comentarios visibles o el comentario recién creado. En lectura es un arreglo plano; en escritura es un único documento poblado.",
        "children": [
            {
                "name": "_id",
                "type": "string",
                "required": true,
                "description": "Identificador del comentario."
            },
            {
                "name": "listingId",
                "type": "string",
                "required": true,
                "description": "Anuncio asociado al comentario."
            },
            {
                "name": "ownerId",
                "type": "string",
                "required": true,
                "description": "Propietario del listing."
            },
            {
                "name": "authorId",
                "type": "object",
                "required": true,
                "description": "Autor poblado con `name`, `username`, `photo` y `accountStatus`. También puede quedar enmascarado.",
                "children": [
                    {
                        "name": "name",
                        "type": "string",
                        "required": true,
                        "description": "Nombre visible del autor."
                    },
                    {
                        "name": "username",
                        "type": "string",
                        "required": true,
                        "description": "Nombre de usuario visible del autor."
                    },
                    {
                        "name": "photo",
                        "type": "string | null",
                        "description": "Foto pública del autor."
                    },
                    {
                        "name": "accountStatus",
                        "type": "string",
                        "required": true,
                        "description": "Estado de cuenta del autor."
                    }
                ]
            },
            {
                "name": "parentCommentId",
                "type": "string | null",
                "description": "Comentario padre cuando existe una respuesta."
            },
            {
                "name": "replyToUserId",
                "type": "string | null",
                "description": "Destinatario de la respuesta."
            },
            {
                "name": "text",
                "type": "string",
                "required": true,
                "description": "Texto persistido del comentario."
            },
            {
                "name": "status",
                "type": "string",
                "required": true,
                "description": "Estado de moderación. En este archivo aparecen `visible` y `archived`."
            },
            {
                "name": "sentiment",
                "type": "string",
                "required": true,
                "description": "Clasificación sentimental calculada por `moderateText`."
            },
            {
                "name": "moderationReason",
                "type": "string",
                "required": true,
                "description": "Motivo de moderación. Se usa `blocked_word` cuando el texto contiene una palabra bloqueada."
            },
            {
                "name": "createdDuringProtection",
                "type": "boolean",
                "required": true,
                "description": "Marca si el comentario fue creado durante lifecycle `protection`."
            },
            {
                "name": "createdDuringAnonymization",
                "type": "boolean",
                "required": true,
                "description": "Marca si el comentario fue creado durante lifecycle `anonymized`."
            },
            {
                "name": "ownerStatusAtCreation",
                "type": "string",
                "required": true,
                "description": "Estado real de la cuenta del owner al momento de crear el comentario."
            }
        ]
    },
    {
        "name": "moderated",
        "type": "boolean",
        "description": "Solo en POST. `true` cuando el comentario no quedó visible."
    }
]
  • BLOCKED_WORDS contiene insultos y expresiones ofensivas como puta, pendejo, cabron, chingar, mierda e imbecil.
  • POSITIVE_WORDS contiene términos de elogio como excelente, bonita, hermoso, perfecto, buena y excelentes amenidades.
  • NEGATIVE_WORDS contiene términos de riesgo o denuncia como fraude, engaño, estafa, peligroso, inseguro, robo y mala atención.

moderateText normaliza el texto con minúsculas, elimina diacríticos y decide el resultado por búsqueda de fragmentos.

Behaviors

  • loadOwnerContextForPolicy(listing) — carga contexto mínimo del owner solo para lifecycle protection y anonymized. Devuelve ownerPreferences y ownerAccountStatus.
  • moderateText(text) — normaliza el texto y devuelve un objeto con status, sentiment y moderationReason.
  • maskCommentAuthor(comment) — devuelve una copia del comentario con authorId enmascarado cuando authorPurged === true.
  • GET / — valida listingId, carga el listing con publication y lifecycle, aplica la policy y devuelve comentarios visibles ordenados por createdAt ascendente.
  • POST / — exige autenticación, valida los campos de entrada, crea el comentario y, si quedó visible, actualiza métricas y genera eventos.
  • POST / — captura errores de duplicidad MongoDB con code === 11000 y responde 409 con Ya respondiste a este comentario.

[!WARNING] replyToUserId no bloquea la creación del comentario. Solo condiciona la creación del ListingEvent de notificación. Si es inválido, el comentario se guarda igual.

[!IMPORTANT] Solo los comentarios con status: "visible" se devuelven en lectura. Solo esos comentarios incrementan metrics.comments, metrics.sentimentSummary.* y metrics.lastInteractionAt.

[!NOTE] El router solo consulta al owner cuando el listing está en protection o anonymized. En normal, el owner se trata como active por invariante. En purged, la policy bloquea antes de necesitar ese contexto.

API

GET /api/listing-comments

{
    "title": "Leer comentarios visibles de un listing",
    "description": "Devuelve los comentarios visibles de un anuncio por `listingId`, con `authorId` poblado y enmascarado cuando aplica.",
    "method": "GET",
    "baseUrl": "/api",
    "endpoint": "/listing-comments",
    "headers": [],
    "queryParams": [
        {
            "key": "listingId",
            "value": "ID del anuncio",
            "required": true
        }
    ],
    "pathParams": [],
    "bodyType": "none",
    "requestBody": "",
    "formData": [],
    "rawBody": "",
    "responses": {
        "200": {
            "description": "\u00c9xito",
            "body": "{\n  \"data\": [\n    {\n      \"_id\": \"66f0c4c2a1b2c3d4e5f67890\",\n      \"listingId\": \"66f0c4c2a1b2c3d4e5f60001\",\n      \"ownerId\": \"66f0c4c2a1b2c3d4e5f60002\",\n      \"authorId\": {\n        \"_id\": \"66f0c4c2a1b2c3d4e5f60003\",\n        \"name\": \"Ana Lopez\",\n        \"username\": \"ana\",\n        \"photo\": \"https://cdn.example.com/u/ana.jpg\",\n        \"accountStatus\": \"active\"\n      },\n      \"parentCommentId\": null,\n      \"replyToUserId\": null,\n      \"text\": \"Excelente ubicaci\u00f3n\",\n      \"status\": \"visible\",\n      \"sentiment\": \"positive\",\n      \"moderationReason\": \"\",\n      \"createdDuringProtection\": false,\n      \"createdDuringAnonymization\": false,\n      \"ownerStatusAtCreation\": \"active\"\n    }\n  ]\n}"
        },
        "400": {
            "description": "ID de anuncio inv\u00e1lido",
            "body": "{\n  \"message\": \"ID de anuncio inv\u00e1lido.\"\n}"
        },
        "404": {
            "description": "Anuncio no encontrado o hilo no legible por policy",
            "body": "{\n  \"message\": \"Anuncio no encontrado.\"\n}"
        }
    }
}

POST /api/listing-comments

{
    "title": "Crear comentario de listing",
    "description": "Crea un comentario o una respuesta, aplica moderaci\u00f3n simple y devuelve el comentario poblado junto con el indicador `moderated`.",
    "method": "POST",
    "baseUrl": "/api",
    "endpoint": "/listing-comments",
    "headers": [
        {
            "key": "Authorization",
            "value": "Bearer <token>",
            "required": true
        },
        {
            "key": "Content-Type",
            "value": "application/json",
            "required": true
        }
    ],
    "queryParams": [],
    "pathParams": [],
    "bodyType": "json",
    "requestBody": "{\n  \"listingId\": \"66f0c4c2a1b2c3d4e5f60001\",\n  \"text\": \"Excelente ubicaci\u00f3n\",\n  \"parentCommentId\": null,\n  \"replyToUserId\": null,\n  \"actorName\": \"Ana Lopez\"\n}",
    "formData": [],
    "rawBody": "",
    "responses": {
        "201": {
            "description": "Comentario creado",
            "body": "{\n  \"data\": {\n    \"_id\": \"66f0c4c2a1b2c3d4e5f67891\",\n    \"listingId\": \"66f0c4c2a1b2c3d4e5f60001\",\n    \"ownerId\": \"66f0c4c2a1b2c3d4e5f60002\",\n    \"authorId\": {\n      \"_id\": \"66f0c4c2a1b2c3d4e5f60003\",\n      \"name\": \"Ana Lopez\",\n      \"username\": \"ana\",\n      \"photo\": \"https://cdn.example.com/u/ana.jpg\",\n      \"accountStatus\": \"active\"\n    },\n    \"parentCommentId\": null,\n    \"replyToUserId\": null,\n    \"text\": \"Excelente ubicaci\u00f3n\",\n    \"status\": \"visible\",\n    \"sentiment\": \"positive\",\n    \"moderationReason\": \"\",\n    \"createdDuringProtection\": false,\n    \"createdDuringAnonymization\": false,\n    \"ownerStatusAtCreation\": \"active\"\n  },\n  \"moderated\": false\n}"
        },
        "400": {
            "description": "Validaci\u00f3n fallida",
            "body": "{\n  \"message\": \"El comentario es requerido.\"\n}"
        },
        "404": {
            "description": "Anuncio no encontrado o policy denegada",
            "body": "{\n  \"message\": \"Anuncio no encontrado.\"\n}"
        },
        "409": {
            "description": "Respuesta duplicada",
            "body": "{\n  \"message\": \"Ya respondiste a este comentario.\"\n}"
        }
    }
}

Workflows

1. Leer hilo | Recibe `listingId`, valida el ObjectId, carga el listing y evalúa la policy de interacción.
2. Filtrar comentarios | Consulta `ListingComment` con `status: "visible"`, ordena por `createdAt` y popula `authorId`.
3. Enmascarar salida | Aplica `maskCommentAuthor` antes de responder.
4. Crear comentario | Exige autenticación, valida `listingId`, `text` y `parentCommentId`, y carga el listing.
5. Evaluar policy y moderar | Carga contexto del owner cuando aplica, ejecuta `assertListingCanReceiveComment` y llama a `moderateText`.
6. Persistir y derivar efectos | Crea `ListingComment`, actualiza métricas si quedó visible y crea `ListingEvent` para owner y respuesta cuando corresponde.

Gotchas & edge cases

[!WARNING] Un parentCommentId inválido corta la creación con 400, pero un replyToUserId inválido no la corta. Solo omite la notificación derivada.

[!CAUTION] moderateText puede archivar el comentario si detecta una palabra bloqueada. En ese caso, la respuesta sigue existiendo, pero moderated pasa a true y no se actualizan métricas ni eventos.

[!IMPORTANT] El router no borra comentarios archivados. Solo deja de exponerlos en la lectura pública, que filtra por status: "visible".

[!NOTE] El GET no incluye replyToUserId poblado. Ese campo viaja como valor crudo, sin campos de identidad asociados.

[!WARNING] Si MongoDB lanza un error de duplicidad al crear la respuesta, el handler devuelve 409. El mensaje es Ya respondiste a este comentario.

Este módulo se apoya en services/api/src/models/Listing.js para leer createdBy, publication, lifecycle y metrics. También usa services/api/src/models/ListingComment.js para persistir el comentario, services/api/src/models/ListingEvent.js para registrar actividad, y services/api/src/models/User.js para leer el contexto mínimo del owner.

La autorización de escritura depende de services/api/src/modules/auth/auth.middleware.js, y la elegibilidad del listing depende de services/api/src/modules/listings/listing-interaction-policy.js. La identidad pública se ajusta con services/api/src/utils/publicUserIdentity.js.

En el frontend, este contrato alimenta apps/backoffice-web/src/features/feed/api/getListingComments.js, apps/backoffice-web/src/features/feed/api/createListingComment.js y apps/backoffice-web/src/features/feed/components/CommentsDrawer.jsx, donde el arreglo plano se reconstruye por parentCommentId.