requirement-association.service.js

Overview

Este módulo coordina la asociación, el cambio y la desvinculación del Requirement activo de una Conversation. También gestiona las reservas internas que protegen esa asociación cuando un Requirement puede estar en eliminación. La ruta de conversaciones delega aquí la asociación y la actualización segura, y el servicio de eliminación reutiliza sus helpers de invalidación .

[!IMPORTANT] El coordinador no usa transacciones. La seguridad depende de escrituras atómicas sobre un solo documento y de filtros compare-and-swap en cada paso. El propio archivo lo declara como la única vía para estos cambios, y una prueba estática vigila las reglas de reserva .

[!NOTE] Este módulo no expone endpoints HTTP. Lo consumen conversations.routes.js y requirement-deletion.service.js .

How it works

flowchart TD
    Precheck[isRequirementAttachable]
    Create[createConversationWithRequirement]
    Attach[attachRequirement]
    Detach[detachRequirement]
    Update[updateConversationFields]
    Release[releaseReservation]
    PruneReq[pruneExpiredReservations]
    PrunePending[pruneExpiredPendingAttach]
    CountReq[countReservationsForRequirements]
    CountPending[countPendingAttachForRequirements]
    Conv[Conversation model]
    List[Listing model]

    Precheck --> Create
    Create --> Attach
    Attach --> Conv
    Attach --> List
    Attach --> Release
    Detach --> Conv
    Update --> Conv
    PruneReq --> List
    PruneReq --> Release
    PrunePending --> Conv
    CountReq --> List
    CountPending --> Conv

El flujo principal reserva primero en Conversation, registra después en Listing, y confirma al final con un compare-and-swap. Si algo falla, el módulo diagnostica el motivo y libera la reserva propia. Las rutas de conversación usan este mismo coordinador para crear, asociar, desvincular y actualizar con seguridad .

Key components

Este archivo concentra varias funciones públicas. Las más importantes manejan la vida completa de una asociación y sus reservas.

{
    "title": "attachRequirement",
    "content": "Asocia o cambia el Requirement activo de una Conversation con reserva, registro, confirmaci\u00f3n y liberaci\u00f3n atomizadas.",
    "type": "function",
    "filePath": "services/api/src/modules/conversations/requirement-association.service.js",
    "badges": [
        "public"
    ]
}
{
    "title": "detachRequirement",
    "content": "Quita el Requirement activo, cancela cualquier asociaci\u00f3n en curso y puede agregar el evento requirement_changed.",
    "type": "function",
    "filePath": "services/api/src/modules/conversations/requirement-association.service.js",
    "badges": [
        "public"
    ]
}
{
    "title": "createConversationWithRequirement",
    "content": "Crea una Conversation propia y luego intenta asociarle el Requirement con el protocolo del coordinador.",
    "type": "function",
    "filePath": "services/api/src/modules/conversations/requirement-association.service.js",
    "badges": [
        "public"
    ]
}
{
    "title": "updateConversationFields",
    "content": "Aplica title y status con protecci\u00f3n CAS sobre la Conversation y sin tocar la asociaci\u00f3n si el estado cambi\u00f3.",
    "type": "function",
    "filePath": "services/api/src/modules/conversations/requirement-association.service.js",
    "badges": [
        "public"
    ]
}
{
    "title": "releaseReservation",
    "content": "Invalida la reserva en la Conversation y despu\u00e9s la retira del Requirement.",
    "type": "function",
    "filePath": "services/api/src/modules/conversations/requirement-association.service.js",
    "badges": [
        "public"
    ]
}
{
    "title": "pruneExpiredReservations",
    "content": "Elimina reservas vencidas de un Requirement y las procesa con releaseReservation.",
    "type": "function",
    "filePath": "services/api/src/modules/conversations/requirement-association.service.js",
    "badges": [
        "public"
    ]
}

El resto de funciones públicas completan la lectura segura y la recuperación de estado. Algunas son utilidades de solo lectura, y otras son helpers de invalidación usados por la eliminación.

Data & fields

La coordinación gira alrededor de tres estructuras principales. Dos viven en MongoDB, y una representa el evento de historial que se inserta cuando cambia la asociación.

[
    {
        "name": "pendingAttach",
        "type": "object",
        "description": "Reserva interna en Conversation para una asociación en curso.",
        "children": [
            {
                "name": "requirementId",
                "type": "ObjectId",
                "required": true,
                "description": "Requirement que se intenta asociar."
            },
            {
                "name": "token",
                "type": "ObjectId",
                "required": true,
                "description": "Identificador de la reserva. Debe coincidir para confirmar o invalidar."
            },
            {
                "name": "at",
                "type": "Date",
                "required": true,
                "description": "Marca temporal calculada por el servidor."
            },
            {
                "name": "expiresAt",
                "type": "Date",
                "required": true,
                "description": "Vencimiento calculado por el servidor."
            }
        ]
    },
    {
        "name": "conversationReservations",
        "type": "array<object>",
        "description": "Reservas registradas dentro de Listing para conversaciones que intentan asociarse.",
        "children": [
            {
                "name": "conversationId",
                "type": "ObjectId",
                "required": true,
                "description": "Conversation que pidió la asociación."
            },
            {
                "name": "token",
                "type": "ObjectId",
                "required": true,
                "description": "Token que enlaza la reserva con la Conversation."
            },
            {
                "name": "expiresAt",
                "type": "Date",
                "required": true,
                "description": "Caducidad de la reserva registrada en el Requirement."
            }
        ]
    },
    {
        "name": "requirement_changed event",
        "type": "object",
        "required": true,
        "description": "Evento de sistema que se agrega al historial cuando cambia la asociación activa.",
        "children": [
            {
                "name": "_id",
                "type": "ObjectId",
                "required": true,
                "description": "Identificador del mensaje."
            },
            {
                "name": "role",
                "type": "string",
                "required": true,
                "description": "Siempre system."
            },
            {
                "name": "kind",
                "type": "string",
                "required": true,
                "description": "Siempre requirement_changed."
            },
            {
                "name": "text",
                "type": "string",
                "required": true,
                "description": "Siempre vacío."
            },
            {
                "name": "requirementId",
                "type": "ObjectId | null",
                "required": true,
                "description": "Requirement nuevo, o null al desvincular."
            },
            {
                "name": "createdAt",
                "type": "Date",
                "required": true,
                "description": "Fecha del evento."
            }
        ]
    }
]

Los campos auxiliares también importan. RESERVATION_LEASE_MS fija el plazo por defecto. ALSO_SET_FIELDS limita alsoSet a title y status. ATTACHABLE_LIFECYCLE excluye protection, anonymized y purged. PIPELINE_NO_TIMESTAMPS y NO_TIMESTAMPS desactivan timestamps en actualizaciones puntuales.

Behaviors

Las funciones públicas cubren lectura, validación, escritura segura, limpieza y conteos. Los helpers internos sostienen las garantías de consistencia y de seguridad.

Funciones públicas

  • isRequirementAttachable [badge:public] — Devuelve boolean. Revisa Listing.exists() con el filtro compartido de asociabilidad.
  • buildRequirementChangedEvent [badge:public] — Crea el evento requirement_changed para el historial de la Conversation.
  • toConversationResponse [badge:public] — Copia la Conversation y elimina pendingAttach antes de responder.
  • invalidateReservation [badge:public] — Borra pendingAttach solo si el token coincide. Devuelve si invalidó una reserva vigente.
  • releaseReservation [badge:public] — Invalida primero en Conversation y después hace $pull en Listing.
  • invalidateReservations [badge:public] — Recorre una lista de reservas y llama a invalidateReservation en cada una.
  • invalidateReservationsOfMarkedRequirement [badge:public] — Lee un Requirement marcado para eliminación e invalida todas sus reservas registradas.
  • updateConversationFields [badge:public] — Actualiza title y status con CAS sobre activeRequirementId y deletedRequirement.requirementId.
  • attachRequirement [badge:public] — Ejecuta el protocolo completo de asociación o cambio de Requirement.
  • detachRequirement [badge:public] — Desvincula el Requirement activo y cancela la asociación en curso si existe.
  • createConversationWithRequirement [badge:public] — Crea una Conversation y luego intenta asociarle un Requirement.
  • pruneExpiredReservations [badge:public] — Retira reservas vencidas de un Requirement, con margen opcional.
  • pruneExpiredPendingAttach [badge:public] — Limpia reservas vencidas que quedaron solo en Conversation.
  • countReservationsForRequirements [badge:public] — Devuelve un Map con conteos total y vigente de reservas por Requirement.
  • countPendingAttachForRequirements [badge:public] — Devuelve un Map con conteos total y vigente de pendingAttach por Requirement.

Helpers internos

  • toObjectId — Convierte valores a mongoose.Types.ObjectId.
  • sanitizeAlsoSet — Permite solo title y status dentro de alsoSet.
  • attachableRequirementFilter — Construye el filtro único de Requirement asociable.
  • withMessageCapacity — Agrega la condición de capacidad para insertar el evento.
  • step — Ejecuta hooks de prueba, si existen.
  • diagnoseFailedCommit — Explica por qué falló la confirmación de attachRequirement.
  • applyAlsoSetIfStillAttached — Aplica alsoSet si el Requirement sigue activo.
  • detachAlreadyDetached — Resuelve el caso en que la Conversation ya no tenía Requirement activo.
  • compensateCreatedConversation — Borra la Conversation recién creada solo si sigue intacta.

attachRequirement devuelve motivos concretos cuando no puede confirmar la asociación. Los valores observados en el código son:

  • not_found
  • requirement_unavailable
  • limit_reached
  • expired
  • superseded
  • already_attached

El diagnóstico usa una agregación interna sobre Conversation. Lee activeRequirementId, el token pendiente, su vencimiento y el número de mensajes.

Workflows

Los flujos siguientes son los que este archivo implementa de forma explícita. Cada uno usa escrituras atómicas y condiciones de lectura o confirmación bien definidas.

Asociación de Requirement

1. Validar temprano | `isRequirementAttachable` rechaza casos obvios sin escribir nada.
2. Reservar en Conversation | `attachRequirement` escribe `pendingAttach` con token y vencimiento.
3. Registrar en Listing | Se agrega la reserva a `conversationReservations` solo si el Requirement sigue asociable.
4. Confirmar en Conversation | Se ejecuta el CAS sobre `pendingAttach`, `activeRequirementId` y capacidad de mensajes.
5. Liberar la reserva | `releaseReservation` invalida antes de retirar del Requirement.

Desvinculación

1. Intentar la desvinculación atómica | `detachRequirement` pone `activeRequirementId` en `null`.
2. Cancelar la asociación en curso | La misma escritura quita `pendingAttach`.
3. Agregar historial si corresponde | Si había Requirement activo, se inserta `requirement_changed`.
4. Resolver el caso ya desvinculado | `detachAlreadyDetached` aplica la ruta sin evento o con campos extra.

Creación con Requirement

1. Comprobar asociabilidad | `createConversationWithRequirement` llama a `isRequirementAttachable`.
2. Crear Conversation vacía | Inserta `ownerId`, `title`, `status` y `messages` vacíos.
3. Intentar la asociación | Reutiliza `attachRequirement` con el mismo protocolo.
4. Compensar si falla | `compensateCreatedConversation` borra solo si el documento sigue intacto.

Limpieza y recuperación

1. Tomar una foto de estado | `pruneExpiredReservations` y `invalidateReservationsOfMarkedRequirement` usan agregaciones de lectura.
2. Comparar vencimientos | Se decide con el reloj del servidor y, si aplica, con `graceMs`.
3. Invalidar antes de retirar | La limpieza pasa por `releaseReservation`.
4. Limpiar reservas huérfanas | `pruneExpiredPendingAttach` borra `pendingAttach` vencidos en Conversation.

Gotchas & edge cases

[!WARNING] sanitizeAlsoSet rechaza cualquier clave fuera de title y status. Si llega otra, lanza un error inmediato.

[!IMPORTANT] releaseReservation invalida la reserva en Conversation antes de hacer $pull en Listing. Esa secuencia es única en este módulo .

[!CAUTION] Los tokens de reserva no se renuevan. El vencimiento es inmutable. La limpieza trabaja sobre una foto de reservas, y ese diseño evita perder disponibilidad .

[!NOTE] toConversationResponse elimina pendingAttach aunque el documento provenga de Conversation.create(). Eso evita exponer la reserva interna en respuestas HTTP.

  • attachRequirement puede terminar como superseded si otra operación cambia la Conversation entre la lectura y la confirmación.
  • updateConversationFields protege contra el caso ABA con deletedRequirement.requirementId.
  • detachRequirement también cancela cualquier asociación en curso sobre la misma Conversation.
  • pruneExpiredReservations no deshace asociaciones confirmadas.
  • pruneExpiredPendingAttach solo limpia reservas vencidas que quedaron en Conversation.
  • countReservationsForRequirements y countPendingAttachForRequirements devuelven números, nunca tokens ni ids completos.

Este módulo vive en el centro de la relación entre conversaciones y requirements. La capa de rutas de services/api/src/modules/conversations/conversations.routes.js usa attachRequirement, detachRequirement, createConversationWithRequirement, updateConversationFields y toConversationResponse para mantener el acceso alineado con el coordinador . El servicio services/api/src/modules/listings/requirement-deletion.service.js importa invalidateReservations e invalidateReservationsOfMarkedRequirement para cerrar reservas durante la eliminación segura de un Requirement .

También hay pruebas estáticas que refuerzan estas reglas. Una verifica que pendingAttach y conversationReservations solo aparezcan en los archivos permitidos, incluidos este coordinador y el servicio de eliminación . Otra comprueba que las rutas de conversaciones no escriben activeRequirementId directamente y que toda asociación pasa por este servicio .