Refactor part 2: Electric Boogaloo. Standardize way of passing filter parameters.

This commit is contained in:
Mike Cao 2025-07-04 01:23:11 -07:00
parent f26f1b0581
commit cdf391d5c2
90 changed files with 867 additions and 709 deletions

View file

@ -136,7 +136,7 @@ function getQueryParams(filters: Record<string, any>) {
};
}
async function parseFilters(filters: Record<string, any>, options?: QueryOptions) {
function parseFilters(filters: Record<string, any>, options?: QueryOptions) {
return {
filterQuery: getFilterQuery(filters, options),
dateQuery: getDateQuery(filters),

View file

@ -16,7 +16,8 @@ const enabled = Boolean(process.env.KAFKA_URL && process.env.KAFKA_BROKER);
function getClient() {
const { username, password } = new URL(process.env.KAFKA_URL);
const brokers = process.env.KAFKA_BROKER.split(',');
const mechanism = process.env.KAFKA_SASL_MECHANISM as 'plain' | 'scram-sha-256' | 'scram-sha-512';
const mechanism =
(process.env.KAFKA_SASL_MECHANISM as 'plain' | 'scram-sha-256' | 'scram-sha-512') || 'plain';
const ssl: { ssl?: tls.ConnectionOptions | boolean; sasl?: SASLOptions } =
username && password

View file

@ -37,6 +37,7 @@ export function filtersToArray(filters: QueryFilters, options: QueryOptions = {}
column: options?.columns?.[key] ?? FILTER_COLUMNS[key],
operator,
value,
prefix: options?.prefix,
});
}, []);
}

View file

@ -84,15 +84,10 @@ function getTimestampDiffSQL(field1: string, field2: string): string {
}
function getSearchSQL(column: string, param: string = 'search'): string {
const db = getDatabaseType();
const like = db === POSTGRESQL ? 'ilike' : 'like';
return `and ${column} ${like} {{${param}}}`;
return `and ${column} ilike {{${param}}}`;
}
function mapFilter(column: string, operator: string, name: string, type: string = '') {
const db = getDatabaseType();
const like = db === POSTGRESQL ? 'ilike' : 'like';
const value = `{{${name}${type ? `::${type}` : ''}}}`;
switch (operator) {
@ -101,28 +96,31 @@ function mapFilter(column: string, operator: string, name: string, type: string
case OPERATORS.notEquals:
return `${column} != ${value}`;
case OPERATORS.contains:
return `${column} ${like} ${value}`;
return `${column} ilike ${value}`;
case OPERATORS.doesNotContain:
return `${column} not ${like} ${value}`;
return `${column} not ilike ${value}`;
default:
return '';
}
}
function getFilterQuery(filters: Record<string, any>, options: QueryOptions = {}): string {
const query = filtersToArray(filters, options).reduce((arr, { name, column, operator }) => {
if (column) {
arr.push(`and ${mapFilter(column, operator, name)}`);
const query = filtersToArray(filters, options).reduce(
(arr, { name, column, operator, prefix = '' }) => {
if (column) {
arr.push(`and ${mapFilter(`${prefix}${column}`, operator, name)}`);
if (name === 'referrer') {
arr.push(
`and (website_event.referrer_domain != website_event.hostname or website_event.referrer_domain is null)`,
);
if (name === 'referrer') {
arr.push(
`and (website_event.referrer_domain != website_event.hostname or website_event.referrer_domain is null)`,
);
}
}
}
return arr;
}, []);
return arr;
},
[],
);
return query.join('\n');
}
@ -154,7 +152,7 @@ function getQueryParams(filters: Record<string, any>) {
};
}
async function parseFilters(filters: Record<string, any>, options?: QueryOptions) {
function parseFilters(filters: Record<string, any>, options?: QueryOptions) {
const joinSession = Object.keys(filters).find(key =>
['referrer', ...SESSION_COLUMNS].includes(key),
);

View file

@ -1,7 +1,7 @@
import { z } from 'zod/v4';
import { FILTER_COLUMNS, DEFAULT_PAGE_SIZE } from '@/lib/constants';
import { badRequest, unauthorized } from '@/lib/response';
import { getAllowedUnits, getCompareDate, getMinimumUnit, maxDate } from '@/lib/date';
import { getAllowedUnits, getMinimumUnit, maxDate } from '@/lib/date';
import { checkAuth } from '@/lib/auth';
import { fetchWebsite } from '@/lib/load';
import { QueryFilters } from '@/lib/types';
@ -50,23 +50,14 @@ export async function getJsonBody(request: Request) {
}
export function getRequestDateRange(query: Record<string, string>) {
const { startAt, endAt, unit, compare, timezone } = query;
const { startAt, endAt, unit, timezone } = query;
const startDate = new Date(+startAt);
const endDate = new Date(+endAt);
const { startDate: compareStartDate, endDate: compareEndDate } = getCompareDate(
compare,
startDate,
endDate,
);
return {
startDate,
endDate,
compare,
compareStartDate,
compareEndDate,
timezone,
unit: getAllowedUnits(startDate, endDate).includes(unit)
? unit
@ -86,11 +77,21 @@ export function getRequestFilters(query: Record<string, any>) {
}, {});
}
export async function getQueryFilters(params: Record<string, any>): Promise<QueryFilters> {
export async function setWebsiteDate(websiteId: string, data: Record<string, any>) {
const website = await fetchWebsite(websiteId);
if (website) {
data.startDate = maxDate(data.startDate, new Date(website?.resetAt));
}
return data;
}
export function getQueryFilters(params: Record<string, any>): QueryFilters {
const dateRange = getRequestDateRange(params);
const filters = getRequestFilters(params);
const data = {
return {
...dateRange,
...filters,
page: params?.page,
@ -98,17 +99,5 @@ export async function getQueryFilters(params: Record<string, any>): Promise<Quer
orderBy: params?.orderBy,
sortDescending: params?.sortDescending,
search: params?.search,
websiteId: undefined,
};
const { websiteId } = params;
if (websiteId) {
const website = await fetchWebsite(websiteId);
data.websiteId = websiteId;
data.startDate = maxDate(data.startDate, new Date(website?.resetAt));
}
return data;
}

View file

@ -11,8 +11,10 @@ export const unitParam = z.string().refine(value => UNIT_TYPES.includes(value),
});
export const dateRangeParams = {
startAt: z.coerce.number(),
endAt: z.coerce.number(),
startAt: z.coerce.number().optional(),
endAt: z.coerce.number().optional(),
startDate: z.coerce.date().optional(),
endDate: z.coerce.date().optional(),
timezone: timezoneParam.optional(),
unit: unitParam.optional(),
compare: z.string().optional(),
@ -33,12 +35,12 @@ export const filterParams = {
hostname: z.string().optional(),
language: z.string().optional(),
event: z.string().optional(),
search: z.string().optional(),
};
export const pagingParams = {
page: z.coerce.number().int().positive().optional(),
pageSize: z.coerce.number().int().positive().optional(),
search: z.string().optional(),
};
export const sortingParams = {
@ -93,23 +95,63 @@ export const reportTypeParam = z.enum([
'utm',
]);
export const reportParms = {
websiteId: z.string().uuid(),
dateRange: z.object({
startDate: z.coerce.date(),
endDate: z.coerce.date(),
timezone: timezoneParam.optional(),
unit: unitParam.optional(),
compare: z.string().optional(),
compareStartDate: z.coerce.date().optional(),
compareEndDate: z.coerce.date().optional(),
}),
};
export const dateRangeSchema = z.object({ ...dateRangeParams }).superRefine((data, ctx) => {
const hasTimestamps = data.startAt !== undefined && data.endAt !== undefined;
const hasDates = data.startDate !== undefined && data.endDate !== undefined;
if (!hasTimestamps && !hasDates) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'You must provide either startAt & endAt or startDate & endDate.',
});
}
if (hasTimestamps && hasDates) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Provide either startAt & endAt or startDate & endDate, not both.',
});
}
if (data.startAt !== undefined && data.endAt === undefined) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'If you provide startAt, you must also provide endAt.',
path: ['endAt'],
});
}
if (data.endAt !== undefined && data.startAt === undefined) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'If you provide endAt, you must also provide startAt.',
path: ['startAt'],
});
}
if (data.startDate !== undefined && data.endDate === undefined) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'If you provide startDate, you must also provide endDate.',
path: ['endDate'],
});
}
if (data.endDate !== undefined && data.startDate === undefined) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'If you provide endDate, you must also provide startDate.',
path: ['startDate'],
});
}
});
export const goalReportSchema = z.object({
type: z.literal('goal'),
parameters: z
.object({
startDate: z.coerce.date(),
endDate: z.coerce.date(),
type: z.string(),
value: z.string(),
operator: z.enum(['count', 'sum', 'average']).optional(),
@ -126,6 +168,8 @@ export const goalReportSchema = z.object({
export const funnelReportSchema = z.object({
type: z.literal('funnel'),
parameters: z.object({
startDate: z.coerce.date(),
endDate: z.coerce.date(),
window: z.coerce.number().positive(),
steps: z
.array(
@ -142,6 +186,8 @@ export const funnelReportSchema = z.object({
export const journeyReportSchema = z.object({
type: z.literal('journey'),
parameters: z.object({
startDate: z.coerce.date(),
endDate: z.coerce.date(),
steps: z.coerce.number().min(2).max(7),
startStep: z.string().optional(),
endStep: z.string().optional(),
@ -150,15 +196,27 @@ export const journeyReportSchema = z.object({
export const retentionReportSchema = z.object({
type: z.literal('retention'),
parameters: z.object({
startDate: z.coerce.date(),
endDate: z.coerce.date(),
timezone: z.string().optional(),
}),
});
export const utmReportSchema = z.object({
type: z.literal('utm'),
parameters: z.object({
startDate: z.coerce.date(),
endDate: z.coerce.date(),
}),
});
export const revenueReportSchema = z.object({
type: z.literal('revenue'),
parameters: z.object({
startDate: z.coerce.date(),
endDate: z.coerce.date(),
timezone: z.string().optional(),
currency: z.string(),
}),
});
@ -166,6 +224,8 @@ export const revenueReportSchema = z.object({
export const attributionReportSchema = z.object({
type: z.literal('attribution'),
parameters: z.object({
startDate: z.coerce.date(),
endDate: z.coerce.date(),
model: z.enum(['first-click', 'last-click']),
type: z.enum(['page', 'event']),
step: z.string(),
@ -176,6 +236,8 @@ export const attributionReportSchema = z.object({
export const breakdownReportSchema = z.object({
type: z.literal('breakdown'),
parameters: z.object({
startDate: z.coerce.date(),
endDate: z.coerce.date(),
fields: z.array(fieldsParam),
}),
});
@ -202,7 +264,7 @@ export const reportSchema = z.intersection(reportBaseSchema, reportTypeSchema);
export const reportResultSchema = z.intersection(
z.object({
...reportParms,
websiteId: z.string().uuid(),
filters: z.object({ ...filterParams }),
}),
reportTypeSchema,

View file

@ -41,19 +41,20 @@ export interface QueryOptions {
joinSession?: boolean;
columns?: Record<string, string>;
limit?: number;
prefix?: string;
}
export interface QueryFilters {
websiteId?: string;
// Date range
export interface QueryFilters extends DateParams, FilterParams, SortParams, PageParams {}
export interface DateParams {
startDate?: Date;
endDate?: Date;
compareStartDate?: Date;
compareEndDate?: Date;
compare?: string;
unit?: string;
timezone?: string;
// Filters
compareDate?: Date;
}
export interface FilterParams {
path?: string;
referrer?: string;
title?: string;
@ -70,20 +71,16 @@ export interface QueryFilters {
search?: string;
tag?: string;
eventType?: number;
// Paging
page?: number;
pageSize?: number;
// Sorting
}
export interface SortParams {
orderBy?: string;
sortDescending?: boolean;
}
export interface PageParams {
page: number;
pageSize: number;
orderBy?: string;
sortDescending?: boolean;
search?: string;
page?: number;
pageSize?: number;
}
export interface PageResult<T> {