Rutas de Conversation para historial privado y refinamientos estructurados

Overview

Este archivo expone la superficie HTTP privada de Conversation para el usuario autenticado. La documentación del repositorio lo sitúa en la Fase M3.1 y enumera siete rutas HTTP para crear, listar, leer, actualizar, borrar, añadir mensajes y aplicar refinamientos estructurados.

El router mantiene una frontera clara: los mensajes solo guardan historial, y la conversación no persiste resultados de Match. El resultado de Match se recalcula aparte desde activeRequirementId, fuera de este archivo.

[!IMPORTANT] Todas las rutas pasan por requireAuth. Solo POST /api/conversations/:id/refinements añade requireVerifiedEmail.

How it works

flowchart TB
  Client[Client]
  Auth[requireAuth]
  Router[conversations routes router]

  Client --> Auth
  Auth --> Router

  Router --> Create[POST conversations]
  Router --> List[GET conversations]
  Router --> Detail[GET conversations id]
  Router --> Patch[PATCH conversations id]
  Router --> Delete[DELETE conversations id]
  Router --> Message[POST conversations id messages]
  Router --> Refine[POST conversations id refinements]

  subgraph AssociationService[requirement association service]
    CreateReq[createConversationWithRequirement]
    Attach[attachRequirement]
    Detach[detachRequirement]
    Update[updateConversationFields]
    Response[toConversationResponse]
  end

  Create --> CreateReq
  Patch --> Attach
  Patch --> Detach
  Patch --> Update
  CreateReq --> Response
  Attach --> Response
  Detach --> Response
  Update --> Response

  Message --> Model[Conversation model]
  List --> Model
  Detail --> Model

  Refine --> Verify[requireVerifiedEmail]
  Refine --> RefineSvc[applyRequirementRefinement]
  RefineSvc --> Model

Every request enters through requireAuth. The router validates the request shape before touching MongoDB. Ownership always stays inside the database filter, not in a later manual check. When the request touches activeRequirementId, the route delegates the decision to the requirement-association coordinator.

Key components

{
    "title": "Conversation",
    "content": "MongoDB model imported from services/api/src/models/Conversation.js. It provides the persistence layer used by every route in this file.",
    "type": "class",
    "filePath": "services/api/src/models/Conversation.js",
    "badges": [
        "public"
    ]
}
{
    "title": "requireAuth",
    "content": "Middleware that protects the whole router. Every Conversation route requires an authenticated user.",
    "type": "function",
    "filePath": "services/api/src/modules/auth/auth.middleware.js",
    "badges": [
        "public"
    ]
}
{
    "title": "requireVerifiedEmail",
    "content": "Additional middleware used only by POST /:id/refinements. It adds a verified-email gate before structured requirement writes.",
    "type": "function",
    "filePath": "services/api/src/modules/auth/auth.middleware.js",
    "badges": [
        "public"
    ]
}
{
    "title": "createConversationWithRequirement",
    "content": "Coordinator used when a new conversation is created with activeRequirementId. It handles the creation and requirement association together.",
    "type": "function",
    "filePath": "services/api/src/modules/conversations/requirement-association.service.js",
    "badges": [
        "public"
    ]
}
{
    "title": "attachRequirement",
    "content": "Coordinator used by PATCH /:id when activeRequirementId is present. It updates the active requirement and returns a conversation response payload.",
    "type": "function",
    "filePath": "services/api/src/modules/conversations/requirement-association.service.js",
    "badges": [
        "public"
    ]
}
{
    "title": "detachRequirement",
    "content": "Coordinator used by PATCH /:id when activeRequirementId is null. It removes the active requirement from the conversation in the same write path.",
    "type": "function",
    "filePath": "services/api/src/modules/conversations/requirement-association.service.js",
    "badges": [
        "public"
    ]
}
{
    "title": "updateConversationFields",
    "content": "Coordinator used by PATCH /:id when only title or status changes. It writes those fields while preserving the current requirement association.",
    "type": "function",
    "filePath": "services/api/src/modules/conversations/requirement-association.service.js",
    "badges": [
        "public"
    ]
}
{
    "title": "toConversationResponse",
    "content": "Response normalizer used by write routes. It converts a conversation document into the payload returned to the client.",
    "type": "function",
    "filePath": "services/api/src/modules/conversations/requirement-association.service.js",
    "badges": [
        "public"
    ]
}
{
    "title": "applyRequirementRefinement",
    "content": "Structured refinement coordinator imported from the listings module. It applies validated changes to the active requirement and reports the outcome.",
    "type": "function",
    "filePath": "services/api/src/modules/listings/requirement-refinement.js",
    "badges": [
        "public"
    ]
}

Data & fields

Conversation create and patch input

[
    {
        "name": "title",
        "type": "string",
        "description": "Optional title. The route trims it and rejects strings longer than CONVERSATION_TITLE_MAX_LENGTH."
    },
    {
        "name": "activeRequirementId",
        "type": "string | null",
        "description": "Requirement id to attach or detach. The route validates it as a 24 character hexadecimal ObjectId string."
    },
    {
        "name": "status",
        "type": "string",
        "description": "Conversation state. The route accepts only values listed in CONVERSATION_STATUSES."
    }
]

activeRequirementId is optional on create and patch. When it is present, the route delegates the association decision to the coordinator. When it is null, the route detaches the requirement.

Message input

[
    {
        "name": "text",
        "type": "string",
        "required": true,
        "description": "User message text. The route trims it and enforces CONVERSATION_MESSAGE_MAX_LENGTH."
    }
]

The server sets role and kind itself. The client only sends text.

Refinement input

[
    {
        "name": "changes",
        "type": "object",
        "required": true,
        "description": "Structured refinement payload. The route only checks that it is a plain object."
    }
]

The route does not inspect the inner keys. requirement-refinement.js decides which fields are supported, which ones are refinements, and which ones are pivots.

List query parameters

[
    {
        "name": "status",
        "type": "string",
        "description": "List filter. Defaults to active when omitted."
    },
    {
        "name": "page",
        "type": "number",
        "description": "1-based page number. Invalid values fall back to 1."
    },
    {
        "name": "limit",
        "type": "number",
        "description": "Page size. Invalid values fall back to LIST_DEFAULT_LIMIT, with a hard maximum of LIST_MAX_LIMIT."
    }
]

GET /api/conversations returns only the caller's own documents. It also removes messages from the list payload.

Common response fields

[
    {
        "name": "success",
        "type": "boolean",
        "required": true,
        "description": "Success flag used by every response envelope in this file."
    },
    {
        "name": "data",
        "type": "object | array",
        "description": "Payload returned by successful read and write operations."
    },
    {
        "name": "count",
        "type": "number",
        "description": "Number of items in the current list page."
    },
    {
        "name": "total",
        "type": "number",
        "description": "Total number of documents that match the list filter."
    },
    {
        "name": "page",
        "type": "number",
        "description": "Current page number in list responses."
    },
    {
        "name": "pages",
        "type": "number",
        "description": "Total page count for the chosen limit."
    },
    {
        "name": "message",
        "type": "string",
        "description": "Human-readable message used by success and error responses."
    },
    {
        "name": "code",
        "type": "string",
        "description": "Machine-readable error code."
    },
    {
        "name": "field",
        "type": "string | null",
        "description": "Field that triggered the validation error when the route provides one."
    },
    {
        "name": "retryable",
        "type": "boolean",
        "description": "Signals that the conversation conflict can be retried."
    },
    {
        "name": "requiresNewRequirement",
        "type": "boolean",
        "description": "Signals that the proposed change is a pivot, not a refinement."
    },
    {
        "name": "errors",
        "type": "array",
        "description": "Validation errors returned by the refinement service."
    }
]

Behaviors

Internal helpers

  • isObjectIdString — Accepts only 24 character hexadecimal ObjectId strings.
  • readBody — Returns a plain object body or an empty object.
  • findDisallowedField — Rejects the first key outside the route whitelist.
  • badRequest — Emits a 400 response with success, code, field, and message.
  • fieldNotAllowed — Emits CONVERSATION_FIELD_NOT_ALLOWED for unsupported top-level keys.
  • parseTitle — Trims the title and enforces CONVERSATION_TITLE_MAX_LENGTH.
  • invalidActiveRequirement — Emits one generic rejection for malformed, missing, foreign, or otherwise invalid requirement ids.
  • respondAssociationRejected — Maps coordinator rejection reasons to HTTP responses.
  • buildRequirementRefinedEvent — Builds the system message appended after a successful refinement.
  • withMessageCapacity — Adds the atomic capacity check used to allow only one more message.
  • respondMessageWriteBlocked — Distinguishes between missing, archived, and full conversations.

Route responsibilities

  • POST / — Creates a conversation for the authenticated owner.
  • GET / — Lists the owner's conversations, newest first, without messages.
  • GET /:id — Returns the full conversation, including history, for the owner.
  • PATCH /:id — Updates only title, status, and activeRequirementId.
  • DELETE /:id — Physically deletes the owner’s conversation.
  • POST /:id/messages — Appends one user text message to the conversation history.
  • POST /:id/refinements — Applies a structured refinement to the active requirement.

Write-path details

  • POST / with activeRequirementId delegates creation and association to createConversationWithRequirement.
  • PATCH /:id checks existence before validating activeRequirementId.
  • PATCH /:id applies title and status together with association changes when needed.
  • POST /:id/messages fixes role to user and kind to text.
  • POST /:id/refinements applies the requirement first, then appends the history event best effort.

API

POST /api/conversations

Creates a private conversation for the authenticated owner. If activeRequirementId is present, the association coordinator handles creation and linkage together.

{
    "title": "Crear b\u00fasqueda en Puebla",
    "description": "Creates a private conversation. The owner is taken from the authenticated request. If activeRequirementId is sent, the coordinator attaches it during creation.",
    "method": "POST",
    "baseUrl": "/api",
    "endpoint": "/conversations",
    "headers": [
        {
            "key": "Authorization",
            "value": "Bearer <token>",
            "required": true
        },
        {
            "key": "Content-Type",
            "value": "application/json",
            "required": true
        }
    ],
    "queryParams": [],
    "pathParams": [],
    "bodyType": "json",
    "requestBody": "{\n  \"title\": \"Crear b\u00fasqueda en Puebla\",\n  \"activeRequirementId\": \"66a1f0c2d4e5f67890123456\"\n}",
    "formData": [],
    "rawBody": "",
    "responses": {
        "201": {
            "description": "Conversation created",
            "body": "{\n  \"success\": true,\n  \"data\": {\n    \"_id\": \"66a1f0c2d4e5f67890123400\",\n    \"title\": \"Crear b\u00fasqueda en Puebla\",\n    \"status\": \"active\",\n    \"activeRequirementId\": \"66a1f0c2d4e5f67890123456\"\n  }\n}"
        },
        "400": {
            "description": "Invalid title or invalid active requirement",
            "body": "{\n  \"success\": false,\n  \"code\": \"INVALID_CONVERSATION_TITLE\",\n  \"field\": \"title\",\n  \"message\": \"El t\u00edtulo debe ser texto de m\u00e1ximo 120 caracteres.\"\n}"
        },
        "404": {
            "description": "Conversation or requirement association not found",
            "body": "{\n  \"success\": false,\n  \"message\": \"Conversaci\u00f3n no encontrada.\"\n}"
        },
        "409": {
            "description": "Association conflict or limit reached",
            "body": "{\n  \"success\": false,\n  \"code\": \"CONVERSATION_CONFLICT\",\n  \"retryable\": true,\n  \"message\": \"La conversaci\u00f3n cambi\u00f3 mientras se guardaba. Vuelve a intentarlo.\"\n}"
        },
        "500": {
            "description": "Server error",
            "body": "{\n  \"success\": false,\n  \"message\": \"No se pudo crear la conversaci\u00f3n.\"\n}"
        }
    }
}

GET /api/conversations

Lists the authenticated owner's conversations. It sorts by updatedAt descending, excludes messages, and uses page, limit, and status.

{
    "title": "List Conversations",
    "description": "Returns only the authenticated user's conversations. Results are sorted by updatedAt descending and exclude the embedded message history.",
    "method": "GET",
    "baseUrl": "/api",
    "endpoint": "/conversations",
    "headers": [
        {
            "key": "Authorization",
            "value": "Bearer <token>",
            "required": true
        }
    ],
    "queryParams": [
        {
            "key": "status",
            "value": "active or archived",
            "required": false
        },
        {
            "key": "page",
            "value": "1-based page number",
            "required": false
        },
        {
            "key": "limit",
            "value": "Page size up to 50",
            "required": false
        }
    ],
    "pathParams": [],
    "bodyType": "none",
    "requestBody": "",
    "formData": [],
    "rawBody": "",
    "responses": {
        "200": {
            "description": "List returned",
            "body": "{\n  \"success\": true,\n  \"count\": 2,\n  \"total\": 7,\n  \"page\": 1,\n  \"pages\": 4,\n  \"data\": [\n    {\n      \"_id\": \"66a1f0c2d4e5f67890123401\",\n      \"title\": \"Casa en Puebla\",\n      \"status\": \"active\",\n      \"updatedAt\": \"2026-02-01T00:00:00.000Z\"\n    }\n  ]\n}"
        },
        "400": {
            "description": "Invalid status",
            "body": "{\n  \"success\": false,\n  \"code\": \"INVALID_CONVERSATION_STATUS\",\n  \"field\": \"status\",\n  \"message\": \"Estado de conversaci\u00f3n no v\u00e1lido.\"\n}"
        },
        "500": {
            "description": "Server error",
            "body": "{\n  \"success\": false,\n  \"message\": \"No se pudieron obtener las conversaciones.\"\n}"
        }
    }
}

GET /api/conversations/:id

Returns the full conversation, including its embedded history, for the authenticated owner.

{
    "title": "Get Conversation",
    "description": "Returns one full conversation document, including messages, only when it belongs to the authenticated owner.",
    "method": "GET",
    "baseUrl": "/api",
    "endpoint": "/conversations/:id",
    "headers": [
        {
            "key": "Authorization",
            "value": "Bearer <token>",
            "required": true
        }
    ],
    "queryParams": [],
    "pathParams": [
        {
            "key": "id",
            "value": "Conversation id",
            "required": true
        }
    ],
    "bodyType": "none",
    "requestBody": "",
    "formData": [],
    "rawBody": "",
    "responses": {
        "200": {
            "description": "Conversation returned",
            "body": "{\n  \"success\": true,\n  \"data\": {\n    \"_id\": \"66a1f0c2d4e5f67890123401\",\n    \"title\": \"Casa en Puebla\",\n    \"status\": \"active\",\n    \"messages\": [\n      {\n        \"_id\": \"66a1f0c2d4e5f67890123410\",\n        \"role\": \"user\",\n        \"kind\": \"text\",\n        \"text\": \"Busco una casa familiar\"\n      }\n    ]\n  }\n}"
        },
        "400": {
            "description": "Invalid id",
            "body": "{\n  \"success\": false,\n  \"message\": \"ID inv\u00e1lido.\"\n}"
        },
        "404": {
            "description": "Missing or foreign conversation",
            "body": "{\n  \"success\": false,\n  \"message\": \"Conversaci\u00f3n no encontrada.\"\n}"
        }
    }
}

PATCH /api/conversations/:id

Updates only title, status, and activeRequirementId. When activeRequirementId is present, the coordinator handles the association or detachment in the same write path.

{
    "title": "Rename Conversation",
    "description": "Updates the allowed conversation fields only. If activeRequirementId is present, the association coordinator performs the change and the write can return a conflict or a generic association rejection.",
    "method": "PATCH",
    "baseUrl": "/api",
    "endpoint": "/conversations/:id",
    "headers": [
        {
            "key": "Authorization",
            "value": "Bearer <token>",
            "required": true
        },
        {
            "key": "Content-Type",
            "value": "application/json",
            "required": true
        }
    ],
    "queryParams": [],
    "pathParams": [
        {
            "key": "id",
            "value": "Conversation id",
            "required": true
        }
    ],
    "bodyType": "json",
    "requestBody": "{\n  \"title\": \"Busqueda principal\",\n  \"status\": \"archived\",\n  \"activeRequirementId\": null\n}",
    "formData": [],
    "rawBody": "",
    "responses": {
        "200": {
            "description": "Conversation updated",
            "body": "{\n  \"success\": true,\n  \"data\": {\n    \"_id\": \"66a1f0c2d4e5f67890123401\",\n    \"title\": \"Busqueda principal\",\n    \"status\": \"archived\"\n  }\n}"
        },
        "400": {
            "description": "Invalid field, empty update, invalid status, invalid title, or invalid active requirement",
            "body": "{\n  \"success\": false,\n  \"code\": \"CONVERSATION_EMPTY_UPDATE\",\n  \"field\": null,\n  \"message\": \"No hay campos para actualizar.\"\n}"
        },
        "404": {
            "description": "Missing or foreign conversation",
            "body": "{\n  \"success\": false,\n  \"message\": \"Conversaci\u00f3n no encontrada.\"\n}"
        },
        "409": {
            "description": "Association conflict or limit reached",
            "body": "{\n  \"success\": false,\n  \"code\": \"CONVERSATION_CONFLICT\",\n  \"retryable\": true,\n  \"message\": \"La conversaci\u00f3n cambi\u00f3 mientras se guardaba. Vuelve a intentarlo.\"\n}"
        }
    }
}

DELETE /api/conversations/:id

Physically deletes the authenticated owner's conversation. Archiving is handled by PATCH with status: "archived".

{
    "title": "Delete Conversation",
    "description": "Physically deletes one conversation owned by the authenticated user. This route does not archive the conversation.",
    "method": "DELETE",
    "baseUrl": "/api",
    "endpoint": "/conversations/:id",
    "headers": [
        {
            "key": "Authorization",
            "value": "Bearer <token>",
            "required": true
        }
    ],
    "queryParams": [],
    "pathParams": [
        {
            "key": "id",
            "value": "Conversation id",
            "required": true
        }
    ],
    "bodyType": "none",
    "requestBody": "",
    "formData": [],
    "rawBody": "",
    "responses": {
        "200": {
            "description": "Conversation deleted",
            "body": "{\n  \"success\": true,\n  \"message\": \"Conversaci\u00f3n eliminada.\"\n}"
        },
        "400": {
            "description": "Invalid id",
            "body": "{\n  \"success\": false,\n  \"message\": \"ID inv\u00e1lido.\"\n}"
        },
        "404": {
            "description": "Missing or foreign conversation",
            "body": "{\n  \"success\": false,\n  \"message\": \"Conversaci\u00f3n no encontrada.\"\n}"
        }
    }
}

POST /api/conversations/:id/messages

Appends one user text message to the conversation history. The server fixes role to user and kind to text.

{
    "title": "Append Conversation Message",
    "description": "Adds one text message to the conversation history. The write only succeeds when the conversation belongs to the user, is active, and still has room for one more message.",
    "method": "POST",
    "baseUrl": "/api",
    "endpoint": "/conversations/:id/messages",
    "headers": [
        {
            "key": "Authorization",
            "value": "Bearer <token>",
            "required": true
        },
        {
            "key": "Content-Type",
            "value": "application/json",
            "required": true
        }
    ],
    "queryParams": [],
    "pathParams": [
        {
            "key": "id",
            "value": "Conversation id",
            "required": true
        }
    ],
    "bodyType": "json",
    "requestBody": "{\n  \"text\": \"Busco una casa con tres rec\u00e1maras\"\n}",
    "formData": [],
    "rawBody": "",
    "responses": {
        "201": {
            "description": "Message appended",
            "body": "{\n  \"success\": true,\n  \"data\": {\n    \"_id\": \"66a1f0c2d4e5f67890123420\",\n    \"role\": \"user\",\n    \"kind\": \"text\",\n    \"text\": \"Busco una casa con tres rec\u00e1maras\",\n    \"requirementId\": null,\n    \"createdAt\": \"2026-02-01T12:00:00.000Z\"\n  }\n}"
        },
        "400": {
            "description": "Invalid text or invalid id",
            "body": "{\n  \"success\": false,\n  \"code\": \"INVALID_MESSAGE_TEXT\",\n  \"field\": \"text\",\n  \"message\": \"El mensaje debe tener entre 1 y 2000 caracteres.\"\n}"
        },
        "404": {
            "description": "Missing or foreign conversation",
            "body": "{\n  \"success\": false,\n  \"message\": \"Conversaci\u00f3n no encontrada.\"\n}"
        },
        "409": {
            "description": "Archived conversation or full history",
            "body": "{\n  \"success\": false,\n  \"code\": \"CONVERSATION_ARCHIVED\",\n  \"message\": \"La conversaci\u00f3n est\u00e1 archivada. React\u00edvala primero (PATCH status:\\\"active\\\") para agregar mensajes.\"\n}"
        }
    }
}

POST /api/conversations/:id/refinements

Applies a structured refinement to the active requirement of the conversation. This route also requires verified email.

{
    "title": "Apply Conversation Refinement",
    "description": "Applies a structured refinement to the active requirement. The route validates ownership, conversation state, and the shape of changes before delegating to the refinement service.",
    "method": "POST",
    "baseUrl": "/api",
    "endpoint": "/conversations/:id/refinements",
    "headers": [
        {
            "key": "Authorization",
            "value": "Bearer <token>",
            "required": true
        },
        {
            "key": "Content-Type",
            "value": "application/json",
            "required": true
        }
    ],
    "queryParams": [],
    "pathParams": [
        {
            "key": "id",
            "value": "Conversation id",
            "required": true
        }
    ],
    "bodyType": "json",
    "requestBody": "{\n  \"changes\": {\n    \"budget\": {\n      \"max\": 1200000\n    }\n  }\n}",
    "formData": [],
    "rawBody": "",
    "responses": {
        "200": {
            "description": "Refinement applied",
            "body": "{\n  \"success\": true,\n  \"data\": {\n    \"requirement\": {\n      \"_id\": \"66a1f0c2d4e5f67890123456\"\n    },\n    \"changedFields\": [\"budget\"]\n  }\n}"
        },
        "400": {
            "description": "Invalid changes, empty refinement, unsupported field, pivot, or no active requirement",
            "body": "{\n  \"success\": false,\n  \"code\": \"INVALID_REFINEMENT_CHANGES\",\n  \"field\": \"changes\",\n  \"message\": \"changes debe ser un objeto.\"\n}"
        },
        "404": {
            "description": "Missing conversation or requirement access",
            "body": "{\n  \"success\": false,\n  \"message\": \"Conversaci\u00f3n no encontrada.\"\n}"
        },
        "409": {
            "description": "Archived conversation or requirement deletion pending",
            "body": "{\n  \"success\": false,\n  \"code\": \"CONVERSATION_ARCHIVED\",\n  \"message\": \"La conversaci\u00f3n est\u00e1 archivada. React\u00edvala primero (PATCH status:\\\"active\\\") para refinar tu b\u00fasqueda.\"\n}"
        }
    }
}

Workflows

sequenceDiagram
  participant C as Client
  participant A as requireAuth
  participant R as conversations routes router
  participant M as Conversation model
  participant S as requirement association service
  participant F as applyRequirementRefinement

  C->>A: authenticated request
  A->>R: request with user id

  alt POST conversations
    R->>S: createConversationWithRequirement when needed
    S-->>R: outcome and conversation
    R-->>C: response with conversation data
  else GET conversations or GET conversation
    R->>M: query with ownerId filter
    M-->>R: list or document
    R-->>C: success envelope
  else PATCH conversation
    R->>S: attachRequirement or detachRequirement or updateConversationFields
    S-->>R: outcome and conversation
    R-->>C: response with updated data
  else POST messages
    R->>M: atomic update with active status and capacity
    M-->>R: updated conversation
    R-->>C: message data
  else POST refinements
    R->>F: applyRequirementRefinement
    F-->>R: result and changed fields
    R->>M: append requirement_refined event
    R-->>C: requirement and changedFields
  end

The shared pattern is simple. The router validates shape first. Then it delegates persistent work to the model or coordinator. Refinements are special because they update the requirement first and append history later, best effort.

Gotchas & edge cases

[!WARNING] Ownership always lives inside the MongoDB filter. A conversation that belongs to another user returns the same 404 as a missing one. This is intentional anti-enumeration behavior.

[!NOTE] PATCH /:id rejects empty bodies with CONVERSATION_EMPTY_UPDATE. It also rejects any top-level field not in the allowlist.

[!IMPORTANT] POST /:id/messages only writes when the conversation is active and still has room for one more message. The atomic filter checks both conditions in the same update.

[!CAUTION] POST /:id/refinements is the only route in this file that also requires requireVerifiedEmail. The rest of the Conversation routes do not.

[
    {
        "title": "Ownership and anti enumeration",
        "body": "Every mutable read or write filters by ownerId inside MongoDB. A foreign document gets the same 404 as a missing one."
    },
    {
        "title": "Message capacity",
        "body": "POST /:id/messages checks status active and available capacity in the same atomic update. If the write fails, the route performs a minimal lookup only to choose the right error."
    },
    {
        "title": "Refinement side effects",
        "body": "POST /:id/refinements updates the Requirement first. The history event is best effort and never blocks a successful requirement write."
    }
]
  • services/api/src/app.js imports conversationsRoutes as part of the API assembly.
  • services/api/src/modules/auth/auth.middleware.js provides requireAuth and requireVerifiedEmail.
  • services/api/src/modules/conversations/requirement-association.service.js coordinates create, attach, detach, update, and response normalization.
  • services/api/src/modules/listings/requirement-refinement.js applies the structured refinement payload.
  • services/api/src/models/Conversation.js defines the constants and persistence rules used by this router, including the limits and allowed statuses.