diff --git a/services/api/src/audience/audience.controller.ts b/services/api/src/audience/audience.controller.ts index abea290..53d4b4a 100644 --- a/services/api/src/audience/audience.controller.ts +++ b/services/api/src/audience/audience.controller.ts @@ -123,4 +123,13 @@ export class AudienceController { async getGovAlert(@Query() query: AudienceQueryDto) { return this.audienceService.getGovAlert(query); } + + /** + * Get IP classification list — sessions grouped by org/ASN with detection signals + * GET /audience/ip-classifications?startDate=...&endDate=... + */ + @Get('ip-classifications') + async getIPClassifications(@Query() query: AudienceQueryDto) { + return this.audienceService.getIPClassifications(query); + } } diff --git a/services/api/src/audience/audience.service.ts b/services/api/src/audience/audience.service.ts index bbbdffa..c3d6e0a 100644 --- a/services/api/src/audience/audience.service.ts +++ b/services/api/src/audience/audience.service.ts @@ -750,6 +750,71 @@ export class AudienceService { } } + /** + * Get IP classification list — sessions grouped by org/ASN with their detection signals. + * Returns top 100 groups ordered by session count descending. + */ + async getIPClassifications(query: AudienceQueryDto): Promise> { + const { startDate, endDate } = query; + + const sql = ` + SELECT + "org", + "asn", + COALESCE("orgType", 'NORMAL') as org_type, + COALESCE("proxyType", 'NONE') as proxy_type, + "responseTier" as response_tier, + "country", + COUNT(*) as sessions, + MIN("createdAt") as first_seen, + MAX("createdAt") as last_seen + FROM session_fingerprints + WHERE "createdAt" BETWEEN $1 AND $2 + AND "isBot" = false + AND ( + "org" IS NOT NULL + OR "asn" IS NOT NULL + OR "orgType" NOT IN ('NORMAL', 'UNKNOWN') + OR "proxyType" NOT IN ('NONE', 'UNKNOWN') + ) + GROUP BY "org", "asn", "orgType", "proxyType", "responseTier", "country" + ORDER BY sessions DESC + LIMIT 100 + `; + + try { + const result = await this.dataSource.query(sql, [startDate, endDate]); + return result.map((row: Record) => ({ + org: row.org ? String(row.org) : null, + asn: row.asn ? Number(row.asn) : null, + orgType: row.org_type ? String(row.org_type) : null, + proxyType: row.proxy_type ? String(row.proxy_type) : null, + responseTier: row.response_tier ? String(row.response_tier) : null, + country: row.country ? String(row.country) : null, + sessions: Number(row.sessions) || 0, + firstSeen: row.first_seen instanceof Date + ? row.first_seen.toISOString() + : String(row.first_seen), + lastSeen: row.last_seen instanceof Date + ? row.last_seen.toISOString() + : String(row.last_seen), + })); + } catch (error) { + this.logger.error('Failed to get IP classifications', error); + return []; + } + } + private getGeoColumn(granularity?: GeoGranularity): string { switch (granularity) { case GeoGranularity.REGION: