feat(engagement): Add corporate filtering capability to engagement queries by implementing the corp filter parameter in EngagementQueryDto, validating it in EngagementController, and integrating corp-based filtering logic in EngagementService

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-05-15 21:17:45 -07:00
parent 2b362cde74
commit 5e25fbd33c
3 changed files with 140 additions and 28 deletions

View file

@ -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()

View file

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

View file

@ -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<EngagementOverview> {
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<PageMetrics[]> {
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<EventMetrics[]> {
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<EventByPageMetrics[]> {
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<ScrollDepthMetrics[]> {
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<UserFlow[]> {
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<string, UserFlow>();
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<NavigationFlowData> {
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<CorpEngagementRow[]> {
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<Record<string, unknown>> = 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: