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
requireAuthantes 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:
Listingdesdeservices/api/src/models/Listing.jsListingLikedesdeservices/api/src/models/ListingLike.jsListingEventdesdeservices/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 detectarself-like.title[badge:read-only]string— se copia enListingEvent.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(...)— ajustametrics.likesymetrics.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}deListingLike. Si dos solicitudes llegan al mismo tiempo, una puede recibirE11000y el handler responde de forma idempotente.
[!NOTE] Cuando el usuario es el propietario del anuncio,
isSelfLikeevita la creación deListingEvent. El like o unlike sigue afectando aListingLikeymetrics.likes.
[!CAUTION] Si
Listing.findByIdno devuelve un documento concreatedBy, el handler responde404conAnuncio 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
listingIdválidos para Mongoose. Un valor mal formado corta el flujo con400antes de consultar la base.
Related
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 eventoslikeyunlikepara el feed.services/api/src/modules/auth/auth.middleware.js— middleware que exponereq.authy 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.