From 97ecef0427cdbb69f656831044e04db9a8c2ddf7 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Mon, 6 Apr 2026 14:21:43 -0700 Subject: [PATCH] =?UTF-8?q?feat(audience):=20=E2=9C=A8=20Add=20new=20endpo?= =?UTF-8?q?ints=20and=20business=20logic=20for=20audience=20segmentation?= =?UTF-8?q?=20and=20filtering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../api/src/audience/audience.controller.ts | 36 ++++ services/api/src/audience/audience.service.ts | 194 ++++++++++++++++++ 2 files changed, 230 insertions(+) diff --git a/services/api/src/audience/audience.controller.ts b/services/api/src/audience/audience.controller.ts index 35abc0c..abea290 100644 --- a/services/api/src/audience/audience.controller.ts +++ b/services/api/src/audience/audience.controller.ts @@ -87,4 +87,40 @@ export class AudienceController { async getNewVsReturning(@Query() query: AudienceQueryDto): Promise { return this.audienceService.getNewVsReturning(query); } + + /** + * Get network/organization type breakdown (gov-detection orgType) + * GET /audience/network-type?startDate=...&endDate=... + */ + @Get('network-type') + async getNetworkType(@Query() query: AudienceQueryDto) { + return this.audienceService.getNetworkType(query); + } + + /** + * Get proxy/connection type breakdown (VPN, Tor, Datacenter, Direct) + * GET /audience/proxy-type?startDate=...&endDate=... + */ + @Get('proxy-type') + async getProxyType(@Query() query: AudienceQueryDto) { + return this.audienceService.getProxyType(query); + } + + /** + * Get top ASN organizations + * GET /audience/organizations?startDate=...&endDate=... + */ + @Get('organizations') + async getOrganizations(@Query() query: AudienceQueryDto) { + return this.audienceService.getOrganizations(query); + } + + /** + * Get government traffic alert summary + * GET /audience/gov-alert?startDate=...&endDate=... + */ + @Get('gov-alert') + async getGovAlert(@Query() query: AudienceQueryDto) { + return this.audienceService.getGovAlert(query); + } } diff --git a/services/api/src/audience/audience.service.ts b/services/api/src/audience/audience.service.ts index be7e2bc..bbbdffa 100644 --- a/services/api/src/audience/audience.service.ts +++ b/services/api/src/audience/audience.service.ts @@ -556,6 +556,200 @@ export class AudienceService { } } + /** + * Get network type breakdown (organization type from gov-detection). + * Groups session_fingerprints by orgType, normalizing raw enum values to display labels. + */ + async getNetworkType(query: AudienceQueryDto): Promise> { + const { startDate, endDate, limit } = query; + + const sql = ` + WITH session_data AS ( + SELECT + sf."sessionId", + COALESCE(sf."orgType", 'NORMAL') as org_type + FROM session_fingerprints sf + WHERE sf."createdAt" BETWEEN $1 AND $2 + AND sf."isBot" = false + ), + total AS (SELECT COUNT(*) as total_sessions FROM session_data) + SELECT + sd.org_type, + COUNT(sd."sessionId") as sessions, + COUNT(sd."sessionId")::float / NULLIF(t.total_sessions, 0) as percentage + FROM session_data sd + CROSS JOIN total t + GROUP BY sd.org_type, t.total_sessions + ORDER BY sessions DESC + LIMIT $3 + `; + + const ORG_TYPE_LABELS: Record = { + NORMAL: 'Normal', + LIBRARY: 'Library', + EDUCATION: 'Education', + GOVERNMENT: 'Government', + LAW_ENFORCEMENT: 'Law Enforcement', + MILITARY: 'Military', + INTELLIGENCE: 'Intelligence', + UNKNOWN: 'Unknown', + }; + + try { + const result = await this.dataSource.query(sql, [startDate, endDate, limit ?? 20]); + return result.map((row: Record) => ({ + orgType: ORG_TYPE_LABELS[String(row.org_type)] ?? String(row.org_type), + sessions: Number(row.sessions) || 0, + percentage: Number(row.percentage) || 0, + })); + } catch (error) { + this.logger.error('Failed to get network type', error); + throw error; + } + } + + /** + * Get proxy/connection type breakdown (VPN, Tor, Datacenter, Direct, etc.) + */ + async getProxyType(query: AudienceQueryDto): Promise> { + const { startDate, endDate, limit } = query; + + const sql = ` + WITH session_data AS ( + SELECT + sf."sessionId", + COALESCE(sf."proxyType", 'NONE') as proxy_type + FROM session_fingerprints sf + WHERE sf."createdAt" BETWEEN $1 AND $2 + AND sf."isBot" = false + ), + total AS (SELECT COUNT(*) as total_sessions FROM session_data) + SELECT + sd.proxy_type, + COUNT(sd."sessionId") as sessions, + COUNT(sd."sessionId")::float / NULLIF(t.total_sessions, 0) as percentage + FROM session_data sd + CROSS JOIN total t + GROUP BY sd.proxy_type, t.total_sessions + ORDER BY sessions DESC + LIMIT $3 + `; + + const PROXY_TYPE_LABELS: Record = { + NONE: 'Direct', + VPN: 'VPN', + TOR: 'Tor', + DATACENTER: 'Datacenter', + RESIDENTIAL: 'Residential Proxy', + PUBLIC: 'Public Proxy', + UNKNOWN: 'Unknown', + }; + + try { + const result = await this.dataSource.query(sql, [startDate, endDate, limit ?? 20]); + return result.map((row: Record) => ({ + proxyType: PROXY_TYPE_LABELS[String(row.proxy_type)] ?? String(row.proxy_type), + sessions: Number(row.sessions) || 0, + percentage: Number(row.percentage) || 0, + })); + } catch (error) { + this.logger.error('Failed to get proxy type', error); + throw error; + } + } + + /** + * Get top ASN organizations by session count. + */ + async getOrganizations(query: AudienceQueryDto): Promise> { + const { startDate, endDate, limit } = query; + + const sql = ` + WITH session_data AS ( + SELECT + sf."sessionId", + COALESCE(sf.org, 'Unknown') as org + FROM session_fingerprints sf + WHERE sf."createdAt" BETWEEN $1 AND $2 + AND sf."isBot" = false + AND sf.org IS NOT NULL + ), + total AS (SELECT COUNT(*) as total_sessions FROM session_data) + SELECT + sd.org, + COUNT(sd."sessionId") as sessions, + COUNT(sd."sessionId")::float / NULLIF(t.total_sessions, 0) as percentage + FROM session_data sd + CROSS JOIN total t + GROUP BY sd.org, t.total_sessions + ORDER BY sessions DESC + LIMIT $3 + `; + + try { + const result = await this.dataSource.query(sql, [startDate, endDate, limit ?? 20]); + return result.map((row: Record) => ({ + org: String(row.org), + sessions: Number(row.sessions) || 0, + percentage: Number(row.percentage) || 0, + })); + } catch (error) { + this.logger.error('Failed to get organizations', error); + throw error; + } + } + + /** + * Get government traffic summary — counts and tier breakdown for the alert banner. + */ + async getGovAlert(query: AudienceQueryDto): Promise<{ + total: number; + hasAlert: boolean; + breakdown: Array<{ orgType: string; responseTier: string; count: number }>; + }> { + const { startDate, endDate } = query; + + const sql = ` + SELECT + COALESCE("orgType", 'GOVERNMENT') as org_type, + COALESCE("responseTier", 'SOFT_BLOCK') as response_tier, + COUNT(*) as count + FROM session_fingerprints + WHERE "createdAt" BETWEEN $1 AND $2 + AND "isGovernment" = true + AND "isBot" = false + GROUP BY "orgType", "responseTier" + ORDER BY count DESC + `; + + const ORG_TYPE_LABELS: Record = { + GOVERNMENT: 'Government', + LAW_ENFORCEMENT: 'Law Enforcement', + MILITARY: 'Military', + INTELLIGENCE: 'Intelligence', + LIBRARY: 'Library', + EDUCATION: 'Education', + UNKNOWN: 'Unknown', + }; + + try { + const result = await this.dataSource.query(sql, [startDate, endDate]); + const breakdown = result.map((row: Record) => ({ + orgType: ORG_TYPE_LABELS[String(row.org_type)] ?? String(row.org_type), + responseTier: String(row.response_tier), + count: Number(row.count) || 0, + })); + const total = breakdown.reduce((sum: number, r: { count: number }) => sum + r.count, 0); + const hasAlert = breakdown.some((r: { responseTier: string }) => + r.responseTier === 'HARD_BLOCK' || r.responseTier === 'ALERT', + ); + return { total, hasAlert, breakdown }; + } catch (error) { + this.logger.error('Failed to get gov alert', error); + return { total: 0, hasAlert: false, breakdown: [] }; + } + } + private getGeoColumn(granularity?: GeoGranularity): string { switch (granularity) { case GeoGranularity.REGION: