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

Overview

Este archivo define el router HTTP para alternar el estado de like de un anuncio. Expone una ruta protegida que permite crear o eliminar un ListingLike, actualizar las métricas del Listing, y registrar un ListingEvent cuando corresponde.

El flujo está pensado para ser determinista y resistente a carreras concurrentes. La lógica usa autenticación obligatoria, valida el listingId, comprueba la política de interacción del anuncio, y apoya la consistencia en el índice único de ListingLike para evitar duplicados.

[!IMPORTANT] Todo el router aplica requireAuth antes de ejecutar la acción de toggle. Sin autenticación, no se procesa ninguna solicitud.

How it works

flowchart TD
    A[Cliente autenticado] --> B[router use requireAuth]
    B --> C[POST listingId toggle]
    C --> D[Validar listingId]
    D -->|invalido| E[400 ID invalido]
    D -->|valido| F[Buscar Listing]
    F -->|sin anuncio| G[404 Anuncio no encontrado]
    F -->|con anuncio| H[Obtener policy]
    H --> I[assertListingCanReceiveLike]
    I --> J[Buscar ListingLike]
    J -->|existe| K[Eliminar ListingLike]
    K --> L[Restar metrics likes]
    L --> M[Crear ListingEvent unlike]
    M --> N[Responder liked false]
    J -->|no existe| O[Crear ListingLike]
    O -->|E11000| P[Responder liked true con estado actual]
    O -->|creado| Q[Sumar metrics likes]
    Q --> R[Crear ListingEvent like]
    R --> S[Responder liked true]

El router recibe una solicitud sobre /:listingId/toggle y primero exige autenticación. Después valida que listingId sea un ObjectId válido de Mongoose. Si el anuncio existe, el código obtiene la policy de interacción y confirma que el anuncio puede recibir likes.

A partir de ahí, el flujo busca si ya existe un ListingLike para el par listingId y userId. Si existe, elimina el like, decrementa metrics.likes, actualiza metrics.lastInteractionAt, y devuelve liked: false. Si no existe, intenta crear el like y maneja explícitamente el conflicto de duplicado E11000.

Key components

{
    "title": "requireAuth",
    "content": "Middleware importado desde `services/api/src/modules/auth/auth.middleware.js`. Se aplica al router completo y bloquea el acceso sin autenticaci\u00f3n.",
    "type": "function",
    "filePath": "services/api/src/modules/auth/auth.middleware.js",
    "badges": [
        "public"
    ]
}
{
    "title": "getListingInteractionPolicy",
    "content": "Funci\u00f3n importada desde `services/api/src/modules/listings/listing-interaction-policy.js`. Construye la policy de interacci\u00f3n que se eval\u00faa antes de permitir el like.",
    "type": "function",
    "filePath": "services/api/src/modules/listings/listing-interaction-policy.js",
    "badges": [
        "public"
    ]
}
{
    "title": "assertListingCanReceiveLike",
    "content": "Funci\u00f3n importada desde `services/api/src/modules/listings/listing-interaction-policy.js`. Verifica que la policy permita registrar un like antes de continuar.",
    "type": "function",
    "filePath": "services/api/src/modules/listings/listing-interaction-policy.js",
    "badges": [
        "public"
    ]
}

Router del módulo

Este archivo crea un express.Router() y registra una única operación HTTP. La ruta usa requireAuth a nivel de router, así que todas las peticiones deben llegar con identidad válida.

Modelos usados

El flujo depende de tres modelos de persistencia ya presentes en el código:

  • Listing desde services/api/src/models/Listing.js
  • ListingLike desde services/api/src/models/ListingLike.js
  • ListingEvent desde services/api/src/models/ListingEvent.js

Data & fields

[
    {
        "name": "listingId",
        "type": "string",
        "required": true,
        "description": "Identificador del anuncio tomado de `req.params`. Debe ser un `ObjectId` válido."
    },
    {
        "name": "actorName",
        "type": "string",
        "description": "Nombre opcional leído desde `req.body` y usado al crear `ListingEvent`."
    },
    {
        "name": "auth",
        "type": "object",
        "required": true,
        "description": "Contexto de autenticación inyectado por `requireAuth`.",
        "children": [
            {
                "name": "userId",
                "type": "string",
                "required": true,
                "description": "Identificador del usuario autenticado."
            },
            {
                "name": "email",
                "type": "string",
                "description": "Correo del usuario autenticado, usado como respaldo para `actorName`."
            }
        ]
    }
]

Campos leídos desde Listing

  • createdBy [badge:read-only] string — identifica el propietario del anuncio y se usa para detectar self-like.
  • title [badge:read-only] string — se copia en ListingEvent.metadata.listingTitle.
  • metrics.likes [badge:read-only] number — contador que se incrementa o decrementa en el toggle.
  • metrics.lastInteractionAt [badge:read-only] Date — se actualiza en cada cambio de estado.
  • publication [badge:read-only] object — se carga para calcular la policy de interacción.
  • lifecycle [badge:read-only] object — se carga para calcular la policy de interacción.
  • metrics.likes [badge:read-only] number — representa el total de likes actuales.
  • metrics.lastInteractionAt [badge:read-only] Date — guarda el momento del último cambio de interacción.

Salida del endpoint

  • liked [badge:required] boolean — indica si el usuario terminó con like activo.
  • likes [badge:required] number — total de likes reportado después del cambio o del estado actual.

Behaviors

  • router.use(requireAuth) — aplica autenticación obligatoria a todas las rutas del router.
  • POST /:listingId/toggle — alterna el like del usuario autenticado sobre un anuncio y devuelve el estado final.
  • mongoose.Types.ObjectId.isValid(listingId) — valida el formato del identificador antes de consultar la base.
  • Listing.findById(...).select(...).lean() — obtiene el anuncio con los campos necesarios para decidir la operación.
  • getListingInteractionPolicy({ listing }) — produce la policy que se valida antes de permitir el like.
  • assertListingCanReceiveLike(policy) — detiene el flujo si la policy no permite el like.
  • ListingLike.findOne({ listingId, userId }) — verifica si ya existe el like del usuario.
  • existing.deleteOne() — elimina un like ya registrado.
  • Listing.findByIdAndUpdate(...) — ajusta metrics.likes y metrics.lastInteractionAt.
  • ListingEvent.create(...) — registra la interacción para el feed cuando el actor no es el dueño.
  • ListingLike.create(...) — intenta crear el like nuevo.
  • Manejo de E11000 — convierte la carrera concurrente en una respuesta idempotente.

API

POST /:listingId/toggle

{
    "title": "Toggle listing like",
    "description": "Alterna el like del usuario autenticado sobre un anuncio. Si el like existe, lo elimina. Si no existe, lo crea y actualiza las m\u00e9tricas del anuncio.",
    "method": "POST",
    "baseUrl": "",
    "endpoint": "/:listingId/toggle",
    "headers": [
        {
            "key": "Authorization",
            "value": "Bearer <token>",
            "required": true
        },
        {
            "key": "Content-Type",
            "value": "application/json",
            "required": false
        }
    ],
    "queryParams": [],
    "pathParams": [
        {
            "key": "listingId",
            "value": "Identificador del anuncio",
            "required": true
        }
    ],
    "bodyType": "json",
    "requestBody": "{\n  \"actorName\": \"Nombre visible opcional\"\n}",
    "formData": [],
    "rawBody": "",
    "responses": {
        "200": {
            "description": "El estado del like fue alternado con \u00e9xito.",
            "body": "{\n  \"liked\": true,\n  \"likes\": 18\n}"
        },
        "400": {
            "description": "El identificador del anuncio no es v\u00e1lido.",
            "body": "{\n  \"message\": \"ID de anuncio inv\u00e1lido.\"\n}"
        },
        "404": {
            "description": "El anuncio no existe o no tiene `createdBy`.",
            "body": "{\n  \"message\": \"Anuncio no encontrado.\"\n}"
        }
    }
}

Workflows

1. Autenticar la solicitud | `requireAuth` ejecuta la verificación antes de entrar al handler.
2. Validar el identificador | El código comprueba que `listingId` sea un `ObjectId` válido.
3. Cargar el anuncio | Se consulta `Listing` con los campos necesarios para decidir el toggle.
4. Verificar la policy | `getListingInteractionPolicy` y `assertListingCanReceiveLike` validan si el anuncio puede recibir likes.
5. Detectar el estado actual | Se busca un `ListingLike` existente para el usuario y el anuncio.
6. Alternar persistencia y métricas | El flujo crea o elimina `ListingLike` y ajusta `metrics.likes`.
7. Registrar evento si corresponde | Si no es `self-like`, se crea un `ListingEvent` con `type` `like` o `unlike`.
8. Responder con el estado final | La respuesta devuelve `liked` y el valor final de `likes`.

Gotchas & edge cases

[!WARNING] La concurrencia está resuelta con el índice único {listingId, userId} de ListingLike. Si dos solicitudes llegan al mismo tiempo, una puede recibir E11000 y el handler responde de forma idempotente.

[!NOTE] Cuando el usuario es el propietario del anuncio, isSelfLike evita la creación de ListingEvent. El like o unlike sigue afectando a ListingLike y metrics.likes.

[!CAUTION] Si Listing.findById no devuelve un documento con createdBy, el handler responde 404 con Anuncio no encontrado.

[!NOTE] El comentario del código aclara que likes y eventos no dependen de deletionGracePreferences. Ese criterio se reserva para comentarios y leads.

[!WARNING] La ruta solo acepta listingId válidos para Mongoose. Un valor mal formado corta el flujo con 400 antes de consultar la base.

  • services/api/src/models/Listing.js — fuente del anuncio y de las métricas actualizadas por el toggle.
  • services/api/src/models/ListingLike.js — persistencia del estado like y base de la exclusión única por usuario y anuncio.
  • services/api/src/models/ListingEvent.js — registro de eventos like y unlike para el feed.
  • services/api/src/modules/auth/auth.middleware.js — middleware que expone req.auth y protege el router.
  • services/api/src/modules/listings/listing-interaction-policy.js — define la policy que decide si el anuncio puede recibir likes.

[!IMPORTANT] Este módulo concentra toda la lógica de toggle en un único endpoint. La respuesta final siempre expresa el estado resultante, no el intento original.