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,assemblePublicListingsForViewerybuildLikedByCurrentUserMap.
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 decreatedByusada para Listings candidatas. Solo seleccionaname,username,photo,coverPhoto,bio,websiteylocation.MATCH_PUBLIC_RESULTS_LIMIT[badge:read-only]number— Límite público de resultados por respuesta. Este archivo lo fija en50.
[!NOTE] La ruta no define una representación propia de
listing. La forma pública completa llega desdelisting-public-assembly.js, y esta ruta solo la reutiliza para hidratar candidatos de Match .
Behaviors
noStorePersonalizedResponse— estableceCache-Control: private, no-storey borraif-none-matcheif-modified-sinceantes de autenticar la solicitud .router.get("/requirements/:requirementId/candidates", ...)— valida elrequirementId, verifica ownership concreatedBy: req.auth.userId, exigeintent: "requirement"y descarta borrados pendientes condeletion: null.- La ruta devuelve
400si el ID no es válido,404con el mismo mensaje genérico si el documento no existe, no pertenece al usuario o en realidad es unOffer, y500si ocurre un error interno . - La generación de candidatos usa
includeBlocked: truepara conservar candidatos que después pueden reclasificarse comobudget_warningpor la capa de presentación . countsTowardTargetse fija conisPrimaryMatchCandidatepara que soloeligibleypendingconsuman el objetivo de búsqueda.budget_warningyalternativese retienen, pero no agotan el cupo principal .- La ruta omite
hiddendel 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]
hiddennunca 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
Requirementno existe, pertenece a otro usuario o en realidad es unOffer. Eso evita enumeración de recursos .
[!WARNING]
includeBlocked: truees imprescindible. Sin ese flag,candidate-retrieval.jsdescartaría candidatosblockedantes de quecandidate-presentation.jspudiera convertir algunos enbudget_warningpresentables .
[!CAUTION]
countsTowardTarget: isPrimaryMatchCandidatecorrige un problema de correctitud. Sin ese predicado, candidatosblockedordinarios 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 fijaCache-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 .
Related
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— AportaassemblePublicListingsForViewerybuildLikedByCurrentUserMap, que Match reutiliza sin crear una segunda política pública de Listing .services/api/src/modules/auth/auth.middleware.js— AportarequireAuth, que protege la ruta con autenticación obligatoria .