From 0781dd9628ef92d6e154df3e7a802076ed5dd626 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Mon, 6 Apr 2026 21:20:04 -0700 Subject: [PATCH] =?UTF-8?q?feat(audience):=20=E2=9C=A8=20Add=20new=20audie?= =?UTF-8?q?nce=20creation=20and=20update=20endpoints=20with=20business=20l?= =?UTF-8?q?ogic=20for=20managing=20audience=20data?= 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 | 9 +++ services/api/src/audience/audience.service.ts | 65 +++++++++++++++++++ 2 files changed, 74 insertions(+) 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: