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, yreplyToUserIdno se puebla aquí. Viaja comoObjectIdcrudo.
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
404y 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_WORDScontiene insultos y expresiones ofensivas comoputa,pendejo,cabron,chingar,mierdaeimbecil.POSITIVE_WORDScontiene términos de elogio comoexcelente,bonita,hermoso,perfecto,buenayexcelentes amenidades.NEGATIVE_WORDScontiene términos de riesgo o denuncia comofraude,engaño,estafa,peligroso,inseguro,roboymala 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 lifecycleprotectionyanonymized. DevuelveownerPreferencesyownerAccountStatus.moderateText(text)— normaliza el texto y devuelve un objeto constatus,sentimentymoderationReason.maskCommentAuthor(comment)— devuelve una copia del comentario conauthorIdenmascarado cuandoauthorPurged === true.GET /— validalistingId, carga el listing conpublicationylifecycle, aplica la policy y devuelve comentarios visibles ordenados porcreatedAtascendente.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 concode === 11000y responde409conYa respondiste a este comentario.
[!WARNING]
replyToUserIdno bloquea la creación del comentario. Solo condiciona la creación delListingEventde 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 incrementanmetrics.comments,metrics.sentimentSummary.*ymetrics.lastInteractionAt.
[!NOTE] El router solo consulta al owner cuando el listing está en
protectionoanonymized. Ennormal, el owner se trata comoactivepor invariante. Enpurged, 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
parentCommentIdinválido corta la creación con400, pero unreplyToUserIdinválido no la corta. Solo omite la notificación derivada.
[!CAUTION]
moderateTextpuede archivar el comentario si detecta una palabra bloqueada. En ese caso, la respuesta sigue existiendo, peromoderatedpasa atruey 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
GETno incluyereplyToUserIdpoblado. 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 esYa respondiste a este comentario.
Related
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.