feat(audience): Add new endpoints and business logic for audience segmentation and filtering

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-04-06 14:21:43 -07:00
parent 643e292a3e
commit 97ecef0427
2 changed files with 230 additions and 0 deletions

View file

@ -87,4 +87,40 @@ export class AudienceController {
async getNewVsReturning(@Query() query: AudienceQueryDto): Promise<NewVsReturningMetrics[]> { async getNewVsReturning(@Query() query: AudienceQueryDto): Promise<NewVsReturningMetrics[]> {
return this.audienceService.getNewVsReturning(query); 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);
}
} }

View file

@ -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<Array<{ orgType: string; sessions: number; percentage: number }>> {
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<string, string> = {
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<string, unknown>) => ({
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<Array<{ proxyType: string; sessions: number; percentage: number }>> {
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<string, string> = {
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<string, unknown>) => ({
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<Array<{ org: string; sessions: number; percentage: number }>> {
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<string, unknown>) => ({
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<string, string> = {
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<string, unknown>) => ({
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 { private getGeoColumn(granularity?: GeoGranularity): string {
switch (granularity) { switch (granularity) {
case GeoGranularity.REGION: case GeoGranularity.REGION: