From 5e25fbd33c9622d3215f8cfbfd763a3b95ccc33e Mon Sep 17 00:00:00 2001 From: autocommit Date: Fri, 15 May 2026 21:17:45 -0700 Subject: [PATCH] =?UTF-8?q?feat(engagement):=20=E2=9C=A8=20Add=20corporate?= =?UTF-8?q?=20filtering=20capability=20to=20engagement=20queries=20by=20im?= =?UTF-8?q?plementing=20the=20corp=20filter=20parameter=20in=20EngagementQ?= =?UTF-8?q?ueryDto,=20validating=20it=20in=20EngagementController,=20and?= =?UTF-8?q?=20integrating=20corp-based=20filtering=20logic=20in=20Engageme?= =?UTF-8?q?ntService?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../engagement/dto/engagement-query.dto.ts | 16 ++ .../src/engagement/engagement.controller.ts | 10 ++ .../api/src/engagement/engagement.service.ts | 142 ++++++++++++++---- 3 files changed, 140 insertions(+), 28 deletions(-) diff --git a/services/api/src/engagement/dto/engagement-query.dto.ts b/services/api/src/engagement/dto/engagement-query.dto.ts index 27d47c4..ebacf93 100644 --- a/services/api/src/engagement/dto/engagement-query.dto.ts +++ b/services/api/src/engagement/dto/engagement-query.dto.ts @@ -37,6 +37,10 @@ export class EngagementQueryDto { @IsString() country?: string; + @IsOptional() + @IsString() + corp?: string; + @IsOptional() @Type(() => Number) @IsInt() @@ -75,6 +79,10 @@ export class ScrollDepthQueryDto { @IsOptional() @IsString() page?: string; + + @IsOptional() + @IsString() + corp?: string; } export class UserFlowQueryDto { @@ -88,6 +96,10 @@ export class UserFlowQueryDto { @IsString() startPage?: string; + @IsOptional() + @IsString() + corp?: string; + @IsOptional() @Type(() => Number) @IsInt() @@ -113,6 +125,10 @@ export class NavigationFlowsQueryDto { @IsDateString() endDate!: string; + @IsOptional() + @IsString() + corp?: string; + @IsOptional() @Type(() => Number) @IsInt() diff --git a/services/api/src/engagement/engagement.controller.ts b/services/api/src/engagement/engagement.controller.ts index 9bfd531..e94b468 100644 --- a/services/api/src/engagement/engagement.controller.ts +++ b/services/api/src/engagement/engagement.controller.ts @@ -8,6 +8,7 @@ import { ScrollDepthMetrics, UserFlow, NavigationFlowData, + CorpEngagementRow, } from './engagement.service'; import { EngagementQueryDto, @@ -84,4 +85,13 @@ export class EngagementController { async getNavigationFlows(@Query() query: NavigationFlowsQueryDto): Promise { return this.engagementService.getNavigationFlows(query); } + + /** + * Per-corp engagement leaderboard (cross-corp; ignores corp filter). + * GET /engagement/by-corp?startDate=2024-01-01&endDate=2024-01-31 + */ + @Get('by-corp') + async getByCorp(@Query() query: EngagementQueryDto): Promise { + return this.engagementService.getByCorp(query); + } } diff --git a/services/api/src/engagement/engagement.service.ts b/services/api/src/engagement/engagement.service.ts index 747dc04..156d5b9 100644 --- a/services/api/src/engagement/engagement.service.ts +++ b/services/api/src/engagement/engagement.service.ts @@ -11,6 +11,7 @@ import { PageSortBy, EventCategory, } from './dto/engagement-query.dto'; +import { resolveCorpId, corpRawEventsFilter } from '../common/corp-filter.util'; export interface EngagementOverview { engagementRate: number; @@ -84,6 +85,18 @@ export interface NavigationFlowData { transitions: NavigationTransition[]; } +export interface CorpEngagementRow { + corpId: number; + corpSlug: string; + corpName: string; + visitors: number; + sessions: number; + pageviews: number; + totalEvents: number; + pageviewsPerVisitor: number; + eventsPerSession: number; +} + /** * Engagement service for user behavior analytics. * Queries raw_events for page and event metrics. @@ -97,9 +110,6 @@ export class EngagementService { private readonly dataSource: DataSource, ) {} - /** - * Get engagement overview metrics - */ async getOverview(query: EngagementQueryDto): Promise { const { startDate, endDate, trafficSource, deviceType, country } = query; @@ -120,6 +130,14 @@ export class EngagementService { if (country) { conditions.push(`sf.country = $${paramIndex}`); params.push(country); + paramIndex++; + } + + const corpId = await resolveCorpId(this.dataSource, query.corp); + if (corpId !== null) { + conditions.push(`re.corp_id = $${paramIndex}`); + params.push(corpId); + paramIndex++; } const whereClause = conditions.join(' AND '); @@ -184,9 +202,6 @@ export class EngagementService { } } - /** - * Get page performance metrics - */ async getPages(query: PageQueryDto): Promise { const { startDate, endDate, pathPattern, sortBy, limit, trafficSource, deviceType, country } = query; @@ -219,6 +234,13 @@ export class EngagementService { paramIndex++; } + const corpId = await resolveCorpId(this.dataSource, query.corp); + if (corpId !== null) { + conditions.push(`pv.corp_id = $${paramIndex}`); + params.push(corpId); + paramIndex++; + } + const whereClause = conditions.join(' AND '); const sortColumn = this.getPageSortColumn(sortBy); @@ -285,9 +307,6 @@ export class EngagementService { } } - /** - * Get event breakdown - */ async getEvents(query: EventQueryDto): Promise { const { startDate, endDate, category, eventType, limit, trafficSource, deviceType, country } = query; @@ -295,7 +314,6 @@ export class EngagementService { const params: (string | number)[] = [startDate, endDate]; let paramIndex = 3; - // Exclude page views conditions.push("re.\"eventType\" NOT IN ('pageView', 'pageview')"); if (category && category !== EventCategory.ALL) { @@ -332,6 +350,13 @@ export class EngagementService { paramIndex++; } + const corpId = await resolveCorpId(this.dataSource, query.corp); + if (corpId !== null) { + conditions.push(`re.corp_id = $${paramIndex}`); + params.push(corpId); + paramIndex++; + } + const whereClause = conditions.join(' AND '); const eventsQuery = ` @@ -373,9 +398,6 @@ export class EngagementService { } } - /** - * Get event breakdown grouped by page - */ async getEventsByPage(query: EventQueryDto): Promise { const { startDate, endDate, category, eventType, limit, trafficSource, deviceType, country } = query; @@ -419,6 +441,13 @@ export class EngagementService { paramIndex++; } + const corpId = await resolveCorpId(this.dataSource, query.corp); + if (corpId !== null) { + conditions.push(`re.corp_id = $${paramIndex}`); + params.push(corpId); + paramIndex++; + } + const whereClause = conditions.join(' AND '); const query_ = ` @@ -456,9 +485,6 @@ export class EngagementService { } } - /** - * Get scroll depth metrics - */ async getScrollDepth(query: ScrollDepthQueryDto): Promise { const { startDate, endDate, page } = query; @@ -472,6 +498,14 @@ export class EngagementService { if (page) { conditions.push(`"pageUrl" LIKE $${paramIndex}`); params.push(`%${page}%`); + paramIndex++; + } + + const corpId = await resolveCorpId(this.dataSource, query.corp); + if (corpId !== null) { + conditions.push(`corp_id = $${paramIndex}`); + params.push(corpId); + paramIndex++; } const whereClause = conditions.join(' AND '); @@ -512,9 +546,6 @@ export class EngagementService { } } - /** - * Get user flow (path analysis) - */ async getUserFlow(query: UserFlowQueryDto): Promise { const { startDate, endDate, startPage, steps, limit } = query; @@ -532,6 +563,13 @@ export class EngagementService { paramIndex++; } + const corpId = await resolveCorpId(this.dataSource, query.corp); + if (corpId !== null) { + conditions.push(`corp_id = $${paramIndex}`); + params.push(corpId); + paramIndex++; + } + const whereClause = conditions.join(' AND '); const stepsParamIndex = paramIndex; @@ -541,7 +579,6 @@ export class EngagementService { const limitParamIndex = paramIndex; params.push(limit ?? 10); - // This is a simplified user flow - full implementation would use recursive CTEs const flowQuery = ` WITH session_pages AS ( SELECT @@ -577,7 +614,6 @@ export class EngagementService { try { const result = await this.dataSource.query(flowQuery, params); - // Group results by start page const flowMap = new Map(); for (const row of result) { @@ -612,15 +648,13 @@ export class EngagementService { } } - /** - * Get page-to-page navigation flows: given a source page path, returns where - * visitors went next within the same session, with session counts and percentages. - * `from` is matched as a path segment against stored full URLs via LIKE. - */ async getNavigationFlows(query: NavigationFlowsQueryDto): Promise { const { from, startDate, endDate, limit = 10 } = query; const pattern = `%${from}%`; + const corpId = await resolveCorpId(this.dataSource, query.corp); + const corpClause = corpRawEventsFilter(5, corpId); + const sql = ` WITH ordered AS ( SELECT @@ -630,7 +664,7 @@ export class EngagementService { FROM raw_events WHERE timestamp BETWEEN $1 AND $2 AND "eventType" IN ('pageView', 'pageview') - AND "pageUrl" IS NOT NULL + AND "pageUrl" IS NOT NULL${corpClause} ), from_rows AS ( SELECT @@ -658,9 +692,12 @@ export class EngagementService { LIMIT $4 `; + const params: (string | number)[] = [startDate, endDate, pattern, limit]; + if (corpId !== null) params.push(corpId); + try { const rows: Array<{ to_page: string; sessions: string; percentage: string; total: string }> = - await this.dataSource.query(sql, [startDate, endDate, pattern, limit]); + await this.dataSource.query(sql, params); const total = rows[0] ? Number(rows[0].total) : 0; return { from, @@ -677,6 +714,55 @@ export class EngagementService { } } + + async getByCorp(query: EngagementQueryDto): Promise { + const { startDate, endDate } = query; + + const sql = ` + SELECT + c.id AS corp_id, + c.slug AS corp_slug, + c.legal_name AS corp_name, + COUNT(DISTINCT re.visitor_id_daily) AS visitors, + COUNT(DISTINCT re."sessionId") AS sessions, + COUNT(*) FILTER (WHERE re."eventType" IN ('pageview', 'pageView')) AS pageviews, + COUNT(re.id) AS total_events + FROM corps c + LEFT JOIN raw_events re + ON re.corp_id = c.id + AND re.timestamp BETWEEN $1 AND $2 + GROUP BY c.id, c.slug, c.legal_name + ORDER BY visitors DESC, c.slug ASC + `; + + try { + const rows: Array> = await this.dataSource.query(sql, [ + startDate, + endDate, + ]); + return rows.map((row) => { + const visitors = Number(row.visitors) || 0; + const sessions = Number(row.sessions) || 0; + const pageviews = Number(row.pageviews) || 0; + const totalEvents = Number(row.total_events) || 0; + return { + corpId: Number(row.corp_id), + corpSlug: row.corp_slug as string, + corpName: row.corp_name as string, + visitors, + sessions, + pageviews, + totalEvents, + pageviewsPerVisitor: visitors > 0 ? pageviews / visitors : 0, + eventsPerSession: sessions > 0 ? totalEvents / sessions : 0, + }; + }); + } catch (error) { + this.logger.error('Failed to get engagement by corp', error); + throw error; + } + } + private getPageSortColumn(sortBy?: PageSortBy): string { switch (sortBy) { case PageSortBy.UNIQUE_VIEWS: