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

Overview

Este archivo define el router HTTP de leads del backend. Expone listado, creación, actualización y eliminación de leads, y protege todo el módulo con requireAuth. El acceso depende siempre de req.auth.userId, así que cada operación trabaja solo sobre leads del usuario autenticado. fileciteturn0file1turn0file2

El archivo también contiene validación y normalización de errores propias del módulo. En particular, traduce errores de Mongoose a ApiError, y valida la relación con Listing sin exponer si un listingId ajeno existe o no.

[!IMPORTANT] Todas las rutas quedan bajo autenticación obligatoria desde router.use(requireAuth). Sin una sesión válida, el módulo no ejecuta ninguna lectura ni escritura sobre Lead. fileciteturn0file1turn0file12

How it works

El router crea una frontera de acceso única y luego distribuye la solicitud según el verbo HTTP. GET lee leads del owner autenticado, POST crea un lead, PATCH actualiza un lead propio y DELETE lo elimina. El flujo de creación y edición usa dos helpers internos: toApiError y resolveOwnedListing.

flowchart TD
    Router[router leads] --> Auth[requireAuth]

    Auth --> GetList[GET leads]
    Auth --> CreateLead[POST lead]
    Auth --> UpdateLead[PATCH lead]
    Auth --> DeleteLead[DELETE lead]

    GetList --> LeadModel[Lead]
    CreateLead --> LeadModel
    UpdateLead --> LeadModel
    DeleteLead --> LeadModel

    CreateLead --> ToApiError[toApiError]
    UpdateLead --> ToApiError

    CreateLead --> ResolveOwnedListing[resolveOwnedListing]
    UpdateLead --> ResolveOwnedListing

    ResolveOwnedListing --> ListingModel[Listing]

El listado calcula filtros por status y listingId, y pagina con page y limit. La respuesta incluye el conjunto data, los totales total y filteredTotal, y un resumen statusCounts con los estados new, follow_up, closed y discarded.

La creación valida que name exista, asigna source por defecto a manual, y exige listingId cuando el origen es activity. Si llega un listingId, el helper resolveOwnedListing comprueba formato, existencia y titularidad.

La actualización respeta reglas especiales para leads de actividad. Esos leads no pueden cambiar source ni reasociar listingId. Además, listingTitle solo se procesa de forma editable en leads que no vienen de actividad.

Key components

Este módulo combina el router, dos helpers internos y tres dependencias externas. Cada pieza cumple una responsabilidad concreta en el ciclo de vida del lead.

{
    "title": "Router de leads",
    "content": "Agrupa las rutas HTTP del m\u00f3dulo y aplica autenticaci\u00f3n global antes de cualquier operaci\u00f3n.",
    "type": "function",
    "filePath": "services/api/src/modules/leads/leads.routes.js",
    "badges": [
        "public"
    ]
}
{
    "title": "requireAuth",
    "content": "Middleware aplicado a todo el router. Hace disponible la identidad autenticada en req.auth para filtrar por ownerId.",
    "type": "function",
    "filePath": "services/api/src/modules/auth/auth.middleware.js",
    "badges": [
        "public"
    ]
}
{
    "title": "toApiError",
    "content": "Normaliza errores de Mongoose como CastError y ValidationError a ApiError controlados por el m\u00f3dulo.",
    "type": "function",
    "filePath": "services/api/src/modules/leads/leads.routes.js",
    "badges": [
        "public"
    ]
}
{
    "title": "resolveOwnedListing",
    "content": "Valida que listingId tenga formato ObjectId, exista y pertenezca al usuario autenticado.",
    "type": "function",
    "filePath": "services/api/src/modules/leads/leads.routes.js",
    "badges": [
        "public"
    ]
}
{
    "title": "Lead",
    "content": "Modelo usado para listar, crear, actualizar y eliminar documentos de lead.",
    "type": "class",
    "filePath": "services/api/src/models/Lead.js",
    "badges": [
        "public"
    ]
}
{
    "title": "Listing",
    "content": "Modelo consultado para validar la relaci\u00f3n privada entre lead y anuncio.",
    "type": "class",
    "filePath": "services/api/src/models/Listing.js",
    "badges": [
        "public"
    ]
}
{
    "title": "ApiError",
    "content": "Error controlado usado para devolver validaciones y fallos de acceso con estado HTTP expl\u00edcito.",
    "type": "class",
    "filePath": "services/api/src/utils/apiError.js",
    "badges": [
        "public"
    ]
}

Data & fields

El módulo consume parámetros de consulta en el listado y datos de cuerpo en creación y edición. También devuelve un objeto agregado con métricas de estado. Los campos que siguen están directamente respaldados por el código del archivo.

Query params de GET /api/leads

[
    {
        "name": "status",
        "type": "string",
        "description": "Filtra por estado. Si vale \"all\", no aplica filtro por estado."
    },
    {
        "name": "listingId",
        "type": "string",
        "description": "Filtra por el anuncio asociado al lead."
    },
    {
        "name": "page",
        "type": "number",
        "description": "Página solicitada. Por defecto es 1."
    },
    {
        "name": "limit",
        "type": "number",
        "description": "Cantidad de registros por página. Por defecto es 20."
    }
]

Body de POST /api/leads

[
    {
        "name": "name",
        "type": "string",
        "required": true,
        "description": "Nombre del lead. El archivo lo exige antes de crear el documento."
    },
    {
        "name": "email",
        "type": "string",
        "description": "Correo del lead. Se recorta con trim cuando llega en el body."
    },
    {
        "name": "phone",
        "type": "string",
        "description": "Teléfono del lead. Se recorta con trim cuando llega en el body."
    },
    {
        "name": "message",
        "type": "string",
        "description": "Mensaje del lead. Se recorta con trim cuando llega en el body."
    },
    {
        "name": "notes",
        "type": "string",
        "description": "Notas internas del lead. Se recorta con trim cuando llega en el body."
    },
    {
        "name": "listingId",
        "type": "string",
        "description": "Identificador del anuncio vinculado. Si existe, debe pertenecer al owner autenticado."
    },
    {
        "name": "listingTitle",
        "type": "string",
        "description": "Título del anuncio. Si listingId es válido, el título real del Listing sustituye este valor."
    },
    {
        "name": "status",
        "type": "string",
        "description": "Estado inicial del lead. Si no llega, el archivo usa \"new\"."
    },
    {
        "name": "source",
        "type": "string",
        "description": "Origen del lead. Si no llega, el archivo usa \"manual\". Si vale \"activity\", listingId pasa a ser obligatorio."
    }
]

Body de PATCH /api/leads/:id

[
    {
        "name": "status",
        "type": "string",
        "description": "Actualiza el estado del lead."
    },
    {
        "name": "notes",
        "type": "string",
        "description": "Actualiza las notas internas."
    },
    {
        "name": "name",
        "type": "string",
        "description": "Actualiza el nombre del lead."
    },
    {
        "name": "email",
        "type": "string",
        "description": "Actualiza el correo del lead."
    },
    {
        "name": "phone",
        "type": "string",
        "description": "Actualiza el teléfono del lead."
    },
    {
        "name": "message",
        "type": "string",
        "description": "Actualiza el mensaje del lead."
    },
    {
        "name": "listingTitle",
        "type": "string",
        "description": "Solo se procesa cuando el lead no proviene de actividad."
    },
    {
        "name": "source",
        "type": "string",
        "description": "Cambia el origen del lead, salvo en leads de actividad, donde el valor es inmutable."
    },
    {
        "name": "listingId",
        "type": "string | null",
        "description": "Reasocia o desvincula el anuncio. En leads de actividad no puede modificarse."
    }
]

Respuesta de GET /api/leads

[
    {
        "name": "data",
        "type": "Lead[]",
        "required": true,
        "description": "Lista de leads devuelta por la consulta."
    },
    {
        "name": "total",
        "type": "number",
        "required": true,
        "description": "Total de leads del owner autenticado."
    },
    {
        "name": "filteredTotal",
        "type": "number",
        "required": true,
        "description": "Total después de aplicar filtros de status y listingId."
    },
    {
        "name": "statusCounts",
        "type": "object",
        "required": true,
        "description": "Conteo global por estado para el owner autenticado.",
        "children": [
            {
                "name": "new",
                "type": "number",
                "required": true,
                "description": "Cantidad de leads nuevos."
            },
            {
                "name": "follow_up",
                "type": "number",
                "required": true,
                "description": "Cantidad de leads en seguimiento."
            },
            {
                "name": "closed",
                "type": "number",
                "required": true,
                "description": "Cantidad de leads cerrados."
            },
            {
                "name": "discarded",
                "type": "number",
                "required": true,
                "description": "Cantidad de leads descartados."
            }
        ]
    },
    {
        "name": "page",
        "type": "number",
        "required": true,
        "description": "Página devuelta."
    },
    {
        "name": "pages",
        "type": "number",
        "required": true,
        "description": "Número total de páginas, con mínimo 1."
    }
]

Behaviors

La lógica del archivo se divide en validación, resolución de anuncios y cuatro operaciones HTTP. Cada bloque usa el owner autenticado como criterio central.

  • toApiError(err) — convierte CastError y ValidationError de Mongoose en ApiError con mensajes y códigos controlados.
  • resolveOwnedListing(listingId, ownerId) — valida el formato de listingId, busca el anuncio y comprueba que createdBy coincida con el usuario autenticado.
  • GET /api/leads — lista los leads del usuario autenticado, aplica filtros opcionales y devuelve métricas de paginación y estado.
  • POST /api/leads — crea un lead nuevo, fuerza name, asigna source por defecto y vincula un Listing si corresponde.
  • PATCH /api/leads/:id — actualiza campos permitidos, mantiene inmutables los datos de leads generados desde actividad y gestiona listingId aparte.
  • DELETE /api/leads/:id — elimina un lead propio y responde con { ok: true }.

[!NOTE] resolveOwnedListing no filtra por publication.status ni lifecycle.status. La relación entre Lead y Listing se trata como un vínculo privado e histórico del titular.

[!WARNING] Los leads con source === "activity" tienen reglas especiales. No pueden cambiar su source ni su listingId, y la API rechaza cualquier intento de modificar ese vínculo.

API

GET /api/leads

Este endpoint devuelve los leads del usuario autenticado con filtros opcionales y métricas de estado. El archivo usa status, listingId, page y limit como entrada de consulta.

{
    "title": "Listar leads del usuario autenticado",
    "description": "Devuelve los leads del owner autenticado con filtros opcionales por estado y anuncio, m\u00e1s m\u00e9tricas agregadas de estado.",
    "method": "GET",
    "baseUrl": "/api",
    "endpoint": "/leads",
    "headers": [
        {
            "key": "Authorization",
            "value": "Bearer <token>",
            "required": true
        }
    ],
    "queryParams": [
        {
            "key": "status",
            "value": "new | follow_up | closed | discarded | all",
            "required": false
        },
        {
            "key": "listingId",
            "value": "66f0c2a1b2c3d4e5f6789012",
            "required": false
        },
        {
            "key": "page",
            "value": "1",
            "required": false
        },
        {
            "key": "limit",
            "value": "20",
            "required": false
        }
    ],
    "pathParams": [],
    "bodyType": "none",
    "requestBody": "",
    "formData": [],
    "rawBody": "",
    "responses": {
        "200": {
            "description": "\u00c9xito",
            "body": "{\n  \"data\": [\n    {\n      \"_id\": \"66f0c1a1b2c3d4e5f6789011\",\n      \"name\": \"Ana P\u00e9rez\",\n      \"email\": \"[email protected]\",\n      \"phone\": \"5551234567\",\n      \"message\": \"Me interesa el anuncio\",\n      \"notes\": \"Llamar por la tarde\",\n      \"listingId\": \"66f0c2a1b2c3d4e5f6789012\",\n      \"listingTitle\": \"Departamento en venta\",\n      \"ownerId\": \"64aa11112222333344445555\",\n      \"status\": \"new\",\n      \"source\": \"manual\"\n    }\n  ],\n  \"total\": 12,\n  \"filteredTotal\": 3,\n  \"statusCounts\": {\n    \"new\": 2,\n    \"follow_up\": 1,\n    \"closed\": 0,\n    \"discarded\": 0\n  },\n  \"page\": 1,\n  \"pages\": 1\n}"
        }
    }
}

POST /api/leads

Este endpoint crea un lead manual o vinculado a un anuncio propio. Si source es activity, listingId es obligatorio, y el anuncio debe pertenecer al usuario autenticado.

{
    "title": "Crear lead",
    "description": "Crea un lead nuevo para el usuario autenticado. Valida name, aplica source manual por defecto y resuelve listingId cuando viene en el body.",
    "method": "POST",
    "baseUrl": "/api",
    "endpoint": "/leads",
    "headers": [
        {
            "key": "Authorization",
            "value": "Bearer <token>",
            "required": true
        },
        {
            "key": "Content-Type",
            "value": "application/json",
            "required": true
        }
    ],
    "queryParams": [],
    "pathParams": [],
    "bodyType": "json",
    "requestBody": "{\n  \"name\": \"Ana P\u00e9rez\",\n  \"email\": \"[email protected]\",\n  \"phone\": \"5551234567\",\n  \"message\": \"Quiero m\u00e1s informaci\u00f3n\",\n  \"notes\": \"Llamar despu\u00e9s de las 18\",\n  \"listingId\": \"66f0c2a1b2c3d4e5f6789012\",\n  \"listingTitle\": \"Departamento en venta\",\n  \"status\": \"new\",\n  \"source\": \"manual\"\n}",
    "formData": [],
    "rawBody": "",
    "responses": {
        "201": {
            "description": "Lead creado",
            "body": "{\n  \"_id\": \"66f0c1a1b2c3d4e5f6789011\",\n  \"name\": \"Ana P\u00e9rez\",\n  \"email\": \"[email protected]\",\n  \"phone\": \"5551234567\",\n  \"message\": \"Quiero m\u00e1s informaci\u00f3n\",\n  \"notes\": \"Llamar despu\u00e9s de las 18\",\n  \"listingId\": \"66f0c2a1b2c3d4e5f6789012\",\n  \"listingTitle\": \"Departamento en venta\",\n  \"ownerId\": \"64aa11112222333344445555\",\n  \"status\": \"new\",\n  \"source\": \"manual\"\n}"
        },
        "400": {
            "description": "Validaci\u00f3n fallida",
            "body": "{\n  \"message\": \"El nombre del lead es obligatorio\"\n}"
        }
    }
}

PATCH /api/leads/:id

Este endpoint actualiza solo un lead propio. También aplica reglas especiales cuando el lead fue creado desde activity, porque ese origen no puede cambiar y su listingId es inmutable.

{
    "title": "Actualizar lead",
    "description": "Actualiza un lead perteneciente al usuario autenticado. Permite cambios parciales y aplica restricciones espec\u00edficas para leads de actividad.",
    "method": "PATCH",
    "baseUrl": "/api",
    "endpoint": "/leads/:id",
    "headers": [
        {
            "key": "Authorization",
            "value": "Bearer <token>",
            "required": true
        },
        {
            "key": "Content-Type",
            "value": "application/json",
            "required": true
        }
    ],
    "queryParams": [],
    "pathParams": [
        {
            "key": "id",
            "value": "Identificador del lead",
            "required": true
        }
    ],
    "bodyType": "json",
    "requestBody": "{\n  \"status\": \"follow_up\",\n  \"notes\": \"Pendiente de llamada\",\n  \"name\": \"Ana P\u00e9rez\",\n  \"email\": \"[email protected]\",\n  \"phone\": \"5551234567\",\n  \"message\": \"Sigue interesada\",\n  \"listingId\": \"66f0c2a1b2c3d4e5f6789012\",\n  \"listingTitle\": \"Departamento en venta\",\n  \"source\": \"manual\"\n}",
    "formData": [],
    "rawBody": "",
    "responses": {
        "200": {
            "description": "Lead actualizado",
            "body": "{\n  \"_id\": \"66f0c1a1b2c3d4e5f6789011\",\n  \"name\": \"Ana P\u00e9rez\",\n  \"email\": \"[email protected]\",\n  \"phone\": \"5551234567\",\n  \"message\": \"Sigue interesada\",\n  \"notes\": \"Pendiente de llamada\",\n  \"listingId\": \"66f0c2a1b2c3d4e5f6789012\",\n  \"listingTitle\": \"Departamento en venta\",\n  \"ownerId\": \"64aa11112222333344445555\",\n  \"status\": \"follow_up\",\n  \"source\": \"manual\"\n}"
        },
        "400": {
            "description": "Regla de actividad o validaci\u00f3n",
            "body": "{\n  \"message\": \"El anuncio relacionado de un lead creado desde Actividad no puede modificarse.\",\n  \"code\": \"ACTIVITY_LEAD_LISTING_IMMUTABLE\",\n  \"field\": \"listingId\"\n}"
        },
        "404": {
            "description": "Lead inexistente",
            "body": "{\n  \"message\": \"Lead no encontrado\"\n}"
        }
    }
}

DELETE /api/leads/:id

Este endpoint elimina un lead que pertenezca al usuario autenticado. Si no existe, responde con un error controlado de tipo Lead no encontrado.

{
    "title": "Eliminar lead",
    "description": "Elimina un lead propio del usuario autenticado.",
    "method": "DELETE",
    "baseUrl": "/api",
    "endpoint": "/leads/:id",
    "headers": [
        {
            "key": "Authorization",
            "value": "Bearer <token>",
            "required": true
        }
    ],
    "queryParams": [],
    "pathParams": [
        {
            "key": "id",
            "value": "Identificador del lead",
            "required": true
        }
    ],
    "bodyType": "none",
    "requestBody": "",
    "formData": [],
    "rawBody": "",
    "responses": {
        "200": {
            "description": "Lead eliminado",
            "body": "{\n  \"ok\": true\n}"
        },
        "404": {
            "description": "Lead inexistente",
            "body": "{\n  \"message\": \"Lead no encontrado\"\n}"
        }
    }
}

Gotchas & edge cases

El archivo contiene varias reglas de negocio que no son visibles solo con mirar el verbo HTTP. Estas reglas protegen la integridad histórica de los leads y evitan filtrar datos sobre anuncios ajenos.

[!WARNING] listingId usa el mismo mensaje genérico para “no existe” y “pertenece a otro owner”. El archivo evita revelar si un anuncio ajeno existe.

[!NOTE] toApiError solo transforma CastError y ValidationError. Cualquier otro error vuelve a next sin esa normalización.

[!IMPORTANT] En GET /api/leads, statusCounts se calcula sobre todos los leads del owner, no solo sobre el subconjunto filtrado. filteredTotal y total representan métricas distintas.

[
    {
        "title": "INVALID_LISTING_ID",
        "body": "Se usa cuando listingId no es un ObjectId válido o cuando el anuncio no pertenece al usuario autenticado."
    },
    {
        "title": "LEAD_VALIDATION_ERROR",
        "body": "Se usa cuando Mongoose detecta un ValidationError o un CastError fuera del caso específico de listingId."
    },
    {
        "title": "ACTIVITY_LEAD_LISTING_REQUIRED",
        "body": "Se lanza en POST cuando source vale activity y el body no incluye listingId."
    },
    {
        "title": "ACTIVITY_LEAD_SOURCE_IMMUTABLE",
        "body": "Se lanza en PATCH cuando un lead de actividad intenta cambiar source a un valor distinto de activity."
    },
    {
        "title": "ACTIVITY_LEAD_LISTING_IMMUTABLE",
        "body": "Se lanza en PATCH cuando un lead de actividad intenta cambiar su listingId por otro valor."
    }
]

Este router se apoya en services/api/src/modules/auth/auth.middleware.js para obtener la identidad autenticada, en services/api/src/models/Lead.js para persistir los documentos, en services/api/src/models/Listing.js para validar el anuncio vinculado, y en services/api/src/utils/apiError.js para devolver errores controlados. fileciteturn0file1turn0file12

También encaja con el resto del backend de services/api/, donde el router queda montado como parte de la superficie /api/* del servidor Express. Esa composición general aparece en la documentación de arranque y montaje del backend.