This commit is contained in:
Carl-Gerhard Lindesvärd
2026-02-18 18:28:23 +01:00
parent d1b39c4c93
commit 765e4aa107
24 changed files with 1332 additions and 200 deletions

View File

@@ -113,6 +113,7 @@ export const chartSegments = {
event: 'All events',
user: 'Unique users',
session: 'Unique sessions',
group: 'Unique groups',
user_average: 'Average users',
one_event_per_user: 'One event per user',
property_sum: 'Sum of property',
@@ -195,7 +196,7 @@ export const metrics = {
} as const;
export function isMinuteIntervalEnabledByRange(
range: keyof typeof timeWindows,
range: keyof typeof timeWindows
) {
return range === '30min' || range === 'lastHour';
}
@@ -210,7 +211,7 @@ export function isHourIntervalEnabledByRange(range: keyof typeof timeWindows) {
}
export function getDefaultIntervalByRange(
range: keyof typeof timeWindows,
range: keyof typeof timeWindows
): keyof typeof intervals {
if (range === '30min' || range === 'lastHour') {
return 'minute';
@@ -231,7 +232,7 @@ export function getDefaultIntervalByRange(
export function getDefaultIntervalByDates(
startDate: string | null,
endDate: string | null,
endDate: string | null
): null | keyof typeof intervals {
if (startDate && endDate) {
if (isSameDay(startDate, endDate)) {

View File

@@ -0,0 +1,69 @@
import fs from 'node:fs';
import path from 'node:path';
import {
addColumns,
runClickhouseMigrationCommands,
} from '../src/clickhouse/migration';
import { getIsCluster } from './helpers';
export async function up() {
const isClustered = getIsCluster();
const databaseUrl = process.env.DATABASE_URL ?? '';
// Parse postgres connection string: postgresql://user:password@host:port/dbname
const match = databaseUrl.match(
/postgresql:\/\/([^:]+):([^@]+)@([^:]+):(\d+)\/(.+?)(\?.*)?$/
);
if (!match) {
throw new Error(`Could not parse DATABASE_URL: ${databaseUrl}`);
}
const [, pgUser, pgPassword, pgHost, pgPort, pgDb] = match;
const dictSql = `CREATE DICTIONARY IF NOT EXISTS groups_dict
(
id String,
project_id String,
type String,
name String,
properties String
)
PRIMARY KEY id, project_id
SOURCE(POSTGRESQL(
host '${pgHost}'
port ${pgPort}
user '${pgUser}'
password '${pgPassword}'
db '${pgDb}'
table 'groups'
))
LIFETIME(MIN 300 MAX 600)
LAYOUT(COMPLEX_KEY_HASHED())`;
const sqls: string[] = [
...addColumns(
'events',
['`groups` Array(String) DEFAULT [] CODEC(ZSTD(3))'],
isClustered
),
dictSql,
];
fs.writeFileSync(
path.join(import.meta.filename.replace('.ts', '.sql')),
sqls
.map((sql) =>
sql
.trim()
.replace(/;$/, '')
.replace(/\n{2,}/g, '\n')
.concat(';')
)
.join('\n\n---\n\n')
);
if (!process.argv.includes('--dry')) {
await runClickhouseMigrationCommands(sqls);
}
}

View File

@@ -1,35 +1,37 @@
export * from './src/prisma-client';
export * from './src/clickhouse/client';
export * from './src/sql-builder';
export * from './src/services/chart.service';
export * from './src/engine';
export * from './src/services/clients.service';
export * from './src/services/dashboard.service';
export * from './src/services/event.service';
export * from './src/services/organization.service';
export * from './src/services/profile.service';
export * from './src/services/project.service';
export * from './src/services/reports.service';
export * from './src/services/salt.service';
export * from './src/services/share.service';
export * from './src/services/session.service';
export * from './src/services/funnel.service';
export * from './src/services/conversion.service';
export * from './src/services/sankey.service';
export * from './src/services/user.service';
export * from './src/services/reference.service';
export * from './src/services/id.service';
export * from './src/services/retention.service';
export * from './src/services/notification.service';
export * from './src/services/access.service';
export * from './src/services/delete.service';
export * from './src/buffers';
export * from './src/types';
export * from './src/clickhouse/client';
export * from './src/clickhouse/query-builder';
export * from './src/encryption';
export * from './src/engine';
export * from './src/engine';
export * from './src/gsc';
export * from './src/prisma-client';
export * from './src/services/access.service';
export * from './src/services/chart.service';
export * from './src/services/clients.service';
export * from './src/services/conversion.service';
export * from './src/services/dashboard.service';
export * from './src/services/delete.service';
export * from './src/services/event.service';
export * from './src/services/funnel.service';
export * from './src/services/group.service';
export * from './src/services/id.service';
export * from './src/services/import.service';
export * from './src/services/insights';
export * from './src/services/notification.service';
export * from './src/services/organization.service';
export * from './src/services/overview.service';
export * from './src/services/pages.service';
export * from './src/services/insights';
export * from './src/services/profile.service';
export * from './src/services/project.service';
export * from './src/services/reference.service';
export * from './src/services/reports.service';
export * from './src/services/retention.service';
export * from './src/services/salt.service';
export * from './src/services/sankey.service';
export * from './src/services/session.service';
export * from './src/services/share.service';
export * from './src/services/user.service';
export * from './src/session-context';
export * from './src/gsc';
export * from './src/encryption';
export * from './src/sql-builder';
export * from './src/types';

View File

@@ -0,0 +1,15 @@
-- CreateTable
CREATE TABLE "public"."groups" (
"id" TEXT NOT NULL DEFAULT '',
"projectId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"name" TEXT NOT NULL,
"properties" JSONB NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "groups_pkey" PRIMARY KEY ("projectId","id")
);
-- AddForeignKey
ALTER TABLE "public"."groups" ADD CONSTRAINT "groups_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "public"."projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -199,6 +199,7 @@ model Project {
meta EventMeta[]
references Reference[]
access ProjectAccess[]
groups Group[]
notificationRules NotificationRule[]
notifications Notification[]
@@ -215,6 +216,20 @@ model Project {
@@map("projects")
}
model Group {
id String @default("")
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
type String
name String
properties Json @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@@id([projectId, id])
@@map("groups")
}
enum AccessLevel {
read
write

View File

@@ -61,6 +61,7 @@ export const TABLE_NAMES = {
gsc_daily: 'gsc_daily',
gsc_pages_daily: 'gsc_pages_daily',
gsc_queries_daily: 'gsc_queries_daily',
groups: 'groups',
};
/**

View File

@@ -1,20 +1,18 @@
import sqlstring from 'sqlstring';
import { DateTime, stripLeadingAndTrailingSlashes } from '@openpanel/common';
import type {
IChartEventFilter,
IReportInput,
IChartRange,
IGetChartDataInput,
IReportInput,
} from '@openpanel/validation';
import { TABLE_NAMES, formatClickhouseDate } from '../clickhouse/client';
import sqlstring from 'sqlstring';
import { formatClickhouseDate, TABLE_NAMES } from '../clickhouse/client';
import { createSqlBuilder } from '../sql-builder';
export function transformPropertyKey(property: string) {
const propertyPatterns = ['properties', 'profile.properties'];
const match = propertyPatterns.find((pattern) =>
property.startsWith(`${pattern}.`),
property.startsWith(`${pattern}.`)
);
if (!match) {
@@ -32,21 +30,49 @@ export function transformPropertyKey(property: string) {
return `${match}['${property.replace(new RegExp(`^${match}.`), '')}']`;
}
export function getSelectPropertyKey(property: string) {
// Returns a SQL expression for a group property using dictGet
// property format: "group.name", "group.type", "group.properties.plan"
export function getGroupPropertySql(
property: string,
projectId: string
): string {
const withoutPrefix = property.replace(/^group\./, '');
if (withoutPrefix === 'name') {
return `dictGet('${TABLE_NAMES.groups_dict}', 'name', tuple(_group_id, ${sqlstring.escape(projectId)}))`;
}
if (withoutPrefix === 'type') {
return `dictGet('${TABLE_NAMES.groups_dict}', 'type', tuple(_group_id, ${sqlstring.escape(projectId)}))`;
}
if (withoutPrefix.startsWith('properties.')) {
const propKey = withoutPrefix.replace(/^properties\./, '');
// properties is stored as JSON string in dict; use JSONExtractString
return `JSONExtractString(dictGet('${TABLE_NAMES.groups_dict}', 'properties', tuple(_group_id, ${sqlstring.escape(projectId)})), ${sqlstring.escape(propKey)})`;
}
return '_group_id';
}
export function getSelectPropertyKey(property: string, projectId?: string) {
if (property === 'has_profile') {
return `if(profile_id != device_id, 'true', 'false')`;
}
// Handle group properties — requires ARRAY JOIN to be present in query
if (property.startsWith('group.') && projectId) {
return getGroupPropertySql(property, projectId);
}
const propertyPatterns = ['properties', 'profile.properties'];
const match = propertyPatterns.find((pattern) =>
property.startsWith(`${pattern}.`),
property.startsWith(`${pattern}.`)
);
if (!match) return property;
if (!match) {
return property;
}
if (property.includes('*')) {
return `arrayMap(x -> trim(x), mapValues(mapExtractKeyLike(${match}, ${sqlstring.escape(
transformPropertyKey(property),
transformPropertyKey(property)
)})))`;
}
@@ -78,7 +104,7 @@ export function getChartSql({
with: addCte,
} = createSqlBuilder();
sb.where = getEventFiltersWhereClause(event.filters);
sb.where = getEventFiltersWhereClause(event.filters, projectId);
sb.where.projectId = `project_id = ${sqlstring.escape(projectId)}`;
if (event.name !== '*') {
@@ -89,11 +115,23 @@ export function getChartSql({
}
const anyFilterOnProfile = event.filters.some((filter) =>
filter.name.startsWith('profile.'),
filter.name.startsWith('profile.')
);
const anyBreakdownOnProfile = breakdowns.some((breakdown) =>
breakdown.name.startsWith('profile.'),
breakdown.name.startsWith('profile.')
);
const anyFilterOnGroup = event.filters.some((filter) =>
filter.name.startsWith('group.')
);
const anyBreakdownOnGroup = breakdowns.some((breakdown) =>
breakdown.name.startsWith('group.')
);
const needsGroupArrayJoin =
anyFilterOnGroup || anyBreakdownOnGroup || event.segment === 'group';
if (needsGroupArrayJoin) {
sb.joins.groups = 'ARRAY JOIN groups AS _group_id';
}
// Build WHERE clause without the bar filter (for use in subqueries and CTEs)
// Define this early so we can use it in CTE definitions
@@ -178,8 +216,8 @@ export function getChartSql({
addCte(
'profile',
`SELECT ${selectFields.join(', ')}
FROM ${TABLE_NAMES.profiles} FINAL
WHERE project_id = ${sqlstring.escape(projectId)}`,
FROM ${TABLE_NAMES.profiles} FINAL
WHERE project_id = ${sqlstring.escape(projectId)}`
);
// Use the CTE reference in the main query
@@ -228,28 +266,33 @@ export function getChartSql({
// Use CTE to define top breakdown values once, then reference in WHERE clause
if (breakdowns.length > 0 && limit) {
const breakdownSelects = breakdowns
.map((b) => getSelectPropertyKey(b.name))
.map((b) => getSelectPropertyKey(b.name, projectId))
.join(', ');
const groupArrayJoinClause = needsGroupArrayJoin
? 'ARRAY JOIN groups AS _group_id'
: '';
// Add top_breakdowns CTE using the builder
addCte(
'top_breakdowns',
`SELECT ${breakdownSelects}
FROM ${TABLE_NAMES.events} e
${profilesJoinRef ? `${profilesJoinRef} ` : ''}${getWhereWithoutBar()}
${profilesJoinRef ? `${profilesJoinRef} ` : ''}${groupArrayJoinClause ? `${groupArrayJoinClause} ` : ''}${getWhereWithoutBar()}
GROUP BY ${breakdownSelects}
ORDER BY count(*) DESC
LIMIT ${limit}`,
LIMIT ${limit}`
);
// Filter main query to only include top breakdown values
sb.where.bar = `(${breakdowns.map((b) => getSelectPropertyKey(b.name)).join(',')}) IN (SELECT * FROM top_breakdowns)`;
sb.where.bar = `(${breakdowns.map((b) => getSelectPropertyKey(b.name, projectId)).join(',')}) IN (SELECT * FROM top_breakdowns)`;
}
breakdowns.forEach((breakdown, index) => {
// Breakdowns start at label_1 (label_0 is reserved for event name)
const key = `label_${index + 1}`;
sb.select[key] = `${getSelectPropertyKey(breakdown.name)} as ${key}`;
sb.select[key] =
`${getSelectPropertyKey(breakdown.name, projectId)} as ${key}`;
sb.groupBy[key] = `${key}`;
});
@@ -261,6 +304,10 @@ export function getChartSql({
sb.select.count = 'countDistinct(session_id) as count';
}
if (event.segment === 'group') {
sb.select.count = 'countDistinct(_group_id) as count';
}
if (event.segment === 'user_average') {
sb.select.count =
'COUNT(*)::float / COUNT(DISTINCT profile_id)::float as count';
@@ -289,7 +336,7 @@ export function getChartSql({
sb.from = `(
SELECT DISTINCT ON (profile_id) * from ${TABLE_NAMES.events} ${getJoins()} WHERE ${join(
sb.where,
' AND ',
' AND '
)}
ORDER BY profile_id, created_at DESC
) as subQuery`;
@@ -308,7 +355,7 @@ export function getChartSql({
// Since outer query groups by label_X, we reference those in the correlation
const breakdownMatches = breakdowns
.map((b, index) => {
const propertyKey = getSelectPropertyKey(b.name);
const propertyKey = getSelectPropertyKey(b.name, projectId);
// Correlate: match the property expression with outer query's label_X value
// ClickHouse allows referencing outer query columns in correlated subqueries
return `${propertyKey} = label_${index + 1}`;
@@ -359,7 +406,7 @@ export function getAggregateChartSql({
}) {
const { sb, join, getJoins, with: addCte, getSql } = createSqlBuilder();
sb.where = getEventFiltersWhereClause(event.filters);
sb.where = getEventFiltersWhereClause(event.filters, projectId);
sb.where.projectId = `project_id = ${sqlstring.escape(projectId)}`;
if (event.name !== '*') {
@@ -370,11 +417,23 @@ export function getAggregateChartSql({
}
const anyFilterOnProfile = event.filters.some((filter) =>
filter.name.startsWith('profile.'),
filter.name.startsWith('profile.')
);
const anyBreakdownOnProfile = breakdowns.some((breakdown) =>
breakdown.name.startsWith('profile.'),
breakdown.name.startsWith('profile.')
);
const anyFilterOnGroup = event.filters.some((filter) =>
filter.name.startsWith('group.')
);
const anyBreakdownOnGroup = breakdowns.some((breakdown) =>
breakdown.name.startsWith('group.')
);
const needsGroupArrayJoin =
anyFilterOnGroup || anyBreakdownOnGroup || event.segment === 'group';
if (needsGroupArrayJoin) {
sb.joins.groups = 'ARRAY JOIN groups AS _group_id';
}
// Build WHERE clause without the bar filter (for use in subqueries and CTEs)
const getWhereWithoutBar = () => {
@@ -455,8 +514,8 @@ export function getAggregateChartSql({
addCte(
'profile',
`SELECT ${selectFields.join(', ')}
FROM ${TABLE_NAMES.profiles} FINAL
WHERE project_id = ${sqlstring.escape(projectId)}`,
FROM ${TABLE_NAMES.profiles} FINAL
WHERE project_id = ${sqlstring.escape(projectId)}`
);
sb.joins.profiles = profilesJoinRef;
@@ -478,28 +537,33 @@ export function getAggregateChartSql({
// Use CTE to define top breakdown values once, then reference in WHERE clause
if (breakdowns.length > 0 && limit) {
const breakdownSelects = breakdowns
.map((b) => getSelectPropertyKey(b.name))
.map((b) => getSelectPropertyKey(b.name, projectId))
.join(', ');
const groupArrayJoinClause = needsGroupArrayJoin
? 'ARRAY JOIN groups AS _group_id'
: '';
addCte(
'top_breakdowns',
`SELECT ${breakdownSelects}
FROM ${TABLE_NAMES.events} e
${profilesJoinRef ? `${profilesJoinRef} ` : ''}${getWhereWithoutBar()}
${profilesJoinRef ? `${profilesJoinRef} ` : ''}${groupArrayJoinClause ? `${groupArrayJoinClause} ` : ''}${getWhereWithoutBar()}
GROUP BY ${breakdownSelects}
ORDER BY count(*) DESC
LIMIT ${limit}`,
LIMIT ${limit}`
);
// Filter main query to only include top breakdown values
sb.where.bar = `(${breakdowns.map((b) => getSelectPropertyKey(b.name)).join(',')}) IN (SELECT * FROM top_breakdowns)`;
sb.where.bar = `(${breakdowns.map((b) => getSelectPropertyKey(b.name, projectId)).join(',')}) IN (SELECT * FROM top_breakdowns)`;
}
// Add breakdowns to SELECT and GROUP BY
breakdowns.forEach((breakdown, index) => {
// Breakdowns start at label_1 (label_0 is reserved for event name)
const key = `label_${index + 1}`;
sb.select[key] = `${getSelectPropertyKey(breakdown.name)} as ${key}`;
sb.select[key] =
`${getSelectPropertyKey(breakdown.name, projectId)} as ${key}`;
sb.groupBy[key] = `${key}`;
});
@@ -518,6 +582,10 @@ export function getAggregateChartSql({
sb.select.count = 'countDistinct(session_id) as count';
}
if (event.segment === 'group') {
sb.select.count = 'countDistinct(_group_id) as count';
}
if (event.segment === 'user_average') {
sb.select.count =
'COUNT(*)::float / COUNT(DISTINCT profile_id)::float as count';
@@ -531,7 +599,7 @@ export function getAggregateChartSql({
}[event.segment as string];
if (mathFunction && event.property) {
const propertyKey = getSelectPropertyKey(event.property);
const propertyKey = getSelectPropertyKey(event.property, projectId);
if (isNumericColumn(event.property)) {
sb.select.count = `${mathFunction}(${propertyKey}) as count`;
@@ -546,7 +614,7 @@ export function getAggregateChartSql({
sb.from = `(
SELECT DISTINCT ON (profile_id) * from ${TABLE_NAMES.events} ${getJoins()} WHERE ${join(
sb.where,
' AND ',
' AND '
)}
ORDER BY profile_id, created_at DESC
) as subQuery`;
@@ -579,7 +647,10 @@ function isNumericColumn(columnName: string): boolean {
return numericColumns.includes(columnName);
}
export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
export function getEventFiltersWhereClause(
filters: IChartEventFilter[],
projectId?: string
) {
const where: Record<string, string> = {};
filters.forEach((filter, index) => {
const id = `f${index}`;
@@ -602,6 +673,67 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
return;
}
// Handle group. prefixed filters using dictGet (requires ARRAY JOIN in query)
if (name.startsWith('group.') && projectId) {
const whereFrom = getGroupPropertySql(name, projectId);
switch (operator) {
case 'is': {
if (value.length === 1) {
where[id] =
`${whereFrom} = ${sqlstring.escape(String(value[0]).trim())}`;
} else {
where[id] =
`${whereFrom} IN (${value.map((val) => sqlstring.escape(String(val).trim())).join(', ')})`;
}
break;
}
case 'isNot': {
if (value.length === 1) {
where[id] =
`${whereFrom} != ${sqlstring.escape(String(value[0]).trim())}`;
} else {
where[id] =
`${whereFrom} NOT IN (${value.map((val) => sqlstring.escape(String(val).trim())).join(', ')})`;
}
break;
}
case 'contains': {
where[id] =
`(${value.map((val) => `${whereFrom} LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`).join(' OR ')})`;
break;
}
case 'doesNotContain': {
where[id] =
`(${value.map((val) => `${whereFrom} NOT LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`).join(' OR ')})`;
break;
}
case 'startsWith': {
where[id] =
`(${value.map((val) => `${whereFrom} LIKE ${sqlstring.escape(`${String(val).trim()}%`)}`).join(' OR ')})`;
break;
}
case 'endsWith': {
where[id] =
`(${value.map((val) => `${whereFrom} LIKE ${sqlstring.escape(`%${String(val).trim()}`)}`).join(' OR ')})`;
break;
}
case 'isNull': {
where[id] = `(${whereFrom} = '' OR ${whereFrom} IS NULL)`;
break;
}
case 'isNotNull': {
where[id] = `(${whereFrom} != '' AND ${whereFrom} IS NOT NULL)`;
break;
}
case 'regex': {
where[id] =
`(${value.map((val) => `match(${whereFrom}, ${sqlstring.escape(String(val).trim())})`).join(' OR ')})`;
break;
}
}
return;
}
if (
name.startsWith('properties.') ||
name.startsWith('profile.properties.')
@@ -616,15 +748,13 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
where[id] = `arrayExists(x -> ${value
.map((val) => `x = ${sqlstring.escape(String(val).trim())}`)
.join(' OR ')}, ${whereFrom})`;
} else if (value.length === 1) {
where[id] =
`${whereFrom} = ${sqlstring.escape(String(value[0]).trim())}`;
} else {
if (value.length === 1) {
where[id] =
`${whereFrom} = ${sqlstring.escape(String(value[0]).trim())}`;
} else {
where[id] = `${whereFrom} IN (${value
.map((val) => sqlstring.escape(String(val).trim()))
.join(', ')})`;
}
where[id] = `${whereFrom} IN (${value
.map((val) => sqlstring.escape(String(val).trim()))
.join(', ')})`;
}
break;
}
@@ -633,15 +763,13 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
where[id] = `arrayExists(x -> ${value
.map((val) => `x != ${sqlstring.escape(String(val).trim())}`)
.join(' OR ')}, ${whereFrom})`;
} else if (value.length === 1) {
where[id] =
`${whereFrom} != ${sqlstring.escape(String(value[0]).trim())}`;
} else {
if (value.length === 1) {
where[id] =
`${whereFrom} != ${sqlstring.escape(String(value[0]).trim())}`;
} else {
where[id] = `${whereFrom} NOT IN (${value
.map((val) => sqlstring.escape(String(val).trim()))
.join(', ')})`;
}
where[id] = `${whereFrom} NOT IN (${value
.map((val) => sqlstring.escape(String(val).trim()))
.join(', ')})`;
}
break;
}
@@ -649,15 +777,14 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
if (isWildcard) {
where[id] = `arrayExists(x -> ${value
.map(
(val) =>
`x LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`,
(val) => `x LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`
)
.join(' OR ')}, ${whereFrom})`;
} else {
where[id] = `(${value
.map(
(val) =>
`${whereFrom} LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`,
`${whereFrom} LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`
)
.join(' OR ')})`;
}
@@ -668,14 +795,14 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
where[id] = `arrayExists(x -> ${value
.map(
(val) =>
`x NOT LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`,
`x NOT LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`
)
.join(' OR ')}, ${whereFrom})`;
} else {
where[id] = `(${value
.map(
(val) =>
`${whereFrom} NOT LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`,
`${whereFrom} NOT LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`
)
.join(' OR ')})`;
}
@@ -685,14 +812,14 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
if (isWildcard) {
where[id] = `arrayExists(x -> ${value
.map(
(val) => `x LIKE ${sqlstring.escape(`${String(val).trim()}%`)}`,
(val) => `x LIKE ${sqlstring.escape(`${String(val).trim()}%`)}`
)
.join(' OR ')}, ${whereFrom})`;
} else {
where[id] = `(${value
.map(
(val) =>
`${whereFrom} LIKE ${sqlstring.escape(`${String(val).trim()}%`)}`,
`${whereFrom} LIKE ${sqlstring.escape(`${String(val).trim()}%`)}`
)
.join(' OR ')})`;
}
@@ -702,14 +829,14 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
if (isWildcard) {
where[id] = `arrayExists(x -> ${value
.map(
(val) => `x LIKE ${sqlstring.escape(`%${String(val).trim()}`)}`,
(val) => `x LIKE ${sqlstring.escape(`%${String(val).trim()}`)}`
)
.join(' OR ')}, ${whereFrom})`;
} else {
where[id] = `(${value
.map(
(val) =>
`${whereFrom} LIKE ${sqlstring.escape(`%${String(val).trim()}`)}`,
`${whereFrom} LIKE ${sqlstring.escape(`%${String(val).trim()}`)}`
)
.join(' OR ')})`;
}
@@ -724,7 +851,7 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
where[id] = `(${value
.map(
(val) =>
`match(${whereFrom}, ${sqlstring.escape(String(val).trim())})`,
`match(${whereFrom}, ${sqlstring.escape(String(val).trim())})`
)
.join(' OR ')})`;
}
@@ -752,14 +879,14 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
where[id] = `arrayExists(x -> ${value
.map(
(val) =>
`toFloat64OrZero(x) > toFloat64(${sqlstring.escape(String(val).trim())})`,
`toFloat64OrZero(x) > toFloat64(${sqlstring.escape(String(val).trim())})`
)
.join(' OR ')}, ${whereFrom})`;
} else {
where[id] = `(${value
.map(
(val) =>
`toFloat64OrZero(${whereFrom}) > toFloat64(${sqlstring.escape(String(val).trim())})`,
`toFloat64OrZero(${whereFrom}) > toFloat64(${sqlstring.escape(String(val).trim())})`
)
.join(' OR ')})`;
}
@@ -770,14 +897,14 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
where[id] = `arrayExists(x -> ${value
.map(
(val) =>
`toFloat64OrZero(x) < toFloat64(${sqlstring.escape(String(val).trim())})`,
`toFloat64OrZero(x) < toFloat64(${sqlstring.escape(String(val).trim())})`
)
.join(' OR ')}, ${whereFrom})`;
} else {
where[id] = `(${value
.map(
(val) =>
`toFloat64OrZero(${whereFrom}) < toFloat64(${sqlstring.escape(String(val).trim())})`,
`toFloat64OrZero(${whereFrom}) < toFloat64(${sqlstring.escape(String(val).trim())})`
)
.join(' OR ')})`;
}
@@ -788,14 +915,14 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
where[id] = `arrayExists(x -> ${value
.map(
(val) =>
`toFloat64OrZero(x) >= toFloat64(${sqlstring.escape(String(val).trim())})`,
`toFloat64OrZero(x) >= toFloat64(${sqlstring.escape(String(val).trim())})`
)
.join(' OR ')}, ${whereFrom})`;
} else {
where[id] = `(${value
.map(
(val) =>
`toFloat64OrZero(${whereFrom}) >= toFloat64(${sqlstring.escape(String(val).trim())})`,
`toFloat64OrZero(${whereFrom}) >= toFloat64(${sqlstring.escape(String(val).trim())})`
)
.join(' OR ')})`;
}
@@ -806,14 +933,14 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
where[id] = `arrayExists(x -> ${value
.map(
(val) =>
`toFloat64OrZero(x) <= toFloat64(${sqlstring.escape(String(val).trim())})`,
`toFloat64OrZero(x) <= toFloat64(${sqlstring.escape(String(val).trim())})`
)
.join(' OR ')}, ${whereFrom})`;
} else {
where[id] = `(${value
.map(
(val) =>
`toFloat64OrZero(${whereFrom}) <= toFloat64(${sqlstring.escape(String(val).trim())})`,
`toFloat64OrZero(${whereFrom}) <= toFloat64(${sqlstring.escape(String(val).trim())})`
)
.join(' OR ')})`;
}
@@ -856,7 +983,7 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
where[id] = `(${value
.map(
(val) =>
`${name} LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`,
`${name} LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`
)
.join(' OR ')})`;
break;
@@ -865,7 +992,7 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
where[id] = `(${value
.map(
(val) =>
`${name} NOT LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`,
`${name} NOT LIKE ${sqlstring.escape(`%${String(val).trim()}%`)}`
)
.join(' OR ')})`;
break;
@@ -874,7 +1001,7 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
where[id] = `(${value
.map(
(val) =>
`${name} LIKE ${sqlstring.escape(`${String(val).trim()}%`)}`,
`${name} LIKE ${sqlstring.escape(`${String(val).trim()}%`)}`
)
.join(' OR ')})`;
break;
@@ -883,7 +1010,7 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
where[id] = `(${value
.map(
(val) =>
`${name} LIKE ${sqlstring.escape(`%${String(val).trim()}`)}`,
`${name} LIKE ${sqlstring.escape(`%${String(val).trim()}`)}`
)
.join(' OR ')})`;
break;
@@ -892,7 +1019,7 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
where[id] = `(${value
.map(
(val) =>
`match(${name}, ${sqlstring.escape(stripLeadingAndTrailingSlashes(String(val)).trim())})`,
`match(${name}, ${sqlstring.escape(stripLeadingAndTrailingSlashes(String(val)).trim())})`
)
.join(' OR ')})`;
break;
@@ -902,7 +1029,7 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
where[id] = `(${value
.map(
(val) =>
`toFloat64(${name}) > toFloat64(${sqlstring.escape(String(val).trim())})`,
`toFloat64(${name}) > toFloat64(${sqlstring.escape(String(val).trim())})`
)
.join(' OR ')})`;
} else {
@@ -917,7 +1044,7 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
where[id] = `(${value
.map(
(val) =>
`toFloat64(${name}) < toFloat64(${sqlstring.escape(String(val).trim())})`,
`toFloat64(${name}) < toFloat64(${sqlstring.escape(String(val).trim())})`
)
.join(' OR ')})`;
} else {
@@ -932,13 +1059,13 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
where[id] = `(${value
.map(
(val) =>
`toFloat64(${name}) >= toFloat64(${sqlstring.escape(String(val).trim())})`,
`toFloat64(${name}) >= toFloat64(${sqlstring.escape(String(val).trim())})`
)
.join(' OR ')})`;
} else {
where[id] = `(${value
.map(
(val) => `${name} >= ${sqlstring.escape(String(val).trim())}`,
(val) => `${name} >= ${sqlstring.escape(String(val).trim())}`
)
.join(' OR ')})`;
}
@@ -949,13 +1076,13 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
where[id] = `(${value
.map(
(val) =>
`toFloat64(${name}) <= toFloat64(${sqlstring.escape(String(val).trim())})`,
`toFloat64(${name}) <= toFloat64(${sqlstring.escape(String(val).trim())})`
)
.join(' OR ')})`;
} else {
where[id] = `(${value
.map(
(val) => `${name} <= ${sqlstring.escape(String(val).trim())}`,
(val) => `${name} <= ${sqlstring.escape(String(val).trim())}`
)
.join(' OR ')})`;
}
@@ -974,15 +1101,15 @@ export function getChartStartEndDate(
endDate,
range,
}: Pick<IReportInput, 'endDate' | 'startDate' | 'range'>,
timezone: string,
timezone: string
) {
if (startDate && endDate) {
return { startDate: startDate, endDate: endDate };
return { startDate, endDate };
}
const ranges = getDatesFromRange(range, timezone);
if (!startDate && endDate) {
return { startDate: ranges.startDate, endDate: endDate };
return { startDate: ranges.startDate, endDate };
}
return ranges;
@@ -1002,8 +1129,8 @@ export function getDatesFromRange(range: IChartRange, timezone: string) {
.toFormat('yyyy-MM-dd HH:mm:ss');
return {
startDate: startDate,
endDate: endDate,
startDate,
endDate,
};
}
@@ -1018,8 +1145,8 @@ export function getDatesFromRange(range: IChartRange, timezone: string) {
.toFormat('yyyy-MM-dd HH:mm:ss');
return {
startDate: startDate,
endDate: endDate,
startDate,
endDate,
};
}
@@ -1035,8 +1162,8 @@ export function getDatesFromRange(range: IChartRange, timezone: string) {
.endOf('day')
.toFormat('yyyy-MM-dd HH:mm:ss');
return {
startDate: startDate,
endDate: endDate,
startDate,
endDate,
};
}
@@ -1053,8 +1180,8 @@ export function getDatesFromRange(range: IChartRange, timezone: string) {
.toFormat('yyyy-MM-dd HH:mm:ss');
return {
startDate: startDate,
endDate: endDate,
startDate,
endDate,
};
}
@@ -1071,8 +1198,8 @@ export function getDatesFromRange(range: IChartRange, timezone: string) {
.toFormat('yyyy-MM-dd HH:mm:ss');
return {
startDate: startDate,
endDate: endDate,
startDate,
endDate,
};
}
@@ -1089,8 +1216,8 @@ export function getDatesFromRange(range: IChartRange, timezone: string) {
.toFormat('yyyy-MM-dd HH:mm:ss');
return {
startDate: startDate,
endDate: endDate,
startDate,
endDate,
};
}
@@ -1106,8 +1233,8 @@ export function getDatesFromRange(range: IChartRange, timezone: string) {
.toFormat('yyyy-MM-dd HH:mm:ss');
return {
startDate: startDate,
endDate: endDate,
startDate,
endDate,
};
}
@@ -1124,8 +1251,8 @@ export function getDatesFromRange(range: IChartRange, timezone: string) {
.toFormat('yyyy-MM-dd HH:mm:ss');
return {
startDate: startDate,
endDate: endDate,
startDate,
endDate,
};
}
@@ -1141,8 +1268,8 @@ export function getDatesFromRange(range: IChartRange, timezone: string) {
.toFormat('yyyy-MM-dd HH:mm:ss');
return {
startDate: startDate,
endDate: endDate,
startDate,
endDate,
};
}
@@ -1152,8 +1279,8 @@ export function getDatesFromRange(range: IChartRange, timezone: string) {
const endDate = year.endOf('year').toFormat('yyyy-MM-dd HH:mm:ss');
return {
startDate: startDate,
endDate: endDate,
startDate,
endDate,
};
}
@@ -1170,8 +1297,8 @@ export function getDatesFromRange(range: IChartRange, timezone: string) {
.toFormat('yyyy-MM-dd HH:mm:ss');
return {
startDate: startDate,
endDate: endDate,
startDate,
endDate,
};
}
@@ -1183,7 +1310,7 @@ export function getChartPrevStartEndDate({
endDate: string;
}) {
let diff = DateTime.fromFormat(endDate, 'yyyy-MM-dd HH:mm:ss').diff(
DateTime.fromFormat(startDate, 'yyyy-MM-dd HH:mm:ss'),
DateTime.fromFormat(startDate, 'yyyy-MM-dd HH:mm:ss')
);
// this will make sure our start and end date's are correct

View File

@@ -92,6 +92,7 @@ export interface IClickhouseEvent {
sdk_name: string;
sdk_version: string;
revenue?: number;
groups: string[];
// They do not exist here. Just make ts happy for now
profile?: IServiceProfile;
@@ -143,6 +144,7 @@ export function transformSessionToEvent(
importedAt: undefined,
sdkName: undefined,
sdkVersion: undefined,
groups: [],
};
}
@@ -179,6 +181,7 @@ export function transformEvent(event: IClickhouseEvent): IServiceEvent {
sdkVersion: event.sdk_version,
profile: event.profile,
revenue: event.revenue,
groups: event.groups ?? [],
};
}
@@ -227,6 +230,7 @@ export interface IServiceEvent {
sdkName: string | undefined;
sdkVersion: string | undefined;
revenue?: number;
groups: string[];
}
type SelectHelper<T> = {
@@ -386,6 +390,7 @@ export async function createEvent(payload: IServiceCreateEventPayload) {
sdk_name: payload.sdkName ?? '',
sdk_version: payload.sdkVersion ?? '',
revenue: payload.revenue,
groups: payload.groups ?? [],
};
const promises = [sessionBuffer.add(event), eventBuffer.add(event)];

View File

@@ -0,0 +1,147 @@
import { db } from '../prisma-client';
export type IServiceGroup = {
id: string;
projectId: string;
type: string;
name: string;
properties: Record<string, unknown>;
createdAt: Date;
updatedAt: Date;
};
export type IServiceUpsertGroup = {
id: string;
projectId: string;
type: string;
name: string;
properties?: Record<string, unknown>;
};
export async function upsertGroup(input: IServiceUpsertGroup) {
const { id, projectId, type, name, properties = {} } = input;
await db.group.upsert({
where: {
projectId_id: { projectId, id },
},
update: {
type,
name,
properties: properties as Record<string, string>,
updatedAt: new Date(),
},
create: {
id,
projectId,
type,
name,
properties: properties as Record<string, string>,
},
});
}
export async function getGroupById(
id: string,
projectId: string
): Promise<IServiceGroup | null> {
const group = await db.group.findUnique({
where: { projectId_id: { projectId, id } },
});
if (!group) {
return null;
}
return {
id: group.id,
projectId: group.projectId,
type: group.type,
name: group.name,
properties: (group.properties as Record<string, unknown>) ?? {},
createdAt: group.createdAt,
updatedAt: group.updatedAt,
};
}
export async function getGroupList({
projectId,
cursor,
take,
search,
type,
}: {
projectId: string;
cursor?: number;
take: number;
search?: string;
type?: string;
}): Promise<IServiceGroup[]> {
const groups = await db.group.findMany({
where: {
projectId,
...(type ? { type } : {}),
...(search
? {
OR: [
{ name: { contains: search, mode: 'insensitive' } },
{ id: { contains: search, mode: 'insensitive' } },
],
}
: {}),
},
orderBy: { createdAt: 'desc' },
take,
skip: cursor,
});
return groups.map((group) => ({
id: group.id,
projectId: group.projectId,
type: group.type,
name: group.name,
properties: (group.properties as Record<string, unknown>) ?? {},
createdAt: group.createdAt,
updatedAt: group.updatedAt,
}));
}
export async function getGroupListCount({
projectId,
type,
search,
}: {
projectId: string;
type?: string;
search?: string;
}): Promise<number> {
return db.group.count({
where: {
projectId,
...(type ? { type } : {}),
...(search
? {
OR: [
{ name: { contains: search, mode: 'insensitive' } },
{ id: { contains: search, mode: 'insensitive' } },
],
}
: {}),
},
});
}
export async function getGroupTypes(projectId: string): Promise<string[]> {
const groups = await db.group.findMany({
where: { projectId },
select: { type: true },
distinct: ['type'],
});
return groups.map((g) => g.type);
}
export async function deleteGroup(id: string, projectId: string) {
return db.group.delete({
where: { projectId_id: { projectId, id } },
});
}

View File

@@ -355,6 +355,7 @@ export async function createSessionsStartEndEvents(
profile_id: session.profile_id,
project_id: session.project_id,
session_id: session.session_id,
groups: [],
path: firstPath,
origin: firstOrigin,
referrer: firstReferrer,
@@ -390,6 +391,7 @@ export async function createSessionsStartEndEvents(
profile_id: session.profile_id,
project_id: session.project_id,
session_id: session.session_id,
groups: [],
path: lastPath,
origin: lastOrigin,
referrer: firstReferrer,

View File

@@ -3,6 +3,7 @@
import type {
IAliasPayload as AliasPayload,
IDecrementPayload as DecrementPayload,
IGroupPayload as GroupPayload,
IIdentifyPayload as IdentifyPayload,
IIncrementPayload as IncrementPayload,
ITrackHandlerPayload as TrackHandlerPayload,
@@ -13,6 +14,7 @@ import { Api } from './api';
export type {
AliasPayload,
DecrementPayload,
GroupPayload,
IdentifyPayload,
IncrementPayload,
TrackHandlerPayload,
@@ -22,8 +24,11 @@ export type {
export interface TrackProperties {
[key: string]: unknown;
profileId?: string;
groups?: string[];
}
export type GroupMetadata = Omit<GroupPayload, 'id'>;
export interface OpenPanelOptions {
clientId: string;
clientSecret?: string;
@@ -45,6 +50,7 @@ export class OpenPanel {
api: Api;
options: OpenPanelOptions;
profileId?: string;
groups: string[] = [];
deviceId?: string;
sessionId?: string;
global?: Record<string, unknown>;
@@ -142,14 +148,19 @@ export class OpenPanel {
track(name: string, properties?: TrackProperties) {
this.log('track event', name, properties);
const { groups: groupsOverride, profileId, ...rest } = properties ?? {};
const mergedGroups = [
...new Set([...this.groups, ...(groupsOverride ?? [])]),
];
return this.send({
type: 'track',
payload: {
name,
profileId: properties?.profileId ?? this.profileId,
profileId: profileId ?? this.profileId,
groups: mergedGroups.length > 0 ? mergedGroups : undefined,
properties: {
...(this.global ?? {}),
...(properties ?? {}),
...rest,
},
},
});
@@ -176,6 +187,27 @@ export class OpenPanel {
}
}
setGroups(groupIds: string[]) {
this.log('set groups', groupIds);
this.groups = groupIds;
}
setGroup(groupId: string, metadata?: GroupMetadata) {
this.log('set group', groupId, metadata);
if (!this.groups.includes(groupId)) {
this.groups = [...this.groups, groupId];
}
if (metadata) {
return this.send({
type: 'group',
payload: {
id: groupId,
...metadata,
},
});
}
}
/**
* @deprecated This method is deprecated and will be removed in a future version.
*/
@@ -227,10 +259,47 @@ export class OpenPanel {
clear() {
this.profileId = undefined;
this.groups = [];
this.deviceId = undefined;
this.sessionId = undefined;
}
private buildFlushPayload(
item: TrackHandlerPayload
): TrackHandlerPayload['payload'] {
if (item.type === 'replay') {
return item.payload;
}
if (item.type === 'track') {
const queuedGroups =
'groups' in item.payload ? (item.payload.groups ?? []) : [];
const mergedGroups = [...new Set([...this.groups, ...queuedGroups])];
return {
...item.payload,
profileId:
'profileId' in item.payload
? (item.payload.profileId ?? this.profileId)
: this.profileId,
groups: mergedGroups.length > 0 ? mergedGroups : undefined,
};
}
if (
item.type === 'identify' ||
item.type === 'increment' ||
item.type === 'decrement'
) {
return {
...item.payload,
profileId: String(
'profileId' in item.payload
? (item.payload.profileId ?? this.profileId)
: (this.profileId ?? '')
),
} as TrackHandlerPayload['payload'];
}
return item.payload;
}
flush() {
const remaining: TrackHandlerPayload[] = [];
for (const item of this.queue) {
@@ -238,16 +307,7 @@ export class OpenPanel {
remaining.push(item);
continue;
}
const payload =
item.type === 'replay'
? item.payload
: {
...item.payload,
profileId:
'profileId' in item.payload
? (item.payload.profileId ?? this.profileId)
: this.profileId,
};
const payload = this.buildFlushPayload(item);
this.send({ ...item, payload } as TrackHandlerPayload);
}
this.queue = remaining;

View File

@@ -1,11 +1,12 @@
import { authRouter } from './routers/auth';
import { gscRouter } from './routers/gsc';
import { chartRouter } from './routers/chart';
import { chatRouter } from './routers/chat';
import { clientRouter } from './routers/client';
import { dashboardRouter } from './routers/dashboard';
import { emailRouter } from './routers/email';
import { eventRouter } from './routers/event';
import { groupRouter } from './routers/group';
import { gscRouter } from './routers/gsc';
import { importRouter } from './routers/import';
import { insightRouter } from './routers/insight';
import { integrationRouter } from './routers/integration';
@@ -55,6 +56,7 @@ export const appRouter = createTRPCRouter({
widget: widgetRouter,
email: emailRouter,
gsc: gscRouter,
group: groupRouter,
});
// export type definition of API

View File

@@ -0,0 +1,125 @@
import {
chQuery,
db,
deleteGroup,
getGroupById,
getGroupList,
getGroupListCount,
getGroupTypes,
TABLE_NAMES,
} from '@openpanel/db';
import sqlstring from 'sqlstring';
import { z } from 'zod';
import { createTRPCRouter, protectedProcedure } from '../trpc';
export const groupRouter = createTRPCRouter({
list: protectedProcedure
.input(
z.object({
projectId: z.string(),
cursor: z.number().optional(),
take: z.number().default(50),
search: z.string().optional(),
type: z.string().optional(),
})
)
.query(async ({ input }) => {
const [data, count] = await Promise.all([
getGroupList(input),
getGroupListCount(input),
]);
return { data, meta: { count, take: input.take } };
}),
byId: protectedProcedure
.input(z.object({ id: z.string(), projectId: z.string() }))
.query(async ({ input: { id, projectId } }) => {
return getGroupById(id, projectId);
}),
delete: protectedProcedure
.input(z.object({ id: z.string(), projectId: z.string() }))
.mutation(async ({ input: { id, projectId } }) => {
return deleteGroup(id, projectId);
}),
types: protectedProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ input: { projectId } }) => {
return getGroupTypes(projectId);
}),
metrics: protectedProcedure
.input(z.object({ id: z.string(), projectId: z.string() }))
.query(async ({ input: { id, projectId } }) => {
return chQuery<{
totalEvents: number;
uniqueProfiles: number;
firstSeen: string;
lastSeen: string;
}>(`
SELECT
count() AS totalEvents,
uniqExact(profile_id) AS uniqueProfiles,
min(created_at) AS firstSeen,
max(created_at) AS lastSeen
FROM ${TABLE_NAMES.events}
WHERE project_id = ${sqlstring.escape(projectId)}
AND has(groups, ${sqlstring.escape(id)})
`);
}),
activity: protectedProcedure
.input(z.object({ id: z.string(), projectId: z.string() }))
.query(async ({ input: { id, projectId } }) => {
return chQuery<{ count: number; date: string }>(`
SELECT count() AS count, toStartOfDay(created_at) AS date
FROM ${TABLE_NAMES.events}
WHERE project_id = ${sqlstring.escape(projectId)}
AND has(groups, ${sqlstring.escape(id)})
GROUP BY date
ORDER BY date DESC
`);
}),
members: protectedProcedure
.input(z.object({ id: z.string(), projectId: z.string() }))
.query(async ({ input: { id, projectId } }) => {
return chQuery<{
profileId: string;
lastSeen: string;
eventCount: number;
}>(`
SELECT
profile_id AS profileId,
max(created_at) AS lastSeen,
count() AS eventCount
FROM ${TABLE_NAMES.events}
WHERE project_id = ${sqlstring.escape(projectId)}
AND has(groups, ${sqlstring.escape(id)})
AND profile_id != device_id
GROUP BY profile_id
ORDER BY lastSeen DESC
LIMIT 100
`);
}),
properties: protectedProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ input: { projectId } }) => {
// Returns distinct property keys across all groups for this project
// Used by breakdown/filter pickers in the chart builder
const groups = await db.group.findMany({
where: { projectId },
select: { properties: true },
});
const keys = new Set<string>();
for (const group of groups) {
const props = group.properties as Record<string, unknown>;
for (const key of Object.keys(props)) {
keys.add(key);
}
}
return Array.from(keys).sort();
}),
});

View File

@@ -2,11 +2,19 @@ import { RESERVED_EVENT_NAMES } from '@openpanel/constants';
import { z } from 'zod';
import { isBlockedEventName } from './event-blocklist';
export const zGroupPayload = z.object({
id: z.string().min(1),
type: z.string().min(1),
name: z.string().min(1),
properties: z.record(z.unknown()).optional(),
});
export const zTrackPayload = z
.object({
name: z.string().min(1),
properties: z.record(z.string(), z.unknown()).optional(),
profileId: z.string().or(z.number()).optional(),
groups: z.array(z.string()).optional(),
})
.refine((data) => !RESERVED_EVENT_NAMES.includes(data.name as any), {
message: `Event name cannot be one of the reserved names: ${RESERVED_EVENT_NAMES.join(', ')}`,
@@ -97,6 +105,10 @@ export const zTrackHandlerPayload = z.discriminatedUnion('type', [
type: z.literal('replay'),
payload: zReplayPayload,
}),
z.object({
type: z.literal('group'),
payload: zGroupPayload,
}),
]);
export type ITrackPayload = z.infer<typeof zTrackPayload>;
@@ -105,6 +117,7 @@ export type IIncrementPayload = z.infer<typeof zIncrementPayload>;
export type IDecrementPayload = z.infer<typeof zDecrementPayload>;
export type IAliasPayload = z.infer<typeof zAliasPayload>;
export type IReplayPayload = z.infer<typeof zReplayPayload>;
export type IGroupPayload = z.infer<typeof zGroupPayload>;
export type ITrackHandlerPayload = z.infer<typeof zTrackHandlerPayload>;
// Deprecated types for beta version of the SDKs