services/api/src/modules/match/match.routes.js

Overview

Este archivo define la primera ruta HTTP pública de Match. Su objetivo es exponer candidatos de Match para un Requirement propio, con cálculo bajo demanda y sin persistencia de resultados. La ruta es estrictamente de solo lectura y no crea Lead, ListingEvent, Activity, Notification, Email ni Campaign .

La implementación también mantiene una regla arquitectónica clave: Match no construye una representación propia de Listing. En su lugar, reutiliza el mismo pipeline público compartido que usan GET /api/listings y GET /api/listings/:id, para que la misma Listing se vea igual en Muro y en Match .

[!IMPORTANT] Esta ruta nunca reimplementa comparación ni presentación. Reusa generateCandidatesForRequirement, orderCandidatesForPresentation, assemblePublicListingsForViewer y buildLikedByCurrentUserMap .

El módulo sigue el patrón del proyecto de routes.js con handlers inline. La orquestación real del dominio ya vive en candidate-retrieval.js y candidate-presentation.js, así que un service.js o controller.js separado no aporta lógica nueva .

How it works

flowchart TD
    Client[Client]
    Route[match routes]
    NoStore[noStorePersonalizedResponse]
    Auth[requireAuth]
    Check[Requirement ownership check]
    Generate[generateCandidatesForRequirement]
    Present[orderCandidatesForPresentation]
    Hydrate[Listing hydrate query]
    PublicAsm[assemblePublicListingsForViewer]
    LikeMap[buildLikedByCurrentUserMap]
    Metadata[toPublicMatchMetadata]
    Response[200 JSON response]

    Client --> Route
    Route --> NoStore
    NoStore --> Auth
    Auth --> Check
    Check --> Generate
    Generate --> Present
    Present --> Hydrate
    Hydrate --> PublicAsm
    Hydrate --> LikeMap
    Present --> Metadata
    PublicAsm --> Response
    LikeMap --> Response
    Metadata --> Response

La ruta empieza con un middleware de cache personalizado, continúa con autenticación y luego valida que el Requirement pertenezca al usuario autenticado. Después recalcula candidatos de Match, filtra los presentables, hidrata las Listings candidatas y compone la respuesta pública final con metadata de Match y la vista pública compartida de cada Listing .

La ruta reconstruye primero el orden de Match y solo después hidrata las Listings. Eso evita depender del orden de retorno de Mongo para el operador $in, que no está garantizado. Si una Listing desaparece entre la evaluación y la hidratación, la respuesta no falla; esa candidata se omite en silencio .

La hidratación usa un único Listing.find({ _id: { $in: ... } }) batched y un único ListingLike.find(...) para construir likedByCurrentUser. Eso evita un patrón N+1 y mantiene la respuesta alineada con el ensamblaje público usado por Muro .

Key components

Este módulo coordina varias funciones exportadas en otros archivos. Cada una cubre una parte precisa del flujo, y esta ruta solo las ensambla en tiempo de petición.

{
    "title": "noStorePersonalizedResponse",
    "content": "Middleware interno de esta ruta. Marca la respuesta como `private, no-store` y elimina `if-none-match` e `if-modified-since` antes de continuar. As\u00ed evita respuestas 304 basadas en validadores viejos en una respuesta personalizada.",
    "type": "function",
    "filePath": "services/api/src/modules/match/match.routes.js",
    "badges": [
        "read-only"
    ]
}
{
    "title": "generateCandidatesForRequirement",
    "content": "Funci\u00f3n de `services/api/src/modules/match/candidate-retrieval.js` que recalcula candidatos bajo demanda. Esta ruta la llama con `includeBlocked: true` y con un predicado que solo hace contar a los candidatos principales hacia el objetivo.",
    "type": "function",
    "filePath": "services/api/src/modules/match/candidate-retrieval.js",
    "badges": [
        "public",
        "read-only"
    ]
}
{
    "title": "orderCandidatesForPresentation",
    "content": "Funci\u00f3n de `services/api/src/modules/match/candidate-presentation.js` que define el orden determinista final de los candidatos presentables. Esta ruta depende de ella para excluir `hidden` del l\u00edmite p\u00fablico y respetar el orden ya calculado.",
    "type": "function",
    "filePath": "services/api/src/modules/match/candidate-presentation.js",
    "badges": [
        "public",
        "read-only"
    ]
}
{
    "title": "toPublicMatchMetadata",
    "content": "Funci\u00f3n de `services/api/src/modules/match/match-response.js` que traduce el candidato interno a metadata p\u00fablica. Expone solo `presentationState`, `warningCode`, `matchedCriteria`, `differenceCriteria` y `pendingCriteria`.",
    "type": "function",
    "filePath": "services/api/src/modules/match/match-response.js",
    "badges": [
        "public",
        "read-only"
    ]
}

Data & fields

Esta ruta trabaja con un requirementId de la URL, varios filtros de consulta internos y una respuesta JSON con resultados ordenados. La forma completa de listing no se redefine aquí; llega desde el pipeline compartido de ensamblaje público, y esta ruta solo añade likedByCurrentUser y la metadata de Match .

[
    {
        "name": "requirementId",
        "type": "string",
        "required": true,
        "description": "ID del Requirement recibido en la ruta /requirements/:requirementId/candidates."
    },
    {
        "name": "results",
        "type": "array",
        "required": true,
        "description": "Lista de candidatos presentables devueltos por Match.",
        "children": [
            {
                "name": "listingId",
                "type": "string",
                "required": true,
                "description": "ID de la Listing candidata."
            },
            {
                "name": "listing",
                "type": "object",
                "required": true,
                "description": "Listing pública ensamblada con el pipeline compartido.",
                "children": [
                    {
                        "name": "likedByCurrentUser",
                        "type": "boolean",
                        "required": true,
                        "description": "Indica si el usuario autenticado ya dio like a la Listing."
                    }
                ]
            },
            {
                "name": "match",
                "type": "object",
                "required": true,
                "description": "Metadata pública de Match para explicar el resultado.",
                "children": [
                    {
                        "name": "presentationState",
                        "type": "string",
                        "required": true,
                        "description": "Estado público calculado para el candidato."
                    },
                    {
                        "name": "warningCode",
                        "type": "string | null",
                        "description": "Código de advertencia, usado cuando el estado es budget_warning."
                    },
                    {
                        "name": "matchedCriteria",
                        "type": "array",
                        "required": true,
                        "description": "Criterios que coincidieron."
                    },
                    {
                        "name": "differenceCriteria",
                        "type": "array",
                        "required": true,
                        "description": "Criterios de diferencia cuando el estado los expone."
                    },
                    {
                        "name": "pendingCriteria",
                        "type": "array",
                        "required": true,
                        "description": "Criterios aún pendientes."
                    }
                ]
            }
        ]
    },
    {
        "name": "meta.candidatesReturned",
        "type": "number",
        "required": true,
        "description": "Cantidad total de candidatos incluidos en la respuesta."
    }
]
  • CANDIDATE_LISTING_OWNER_PROJECTION [badge:read-only] string — Proyección de createdBy usada para Listings candidatas. Solo selecciona name, username, photo, coverPhoto, bio, website y location .
  • MATCH_PUBLIC_RESULTS_LIMIT [badge:read-only] number — Límite público de resultados por respuesta. Este archivo lo fija en 50 .

[!NOTE] La ruta no define una representación propia de listing. La forma pública completa llega desde listing-public-assembly.js, y esta ruta solo la reutiliza para hidratar candidatos de Match .

Behaviors

  • noStorePersonalizedResponse — establece Cache-Control: private, no-store y borra if-none-match e if-modified-since antes de autenticar la solicitud .
  • router.get("/requirements/:requirementId/candidates", ...) — valida el requirementId, verifica ownership con createdBy: req.auth.userId, exige intent: "requirement" y descarta borrados pendientes con deletion: null .
  • La ruta devuelve 400 si el ID no es válido, 404 con el mismo mensaje genérico si el documento no existe, no pertenece al usuario o en realidad es un Offer, y 500 si ocurre un error interno .
  • La generación de candidatos usa includeBlocked: true para conservar candidatos que después pueden reclasificarse como budget_warning por la capa de presentación .
  • countsTowardTarget se fija con isPrimaryMatchCandidate para que solo eligible y pending consuman el objetivo de búsqueda. budget_warning y alternative se retienen, pero no agotan el cupo principal .
  • La ruta omite hidden del límite público. Esa superficie queda reservada al preview interno de desarrollador, no a la API HTTP pública .
  • La hidratación vuelve a validar que cada Listing siga publicada y disponible. Si ya no cumple, se omite sin romper toda la respuesta .

API

GET /api/match/requirements/:requirementId/candidates

Esta es la única ruta pública de Match documentada en este archivo. Devuelve candidatos presentables para un Requirement propio, con la Listing pública ensamblada mediante el mismo pipeline que usa el resto de la aplicación .

{
    "title": "Obtener candidatos de Match",
    "description": "Devuelve candidatos presentables para un Requirement propio y reutiliza la misma vista p\u00fablica de Listing que usa el resto del sistema.",
    "method": "GET",
    "baseUrl": "/api",
    "endpoint": "/match/requirements/:requirementId/candidates",
    "headers": [
        {
            "key": "Authorization",
            "value": "Bearer <token>",
            "required": true
        }
    ],
    "queryParams": [],
    "pathParams": [
        {
            "key": "requirementId",
            "value": "ID del Requirement consultado.",
            "required": true
        }
    ],
    "bodyType": "none",
    "requestBody": "",
    "formData": [],
    "rawBody": "",
    "responses": {
        "200": {
            "description": "Match calculado correctamente.",
            "body": "{\n  \"success\": true,\n  \"requirementId\": \"66a1d2e3f4b5c6d7e8f90123\",\n  \"results\": [\n    {\n      \"listingId\": \"66b2d3e4f5a6b7c8d9e01234\",\n      \"listing\": {\n        \"_id\": \"66b2d3e4f5a6b7c8d9e01234\",\n        \"likedByCurrentUser\": false\n      },\n      \"match\": {\n        \"presentationState\": \"eligible\",\n        \"warningCode\": null,\n        \"matchedCriteria\": [\n          {\n            \"code\": \"PROPERTY_TYPE\"\n          }\n        ],\n        \"differenceCriteria\": [],\n        \"pendingCriteria\": []\n      }\n    }\n  ],\n  \"meta\": {\n    \"candidatesReturned\": 1\n  }\n}"
        },
        "400": {
            "description": "El ID recibido no tiene formato v\u00e1lido.",
            "body": "{\n  \"success\": false,\n  \"message\": \"ID inv\u00e1lido.\"\n}"
        },
        "404": {
            "description": "El Requirement no existe, no pertenece al usuario o en realidad es un Offer.",
            "body": "{\n  \"success\": false,\n  \"message\": \"Listing no encontrado o sin permisos.\"\n}"
        },
        "500": {
            "description": "Error interno al obtener el Match.",
            "body": "{\n  \"success\": false,\n  \"message\": \"No se pudo obtener el Match para este Requirement.\"\n}"
        }
    }
}

[!WARNING] hidden nunca cruza esta API pública. El archivo lo deja fuera de la respuesta de forma intencional, aunque el preview interno sí lo vea para QA y explainability .

[!CAUTION] Esta ruta no usa el orden de retorno de Mongo para $in. Reconstruye el orden antes de hidratar, y una Listing desaparecida entre pasos se omite sin error global .

Workflows

sequenceDiagram
    participant C as Client
    participant R as match routes
    participant A as noStorePersonalizedResponse
    participant U as requireAuth
    participant M as MongoDB
    participant G as generateCandidatesForRequirement
    participant O as orderCandidatesForPresentation
    participant H as listing hydrate query
    participant P as assemblePublicListingsForViewer
    participant L as buildLikedByCurrentUserMap
    participant T as toPublicMatchMetadata

    C->>R: GET candidates
    R->>A: set no-store and strip validators
    R->>U: authenticate
    U-->>R: auth context
    R->>M: find requirement by id and owner
    M-->>R: requirement or none
    R->>G: recompute candidates
    G-->>R: candidate list
    R->>O: order presentable candidates
    O-->>R: ordered presentable list
    R->>H: hydrate listings in batch
    H-->>R: listings and availability check
    R->>P: build public listings
    P-->>R: public listings
    R->>L: build liked map
    L-->>R: liked map
    R->>T: build public match metadata
    T-->>R: match metadata
    R-->>C: 200 JSON response

El flujo real es lineal y determinista. Primero se aplica la política de no cache, después la autenticación, luego el ownership del Requirement, y finalmente el cálculo y la presentación de candidatos. La respuesta une la metadata de Match con la Listing pública compartida y con likedByCurrentUser .

Gotchas & edge cases

[!WARNING] La ruta usa el mismo 404 genérico para tres casos distintos: el Requirement no existe, pertenece a otro usuario o en realidad es un Offer. Eso evita enumeración de recursos .

[!WARNING] includeBlocked: true es imprescindible. Sin ese flag, candidate-retrieval.js descartaría candidatos blocked antes de que candidate-presentation.js pudiera convertir algunos en budget_warning presentables .

[!CAUTION] countsTowardTarget: isPrimaryMatchCandidate corrige un problema de correctitud. Sin ese predicado, candidatos blocked ordinarios podrían consumir el cupo y ocultar coincidencias principales posteriores .

[!NOTE] La respuesta es personalizada y no debe almacenarse en caché. Por eso el middleware elimina validadores condicionales antes de requireAuth, y también fija Cache-Control: private, no-store .

[!NOTE] La hidratación usa el pipeline público compartido de Listings. Si esa capa se endurece en el futuro, el cambio debe vivir allí, no en Match .

  • services/api/src/modules/match/candidate-retrieval.js — Genera candidatos de Match y concentra el acceso a Mongo para esta funcionalidad .
  • services/api/src/modules/match/candidate-presentation.js — Decide presentabilidad y orden determinista de los candidatos .
  • services/api/src/modules/match/match-response.js — Convierte candidatos internos en metadata pública de Match .
  • services/api/src/modules/listings/listing-public-assembly.js — Aporta assemblePublicListingsForViewer y buildLikedByCurrentUserMap, que Match reutiliza sin crear una segunda política pública de Listing .
  • services/api/src/modules/auth/auth.middleware.js — Aporta requireAuth, que protege la ruta con autenticación obligatoria .