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,trendScoreyqrScansdel 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 conpositive,neutral,negativeydominant.emptyTotals()— crea el acumulado histórico inicial, incluyendolikes.emptyPeriodTotals()— crea el acumulado del periodo sinlikes.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 porlistingIdytype.aggregateCommentsByListingAndSentiment(listingIds, periodStart, periodEnd)— agrupa comentarios visibles porlistingIdysentiment.aggregateLeadsByListing(listingIds, options)— agrupa leads por listing.aggregateLeadsWithoutListing(userIds)— agrupa leads sinlistingIdpor 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=15Define la duración estándar del digest. -
DIGEST_PERIOD_MSConvierte los 15 días a milisegundos. -
DIGEST_PERIOD_EPOCH_MS=0Fija 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())CalculaperiodStartyperiodEndusando una ventana semi-abierta:[periodStart, periodEnd).
Filtros de elegibilidad
-
ELIGIBLE_LISTING_LIFECYCLE_STATUSESConserva los estadosnormal,protectionyanonymizedcomo referencia legible. -
loadEligibleListings(userIds)Filtra por:createdBydentro deuserIds'lifecycle.status'distinto depurgedintentdistinto derequirement
[!IMPORTANT] El filtro actual usa
$ne: 'purged'y no$in. Eso permite incluir listings legacy sinlifecycle.statusfísico en Mongo.
Tipos de eventos considerados
PERIOD_EVENT_TYPESdetail_opencontact_clickpdf_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=5Limita 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:
titlecreatedBypublication.statusmediadescriptioncontactmetricscreatedAt
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:
detailOpenscontactClickspdfDownloadscommentslikessentimentSummary.positivesentimentSummary.neutralsentimentSummary.negativesentimentSummary.dominant
Behaviors
computeDigestPeriodKey(periodEnd)— devuelve una clave estableP15D-<bucketIndex>para deduplicación entre corridas.computeDigestPeriod(periodEnd = new Date())— calculaperiodStart,periodEndyperiodKeypara la ventana actual.computeDominantSentiment({ positive, neutral, negative })— resuelve el sentimiento dominante onone.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 porlistingId.indexByListingIdAndType(rows)— transforma agregados de eventos en un mapa porlistingIdytype.indexByListingIdAndSentiment(rows)— transforma agregados de sentimiento en un mapa porlistingId.buildListingPerformanceDigests({ userIds, periodStart, periodEnd })— arma elMapfinal 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]
periodTotalsno incluyelikes. El archivo mantienelikessolo en el acumulado histórico.
[!IMPORTANT]
buildListingPerformanceDigestsdevuelvenullpara 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 enperiodEndcae en el siguiente periodo.
[!CAUTION]
computeDominantSentimentaplica un desempate fijo. Si hay empate,positivevence primero, luegonegative, yneutralqueda como resultado solo cuando domina sin empate previo.
[!NOTE]
aggregateLeadsByListingexcluyesource: 'activity'. Esa fuente representa una conversión manual del propio usuario.
Related
Este módulo se conecta directamente con los modelos importados:
services/api/src/models/Listing.jsservices/api/src/models/ListingEvent.jsservices/api/src/models/ListingComment.jsservices/api/src/models/Lead.js
También se relaciona con estos flujos mencionados en el código:
sendListingPerformanceDigests.js— adjunta el payload al eventoLISTING_PERFORMANCE_DIGEST.listing-comments.routes.js— define el mismo criterio de desempate parametrics.sentimentSummary.dominant.FeedPage.jsx— confirma el trigger frontend dedetail_open.FeedPostCard.jsx— confirma los triggers frontend decontact_clickypdf_download.
[!NOTE] El archivo se presenta como una capa de agregación determinista. Su responsabilidad termina en la construcción del payload.