import { Injectable } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; export interface CorpRow { corpId: number; slug: string; legalName: string; visitors: number; pageviews: number; } export interface DomainRow { domainId: number; corpId: number; corpSlug: string; hostname: string; role: string; visitors: number; pageviews: number; } export interface FlowEdge { fromCorpId: number; fromCorpSlug: string; toCorpId: number; toCorpSlug: string; visitors: number; } export interface OverlapCell { corpAId: number; corpASlug: string; corpBId: number; corpBSlug: string; visitors: number; } /** * Cross-corp, cross-domain analytics built on the visitor_id_daily + * corp_id + domain_id dimensions stamped onto every raw_events row by the * collector's TrackingService. */ @Injectable() export class NetworkService { constructor( @InjectDataSource() private readonly dataSource: DataSource, ) {} /** * Per-corp totals over the last N days. * `visitors` = distinct visitor_id_daily within the window * (collapses to distinct visitors per UTC day, multi-day visitors counted once per day). */ async getCorps(days: number): Promise { const rows = await this.dataSource.query>( ` SELECT c.id AS corp_id, c.slug AS slug, c.legal_name AS legal_name, COUNT(DISTINCT e.visitor_id_daily) AS visitors, COUNT(e.id) AS pageviews FROM corps c LEFT JOIN raw_events e ON e.corp_id = c.id AND e."timestamp" > NOW() - ($1 || ' days')::interval GROUP BY c.id, c.slug, c.legal_name ORDER BY visitors DESC NULLS LAST, c.slug ASC `, [days], ); return rows.map((r) => ({ corpId: Number(r.corp_id), slug: r.slug, legalName: r.legal_name, visitors: Number(r.visitors ?? 0), pageviews: Number(r.pageviews ?? 0), })); } /** * Per-domain table grouped by corp. */ async getDomains(days: number): Promise { const rows = await this.dataSource.query>( ` SELECT d.id AS domain_id, d.corp_id AS corp_id, c.slug AS corp_slug, d.hostname AS hostname, d.role AS role, COUNT(DISTINCT e.visitor_id_daily) AS visitors, COUNT(e.id) AS pageviews FROM domains d JOIN corps c ON c.id = d.corp_id LEFT JOIN raw_events e ON e.domain_id = d.id AND e."timestamp" > NOW() - ($1 || ' days')::interval GROUP BY d.id, d.corp_id, c.slug, d.hostname, d.role ORDER BY pageviews DESC NULLS LAST, d.hostname ASC `, [days], ); return rows.map((r) => ({ domainId: Number(r.domain_id), corpId: Number(r.corp_id), corpSlug: r.corp_slug, hostname: r.hostname, role: r.role, visitors: Number(r.visitors ?? 0), pageviews: Number(r.pageviews ?? 0), })); } /** * Sankey edges: corp A → corp B transitions. For each visitor_id_daily, * walks events in time order and counts consecutive corp_id changes. */ async getFlow(days: number): Promise { const rows = await this.dataSource.query>( ` WITH ordered AS ( SELECT visitor_id_daily, corp_id, "timestamp", LAG(corp_id) OVER ( PARTITION BY visitor_id_daily ORDER BY "timestamp" ) AS prev_corp_id FROM raw_events WHERE visitor_id_daily IS NOT NULL AND corp_id IS NOT NULL AND "timestamp" > NOW() - ($1 || ' days')::interval ) SELECT prev_corp_id AS from_corp_id, cfrom.slug AS from_slug, corp_id AS to_corp_id, cto.slug AS to_slug, COUNT(DISTINCT visitor_id_daily) AS visitors FROM ordered JOIN corps cfrom ON cfrom.id = prev_corp_id JOIN corps cto ON cto.id = corp_id WHERE prev_corp_id IS NOT NULL AND prev_corp_id <> corp_id GROUP BY prev_corp_id, cfrom.slug, corp_id, cto.slug ORDER BY visitors DESC `, [days], ); return rows.map((r) => ({ fromCorpId: Number(r.from_corp_id), fromCorpSlug: r.from_slug, toCorpId: Number(r.to_corp_id), toCorpSlug: r.to_slug, visitors: Number(r.visitors), })); } /** * Symmetric overlap matrix: distinct visitors who touched BOTH corps in * the window (any order). Returns upper triangle only (corpA < corpB). */ async getOverlap(days: number): Promise { const rows = await this.dataSource.query>( ` WITH visitor_corps AS ( SELECT DISTINCT visitor_id_daily, corp_id FROM raw_events WHERE visitor_id_daily IS NOT NULL AND corp_id IS NOT NULL AND "timestamp" > NOW() - ($1 || ' days')::interval ) SELECT a.corp_id AS corp_a_id, ca.slug AS corp_a_slug, b.corp_id AS corp_b_id, cb.slug AS corp_b_slug, COUNT(DISTINCT a.visitor_id_daily) AS visitors FROM visitor_corps a JOIN visitor_corps b ON a.visitor_id_daily = b.visitor_id_daily AND a.corp_id < b.corp_id JOIN corps ca ON ca.id = a.corp_id JOIN corps cb ON cb.id = b.corp_id GROUP BY a.corp_id, ca.slug, b.corp_id, cb.slug ORDER BY visitors DESC `, [days], ); return rows.map((r) => ({ corpAId: Number(r.corp_a_id), corpASlug: r.corp_a_slug, corpBId: Number(r.corp_b_id), corpBSlug: r.corp_b_slug, visitors: Number(r.visitors), })); } }