services/api/src/modules/listing-events/listing-events.routes.js
Overview
Este módulo define la superficie HTTP para los eventos de listings. Expone lectura paginada, creación de eventos y marcado como leído para el propietario autenticado. El router se monta bajo /api/listing-events y trabaja sobre ListingEvent y Listing, con validación de interacción centralizada antes de persistir datos.
El archivo también conecta este contrato con la trazabilidad del feed. POST / omite los eventos del propio dueño del listing, y otras rutas de negocio del backend también escriben en ListingEvent como efecto secundario. Esa separación mantiene la actividad del anuncio fuera del CRUD principal de Listing.
[!IMPORTANT]
router.use(requireAuth)protege todo el router. Cada handler usareq.auth.userId, y el alta de eventos propios devuelveskipped: trueen lugar de persistir un documento.
How it works
flowchart TB
Auth[requireAuth] --> Get[GET eventos]
Auth --> Post[POST evento]
Auth --> Patch[PATCH read]
Get --> GetFilter[ownerId type read]
Get --> GetPopulate[populate listingId actorId]
Get --> GetCount[total unread page pages]
Post --> PostValidate[validate listingId and type]
Post --> PostLoad[load Listing]
Post --> PostSkip[self event skip]
Post --> PostPolicy[interaction policy]
Post --> PostCreate[create ListingEvent]
Post --> PostMetric[update Listing metrics]
Patch --> PatchLoad[find event by id and ownerId]
Patch --> PatchSave[set read]
El flujo empieza siempre en la autenticación. Después, GET consulta eventos del usuario autenticado, POST crea uno nuevo si el listing es interactuable, y PATCH cambia el estado de lectura del evento propietario. El controlador también enmascara identidades anonimizadas y actualiza métricas del listing cuando el tipo de evento lo permite.
Key components
{
"title": "ListingEvent",
"content": "Modelo de Mongoose usado por este router para persistir eventos de actividad sobre listings y devolverlos al feed del propietario.",
"type": "class",
"filePath": "services/api/src/models/ListingEvent.js",
"badges": [
"public"
]
}
{
"title": "Listing",
"content": "Modelo consultado para validar el anuncio, leer su autor y actualizar m\u00e9tricas agregadas tras crear ciertos eventos.",
"type": "class",
"filePath": "services/api/src/models/Listing.js",
"badges": [
"public"
]
}
router— instancia deexpress.Routerque agrupa las tres rutas del módulo.EVENT_TYPES[badge:read-only]Set<string>— lista los tipos admitidos porGETyPOST.EVENT_METADATA_ALLOWLIST[badge:read-only]Record<string, string[]>— define qué claves demetadatasobreviven por tipo de evento.ALLOWED_METADATA_VALUE_TYPES[badge:read-only]Set<string>— limitametadataastring,numberyboolean.MAX_METADATA_STRING_LENGTH[badge:read-only]number— recorta cadenas de metadata que superen 500 caracteres.sanitizeEventMetadata(type, rawMetadata)— limpiareq.body.metadataantes de guardarla.getListingInteractionPolicy— obtiene la política de interacción del listing antes de crear el evento.assertListingCanReceiveEvent— bloquea la creación si la política no permite la interacción.
Data & fields
El router acepta parámetros de consulta para listar eventos y un cuerpo JSON para crear o actualizar lectura. La lógica de saneamiento de metadata es estricta, porque el endpoint recibe req.body.metadata de forma directa y solo conserva claves concretas.
[
{
"name": "page",
"type": "number",
"description": "Número de página. Si falta, usa 1."
},
{
"name": "limit",
"type": "number",
"description": "Cantidad máxima por página. Se ajusta entre 1 y 100."
},
{
"name": "type",
"type": "string",
"description": "Filtra por un valor presente en EVENT_TYPES."
},
{
"name": "read",
"type": "string",
"description": "Solo acepta 'true' o 'false'. Cualquier otro valor se ignora."
}
]
[
{
"name": "listingId",
"type": "string",
"required": true,
"description": "ID del anuncio. Debe ser un ObjectId válido."
},
{
"name": "type",
"type": "string",
"required": true,
"description": "Tipo de evento. Debe existir en EVENT_TYPES."
},
{
"name": "source",
"type": "string",
"description": "Origen del evento. Si falta, usa 'feed'."
},
{
"name": "actorName",
"type": "string",
"description": "Snapshot textual del actor. Si falta, usa req.auth.email o cadena vacía."
},
{
"name": "metadata",
"type": "object",
"description": "Objeto bruto que el servidor sanitiza antes de guardar.",
"children": [
{
"name": "listingTitle",
"type": "string | number | boolean",
"description": "Se conserva en like, unlike, comment, detail_open, contact_click, pdf_download, qr_scan y profile_open."
},
{
"name": "commentId",
"type": "string | number | boolean",
"description": "Se conserva solo para comment."
},
{
"name": "parentCommentId",
"type": "string | number | boolean",
"description": "Se conserva solo para comment."
},
{
"name": "replyToUserId",
"type": "string | number | boolean",
"description": "Se conserva solo para comment."
},
{
"name": "channel",
"type": "string | number | boolean",
"description": "Se conserva solo para contact_click."
},
{
"name": "generatedOnDemand",
"type": "string | number | boolean",
"description": "Se conserva solo para pdf_download."
}
]
}
]
likeyunlike—listingTitle.comment—listingTitle,commentId,parentCommentId,replyToUserId.detail_open—listingTitle.contact_click—listingTitle,channel.pdf_download—listingTitle,generatedOnDemand.qr_scan—listingTitle.profile_open—listingTitle.
El sanitizador descarta cualquier otra clave. También rechaza objetos o arrays anidados, y solo conserva valores primitivos.
[
{
"name": "id",
"type": "string",
"required": true,
"description": "ID del evento que se marcará como leído o no leído."
},
{
"name": "read",
"type": "boolean",
"description": "Solo false deja el evento como no leído. Cualquier otro valor lo deja como leído."
}
]
Behaviors
router.use(requireAuth)— aplica autenticación obligatoria a toda la superficie.GET /— devuelve eventos paginados del usuario autenticado, con filtros opcionales portypeyread.GET /— ordena porcreatedAtdescendente, usalean(), y populalistingIdyactorId.GET /— devuelvetotal,unread,pageypages, además dedata.GET /— enmascaraactorIdy sustituyeactorNameporUsuario anonimizadocuando la cuenta lo requiere.POST /— validalistingIdytype, busca el listing y omite eventos del propio dueño.POST /— llama agetListingInteractionPolicyyassertListingCanReceiveEventantes de crear el documento.POST /— crea unListingEventconlistingId,ownerId,actorId,actorName,type,sourceymetadata.POST /— actualiza métricas del listing solo para tipos presentes enmetricMap.POST /— incrementa o decrementametrics.likesparalikeyunlike.POST /— actualizametrics.lastInteractionAtcuando el tipo tiene mapeo de métrica.PATCH /:id/read— cambiareadpara un evento que pertenezca al usuario autenticado.sanitizeEventMetadata(type, rawMetadata)— devuelve un objeto vacío cuando la entrada no es un objeto válido.
[!NOTE]
commentyprofile_openson tipos válidos, pero no tienen entrada enmetricMap. El evento se guarda igual, pero no modifica métricas.
API
GET /api/listing-events
Este endpoint devuelve el feed de actividad del propietario autenticado. Acepta paginación y filtros por tipo y por estado de lectura. La respuesta incluye contadores agregados y eventos con listingId y actorId poblados.
{
"title": "Listar eventos",
"description": "Obtiene los eventos del usuario autenticado con paginaci\u00f3n y contadores agregados.",
"method": "GET",
"baseUrl": "/api/listing-events",
"endpoint": "/",
"headers": [
{
"key": "Authorization",
"value": "Bearer <token>",
"required": true
}
],
"queryParams": [
{
"key": "page",
"value": "N\u00famero de p\u00e1gina. Por defecto 1.",
"required": false
},
{
"key": "limit",
"value": "Cantidad por p\u00e1gina. Se limita entre 1 y 100.",
"required": false
},
{
"key": "type",
"value": "Filtra por un tipo de evento admitido.",
"required": false
},
{
"key": "read",
"value": "Filtra por estado de lectura con true o false.",
"required": false
}
],
"pathParams": [],
"bodyType": "none",
"requestBody": "",
"formData": [],
"rawBody": "",
"responses": {
"200": {
"description": "Eventos paginados del usuario autenticado.",
"body": "{\n \"data\": [\n {\n \"_id\": \"66a111111111111111111111\",\n \"listingId\": {\n \"_id\": \"66b222222222222222222222\",\n \"title\": \"Listing destacado\",\n \"media\": [],\n \"price\": 1200,\n \"location\": {},\n \"meta\": {},\n \"metrics\": {},\n \"publication\": {}\n },\n \"ownerId\": \"66c333333333333333333333\",\n \"actorId\": {\n \"_id\": \"66d444444444444444444444\",\n \"name\": \"Ana\",\n \"username\": \"ana\",\n \"photo\": \"\",\n \"email\": \"[email protected]\",\n \"accountStatus\": \"active\"\n },\n \"actorName\": \"Ana\",\n \"type\": \"like\",\n \"source\": \"feed\",\n \"metadata\": {\n \"listingTitle\": \"Residencia Central\"\n },\n \"read\": false,\n \"createdAt\": \"2026-01-01T00:00:00Z\",\n \"updatedAt\": \"2026-01-01T00:00:00Z\"\n }\n ],\n \"total\": 1,\n \"unread\": 1,\n \"page\": 1,\n \"pages\": 1\n}"
}
}
}
POST /api/listing-events
Este endpoint crea un evento conductual para un listing. Valida listingId y type, omite self events y actualiza métricas cuando el tipo lo permite. Si el evento pertenece al propio dueño, responde con skipped: true.
{
"title": "Crear evento",
"description": "Crea un ListingEvent para el listing indicado y actualiza m\u00e9tricas cuando corresponde.",
"method": "POST",
"baseUrl": "/api/listing-events",
"endpoint": "/",
"headers": [
{
"key": "Authorization",
"value": "Bearer <token>",
"required": true
},
{
"key": "Content-Type",
"value": "application/json",
"required": true
}
],
"queryParams": [],
"pathParams": [],
"bodyType": "json",
"requestBody": "{\n \"listingId\": \"66b222222222222222222222\",\n \"type\": \"like\",\n \"source\": \"feed\",\n \"actorName\": \"Andrea L\u00f3pez\",\n \"metadata\": {\n \"listingTitle\": \"Residencia Central\"\n }\n}",
"formData": [],
"rawBody": "",
"responses": {
"201": {
"description": "Evento creado.",
"body": "{\n \"_id\": \"66e555555555555555555555\",\n \"listingId\": \"66b222222222222222222222\",\n \"ownerId\": \"66c333333333333333333333\",\n \"actorId\": \"66d444444444444444444444\",\n \"actorName\": \"Andrea L\u00f3pez\",\n \"type\": \"like\",\n \"source\": \"feed\",\n \"metadata\": {\n \"listingTitle\": \"Residencia Central\"\n },\n \"read\": false,\n \"createdAt\": \"2026-01-01T00:00:00Z\",\n \"updatedAt\": \"2026-01-01T00:00:00Z\"\n}"
},
"200": {
"description": "Evento propio omitido.",
"body": "{\n \"skipped\": true,\n \"reason\": \"self_event\",\n \"message\": \"Las interacciones propias no generan notificaciones.\"\n}"
},
"400": {
"description": "Entrada inv\u00e1lida.",
"body": "{\n \"message\": \"Tipo de evento inv\u00e1lido.\"\n}"
},
"404": {
"description": "Anuncio no encontrado.",
"body": "{\n \"message\": \"Anuncio no encontrado.\"\n}"
}
}
}
PATCH /api/listing-events/:id/read
Este endpoint actualiza el estado de lectura del evento. Solo toca documentos cuyo _id pertenece al usuario autenticado, así que un evento ajeno responde con 404. El valor de read se interpreta como leído salvo que el cuerpo envíe false.
{
"title": "Marcar evento como le\u00eddo",
"description": "Actualiza read para el evento que pertenece al usuario autenticado.",
"method": "PATCH",
"baseUrl": "/api/listing-events",
"endpoint": "/:id/read",
"headers": [
{
"key": "Authorization",
"value": "Bearer <token>",
"required": true
},
{
"key": "Content-Type",
"value": "application/json",
"required": true
}
],
"queryParams": [],
"pathParams": [
{
"key": "id",
"value": "listing-event-123",
"required": true
}
],
"bodyType": "json",
"requestBody": "{\n \"read\": true\n}",
"formData": [],
"rawBody": "",
"responses": {
"200": {
"description": "Evento actualizado.",
"body": "{\n \"_id\": \"listing-event-123\",\n \"listingId\": \"66b222222222222222222222\",\n \"ownerId\": \"66c333333333333333333333\",\n \"actorId\": \"66d444444444444444444444\",\n \"actorName\": \"Andrea L\u00f3pez\",\n \"type\": \"like\",\n \"source\": \"feed\",\n \"metadata\": {\n \"listingTitle\": \"Residencia Central\"\n },\n \"read\": true,\n \"createdAt\": \"2026-01-01T00:00:00Z\",\n \"updatedAt\": \"2026-01-01T00:05:00Z\"\n}"
},
"404": {
"description": "Evento no encontrado.",
"body": "{\n \"message\": \"Evento no encontrado.\"\n}"
}
}
}
Workflows
Este módulo sigue un flujo corto y determinista. Primero autentica, luego decide si el evento existe y pertenece al usuario, y por último persiste o actualiza según la operación.
1. Autenticar solicitud | `requireAuth` llena `req.auth` antes de cualquier handler.
2. Consultar eventos | `GET /` filtra por `ownerId` y aplica `page`, `limit`, `type` y `read`.
3. Crear evento | `POST /` valida `listingId` y `type`, carga `Listing`, evalúa la política y guarda `ListingEvent`.
4. Actualizar lectura | `PATCH /:id/read` modifica `read` solo si el evento pertenece al usuario autenticado.
Gotchas & edge cases
[!WARNING]
sanitizeEventMetadatano persiste objetos ni arrays anidados. También descarta cualquier clave fuera del allowlist y cualquier string mayor a 500 caracteres.
[!WARNING]
GET /solo interpretareadcuando llega como la cadena exacta"true"o"false". Otros valores no filtran nada.
[!NOTE] El contador
unreadse calcula concountDocuments({ ownerId, read: false }). No respeta los filtrostypenireadde la consulta principal.
[!NOTE]
PATCH /:id/readusareq.body?.read !== false. Eso significa que cualquier valor distinto defalsedeja el evento marcado como leído.
[!TIP]
GET /populalistingIdcontitle,media,price,location,meta,metricsypublication, y populaactorIdconname,username,photo,accountStatus. Eso da contexto suficiente al cliente sin consultas extra.
Related
services/api/src/app.js monta este router bajo /api/listing-events, junto con el resto de módulos de negocio del backend.
services/api/src/modules/auth/auth.middleware.js aporta requireAuth, que bloquea toda la superficie del módulo. services/api/src/modules/listings/listing-interaction-policy.js aporta getListingInteractionPolicy y assertListingCanReceiveEvent, que controlan si el listing puede recibir el evento. services/api/src/utils/publicUserIdentity.js aporta isAnonymizedAccountStatus y maskAnonymizedUserIdentity, que protegen la identidad pública del actor.
apps/backoffice-web/src/features/feed/api/createListingEvent.js consume esta API desde el backoffice. Además, services/api/src/modules/listing-likes/listing-likes.routes.js y services/api/src/modules/listing-comments/listing-comments.routes.js también crean documentos en ListingEvent como parte de sus propios flujos.