services/api/src/modules/digest/listing-performance-digest.service.js

Overview

Este módulo construye el payload agregado de Tu Plano Digest para listings de un lote de usuarios. Lee datos de Listing, ListingEvent, ListingComment y Lead, y devuelve un Map por usuario con métricas consolidadas y un resumen ordenado de listings.

El archivo es un agregador puro. No envía notificaciones, no toca Resend y no interactúa con el outbox. Su salida está pensada para que sendListingPerformanceDigests.js adjunte el payload al evento LISTING_PERFORMANCE_DIGEST.

[!NOTE] El módulo excluye deliberadamente views, trendScore y qrScans del digest. La lógica solo usa señales con trazabilidad real en backend y frontend.

How it works

flowchart TD
  A[buildListingPerformanceDigests] --> B[loadEligibleListings]
  B --> C[Listing]
  A --> D[aggregateEventsByListingAndType]
  A --> E[aggregateCommentsByListingAndSentiment]
  A --> F[aggregateLeadsByListing periodo]
  A --> G[aggregateLeadsByListing total]
  A --> H[aggregateLeadsWithoutListing]
  D --> I[indexByListingIdAndType]
  E --> J[indexByListingIdAndSentiment]
  F --> K[indexByListingId]
  G --> K
  H --> K
  I --> L[Construir totals y periodTotals]
  J --> L
  K --> L
  L --> M[computeListingActivityScore]
  M --> N[Ordenar listings]
  N --> O[Map resultado]

El flujo empieza con una consulta de listings elegibles. Después ejecuta agregaciones de eventos, comentarios y leads en paralelo. Luego combina todo por listingId y ownerId, calcula totales históricos y del periodo, y ordena los listings por actividad.

El resultado final se devuelve como un Map<String, payload | null>. Si un usuario no tiene listings elegibles, el valor es null.

Key components

Funciones exportadas

{
    "title": "computeDigestPeriodKey",
    "content": "Calcula una clave estable de deduplicaci\u00f3n para un ciclo de 15 d\u00edas. Usa un bucket fijo basado en el epoch Unix y devuelve un identificador con formato P15D-<bucketIndex>.",
    "type": "function",
    "filePath": "services/api/src/modules/digest/listing-performance-digest.service.js",
    "badges": [
        "public"
    ]
}
{
    "title": "computeDigestPeriod",
    "content": "Deriva la ventana del digest. Devuelve periodStart, periodEnd y periodKey para un periodo semi-abierto de 15 d\u00edas.",
    "type": "function",
    "filePath": "services/api/src/modules/digest/listing-performance-digest.service.js",
    "badges": [
        "public"
    ]
}
{
    "title": "computeDominantSentiment",
    "content": "Resuelve el sentimiento dominante a partir de conteos de positive, neutral y negative. Usa el mismo criterio de desempate que el m\u00f3dulo de comentarios y devuelve none si no hay datos.",
    "type": "function",
    "filePath": "services/api/src/modules/digest/listing-performance-digest.service.js",
    "badges": [
        "public"
    ]
}
{
    "title": "computeListingActivityScore",
    "content": "Calcula una puntuaci\u00f3n de ordenaci\u00f3n para el digest. Da m\u00e1s peso a contactClicks y leads que a detailOpens o comments.",
    "type": "function",
    "filePath": "services/api/src/modules/digest/listing-performance-digest.service.js",
    "badges": [
        "public"
    ]
}
{
    "title": "buildListingPerformanceDigests",
    "content": "Construye el payload agregado por usuario para Tu Plano Digest. Ejecuta una consulta de listings y varias agregaciones Mongo agrupadas para evitar N+1.",
    "type": "function",
    "filePath": "services/api/src/modules/digest/listing-performance-digest.service.js",
    "badges": [
        "public"
    ]
}

Helpers internos

Estos helpers sostienen la agregación y el mapeo intermedio. No se exportan, pero forman parte del flujo real del archivo.

  • emptySentiment() — crea un objeto base con positive, neutral, negative y dominant.
  • emptyTotals() — crea el acumulado histórico inicial, incluyendo likes.
  • emptyPeriodTotals() — crea el acumulado del periodo sin likes.
  • addSentiment(target, sentiment, count) — suma conteos válidos en un acumulado de sentimiento.
  • loadEligibleListings(userIds) — carga listings elegibles del usuario con .lean().
  • aggregateEventsByListingAndType(listingIds, periodStart, periodEnd) — agrupa eventos por listingId y type.
  • aggregateCommentsByListingAndSentiment(listingIds, periodStart, periodEnd) — agrupa comentarios visibles por listingId y sentiment.
  • aggregateLeadsByListing(listingIds, options) — agrupa leads por listing.
  • aggregateLeadsWithoutListing(userIds) — agrupa leads sin listingId por usuario.
  • indexByListingId(rows) — indexa conteos simples por listing.
  • indexByListingIdAndType(rows) — indexa conteos por listing y tipo de evento.
  • indexByListingIdAndSentiment(rows) — indexa conteos por listing y sentimiento.

Constants and selection rules

Ventana temporal del digest

  • DIGEST_PERIOD_DAYS = 15 Define la duración estándar del digest.

  • DIGEST_PERIOD_MS Convierte los 15 días a milisegundos.

  • DIGEST_PERIOD_EPOCH_MS = 0 Fija el ancla de los buckets en el epoch Unix.

  • computeDigestPeriodKey(periodEnd) Usa la ventana de 15 días para generar una clave estable de deduplicación.

  • computeDigestPeriod(periodEnd = new Date()) Calcula periodStart y periodEnd usando una ventana semi-abierta: [periodStart, periodEnd).

Filtros de elegibilidad

  • ELIGIBLE_LISTING_LIFECYCLE_STATUSES Conserva los estados normal, protection y anonymized como referencia legible.

  • loadEligibleListings(userIds) Filtra por:

    • createdBy dentro de userIds
    • 'lifecycle.status' distinto de purged
    • intent distinto de requirement

[!IMPORTANT] El filtro actual usa $ne: 'purged' y no $in. Eso permite incluir listings legacy sin lifecycle.status físico en Mongo.

Tipos de eventos considerados

  • PERIOD_EVENT_TYPES
    • detail_open
    • contact_click
    • pdf_download

Estos tipos tienen trazabilidad real y trigger confirmado en frontend. El módulo excluye qr_scan y profile_open.

Tamaño del detalle

  • MAX_DETAILED_LISTINGS = 5 Limita la lista detallada que vuelve en el payload.

Data & fields

[
    {
        "name": "userIds",
        "type": "Array<string|ObjectId>",
        "required": true,
        "description": "Usuarios para los que se construye el digest."
    },
    {
        "name": "periodStart",
        "type": "Date",
        "required": true,
        "description": "Inicio de la ventana de métricas."
    },
    {
        "name": "periodEnd",
        "type": "Date",
        "required": true,
        "description": "Fin de la ventana de métricas y base de periodKey."
    }
]

Estructura del payload por usuario

[
    {
        "name": "periodStart",
        "type": "Date",
        "required": true,
        "description": "Inicio real de la ventana del digest."
    },
    {
        "name": "periodEnd",
        "type": "Date",
        "required": true,
        "description": "Fin real de la ventana del digest."
    },
    {
        "name": "totals",
        "type": "object",
        "required": true,
        "description": "Acumulado histórico del usuario.",
        "children": [
            {
                "name": "detailOpens",
                "type": "number",
                "required": true,
                "description": "Total histórico de aperturas de detalle."
            },
            {
                "name": "leads",
                "type": "number",
                "required": true,
                "description": "Total histórico de leads."
            },
            {
                "name": "contactClicks",
                "type": "number",
                "required": true,
                "description": "Total histórico de clics de contacto."
            },
            {
                "name": "pdfDownloads",
                "type": "number",
                "required": true,
                "description": "Total histórico de descargas de PDF."
            },
            {
                "name": "comments",
                "type": "number",
                "required": true,
                "description": "Total histórico de comentarios."
            },
            {
                "name": "likes",
                "type": "number",
                "required": true,
                "description": "Total histórico de likes."
            },
            {
                "name": "sentiment",
                "type": "object",
                "required": true,
                "description": "Resumen histórico de sentimiento.",
                "children": [
                    {
                        "name": "positive",
                        "type": "number",
                        "required": true,
                        "description": "Conteo histórico positivo."
                    },
                    {
                        "name": "neutral",
                        "type": "number",
                        "required": true,
                        "description": "Conteo histórico neutral."
                    },
                    {
                        "name": "negative",
                        "type": "number",
                        "required": true,
                        "description": "Conteo histórico negativo."
                    },
                    {
                        "name": "dominant",
                        "type": "string",
                        "required": true,
                        "description": "Sentimiento dominante calculado."
                    }
                ]
            }
        ]
    },
    {
        "name": "periodTotals",
        "type": "object",
        "required": true,
        "description": "Acumulado del periodo actual.",
        "children": [
            {
                "name": "detailOpens",
                "type": "number",
                "required": true,
                "description": "Aperturas de detalle dentro del periodo."
            },
            {
                "name": "leads",
                "type": "number",
                "required": true,
                "description": "Leads dentro del periodo."
            },
            {
                "name": "contactClicks",
                "type": "number",
                "required": true,
                "description": "Clics de contacto dentro del periodo."
            },
            {
                "name": "pdfDownloads",
                "type": "number",
                "required": true,
                "description": "Descargas de PDF dentro del periodo."
            },
            {
                "name": "comments",
                "type": "number",
                "required": true,
                "description": "Comentarios visibles dentro del periodo."
            },
            {
                "name": "sentiment",
                "type": "object",
                "required": true,
                "description": "Sentimiento del periodo.",
                "children": [
                    {
                        "name": "positive",
                        "type": "number",
                        "required": true,
                        "description": "Conteo positivo del periodo."
                    },
                    {
                        "name": "neutral",
                        "type": "number",
                        "required": true,
                        "description": "Conteo neutral del periodo."
                    },
                    {
                        "name": "negative",
                        "type": "number",
                        "required": true,
                        "description": "Conteo negativo del periodo."
                    },
                    {
                        "name": "dominant",
                        "type": "string",
                        "required": true,
                        "description": "Sentimiento dominante del periodo."
                    }
                ]
            }
        ]
    },
    {
        "name": "listingCount",
        "type": "number",
        "required": true,
        "description": "Cantidad total de listings elegibles del usuario."
    },
    {
        "name": "listings",
        "type": "Array<object>",
        "required": true,
        "description": "Lista detallada, ya ordenada por activityScore, recortada a MAX_DETAILED_LISTINGS.",
        "children": [
            {
                "name": "listingId",
                "type": "string",
                "required": true,
                "description": "Identificador del listing."
            },
            {
                "name": "title",
                "type": "string",
                "required": true,
                "description": "Título del listing."
            },
            {
                "name": "publicationStatus",
                "type": "string",
                "required": true,
                "description": "Estado de publicación o draft por defecto."
            },
            {
                "name": "totals",
                "type": "object",
                "required": true,
                "description": "Totales históricos del listing."
            },
            {
                "name": "periodTotals",
                "type": "object",
                "required": true,
                "description": "Totales del listing dentro de la ventana del digest."
            }
        ]
    },
    {
        "name": "moreListingsCount",
        "type": "number",
        "required": true,
        "description": "Cantidad de listings que quedaron fuera del detalle visible."
    },
    {
        "name": "leadsWithoutListing",
        "type": "number",
        "required": true,
        "description": "Leads del usuario sin listingId asociado."
    },
    {
        "name": "_listingsForRecommendations",
        "type": "Array<object>",
        "description": "Campo interno para digest-recommendations.js. No forma parte del payload persistido.",
        "children": [
            {
                "name": "listing",
                "type": "object",
                "required": true,
                "description": "Documento Listing completo usado para reglas posteriores."
            },
            {
                "name": "periodTotals",
                "type": "object",
                "required": true,
                "description": "Totales del periodo para ese listing."
            }
        ]
    }
]

Campos que el módulo carga desde Listing

La consulta a Listing selecciona solo estos campos:

  • title
  • createdBy
  • publication.status
  • media
  • description
  • contact
  • metrics
  • createdAt

El uso de .lean() hace que el módulo trabaje con documentos planos. Por eso el comentario aclara la tolerancia a listings legacy sin lifecycle.status.

Campos de métricas usados en Listing

El código consume estas métricas desde listing.metrics:

  • detailOpens
  • contactClicks
  • pdfDownloads
  • comments
  • likes
  • sentimentSummary.positive
  • sentimentSummary.neutral
  • sentimentSummary.negative
  • sentimentSummary.dominant

Behaviors

  • computeDigestPeriodKey(periodEnd) — devuelve una clave estable P15D-<bucketIndex> para deduplicación entre corridas.
  • computeDigestPeriod(periodEnd = new Date()) — calcula periodStart, periodEnd y periodKey para la ventana actual.
  • computeDominantSentiment({ positive, neutral, negative }) — resuelve el sentimiento dominante o none.
  • computeListingActivityScore(periodTotals) — calcula la puntuación usada para ordenar listings en el digest.
  • loadEligibleListings(userIds) — recupera listings elegibles para los usuarios dados.
  • aggregateEventsByListingAndType(listingIds, periodStart, periodEnd) — cuenta eventos del periodo por listing y tipo.
  • aggregateCommentsByListingAndSentiment(listingIds, periodStart, periodEnd) — cuenta comentarios visibles por listing y sentimiento.
  • aggregateLeadsByListing(listingIds, options) — cuenta leads por listing, con o sin filtro temporal.
  • aggregateLeadsWithoutListing(userIds) — cuenta leads sin listing asociado por usuario.
  • indexByListingId(rows) — transforma agregados simples en un mapa por listingId.
  • indexByListingIdAndType(rows) — transforma agregados de eventos en un mapa por listingId y type.
  • indexByListingIdAndSentiment(rows) — transforma agregados de sentimiento en un mapa por listingId.
  • buildListingPerformanceDigests({ userIds, periodStart, periodEnd }) — arma el Map final con payloads por usuario.

Workflows

sequenceDiagram
  participant Caller
  participant Service as buildListingPerformanceDigests
  participant Listing
  participant ListingEvent
  participant ListingComment
  participant Lead

  Caller->>Service: userIds periodStart periodEnd
  Service->>Listing: loadEligibleListings
  Listing-->>Service: listings
  Service->>ListingEvent: aggregate period events
  ListingEvent-->>Service: eventRows
  Service->>ListingComment: aggregate visible comments
  ListingComment-->>Service: commentRows
  Service->>Lead: aggregate period leads
  Lead-->>Service: leadPeriodRows
  Service->>Lead: aggregate total leads
  Lead-->>Service: leadTotalRows
  Service->>Lead: aggregate leads without listing
  Lead-->>Service: leadsWithoutListingRows
  Service-->>Caller: Map userId payload

El método principal primero prepara una entrada null para cada userId. Después carga listings elegibles y ejecuta cinco agregaciones en paralelo. Con esos resultados arma totales, periodos y resúmenes detallados por listing.

Gotchas & edge cases

[!WARNING] periodTotals no incluye likes. El archivo mantiene likes solo en el acumulado histórico.

[!IMPORTANT] buildListingPerformanceDigests devuelve null para usuarios sin listings elegibles. El caller debe interpretar ese valor como “no enviar digest”.

[!NOTE] La ventana del digest usa un intervalo semi-abierto: [periodStart, periodEnd). Un evento exactamente en periodEnd cae en el siguiente periodo.

[!CAUTION] computeDominantSentiment aplica un desempate fijo. Si hay empate, positive vence primero, luego negative, y neutral queda como resultado solo cuando domina sin empate previo.

[!NOTE] aggregateLeadsByListing excluye source: 'activity'. Esa fuente representa una conversión manual del propio usuario.

Este módulo se conecta directamente con los modelos importados:

  • services/api/src/models/Listing.js
  • services/api/src/models/ListingEvent.js
  • services/api/src/models/ListingComment.js
  • services/api/src/models/Lead.js

También se relaciona con estos flujos mencionados en el código:

  • sendListingPerformanceDigests.js — adjunta el payload al evento LISTING_PERFORMANCE_DIGEST.
  • listing-comments.routes.js — define el mismo criterio de desempate para metrics.sentimentSummary.dominant.
  • FeedPage.jsx — confirma el trigger frontend de detail_open.
  • FeedPostCard.jsx — confirma los triggers frontend de contact_click y pdf_download.

[!NOTE] El archivo se presenta como una capa de agregación determinista. Su responsabilidad termina en la construcción del payload.