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:
parent
2b362cde74
commit
5e25fbd33c
3 changed files with 140 additions and 28 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue