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[]> {
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue