Merge branch 'dev' into jajaja

# Conflicts:
#	db/postgresql/schema.prisma
#	pnpm-lock.yaml
#	src/app/(main)/websites/[websiteId]/WebsiteDetailsPage.tsx
#	src/app/(main)/websites/[websiteId]/compare/WebsiteComparePage.tsx
#	src/app/api/reports/route.ts
#	src/app/api/websites/[websiteId]/events/series/route.ts
#	src/app/api/websites/[websiteId]/metrics/route.ts
#	src/app/api/websites/[websiteId]/pageviews/route.ts
#	src/app/api/websites/[websiteId]/sessions/stats/route.ts
#	src/app/api/websites/[websiteId]/stats/route.ts
#	src/app/api/websites/[websiteId]/values/route.ts
#	src/components/hooks/useFields.ts
#	src/components/hooks/useFilterParams.ts
#	src/lang/vi-VN.json
#	src/lib/clickhouse.ts
#	src/lib/detect.ts
#	src/lib/prisma.ts
#	src/lib/request.ts
#	src/lib/schema.ts
#	src/lib/types.ts
#	src/queries/sql/events/getEventDataFields.ts
#	src/queries/sql/events/getEventDataProperties.ts
#	src/queries/sql/events/getEventDataStats.ts
#	src/queries/sql/events/getEventDataValues.ts
#	src/queries/sql/events/getEventMetrics.ts
#	src/queries/sql/events/getWebsiteEvents.ts
#	src/queries/sql/getChannelMetrics.ts
#	src/queries/sql/getRealtimeActivity.ts
#	src/queries/sql/getWebsiteStats.ts
#	src/queries/sql/pageviews/getPageviewMetrics.ts
#	src/queries/sql/pageviews/getPageviewStats.ts
#	src/queries/sql/reports/getBreakdown.ts
#	src/queries/sql/sessions/getSessionDataProperties.ts
#	src/queries/sql/sessions/getSessionDataValues.ts
#	src/queries/sql/sessions/getSessionMetrics.ts
#	src/queries/sql/sessions/getSessionStats.ts
#	src/queries/sql/sessions/getWebsiteSessionStats.ts
#	src/queries/sql/sessions/getWebsiteSessions.ts
This commit is contained in:
Mike Cao 2025-07-08 22:03:55 -07:00
commit 87449ece9e
49 changed files with 704 additions and 345 deletions

View file

@ -87,6 +87,21 @@ function mapFilter(column: string, operator: string, name: string, type: string
}
}
function mapCohortFilter(column: string, operator: string, value: string) {
switch (operator) {
case OPERATORS.equals:
return `${column} = '${value}'`;
case OPERATORS.notEquals:
return `${column} != '${value}'`;
case OPERATORS.contains:
return `positionCaseInsensitive(${column}, '${value}') > 0`;
case OPERATORS.doesNotContain:
return `positionCaseInsensitive(${column}, '${value}') = 0`;
default:
return '';
}
}
function getFilterQuery(filters: Record<string, any>, options: QueryOptions = {}) {
const query = filtersToArray(filters, options).reduce((arr, { name, column, operator }) => {
if (column) {
@ -103,6 +118,42 @@ function getFilterQuery(filters: Record<string, any>, options: QueryOptions = {}
return query.join('\n');
}
function getCohortQuery(websiteId: string, filters: QueryFilters = {}, options: QueryOptions = {}) {
const query = filtersToArray(filters, options).reduce(
(arr, { name, column, operator, value }) => {
if (column) {
arr.push(
`${arr.length === 0 ? 'where' : 'and'} ${mapCohortFilter(column, operator, value)}`,
);
if (name === 'referrer') {
arr.push(`and referrer_domain != hostname`);
}
}
return arr;
},
[],
);
if (query.length > 0) {
// add website and date range filters
query.push(`and website_id = '${websiteId}'`);
query.push(
`and created_at between parseDateTimeBestEffort('${filters.startDate}') and parseDateTimeBestEffort('${filters.endDate}')`,
);
return `join
(select distinct session_id
from website_event
${query.join('\n')}) cohort
on cohort.session_id = website_event.session_id
`;
}
return '';
}
function getDateQuery(filters: Record<string, any>) {
const { startDate, endDate, timezone } = filters;
@ -141,6 +192,7 @@ function parseFilters(filters: Record<string, any>, options?: QueryOptions) {
filterQuery: getFilterQuery(filters, options),
dateQuery: getDateQuery(filters),
queryParams: getQueryParams(filters),
cohortQuery: getCohortQuery(filters?.cohort),
};
}

View file

@ -49,6 +49,11 @@ export const SESSION_COLUMNS = [
'hostname',
];
export const FILTER_GROUPS = {
segment: 'segment',
cohort: 'cohort',
};
export const FILTER_COLUMNS = {
path: 'url_path',
entry: 'url_path',

View file

@ -104,6 +104,24 @@ function mapFilter(column: string, operator: string, name: string, type: string
}
}
function mapCohortFilter(column: string, operator: string, value: string) {
const db = getDatabaseType();
const like = db === POSTGRESQL ? 'ilike' : 'like';
switch (operator) {
case OPERATORS.equals:
return `${column} = '${value}'`;
case OPERATORS.notEquals:
return `${column} != '${value}'`;
case OPERATORS.contains:
return `${column} ${like} '${value}'`;
case OPERATORS.doesNotContain:
return `${column} not ${like} '${value}'`;
default:
return '';
}
}
function getFilterQuery(filters: Record<string, any>, options: QueryOptions = {}): string {
const query = filtersToArray(filters, options).reduce(
(arr, { name, column, operator, prefix = '' }) => {
@ -125,6 +143,43 @@ function getFilterQuery(filters: Record<string, any>, options: QueryOptions = {}
return query.join('\n');
}
function getCohortQuery(websiteId: string, filters: QueryFilters = {}, options: QueryOptions = {}) {
const query = filtersToArray(filters, options).reduce(
(arr, { name, column, operator, value }) => {
if (column) {
arr.push(
`${arr.length === 0 ? 'where' : 'and'} ${mapCohortFilter(column, operator, value)}`,
);
if (name === 'referrer') {
arr.push(`and referrer_domain != hostname`);
}
}
return arr;
},
[],
);
if (query.length > 0) {
// add website and date range filters
query.push(`and website_event.website_id = '${websiteId}'`);
query.push(
`and website_event.created_at between '${filters.startDate}'::timestamptz and '${filters.endDate}'::timestamptz`,
);
return `join
(select distinct website_event.session_id
from website_event
join session on session.session_id = website_event.session_id
${query.join('\n')}) cohort
on cohort.session_id = website_event.session_id
`;
}
return '';
}
function getDateQuery(filters: Record<string, any>) {
const { startDate, endDate } = filters;
@ -165,6 +220,7 @@ function parseFilters(filters: Record<string, any>, options?: QueryOptions) {
dateQuery: getDateQuery(filters),
filterQuery: getFilterQuery(filters, options),
queryParams: getQueryParams(filters),
cohortQuery: getCohortQuery(filters?.cohort),
};
}

View file

@ -1,10 +1,11 @@
import { z } from 'zod/v4';
import { FILTER_COLUMNS, DEFAULT_PAGE_SIZE } from '@/lib/constants';
import { FILTER_COLUMNS, DEFAULT_PAGE_SIZE, FILTER_GROUPS } from '@/lib/constants';
import { badRequest, unauthorized } from '@/lib/response';
import { getAllowedUnits, getMinimumUnit, maxDate } from '@/lib/date';
import { checkAuth } from '@/lib/auth';
import { fetchWebsite } from '@/lib/load';
import { QueryFilters } from '@/lib/types';
import { getWebsiteSegment } from '@/queries';
export async function parseRequest(
request: Request,
@ -65,16 +66,30 @@ export function getRequestDateRange(query: Record<string, string>) {
};
}
export function getRequestFilters(query: Record<string, any>) {
return Object.keys(FILTER_COLUMNS).reduce((obj, key) => {
export async function getRequestFilters(query: Record<string, any>, websiteId?: string) {
const result: Record<string, any> = {};
for (const key of Object.keys(FILTER_COLUMNS)) {
const value = query[key];
if (value !== undefined) {
obj[key] = value;
result[key] = value;
}
}
return obj;
}, {});
for (const key of Object.keys(FILTER_GROUPS)) {
const value = query[key];
if (value !== undefined) {
const segment = await getWebsiteSegment(websiteId, key, value);
if (key === 'segment') {
// merge filters into result
Object.assign(result, segment.parameters);
} else {
result[key] = segment.parameters;
}
}
}
return result;
}
export async function setWebsiteDate(websiteId: string, data: Record<string, any>) {

View file

@ -35,6 +35,8 @@ export const filterParams = {
hostname: z.string().optional(),
language: z.string().optional(),
event: z.string().optional(),
segment: z.string().optional(),
cohort: z.string().optional(),
};
export const searchParams = {
@ -272,3 +274,5 @@ export const reportResultSchema = z.intersection(
}),
reportTypeSchema,
);
export const segmentTypeParam = z.enum(['segment', 'cohort']);