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:
parent
643e292a3e
commit
97ecef0427
2 changed files with 230 additions and 0 deletions
|
|
@ -87,4 +87,40 @@ export class AudienceController {
|
|||
async getNewVsReturning(@Query() query: AudienceQueryDto): Promise<NewVsReturningMetrics[]> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
switch (granularity) {
|
||||
case GeoGranularity.REGION:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue