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 usa req.auth.userId, y el alta de eventos propios devuelve skipped: true en 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 de express.Router que agrupa las tres rutas del módulo.
  • EVENT_TYPES [badge:read-only] Set<string> — lista los tipos admitidos por GET y POST.
  • EVENT_METADATA_ALLOWLIST [badge:read-only] Record<string, string[]> — define qué claves de metadata sobreviven por tipo de evento.
  • ALLOWED_METADATA_VALUE_TYPES [badge:read-only] Set<string> — limita metadata a string, number y boolean.
  • MAX_METADATA_STRING_LENGTH [badge:read-only] number — recorta cadenas de metadata que superen 500 caracteres.
  • sanitizeEventMetadata(type, rawMetadata) — limpia req.body.metadata antes 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."
            }
        ]
    }
]
  • like y unlike — 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 por type y read.
  • GET / — ordena por createdAt descendente, usa lean(), y popula listingId y actorId.
  • GET / — devuelve total, unread, page y pages, además de data.
  • GET / — enmascara actorId y sustituye actorName por Usuario anonimizado cuando la cuenta lo requiere.
  • POST / — valida listingId y type, busca el listing y omite eventos del propio dueño.
  • POST / — llama a getListingInteractionPolicy y assertListingCanReceiveEvent antes de crear el documento.
  • POST / — crea un ListingEvent con listingId, ownerId, actorId, actorName, type, source y metadata.
  • POST / — actualiza métricas del listing solo para tipos presentes en metricMap.
  • POST / — incrementa o decrementa metrics.likes para like y unlike.
  • POST / — actualiza metrics.lastInteractionAt cuando el tipo tiene mapeo de métrica.
  • PATCH /:id/read — cambia read para 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] comment y profile_open son tipos válidos, pero no tienen entrada en metricMap. 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] sanitizeEventMetadata no persiste objetos ni arrays anidados. También descarta cualquier clave fuera del allowlist y cualquier string mayor a 500 caracteres.

[!WARNING] GET / solo interpreta read cuando llega como la cadena exacta "true" o "false". Otros valores no filtran nada.

[!NOTE] El contador unread se calcula con countDocuments({ ownerId, read: false }). No respeta los filtros type ni read de la consulta principal.

[!NOTE] PATCH /:id/read usa req.body?.read !== false. Eso significa que cualquier valor distinto de false deja el evento marcado como leído.

[!TIP] GET / popula listingId con title, media, price, location, meta, metrics y publication, y popula actorId con name, username, photo, email y accountStatus. Eso da contexto suficiente al cliente sin consultas extra.

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.