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:
parent
97ecef0427
commit
dd50408432
2 changed files with 199 additions and 9 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue