From 2b362cde74f0842d5f2aae40001b5b5f0398ae93 Mon Sep 17 00:00:00 2001 From: autocommit Date: Fri, 15 May 2026 21:17:45 -0700 Subject: [PATCH] =?UTF-8?q?feat(audience):=20=E2=9C=A8=20Add=20corporate?= =?UTF-8?q?=20affiliation=20filter=20to=20AudienceQueryDto=20and=20update?= =?UTF-8?q?=20AudienceService=20for=20corp-based=20audience=20queries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- services/api/src/audience/audience.service.ts | 102 ++++++++++++++---- .../src/audience/dto/audience-query.dto.ts | 4 + 2 files changed, 84 insertions(+), 22 deletions(-) diff --git a/services/api/src/audience/audience.service.ts b/services/api/src/audience/audience.service.ts index c3d6e0a..953299c 100644 --- a/services/api/src/audience/audience.service.ts +++ b/services/api/src/audience/audience.service.ts @@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; import { AudienceQueryDto, GeoQueryDto, DeviceQueryDto, GeoGranularity } from './dto/audience-query.dto'; +import { resolveCorpId, corpSessionFilter } from '../common/corp-filter.util'; export interface AudienceOverview { totalUsers: number; @@ -88,6 +89,8 @@ export class AudienceService { */ async getOverview(query: AudienceQueryDto): Promise { const { startDate, endDate, limit } = query; + const corpId = await resolveCorpId(this.dataSource, query.corp); + const corpClause = corpSessionFilter(3, corpId); const overviewQuery = ` WITH user_sessions AS ( @@ -99,7 +102,7 @@ export class AudienceService { sf."createdAt" FROM session_fingerprints sf WHERE sf."createdAt" BETWEEN $1 AND $2 - AND sf."isBot" = false + AND sf."isBot" = false${corpClause} ), user_first AS ( SELECT "userId", MIN("createdAt") as first_session @@ -116,8 +119,10 @@ export class AudienceService { LEFT JOIN user_first uf ON us."userId" = uf."userId" `; + const overviewParams: unknown[] = corpId === null ? [startDate, endDate] : [startDate, endDate, corpId]; + const [overview, topCountries, topDevices] = await Promise.all([ - this.dataSource.query(overviewQuery, [startDate, endDate]), + this.dataSource.query(overviewQuery, overviewParams), this.getGeography({ ...query, granularity: GeoGranularity.COUNTRY, limit: 5 }), this.getDevices({ ...query, limit: 5 }), ]); @@ -143,6 +148,8 @@ export class AudienceService { */ async getDemographics(query: AudienceQueryDto): Promise { const { startDate, endDate } = query; + const corpId = await resolveCorpId(this.dataSource, query.corp); + const corpClause = corpSessionFilter(3, corpId); const demographicsQuery = ` WITH user_sessions AS ( @@ -171,7 +178,7 @@ export class AudienceService { FROM session_fingerprints sf WHERE sf."createdAt" BETWEEN $1 AND $2 AND sf."isBot" = false - AND sf."userId" IS NOT NULL + AND sf."userId" IS NOT NULL${corpClause} ), user_first AS ( SELECT "userId", MIN("createdAt") as first_session @@ -198,7 +205,8 @@ export class AudienceService { `; try { - const result = await this.dataSource.query(demographicsQuery, [startDate, endDate]); + const demoParams: unknown[] = corpId === null ? [startDate, endDate] : [startDate, endDate, corpId]; + const result = await this.dataSource.query(demographicsQuery, demoParams); return result.map((row: Record) => ({ userType: row.user_type as 'new' | 'returning', @@ -278,6 +286,8 @@ export class AudienceService { */ async getBrowsers(query: AudienceQueryDto): Promise { const { startDate, endDate, limit } = query; + const corpId = await resolveCorpId(this.dataSource, query.corp); + const corpClause = corpSessionFilter(4, corpId); const browsersQuery = ` WITH session_data AS ( @@ -288,7 +298,7 @@ export class AudienceService { COALESCE(sf."browserVersion", '') as version FROM session_fingerprints sf WHERE sf."createdAt" BETWEEN $1 AND $2 - AND sf."isBot" = false + AND sf."isBot" = false${corpClause} ), total AS ( SELECT COUNT(*) as total_sessions FROM session_data @@ -307,7 +317,10 @@ export class AudienceService { `; try { - const result = await this.dataSource.query(browsersQuery, [startDate, endDate, limit ?? 20]); + const browserParams: unknown[] = corpId === null + ? [startDate, endDate, limit ?? 20] + : [startDate, endDate, limit ?? 20, corpId]; + const result = await this.dataSource.query(browsersQuery, browserParams); return result.map((row: Record) => ({ browser: row.browser as string, @@ -327,6 +340,8 @@ export class AudienceService { */ async getOperatingSystems(query: AudienceQueryDto): Promise { const { startDate, endDate, limit } = query; + const corpId = await resolveCorpId(this.dataSource, query.corp); + const corpClause = corpSessionFilter(4, corpId); const osQuery = ` WITH session_data AS ( @@ -337,7 +352,7 @@ export class AudienceService { COALESCE(sf."osVersion", '') as version FROM session_fingerprints sf WHERE sf."createdAt" BETWEEN $1 AND $2 - AND sf."isBot" = false + AND sf."isBot" = false${corpClause} ), total AS ( SELECT COUNT(*) as total_sessions FROM session_data @@ -356,7 +371,10 @@ export class AudienceService { `; try { - const result = await this.dataSource.query(osQuery, [startDate, endDate, limit ?? 20]); + const osParams: unknown[] = corpId === null + ? [startDate, endDate, limit ?? 20] + : [startDate, endDate, limit ?? 20, corpId]; + const result = await this.dataSource.query(osQuery, osParams); return result.map((row: Record) => ({ os: row.os as string, @@ -395,6 +413,13 @@ export class AudienceService { paramIndex++; } + const corpId = await resolveCorpId(this.dataSource, query.corp); + if (corpId !== null) { + conditions.push(`sf."sessionId" IN (SELECT DISTINCT "sessionId" FROM raw_events WHERE corp_id = $${paramIndex})`); + params.push(corpId); + paramIndex++; + } + const whereClause = conditions.join(' AND '); const locationColumn = this.getGeoColumn(granularity); @@ -462,6 +487,8 @@ export class AudienceService { */ async getLanguages(query: AudienceQueryDto): Promise { const { startDate, endDate, limit } = query; + const corpId = await resolveCorpId(this.dataSource, query.corp); + const corpClause = corpSessionFilter(4, corpId); const languagesQuery = ` WITH session_data AS ( @@ -471,7 +498,7 @@ export class AudienceService { COALESCE(sf.language, 'unknown') as language FROM session_fingerprints sf WHERE sf."createdAt" BETWEEN $1 AND $2 - AND sf."isBot" = false + AND sf."isBot" = false${corpClause} ), total AS ( SELECT COUNT(*) as total_sessions FROM session_data @@ -489,7 +516,10 @@ export class AudienceService { `; try { - const result = await this.dataSource.query(languagesQuery, [startDate, endDate, limit ?? 20]); + const langParams: unknown[] = corpId === null + ? [startDate, endDate, limit ?? 20] + : [startDate, endDate, limit ?? 20, corpId]; + const result = await this.dataSource.query(languagesQuery, langParams); return result.map((row: Record) => ({ language: row.language as string, @@ -508,6 +538,8 @@ export class AudienceService { */ async getNewVsReturning(query: AudienceQueryDto): Promise { const { startDate, endDate } = query; + const corpId = await resolveCorpId(this.dataSource, query.corp); + const corpClause = corpSessionFilter(3, corpId); const trendQuery = ` WITH daily_sessions AS ( @@ -517,7 +549,7 @@ export class AudienceService { FROM session_fingerprints sf WHERE sf."createdAt" BETWEEN $1 AND $2 AND sf."isBot" = false - AND sf."userId" IS NOT NULL + AND sf."userId" IS NOT NULL${corpClause} ), user_first AS ( SELECT "userId", MIN("createdAt") as first_session @@ -536,7 +568,8 @@ export class AudienceService { `; try { - const result = await this.dataSource.query(trendQuery, [startDate, endDate]); + const trendParams: unknown[] = corpId === null ? [startDate, endDate] : [startDate, endDate, corpId]; + const result = await this.dataSource.query(trendQuery, trendParams); return result.map((row: Record) => { const newUsers = Number(row.new_users) || 0; @@ -562,6 +595,8 @@ export class AudienceService { */ async getNetworkType(query: AudienceQueryDto): Promise> { const { startDate, endDate, limit } = query; + const corpId = await resolveCorpId(this.dataSource, query.corp); + const corpClause = corpSessionFilter(4, corpId); const sql = ` WITH session_data AS ( @@ -570,7 +605,7 @@ export class AudienceService { COALESCE(sf."orgType", 'NORMAL') as org_type FROM session_fingerprints sf WHERE sf."createdAt" BETWEEN $1 AND $2 - AND sf."isBot" = false + AND sf."isBot" = false${corpClause} ), total AS (SELECT COUNT(*) as total_sessions FROM session_data) SELECT @@ -596,7 +631,10 @@ export class AudienceService { }; try { - const result = await this.dataSource.query(sql, [startDate, endDate, limit ?? 20]); + const ntParams: unknown[] = corpId === null + ? [startDate, endDate, limit ?? 20] + : [startDate, endDate, limit ?? 20, corpId]; + const result = await this.dataSource.query(sql, ntParams); return result.map((row: Record) => ({ orgType: ORG_TYPE_LABELS[String(row.org_type)] ?? String(row.org_type), sessions: Number(row.sessions) || 0, @@ -613,6 +651,8 @@ export class AudienceService { */ async getProxyType(query: AudienceQueryDto): Promise> { const { startDate, endDate, limit } = query; + const corpId = await resolveCorpId(this.dataSource, query.corp); + const corpClause = corpSessionFilter(4, corpId); const sql = ` WITH session_data AS ( @@ -621,7 +661,7 @@ export class AudienceService { COALESCE(sf."proxyType", 'NONE') as proxy_type FROM session_fingerprints sf WHERE sf."createdAt" BETWEEN $1 AND $2 - AND sf."isBot" = false + AND sf."isBot" = false${corpClause} ), total AS (SELECT COUNT(*) as total_sessions FROM session_data) SELECT @@ -646,7 +686,10 @@ export class AudienceService { }; try { - const result = await this.dataSource.query(sql, [startDate, endDate, limit ?? 20]); + const ptParams: unknown[] = corpId === null + ? [startDate, endDate, limit ?? 20] + : [startDate, endDate, limit ?? 20, corpId]; + const result = await this.dataSource.query(sql, ptParams); return result.map((row: Record) => ({ proxyType: PROXY_TYPE_LABELS[String(row.proxy_type)] ?? String(row.proxy_type), sessions: Number(row.sessions) || 0, @@ -663,6 +706,8 @@ export class AudienceService { */ async getOrganizations(query: AudienceQueryDto): Promise> { const { startDate, endDate, limit } = query; + const corpId = await resolveCorpId(this.dataSource, query.corp); + const corpClause = corpSessionFilter(4, corpId); const sql = ` WITH session_data AS ( @@ -672,7 +717,7 @@ export class AudienceService { FROM session_fingerprints sf WHERE sf."createdAt" BETWEEN $1 AND $2 AND sf."isBot" = false - AND sf.org IS NOT NULL + AND sf.org IS NOT NULL${corpClause} ), total AS (SELECT COUNT(*) as total_sessions FROM session_data) SELECT @@ -687,7 +732,10 @@ export class AudienceService { `; try { - const result = await this.dataSource.query(sql, [startDate, endDate, limit ?? 20]); + const orgParams: unknown[] = corpId === null + ? [startDate, endDate, limit ?? 20] + : [startDate, endDate, limit ?? 20, corpId]; + const result = await this.dataSource.query(sql, orgParams); return result.map((row: Record) => ({ org: String(row.org), sessions: Number(row.sessions) || 0, @@ -708,6 +756,10 @@ export class AudienceService { breakdown: Array<{ orgType: string; responseTier: string; count: number }>; }> { const { startDate, endDate } = query; + const corpId = await resolveCorpId(this.dataSource, query.corp); + const corpClause = corpId === null + ? '' + : ` AND "sessionId" IN (SELECT DISTINCT "sessionId" FROM raw_events WHERE corp_id = $3)`; const sql = ` SELECT @@ -717,7 +769,7 @@ export class AudienceService { FROM session_fingerprints WHERE "createdAt" BETWEEN $1 AND $2 AND "isGovernment" = true - AND "isBot" = false + AND "isBot" = false${corpClause} GROUP BY "orgType", "responseTier" ORDER BY count DESC `; @@ -733,7 +785,8 @@ export class AudienceService { }; try { - const result = await this.dataSource.query(sql, [startDate, endDate]); + const govParams: unknown[] = corpId === null ? [startDate, endDate] : [startDate, endDate, corpId]; + const result = await this.dataSource.query(sql, govParams); const breakdown = result.map((row: Record) => ({ orgType: ORG_TYPE_LABELS[String(row.org_type)] ?? String(row.org_type), responseTier: String(row.response_tier), @@ -766,6 +819,10 @@ export class AudienceService { lastSeen: string; }>> { const { startDate, endDate } = query; + const corpId = await resolveCorpId(this.dataSource, query.corp); + const corpClause = corpId === null + ? '' + : ` AND "sessionId" IN (SELECT DISTINCT "sessionId" FROM raw_events WHERE corp_id = $3)`; const sql = ` SELECT @@ -786,14 +843,15 @@ export class AudienceService { OR "asn" IS NOT NULL OR "orgType" NOT IN ('NORMAL', 'UNKNOWN') OR "proxyType" NOT IN ('NONE', 'UNKNOWN') - ) + )${corpClause} GROUP BY "org", "asn", "orgType", "proxyType", "responseTier", "country" ORDER BY sessions DESC LIMIT 100 `; try { - const result = await this.dataSource.query(sql, [startDate, endDate]); + const ipParams: unknown[] = corpId === null ? [startDate, endDate] : [startDate, endDate, corpId]; + const result = await this.dataSource.query(sql, ipParams); return result.map((row: Record) => ({ org: row.org ? String(row.org) : null, asn: row.asn ? Number(row.asn) : null, diff --git a/services/api/src/audience/dto/audience-query.dto.ts b/services/api/src/audience/dto/audience-query.dto.ts index e1ed3ab..d0dbd0b 100644 --- a/services/api/src/audience/dto/audience-query.dto.ts +++ b/services/api/src/audience/dto/audience-query.dto.ts @@ -24,6 +24,10 @@ export class AudienceQueryDto { @Min(1) @Max(100) limit?: number = 20; + + @IsOptional() + @IsString() + corp?: string; } export class GeoQueryDto extends AudienceQueryDto {