feat(engagement): Add engagement tracking endpoints and metrics calculation for views/likes in API layer

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-04-06 14:21:43 -07:00
parent 97ecef0427
commit dd50408432
2 changed files with 199 additions and 9 deletions

View file

@ -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<EventByPageMetrics[]> {
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<UserFlow[]> {
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<NavigationFlowData> {
return this.engagementService.getNavigationFlows(query);
}
}

View file

@ -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<string, unknown>) => ({
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<EventByPageMetrics[]> {
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, string> = {
[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<string, unknown>) => ({
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<NavigationFlowData> {
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: