feat(acquisition): Add corporate filtering capability to AcquisitionQuery DTO and AcquisitionService

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-05-15 21:17:45 -07:00
parent 05bbe97a09
commit 4245e76119
2 changed files with 50 additions and 8 deletions

View file

@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { AcquisitionQueryDto, AcquisitionCompareDto, ReferrerQueryDto } from './dto/acquisition-query.dto';
import { resolveCorpId, corpSessionFilter, corpRawEventsFilter } from '../common/corp-filter.util';
export interface ChannelMetrics {
channel: string;
@ -81,6 +82,9 @@ export class AcquisitionService {
*/
async getOverview(query: AcquisitionQueryDto): Promise<AcquisitionOverview> {
const { startDate, endDate, limit } = query;
const corpId = await resolveCorpId(this.dataSource, query.corp);
const overviewCorpClause = corpSessionFilter(3, corpId);
const channelsCorpClause = corpSessionFilter(4, corpId);
const overviewQuery = `
WITH session_data AS (
@ -104,7 +108,7 @@ export class AcquisitionService {
), 0) as page_views
FROM session_fingerprints sf
WHERE sf."createdAt" BETWEEN $1 AND $2
AND sf."isBot" = false
AND sf."isBot" = false${overviewCorpClause}
),
user_first AS (
SELECT "userId", MIN("createdAt") as first_session
@ -147,7 +151,7 @@ export class AcquisitionService {
), 0) as revenue
FROM session_fingerprints sf
WHERE sf."createdAt" BETWEEN $1 AND $2
AND sf."isBot" = false
AND sf."isBot" = false${channelsCorpClause}
),
user_first AS (
SELECT "userId", MIN("createdAt") as first_session
@ -170,10 +174,15 @@ export class AcquisitionService {
LIMIT $3
`;
const overviewParams: (string | number)[] = [startDate, endDate];
if (corpId !== null) overviewParams.push(corpId);
const channelsParams: (string | number)[] = [startDate, endDate, limit ?? 20];
if (corpId !== null) channelsParams.push(corpId);
try {
const [overviewResult, channelsResult] = await Promise.all([
this.dataSource.query(overviewQuery, [startDate, endDate]),
this.dataSource.query(channelsQuery, [startDate, endDate, limit ?? 20]),
this.dataSource.query(overviewQuery, overviewParams),
this.dataSource.query(channelsQuery, channelsParams),
]);
const overview = overviewResult[0] || {};
@ -229,8 +238,11 @@ export class AcquisitionService {
*/
async getSources(query: AcquisitionQueryDto): Promise<SourceMetrics[]> {
const { startDate, endDate, limit } = query;
const corpId = await resolveCorpId(this.dataSource, query.corp);
// First try the aggregated_metrics traffic_source dimension (populated by processor)
// NOTE: aggregated_metrics has no corp dimension — when a corp scope is requested,
// skip this path and go straight to raw_events derivation (which IS corp-scoped).
const aggregatedQuery = `
WITH source_totals AS (
SELECT
@ -249,7 +261,9 @@ export class AcquisitionService {
`;
try {
const aggregated = await this.dataSource.query(aggregatedQuery, [startDate, endDate, limit ?? 20]);
const aggregated = corpId === null
? await this.dataSource.query(aggregatedQuery, [startDate, endDate, limit ?? 20])
: [];
if (aggregated.length > 0) {
return aggregated.map((row: Record<string, unknown>) => ({
@ -286,7 +300,7 @@ export class AcquisitionService {
FROM raw_events
WHERE "eventType" IN ('pageview', 'pageView')
AND "deviceType" IS DISTINCT FROM 'bot'
AND timestamp BETWEEN $1 AND $2
AND timestamp BETWEEN $1 AND $2${corpRawEventsFilter(4, corpId, 'raw_events')}
ORDER BY "sessionId", timestamp ASC
)
SELECT
@ -301,7 +315,9 @@ export class AcquisitionService {
LIMIT $3
`;
const raw = await this.dataSource.query(rawQuery, [startDate, endDate, limit ?? 20]);
const rawParams: (string | number)[] = [startDate, endDate, limit ?? 20];
if (corpId !== null) rawParams.push(corpId);
const raw = await this.dataSource.query(rawQuery, rawParams);
return raw.map((row: Record<string, unknown>) => ({
source: row.source as string,
@ -323,6 +339,7 @@ export class AcquisitionService {
*/
async getCampaigns(query: AcquisitionQueryDto): Promise<CampaignMetrics[]> {
const { startDate, endDate, source, limit } = query;
const corpId = await resolveCorpId(this.dataSource, query.corp);
const conditions: string[] = [
'sf."createdAt" BETWEEN $1 AND $2',
@ -338,6 +355,13 @@ export class AcquisitionService {
paramIndex++;
}
if (corpId !== null) {
// Strip leading ' AND ' so the fragment slots into the AND-joined conditions list.
conditions.push(corpSessionFilter(paramIndex, corpId).replace(/^\s*AND\s+/i, ''));
params.push(corpId);
paramIndex++;
}
const whereClause = conditions.join(' AND ');
const campaignsQuery = `
@ -415,6 +439,7 @@ export class AcquisitionService {
*/
async getReferrers(query: ReferrerQueryDto): Promise<ReferrerMetrics[]> {
const { startDate, endDate, excludeInternal, limit } = query;
const corpId = await resolveCorpId(this.dataSource, query.corp);
const conditions: string[] = [
'sf."createdAt" BETWEEN $1 AND $2',
@ -422,12 +447,19 @@ export class AcquisitionService {
'sf.referrer IS NOT NULL',
];
const params: (string | number)[] = [startDate, endDate];
let paramIndex = 3;
if (excludeInternal) {
conditions.push("sf.referrer NOT LIKE '%atlilith.%'");
conditions.push("sf.referrer NOT LIKE '%trustedmeet.%'");
}
if (corpId !== null) {
conditions.push(corpSessionFilter(paramIndex, corpId).replace(/^\s*AND\s+/i, ''));
params.push(corpId);
paramIndex++;
}
const whereClause = conditions.join(' AND ');
const referrersQuery = `
@ -464,7 +496,7 @@ export class AcquisitionService {
WHERE sd.domain IS NOT NULL
GROUP BY sd.referrer, sd.domain
ORDER BY sessions DESC
LIMIT $3
LIMIT $${paramIndex}
`;
params.push(limit ?? 20);
@ -499,12 +531,14 @@ export class AcquisitionService {
startDate: query.startDate,
endDate: query.endDate,
limit: query.limit,
corp: query.corp,
});
const previous = await this.getOverview({
startDate: query.compareStartDate,
endDate: query.compareEndDate,
limit: query.limit,
corp: query.corp,
});
const calcChange = (curr: number, prev: number) =>

View file

@ -8,6 +8,10 @@ export class AcquisitionQueryDto {
@IsDateString()
endDate!: string;
@IsOptional()
@IsString()
corp?: string;
@IsOptional()
@IsString()
channel?: string;
@ -55,6 +59,10 @@ export class ReferrerQueryDto {
@IsDateString()
endDate!: string;
@IsOptional()
@IsString()
corp?: string;
@IsOptional()
@Transform(({ value }) => value === 'true' || value === true)
@IsBoolean()