From dd5040843224cc762db072a167ae0ec9d6fe2a95 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Mon, 6 Apr 2026 14:21:43 -0700 Subject: [PATCH] =?UTF-8?q?feat(engagement):=20=E2=9C=A8=20Add=20engagemen?= =?UTF-8?q?t=20tracking=20endpoints=20and=20metrics=20calculation=20for=20?= =?UTF-8?q?views/likes=20in=20API=20layer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../src/engagement/engagement.controller.ts | 21 ++ .../api/src/engagement/engagement.service.ts | 187 +++++++++++++++++- 2 files changed, 199 insertions(+), 9 deletions(-) diff --git a/services/api/src/engagement/engagement.controller.ts b/services/api/src/engagement/engagement.controller.ts index 426c83d..9bfd531 100644 --- a/services/api/src/engagement/engagement.controller.ts +++ b/services/api/src/engagement/engagement.controller.ts @@ -4,8 +4,10 @@ import { EngagementOverview, PageMetrics, EventMetrics, + EventByPageMetrics, ScrollDepthMetrics, UserFlow, + NavigationFlowData, } from './engagement.service'; import { EngagementQueryDto, @@ -13,6 +15,7 @@ import { EventQueryDto, ScrollDepthQueryDto, UserFlowQueryDto, + NavigationFlowsQueryDto, } from './dto/engagement-query.dto'; @Controller('engagement') @@ -46,6 +49,15 @@ export class EngagementController { return this.engagementService.getEvents(query); } + /** + * Get event breakdown grouped by page + * GET /engagement/events/by-page?startDate=2024-01-01&endDate=2024-01-31 + */ + @Get('events/by-page') + async getEventsByPage(@Query() query: EventQueryDto): Promise { + return this.engagementService.getEventsByPage(query); + } + /** * Get scroll depth metrics * GET /engagement/scroll-depth?startDate=2024-01-01&endDate=2024-01-31&page=/profile @@ -63,4 +75,13 @@ export class EngagementController { async getUserFlow(@Query() query: UserFlowQueryDto): Promise { return this.engagementService.getUserFlow(query); } + + /** + * Get page-to-page navigation flows + * GET /engagement/navigation/flows?from=/gallery&startDate=2024-01-01&endDate=2024-01-31 + */ + @Get('navigation/flows') + async getNavigationFlows(@Query() query: NavigationFlowsQueryDto): Promise { + return this.engagementService.getNavigationFlows(query); + } } diff --git a/services/api/src/engagement/engagement.service.ts b/services/api/src/engagement/engagement.service.ts index ff91cf6..747dc04 100644 --- a/services/api/src/engagement/engagement.service.ts +++ b/services/api/src/engagement/engagement.service.ts @@ -7,6 +7,7 @@ import { EventQueryDto, ScrollDepthQueryDto, UserFlowQueryDto, + NavigationFlowsQueryDto, PageSortBy, EventCategory, } from './dto/engagement-query.dto'; @@ -34,11 +35,19 @@ export interface PageMetrics { } export interface EventMetrics { - eventType: string; + eventName: string; + eventLabel: string | null; category: string; count: number; uniqueUsers: number; - avgValue: number; +} + +export interface EventByPageMetrics { + eventName: string; + eventLabel: string | null; + page: string; + count: number; + uniqueUsers: number; } export interface ScrollDepthMetrics { @@ -63,6 +72,18 @@ export interface UserFlow { steps: UserFlowStep[]; } +export interface NavigationTransition { + to: string; + sessions: number; + percentage: number; +} + +export interface NavigationFlowData { + from: string; + total: number; + transitions: NavigationTransition[]; +} + /** * Engagement service for user behavior analytics. * Queries raw_events for page and event metrics. @@ -315,7 +336,8 @@ export class EngagementService { const eventsQuery = ` SELECT - re."eventType" as event_type, + COALESCE(re.metadata->>'eventName', re."eventType") as event_name, + re.metadata->>'eventLabel' as event_label, CASE WHEN re."eventType" LIKE 'click%' THEN 'click' WHEN re."eventType" LIKE 'scroll%' THEN 'scroll' @@ -324,27 +346,26 @@ export class EngagementService { ELSE 'custom' END as category, COUNT(*) as count, - COUNT(DISTINCT COALESCE(re."userId", re."sessionId")) as unique_users, - AVG(COALESCE((re.metadata->>'value')::numeric, 0)) as avg_value + COUNT(DISTINCT COALESCE(re."userId", re."sessionId")) as unique_users FROM raw_events re LEFT JOIN session_fingerprints sf ON re."sessionId" = sf."sessionId" WHERE ${whereClause} - GROUP BY re."eventType" + GROUP BY event_name, re.metadata->>'eventLabel', category ORDER BY count DESC LIMIT $${paramIndex} `; - params.push(limit ?? 20); + params.push(limit ?? 100); try { const result = await this.dataSource.query(eventsQuery, params); return result.map((row: Record) => ({ - eventType: row.event_type as string, + eventName: row.event_name as string, + eventLabel: (row.event_label as string | null) ?? null, category: row.category as string, count: Number(row.count) || 0, uniqueUsers: Number(row.unique_users) || 0, - avgValue: Number(row.avg_value) || 0, })); } catch (error) { this.logger.error('Failed to get event metrics', error); @@ -352,6 +373,89 @@ export class EngagementService { } } + /** + * Get event breakdown grouped by page + */ + async getEventsByPage(query: EventQueryDto): Promise { + const { startDate, endDate, category, eventType, limit, trafficSource, deviceType, country } = query; + + const conditions: string[] = ['re.timestamp BETWEEN $1 AND $2']; + const params: (string | number)[] = [startDate, endDate]; + let paramIndex = 3; + + conditions.push("re.\"eventType\" NOT IN ('pageView', 'pageview')"); + + if (category && category !== EventCategory.ALL) { + const categoryPatterns: Record = { + [EventCategory.CLICK]: 'click%', + [EventCategory.SCROLL]: 'scroll%', + [EventCategory.FORM]: 'form%', + [EventCategory.VIDEO]: 'video%', + [EventCategory.CUSTOM]: '%', + [EventCategory.ALL]: '%', + }; + conditions.push(`re."eventType" LIKE $${paramIndex}`); + params.push(categoryPatterns[category]); + paramIndex++; + } + if (eventType) { + conditions.push(`re."eventType" = $${paramIndex}`); + params.push(eventType); + paramIndex++; + } + if (trafficSource) { + conditions.push(`sf."trafficSource" = $${paramIndex}`); + params.push(trafficSource); + paramIndex++; + } + if (deviceType) { + conditions.push(`sf."deviceType" = $${paramIndex}`); + params.push(deviceType); + paramIndex++; + } + if (country) { + conditions.push(`sf.country = $${paramIndex}`); + params.push(country); + paramIndex++; + } + + const whereClause = conditions.join(' AND '); + + const query_ = ` + SELECT + COALESCE(re.metadata->>'eventName', re."eventType") as event_name, + re.metadata->>'eventLabel' as event_label, + COALESCE( + regexp_replace(COALESCE(re."pageUrl", re.metadata->>'pageUrl'), '^https?://[^/]+', ''), + '(unknown)' + ) as page, + COUNT(*) as count, + COUNT(DISTINCT COALESCE(re."userId", re."sessionId")) as unique_users + FROM raw_events re + LEFT JOIN session_fingerprints sf ON re."sessionId" = sf."sessionId" + WHERE ${whereClause} + GROUP BY event_name, re.metadata->>'eventLabel', page + ORDER BY count DESC + LIMIT $${paramIndex} + `; + + params.push(limit ?? 500); + + try { + const result = await this.dataSource.query(query_, params); + return result.map((row: Record) => ({ + eventName: row.event_name as string, + eventLabel: (row.event_label as string | null) ?? null, + page: row.page as string, + count: Number(row.count) || 0, + uniqueUsers: Number(row.unique_users) || 0, + })); + } catch (error) { + this.logger.error('Failed to get events by page', error); + throw error; + } + } + /** * Get scroll depth metrics */ @@ -508,6 +612,71 @@ 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 sql = ` + WITH ordered AS ( + SELECT + "sessionId", + "pageUrl", + LEAD("pageUrl") OVER (PARTITION BY "sessionId" ORDER BY timestamp) as next_url + FROM raw_events + WHERE timestamp BETWEEN $1 AND $2 + AND "eventType" IN ('pageView', 'pageview') + AND "pageUrl" IS NOT NULL + ), + from_rows AS ( + SELECT + COALESCE( + CASE WHEN next_url IS NOT NULL + THEN regexp_replace(next_url, '^https?://[^/]+', '') + END, + '(exit)' + ) as to_page, + COUNT(DISTINCT "sessionId") as sessions + FROM ordered + WHERE "pageUrl" LIKE $3 + GROUP BY to_page + ), + total AS ( + SELECT COUNT(DISTINCT "sessionId") as n + FROM ordered + WHERE "pageUrl" LIKE $3 + ) + SELECT f.to_page, f.sessions, + ROUND(100.0 * f.sessions / NULLIF(t.n, 0), 1) as percentage, + t.n as total + FROM from_rows f, total t + ORDER BY f.sessions DESC + LIMIT $4 + `; + + try { + const rows: Array<{ to_page: string; sessions: string; percentage: string; total: string }> = + await this.dataSource.query(sql, [startDate, endDate, pattern, limit]); + const total = rows[0] ? Number(rows[0].total) : 0; + return { + from, + total, + transitions: rows.map((r) => ({ + to: r.to_page, + sessions: Number(r.sessions), + percentage: Number(r.percentage), + })), + }; + } catch (error) { + this.logger.error('Failed to get navigation flows', error); + throw error; + } + } + private getPageSortColumn(sortBy?: PageSortBy): string { switch (sortBy) { case PageSortBy.UNIQUE_VIEWS: