chore:little fixes and formating and linting and patches

This commit is contained in:
2026-03-31 15:50:54 +02:00
parent a1ce71ffb6
commit 9b197abcfa
815 changed files with 22960 additions and 8982 deletions

View File

@@ -1,7 +1,7 @@
import { parseCookieDomain } from './parse-cookie-domain';
const parsed = parseCookieDomain(
(process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL) ?? '',
(process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL) ?? ''
);
export const COOKIE_MAX_AGE = 60 * 60 * 24 * 30;

View File

@@ -1,2 +1,2 @@
export * from './src';
export * from './constants';
export * from './src';

View File

@@ -24,4 +24,4 @@
"peerDependencies": {
"react": "catalog:"
}
}
}

View File

@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { parseCookieDomain } from './parse-cookie-domain';
describe('parseCookieDomain', () => {
@@ -223,7 +223,7 @@ describe('parseCookieDomain', () => {
it('should handle domains with all URL components', () => {
expect(
parseCookieDomain('https://example.com:8080/path?param=value#fragment'),
parseCookieDomain('https://example.com:8080/path?param=value#fragment')
).toEqual({
domain: '.example.com',
secure: true,
@@ -274,7 +274,7 @@ describe('parseCookieDomain', () => {
it('should throw error for URLs with invalid characters', () => {
expect(() =>
parseCookieDomain('http://example.com:invalid-port'),
parseCookieDomain('http://example.com:invalid-port')
).toThrow();
});
});
@@ -296,7 +296,7 @@ describe('parseCookieDomain', () => {
it('should handle subdomains of openpanel.dev correctly', () => {
expect(
parseCookieDomain('https://staging.dashboard.openpanel.dev'),
parseCookieDomain('https://staging.dashboard.openpanel.dev')
).toEqual({
domain: '.openpanel.dev',
secure: true,

View File

@@ -4,7 +4,7 @@ import { encodeHexLowerCase } from '@oslojs/encoding';
export async function hashPassword(password: string): Promise<string> {
return await hash(password, {
memoryCost: 19456,
memoryCost: 19_456,
timeCost: 2,
outputLen: 32,
parallelism: 1,
@@ -13,13 +13,13 @@ export async function hashPassword(password: string): Promise<string> {
export async function verifyPasswordHash(
hash: string,
password: string,
password: string
): Promise<boolean> {
return await verify(hash, password);
}
export async function verifyPasswordStrength(
password: string,
password: string
): Promise<boolean> {
if (password.length < 8 || password.length > 255) {
return false;
@@ -27,7 +27,7 @@ export async function verifyPasswordStrength(
const hash = encodeHexLowerCase(sha1(new TextEncoder().encode(password)));
const hashPrefix = hash.slice(0, 5);
const response = await fetch(
`https://api.pwnedpasswords.com/range/${hashPrefix}`,
`https://api.pwnedpasswords.com/range/${hashPrefix}`
);
const data = await response.text();
const items = data.split('\n');

View File

@@ -1,5 +1,5 @@
import crypto from 'node:crypto';
import { type Session, type User, db } from '@openpanel/db';
import { db, type Session, type User } from '@openpanel/db';
import { sha256 } from '@oslojs/crypto/sha2';
import {
encodeBase32LowerCaseNoPadding,
@@ -15,7 +15,7 @@ export function generateSessionToken(): string {
export async function createSession(
token: string,
userId: string,
userId: string
): Promise<Session> {
const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
const session: Session = {
@@ -38,7 +38,7 @@ export const EMPTY_SESSION: SessionValidationResult = {
};
export async function createDemoSession(
userId: string,
userId: string
): Promise<SessionValidationResult> {
const user = await db.user.findUniqueOrThrow({
where: {
@@ -66,7 +66,7 @@ export const decodeSessionToken = (token: string): string | null => {
};
export async function validateSessionToken(
token: string | null | undefined,
token: string | null | undefined
): Promise<SessionValidationResult> {
if (process.env.DEMO_USER_ID) {
return createDemoSession(process.env.DEMO_USER_ID);

View File

@@ -1,12 +1,12 @@
export * from './server/get-client-ip';
export * from './src/date';
export * from './src/timezones';
export * from './src/object';
export * from './src/names';
export * from './src/string';
export * from './src/math';
export * from './src/slug';
export * from './src/url';
export * from './src/id';
export * from './src/get-previous-metric';
export * from './src/group-by-labels';
export * from './server/get-client-ip';
export * from './src/id';
export * from './src/math';
export * from './src/names';
export * from './src/object';
export * from './src/slug';
export * from './src/string';
export * from './src/timezones';
export * from './src/url';

View File

@@ -16,13 +16,15 @@ export function generateSalt() {
*/
export async function hashPassword(
password: string,
keyLength = 32,
keyLength = 32
): Promise<string> {
return new Promise((resolve, reject) => {
// generate random 16 bytes long salt - recommended by NodeJS Docs
const salt = generateSalt();
scrypt(password, salt, keyLength, (err, derivedKey) => {
if (err) reject(err);
if (err) {
reject(err);
}
// derivedKey is of type Buffer
resolve(`${salt}.${derivedKey.toString('hex')}`);
});
@@ -38,7 +40,7 @@ export async function hashPassword(
export async function verifyPassword(
password: string,
hash: string,
keyLength = 32,
keyLength = 32
): Promise<boolean> {
return new Promise((resolve, reject) => {
const [salt, hashKey] = hash.split('.');
@@ -50,10 +52,7 @@ export async function verifyPassword(
}
// compare the new supplied password with the hashed password using timeSafeEqual
resolve(
timingSafeEqual(
new Uint8Array(hashKeyBuff),
new Uint8Array(derivedKey),
),
timingSafeEqual(new Uint8Array(hashKeyBuff), new Uint8Array(derivedKey))
);
});
});

View File

@@ -90,7 +90,7 @@ function isValidIp(ip: string): boolean {
export function getClientIpFromHeaders(
headers: Record<string, string | string[] | undefined> | Headers,
overrideHeaderName?: string,
overrideHeaderName?: string
): {
ip: string;
header: string;
@@ -116,7 +116,9 @@ export function getClientIpFromHeaders(
}
}
if (!value) continue;
if (!value) {
continue;
}
// Handle x-forwarded-for (comma separated)
if (headerName === 'x-forwarded-for') {

View File

@@ -1,5 +1,5 @@
export * from './crypto';
export * from './profileId';
export * from './parser-user-agent';
export * from './parse-referrer';
export * from './id';
export * from './parse-referrer';
export * from './parser-user-agent';
export * from './profileId';

View File

@@ -107,7 +107,7 @@ describe('getReferrerWithQuery', () => {
utm_source: 'google',
ref: 'facebook',
utm_referrer: 'twitter',
}),
})
).toEqual({
name: 'Google',
type: 'search',

View File

@@ -1,5 +1,4 @@
import { stripTrailingSlash } from '../src/string';
import referrers from './referrers';
function getHostname(url: string | undefined) {
@@ -26,7 +25,7 @@ export function parseReferrer(url: string | undefined) {
}
export function getReferrerWithQuery(
query: Record<string, string> | undefined,
query: Record<string, string> | undefined
) {
if (!query) {
return null;
@@ -47,7 +46,7 @@ export function getReferrerWithQuery(
referrers[source] ||
referrers[`${source}.com`] ||
Object.values(referrers).find(
(referrer) => referrer.name.toLowerCase() === source,
(referrer) => referrer.name.toLowerCase() === source
);
if (match) {

View File

@@ -201,7 +201,7 @@ describe('getDevice', () => {
describe('parseUserAgent - brand detection', () => {
it('should detect Xiaomi brand from Manufacturer field', () => {
const result = parseUserAgent(
'App/1.0 (Android 12; Model=POCO X5; Manufacturer=Xiaomi)',
'App/1.0 (Android 12; Model=POCO X5; Manufacturer=Xiaomi)'
);
expect(result.brand).toBe('Xiaomi');
expect(result.model).toBe('POCO X5');
@@ -210,7 +210,7 @@ describe('parseUserAgent - brand detection', () => {
it('should detect Samsung brand from model name', () => {
const result = parseUserAgent(
'App/1.0 (Android 13; Model=Galaxy S23 Ultra)',
'App/1.0 (Android 13; Model=Galaxy S23 Ultra)'
);
expect(result.brand).toBe('Samsung');
expect(result.model).toBe('Galaxy S23 Ultra');
@@ -228,7 +228,7 @@ describe('parseUserAgent - brand detection', () => {
it('should detect OnePlus', () => {
const result = parseUserAgent(
'App/1.0 (Android 13; Model=OnePlus 11; Manufacturer=OnePlus)',
'App/1.0 (Android 13; Model=OnePlus 11; Manufacturer=OnePlus)'
);
expect(result.brand).toBe('OnePlus');
expect(result.model).toBe('OnePlus 11');
@@ -239,7 +239,7 @@ describe('parseUserAgent - brand detection', () => {
it('should detect Huawei', () => {
const result = parseUserAgent(
'App/1.0 (Android 12; Model=P60 Pro; Manufacturer=Huawei)',
'App/1.0 (Android 12; Model=P60 Pro; Manufacturer=Huawei)'
);
expect(result.brand).toBe('Huawei');
expect(result.model).toBe('P60 Pro');

View File

@@ -14,9 +14,9 @@ const parsedServerUa = {
// Pre-compile all regex patterns for better performance
const IPHONE_MODEL_REGEX = /(iPhone|iPad)\s*([0-9,]+)/i;
const IOS_MODEL_REGEX = /(iOS)\s*([0-9\.]+)/i;
const IOS_MODEL_REGEX = /(iOS)\s*([0-9.]+)/i;
const IPAD_OS_VERSION_REGEX = /iPadOS\s*([0-9_]+)/i;
const SINGLE_NAME_VERSION_REGEX = /^[^\/]+\/[\d.]+$/;
const SINGLE_NAME_VERSION_REGEX = /^[^/]+\/[\d.]+$/;
// App-style UA patterns (e.g., "Model=Redmi Note 8 Pro; Manufacturer=Xiaomi")
const APP_MODEL_REGEX = /Model=([^;)]+)/i;
@@ -150,7 +150,9 @@ function detectBrand(ua: string, model?: string): string | undefined {
// Check if a model name indicates a phone (not tablet)
function isKnownPhoneModel(model?: string): boolean {
if (!model) return false;
if (!model) {
return false;
}
return KNOWN_PHONE_PATTERNS.some((pattern) => pattern.test(model));
}
@@ -166,7 +168,7 @@ const parse = (ua: string): UAParser.IResult => {
// Some user agents are not detected correctly by ua-parser-js
// Doing some extra checks for ios
if (!res.device.model && !res.os.name) {
if (!(res.device.model || res.os.name)) {
const iphone = isIphone(ua);
if (iphone) {
const result = {
@@ -213,8 +215,8 @@ const parse = (ua: string): UAParser.IResult => {
...res,
device: {
...res.device,
model: model,
vendor: vendor,
model,
vendor,
},
};
}
@@ -242,9 +244,11 @@ export type UserAgentInfo = ReturnType<typeof parseUserAgent>;
export type UserAgentResult = ReturnType<typeof parseUserAgent>;
export function parseUserAgent(
ua?: string | null,
overrides?: Record<string, unknown>,
overrides?: Record<string, unknown>
) {
if (!ua) return parsedServerUa;
if (!ua) {
return parsedServerUa;
}
const res = parse(ua);
if (isServer(res)) {

View File

@@ -1,12 +1,10 @@
import { isNil } from 'ramda';
import type { PreviousValue } from '@openpanel/validation';
import { isNil } from 'ramda';
import { round } from './math';
export function getPreviousMetric(
current: number,
previous: number | null | undefined,
previous: number | null | undefined
): PreviousValue {
if (isNil(previous)) {
return undefined;
@@ -20,7 +18,7 @@ export function getPreviousMetric(
: 0) -
1) *
100,
1,
1
);
return {

View File

@@ -54,7 +54,7 @@ export function groupByLabels(data: ISerieDataItem[]): GroupedResult[] {
const result = Array.from(groupedMap.values()).map((group) => ({
...group,
data: group.data.sort(
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(),
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()
),
}));

View File

@@ -11,7 +11,7 @@ export const average = (arr: (number | null)[], includeZero = false) => {
isNumber(n) &&
!Number.isNaN(n) &&
Number.isFinite(n) &&
(includeZero || n !== 0),
(includeZero || n !== 0)
);
const avg = filtered.reduce((p, c) => p + c, 0) / filtered.length;
return Number.isNaN(avg) ? 0 : avg;
@@ -22,13 +22,17 @@ export const sum = (arr: (number | null | undefined)[]): number =>
export const min = (arr: (number | null | undefined)[]): number => {
const filtered = arr.filter(isNumber);
if (filtered.length === 0) return 0;
if (filtered.length === 0) {
return 0;
}
return filtered.reduce((a, b) => (b < a ? b : a), filtered[0]!);
};
export const max = (arr: (number | null | undefined)[]): number => {
const filtered = arr.filter(isNumber);
if (filtered.length === 0) return 0;
if (filtered.length === 0) {
return 0;
}
return filtered.reduce((a, b) => (b > a ? b : a), filtered[0]!);
};
@@ -36,5 +40,5 @@ export const isFloat = (n: number) => n % 1 !== 0;
export const ifNaN = <T extends number>(
n: number | null | undefined,
defaultValue: T,
defaultValue: T
): T => (Number.isNaN(n) ? defaultValue : (n as T));

View File

@@ -10,7 +10,7 @@ describe('toDots', () => {
arrayWithObjects: [{ a: 1 }, { b: 2 }, { c: 3 }],
objectWithArrays: { a: [1, 2, 3] },
null: null,
undefined: undefined,
undefined,
empty: '',
jsonString: '{"a": 1, "b": 2}',
};

View File

@@ -15,7 +15,7 @@ function isMalformedJsonString(value: string): boolean {
export function toDots(
obj: Record<string, unknown>,
path = '',
path = ''
): Record<string, string> {
// Clickhouse breaks on insert if a property contains invalid surrogate pairs
function removeInvalidSurrogates(value: string): string {
@@ -67,7 +67,7 @@ export function toDots(
}
export function toObject(
obj: Record<string, string | undefined>,
obj: Record<string, string | undefined>
): Record<string, unknown> {
let result: Record<string, unknown> = {};
Object.entries(obj).forEach(([key, value]) => {

View File

@@ -4,7 +4,7 @@ import { slug } from './slug';
describe('slug', () => {
it('should remove pipes from string', () => {
expect(slug('Hello || World, | Test å å ä ä')).toBe(
'hello-world-test-a-a-a-a',
'hello-world-test-a-a-a-a'
);
});
});

View File

@@ -10,7 +10,7 @@ const slugify = (str: string) => {
.replaceAll('Ä', 'A')
.replaceAll('Ö', 'O')
.replace(/\|+/g, '-'),
{ lower: true, strict: true, trim: true },
{ lower: true, strict: true, trim: true }
);
};

View File

@@ -1,5 +1,5 @@
export function parseSearchParams(
params: URLSearchParams,
params: URLSearchParams
): Record<string, string> | undefined {
const result: Record<string, string> = {};
for (const [key, value] of params.entries()) {
@@ -25,7 +25,7 @@ export function parsePath(path?: string): {
// If path does not have a leading /,
// its probably a named route
if (!path.startsWith('/') && !hasOrigin) {
if (!(path.startsWith('/') || hasOrigin)) {
return {
path,
origin: '',
@@ -50,9 +50,9 @@ export function parsePath(path?: string): {
export function isSameDomain(
url1: string | undefined,
url2: string | undefined,
url2: string | undefined
) {
if (!url1 || !url2) {
if (!(url1 && url2)) {
return false;
}
try {

View File

@@ -1,3 +1,3 @@
import { getSharedVitestConfig } from '../../vitest.shared';
export default getSharedVitestConfig({ __dirname });
export default getSharedVitestConfig({ __dirname: import.meta.dirname });

View File

@@ -1,6 +1,9 @@
import fs from 'node:fs';
import path from 'node:path';
import { createTable, runClickhouseMigrationCommands } from '../src/clickhouse/migration';
import {
createTable,
runClickhouseMigrationCommands,
} from '../src/clickhouse/migration';
import { getIsCluster } from './helpers';
export async function up() {
@@ -67,16 +70,16 @@ export async function up() {
];
fs.writeFileSync(
path.join(__filename.replace('.ts', '.sql')),
path.join(import.meta.filename.replace('.ts', '.sql')),
sqls
.map((sql) =>
sql
.trim()
.replace(/;$/, '')
.replace(/\n{2,}/g, '\n')
.concat(';'),
.concat(';')
)
.join('\n\n---\n\n'),
.join('\n\n---\n\n')
);
if (!process.argv.includes('--dry')) {

View File

@@ -1,10 +1,10 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { dirname } from 'node:path';
import path, { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
import { db } from '../index';
import { printBoxMessage } from './helpers';
@@ -17,8 +17,8 @@ const simpleCsvParser = (csv: string): Record<string, unknown>[] => {
acc[headers[index]!] = curr;
return acc;
},
{} as Record<string, unknown>,
),
{} as Record<string, unknown>
)
);
};

View File

@@ -1,10 +1,10 @@
import fs from 'node:fs';
import path from 'node:path';
import { dirname } from 'node:path';
import path, { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
import { formatClickhouseDate } from '../src/clickhouse/client';
import {
createDatabase,
@@ -27,7 +27,7 @@ export async function up() {
const hasEventsBots = existingTables.includes('events_bots_distributed');
const hasProfiles = existingTables.includes('profiles_distributed');
const hasProfileAliases = existingTables.includes(
'profile_aliases_distributed',
'profile_aliases_distributed'
);
const isSelfHosting = getIsSelfHosting();
@@ -50,8 +50,8 @@ export async function up() {
sqls.push(
...existingTables
.filter((table) => {
return (
!table.endsWith('_tmp') && !existingTables.includes(`${table}_tmp`)
return !(
table.endsWith('_tmp') || existingTables.includes(`${table}_tmp`)
);
})
.flatMap((table) => {
@@ -60,7 +60,7 @@ export async function up() {
to: `${table}_tmp`,
isClustered,
});
}),
})
);
}
@@ -263,7 +263,7 @@ export async function up() {
distributionHash: 'cityHash64(project_id, name)',
replicatedVersion,
isClustered,
}),
})
);
if (isSelfHostingPostCluster) {
@@ -321,7 +321,7 @@ export async function up() {
interval: 'week',
},
})
: []),
: [])
);
}
@@ -336,7 +336,7 @@ export async function up() {
interval: 'week',
},
})
: []),
: [])
);
}
@@ -348,9 +348,9 @@ export async function up() {
.trim()
.replace(/;$/, '')
.replace(/\n{2,}/g, '\n')
.concat(';'),
.concat(';')
)
.join('\n\n---\n\n'),
.join('\n\n---\n\n')
);
printBoxMessage('Will start migration for self-hosting setup.', [
@@ -369,9 +369,9 @@ export async function up() {
(table) =>
`docker compose exec -it op-ch clickhouse-client --query "${dropTable(
`openpanel.${table}_tmp`,
false,
)}"`,
),
false
)}"`
)
);
}
}

View File

@@ -1,6 +1,6 @@
import fs from 'node:fs';
import path from 'node:path';
import { TABLE_NAMES, formatClickhouseDate } from '../src/clickhouse/client';
import { formatClickhouseDate, TABLE_NAMES } from '../src/clickhouse/client';
import {
chMigrationClient,
createTable,
@@ -70,16 +70,16 @@ export async function up() {
sqls.push(...(await createOldSessions()));
fs.writeFileSync(
path.join(__filename.replace('.ts', '.sql')),
path.join(import.meta.filename.replace('.ts', '.sql')),
sqls
.map((sql) =>
sql
.trim()
.replace(/;$/, '')
.replace(/\n{2,}/g, '\n')
.concat(';'),
.concat(';')
)
.join('\n\n---\n\n'),
.join('\n\n---\n\n')
);
if (!process.argv.includes('--dry')) {

View File

@@ -1,6 +1,5 @@
import fs from 'node:fs';
import path from 'node:path';
import { TABLE_NAMES } from '../src/clickhouse/client';
import {
createTable,
modifyTTL,
@@ -68,20 +67,20 @@ export async function up() {
tableName: 'events_imports',
isClustered,
ttl: 'imported_at_meta + INTERVAL 7 DAY',
}),
})
);
fs.writeFileSync(
path.join(__filename.replace('.ts', '.sql')),
path.join(import.meta.filename.replace('.ts', '.sql')),
sqls
.map((sql) =>
sql
.trim()
.replace(/;$/, '')
.replace(/\n{2,}/g, '\n')
.concat(';'),
.concat(';')
)
.join('\n\n---\n\n'),
.join('\n\n---\n\n')
);
if (!process.argv.includes('--dry')) {

View File

@@ -13,21 +13,21 @@ export async function up() {
...addColumns(
'events',
['`revenue` UInt64 AFTER `referrer_type`'],
isClustered,
isClustered
),
];
fs.writeFileSync(
path.join(__filename.replace('.ts', '.sql')),
path.join(import.meta.filename.replace('.ts', '.sql')),
sqls
.map((sql) =>
sql
.trim()
.replace(/;$/, '')
.replace(/\n{2,}/g, '\n')
.concat(';'),
.concat(';')
)
.join('\n\n---\n\n'),
.join('\n\n---\n\n')
);
if (!process.argv.includes('--dry')) {

View File

@@ -35,7 +35,7 @@ export async function up() {
Array.isArray(events) &&
events.length > 0 &&
events.some(
(event) => !event || typeof event !== 'object' || !('type' in event),
(event) => !event || typeof event !== 'object' || !('type' in event)
);
// Check if formula exists and isn't already in the series
@@ -46,13 +46,13 @@ export async function up() {
item &&
typeof item === 'object' &&
'type' in item &&
item.type === 'formula',
item.type === 'formula'
);
const needsFormulaMigration = !!oldFormula && !hasFormulaInSeries;
// Skip if no migration needed
if (!needsEventMigration && !needsFormulaMigration) {
if (!(needsEventMigration || needsFormulaMigration)) {
skippedCount++;
continue;
}
@@ -83,7 +83,7 @@ export async function up() {
}
console.log(
`Updating report ${report.name} (${report.id}) with ${migratedSeries.length} series`,
`Updating report ${report.name} (${report.id}) with ${migratedSeries.length} series`
);
// Update the report with migrated series
await db.report.update({

View File

@@ -156,7 +156,7 @@ export async function up() {
to: 'events_new_20251123',
batch: {
startDate: firstEventDate,
endDate: endDate,
endDate,
column: 'toDate(created_at)',
interval: 'month',
transform: (date: Date) => {
@@ -165,7 +165,7 @@ export async function up() {
return `${year}-${month}-01`;
},
},
}),
})
);
}
@@ -182,7 +182,7 @@ export async function up() {
!firstSessionDateJson[0]?.created_at.startsWith('1970')
) {
const firstSessionDate = new Date(
firstSessionDateJson[0]?.created_at ?? '',
firstSessionDateJson[0]?.created_at ?? ''
);
// Set endDate to first of next month to ensure we capture all data in the current month
const endDate = new Date();
@@ -234,7 +234,7 @@ export async function up() {
],
batch: {
startDate: firstSessionDate,
endDate: endDate,
endDate,
column: 'toDate(created_at)',
interval: 'month',
transform: (date: Date) => {
@@ -243,15 +243,15 @@ export async function up() {
return `${year}-${month}-01`;
},
},
}),
})
);
}
sqls.push(
...renameTable({ from: 'events', to: 'events_20251123', isClustered }),
...renameTable({ from: 'events', to: 'events_20251123', isClustered })
);
sqls.push(
...renameTable({ from: 'sessions', to: 'sessions_20251123', isClustered }),
...renameTable({ from: 'sessions', to: 'sessions_20251123', isClustered })
);
if (isClustered && sessionTables[1] && eventTables[1]) {
@@ -264,7 +264,7 @@ export async function up() {
`RENAME TABLE sessions_new_20251123_replicated TO sessions_replicated ON CLUSTER '{cluster}'`,
// Create new distributed tables
eventTables[1].replaceAll('events_new_20251123', 'events'), // creates a new distributed table
sessionTables[1].replaceAll('sessions_new_20251123', 'sessions'), // creates a new distributed table
sessionTables[1].replaceAll('sessions_new_20251123', 'sessions') // creates a new distributed table
);
} else {
sqls.push(
@@ -272,28 +272,28 @@ export async function up() {
from: 'events_new_20251123',
to: 'events',
isClustered,
}),
})
);
sqls.push(
...renameTable({
from: 'sessions_new_20251123',
to: 'sessions',
isClustered,
}),
})
);
}
fs.writeFileSync(
path.join(__filename.replace('.ts', '.sql')),
path.join(import.meta.filename.replace('.ts', '.sql')),
sqls
.map((sql) =>
sql
.trim()
.replace(/;$/, '')
.replace(/\n{2,}/g, '\n')
.concat(';'),
.concat(';')
)
.join('\n\n---\n\n'),
.join('\n\n---\n\n')
);
if (!process.argv.includes('--dry')) {

View File

@@ -63,7 +63,7 @@ export async function up() {
// Only update if we have new options to set
if (newOptions) {
console.log(
`Migrating report ${report.name} (${report.id}) - chartType: ${report.chartType}`,
`Migrating report ${report.name} (${report.id}) - chartType: ${report.chartType}`
);
await db.report.update({

View File

@@ -1,10 +1,10 @@
import fs from 'node:fs';
import path from 'node:path';
import { dirname } from 'node:path';
import path, { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
import { db } from '../index';
import {
getIsCluster,
@@ -38,7 +38,7 @@ async function migrate() {
printBoxMessage('📋 Plan', [
'\t✅ Finished:',
...finishedMigrations.map(
(migration) => `\t- ${migration.name} (${migration.createdAt})`,
(migration) => `\t- ${migration.name} (${migration.createdAt})`
),
'',
'\t🔄 Will run now:',
@@ -46,8 +46,8 @@ async function migrate() {
.filter(
(migration) =>
!finishedMigrations.some(
(finishedMigration) => finishedMigration.name === migration,
),
(finishedMigration) => finishedMigration.name === migration
)
)
.map((migration) => `\t- ${migration}`),
]);
@@ -63,11 +63,11 @@ async function migrate() {
]);
if (!getIsSelfHosting()) {
if (!getIsDry()) {
printBoxMessage('🕒 Migrations starts in 10 seconds', []);
await new Promise((resolve) => setTimeout(resolve, 10000));
} else {
if (getIsDry()) {
printBoxMessage('🕒 Migrations starts now (dry run)', []);
} else {
printBoxMessage('🕒 Migrations starts in 10 seconds', []);
await new Promise((resolve) => setTimeout(resolve, 10_000));
}
}
@@ -93,7 +93,7 @@ async function runMigration(migrationsDir: string, file: string) {
try {
const migration = await import(path.join(migrationsDir, file));
await migration.up();
if (!getIsDry() && !getShouldIgnoreRecord()) {
if (!(getIsDry() || getShouldIgnoreRecord())) {
await db.codeMigration.upsert({
where: {
name: file,

View File

@@ -1,6 +1,5 @@
import { readFileSync, readdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { dirname } from 'node:path';
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
@@ -52,7 +51,7 @@ function parseSchemaForJsonTypes(schemaPath: string): JsonFieldMapping[] {
function processGeneratedFiles(
generatedDir: string,
mappings: JsonFieldMapping[],
mappings: JsonFieldMapping[]
): void {
// Process the main files in the generated directory
const mainFiles = [
@@ -100,7 +99,7 @@ function processGeneratedFiles(
function replaceJsonValueInFileForModel(
filePath: string,
mappings: JsonFieldMapping[],
mappings: JsonFieldMapping[]
): void {
let content = readFileSync(filePath, 'utf-8');
let modified = false;
@@ -109,12 +108,12 @@ function replaceJsonValueInFileForModel(
// Pattern 1: Simple runtime.JsonValue replacement (for select/return types)
const simpleJsonValueRegex = new RegExp(
`\\b${mapping.field}:\\s*runtime\\.JsonValue\\b`,
'g',
'g'
);
if (simpleJsonValueRegex.test(content)) {
content = content.replace(
simpleJsonValueRegex,
`${mapping.field}: PrismaJson.${mapping.type}`,
`${mapping.field}: PrismaJson.${mapping.type}`
);
modified = true;
}
@@ -122,12 +121,12 @@ function replaceJsonValueInFileForModel(
// Pattern 2: runtime.InputJsonValue with optional JsonNullValueInput (for create/update inputs)
const inputJsonValueRegex = new RegExp(
`\\b${mapping.field}:\\s*(?:Prisma\\.JsonNullValueInput\\s*\\|\\s*)?runtime\\.InputJsonValue\\b`,
'g',
'g'
);
if (inputJsonValueRegex.test(content)) {
content = content.replace(
inputJsonValueRegex,
`${mapping.field}: PrismaJson.${mapping.type}`,
`${mapping.field}: PrismaJson.${mapping.type}`
);
modified = true;
}
@@ -135,12 +134,12 @@ function replaceJsonValueInFileForModel(
// Pattern 3: Optional runtime.InputJsonValue with optional JsonNullValueInput
const optionalInputJsonValueRegex = new RegExp(
`\\b${mapping.field}\\?:\\s*(?:Prisma\\.JsonNullValueInput\\s*\\|\\s*)?runtime\\.InputJsonValue\\b`,
'g',
'g'
);
if (optionalInputJsonValueRegex.test(content)) {
content = content.replace(
optionalInputJsonValueRegex,
`${mapping.field}?: PrismaJson.${mapping.type}`,
`${mapping.field}?: PrismaJson.${mapping.type}`
);
modified = true;
}
@@ -151,7 +150,7 @@ function replaceJsonValueInFileForModel(
if (unionJsonValueRegex.test(content)) {
content = content.replace(
unionJsonValueRegex,
`$1PrismaJson.${mapping.type}`,
`$1PrismaJson.${mapping.type}`
);
modified = true;
}
@@ -161,7 +160,7 @@ function replaceJsonValueInFileForModel(
if (simpleInputJsonValueRegex.test(content)) {
content = content.replace(
simpleInputJsonValueRegex,
`| PrismaJson.${mapping.type}`,
`| PrismaJson.${mapping.type}`
);
modified = true;
}
@@ -169,12 +168,12 @@ function replaceJsonValueInFileForModel(
// Pattern 6: Optional union types with JsonNullValueInput | runtime.InputJsonValue
const optionalUnionJsonValueRegex = new RegExp(
`\\b${mapping.field}\\?:\\s*(?:Prisma\\.JsonNullValueInput\\s*\\|\\s*)?runtime\\.InputJsonValue\\b`,
'g',
'g'
);
if (optionalUnionJsonValueRegex.test(content)) {
content = content.replace(
optionalUnionJsonValueRegex,
`${mapping.field}?: PrismaJson.${mapping.type}`,
`${mapping.field}?: PrismaJson.${mapping.type}`
);
modified = true;
}

View File

@@ -31,14 +31,20 @@ async function main() {
const host =
getArg(values.host) || (await rl.question('Remote Host (IP/Domain): '));
if (!host) throw new Error('Host is required');
if (!host) {
throw new Error('Host is required');
}
const user = getArg(values.user) || (await rl.question('Remote User: '));
if (!user) throw new Error('User is required');
if (!user) {
throw new Error('User is required');
}
const password =
getArg(values.password) || (await rl.question('Remote Password: '));
if (!password) throw new Error('Password is required');
if (!password) {
throw new Error('Password is required');
}
const dbName =
getArg(values.db) ||
@@ -48,17 +54,21 @@ async function main() {
const startDate =
getArg(values.start) ||
(await rl.question('Start Date (YYYY-MM-DD HH:mm:ss): '));
if (!startDate) throw new Error('Start date is required');
if (!startDate) {
throw new Error('Start date is required');
}
const endDate =
getArg(values.end) ||
(await rl.question('End Date (YYYY-MM-DD HH:mm:ss): '));
if (!endDate) throw new Error('End date is required');
if (!endDate) {
throw new Error('End date is required');
}
const projectIdsInput =
getArg(values.projects) ||
(await rl.question(
'Project IDs (comma separated, leave empty for all): ',
'Project IDs (comma separated, leave empty for all): '
));
const projectIds = projectIdsInput
? projectIdsInput.split(',').map((s: string) => s.trim())

View File

@@ -1,4 +1,4 @@
import { TABLE_NAMES, ch } from '../src/clickhouse/client';
import { ch, TABLE_NAMES } from '../src/clickhouse/client';
import { clix } from '../src/clickhouse/query-builder';
const START_DATE = new Date('2025-11-10T00:00:00Z');
@@ -7,7 +7,7 @@ const SESSIONS_PER_HOUR = 2;
// Revenue between $10 (1000 cents) and $200 (20000 cents)
const MIN_REVENUE = 1000;
const MAX_REVENUE = 20000;
const MAX_REVENUE = 20_000;
function getRandomRevenue() {
return (
@@ -17,7 +17,7 @@ function getRandomRevenue() {
async function main() {
console.log(
`Starting revenue update for sessions between ${START_DATE.toISOString()} and ${END_DATE.toISOString()}`,
`Starting revenue update for sessions between ${START_DATE.toISOString()} and ${END_DATE.toISOString()}`
);
let currentDate = new Date(START_DATE);
@@ -44,7 +44,7 @@ async function main() {
const sessionIds = sessions.map((s: any) => s.id);
console.log(
`Found ${sessionIds.length} sessions to update: ${sessionIds.join(', ')}`,
`Found ${sessionIds.length} sessions to update: ${sessionIds.join(', ')}`
);
// 2. Construct update query

View File

@@ -1,5 +1,5 @@
import { generateSecureId } from '@openpanel/common/server';
import { type ILogger, createLogger } from '@openpanel/logger';
import { createLogger, type ILogger } from '@openpanel/logger';
import { cronQueue } from '@openpanel/queue';
import { getRedisCache, runEvery } from '@openpanel/redis';
@@ -38,7 +38,7 @@ export class BaseBuffer {
* Utility method to safely get buffer size with counter fallback
*/
protected async getBufferSizeWithCounter(
fallbackFn: () => Promise<number>,
fallbackFn: () => Promise<number>
): Promise<number> {
const key = this.bufferCounterKey;
try {
@@ -75,7 +75,7 @@ export class BaseBuffer {
} catch (error) {
this.logger.warn(
'Failed to get buffer size from counter, using fallback',
{ error },
{ error }
);
return fallbackFn();
}
@@ -126,7 +126,7 @@ export class BaseBuffer {
lockId,
'EX',
this.lockTimeout,
'NX',
'NX'
);
if (acquired === 'OK') {
try {

View File

@@ -1,7 +1,6 @@
import { type Redis, getRedisCache } from '@openpanel/redis';
import { getSafeJson } from '@openpanel/json';
import { TABLE_NAMES, ch } from '../clickhouse/client';
import { getRedisCache, type Redis } from '@openpanel/redis';
import { ch, TABLE_NAMES } from '../clickhouse/client';
import type { IClickhouseBotEvent } from '../services/event.service';
import { BaseBuffer } from './base-buffer';
@@ -48,13 +47,15 @@ export class BotBuffer extends BaseBuffer {
const events = await this.redis.lrange(
this.redisKey,
0,
this.batchSize - 1,
this.batchSize - 1
);
if (events.length === 0) return;
if (events.length === 0) {
return;
}
const parsedEvents = events.map((e) =>
getSafeJson<IClickhouseBotEvent>(e),
getSafeJson<IClickhouseBotEvent>(e)
);
// Insert to ClickHouse

View File

@@ -1,6 +1,7 @@
import { getRedisCache } from '@openpanel/redis';
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest';
import * as chClient from '../clickhouse/client';
const { ch } = chClient;
// Break circular dep: event-buffer -> event.service -> buffers/index -> EventBuffer
@@ -12,7 +13,9 @@ const redis = getRedisCache();
beforeEach(async () => {
const keys = await redis.keys('event*');
if (keys.length > 0) await redis.del(...keys);
if (keys.length > 0) {
await redis.del(...keys);
}
});
afterAll(async () => {
@@ -204,8 +207,11 @@ describe('EventBuffer', () => {
expect(call1Values.length).toBe(2);
expect(call2Values.length).toBe(2);
if (prev === undefined) delete process.env.EVENT_BUFFER_CHUNK_SIZE;
else process.env.EVENT_BUFFER_CHUNK_SIZE = prev;
if (prev === undefined) {
delete process.env.EVENT_BUFFER_CHUNK_SIZE;
} else {
process.env.EVENT_BUFFER_CHUNK_SIZE = prev;
}
insertSpy.mockRestore();
});

View File

@@ -3,7 +3,12 @@ import { getSafeJson } from '@openpanel/json';
import { getRedisCache, type Redis } from '@openpanel/redis';
import shallowEqual from 'fast-deep-equal';
import sqlstring from 'sqlstring';
import { ch, chQuery, formatClickhouseDate, TABLE_NAMES } from '../clickhouse/client';
import {
ch,
chQuery,
formatClickhouseDate,
TABLE_NAMES,
} from '../clickhouse/client';
import { BaseBuffer } from './base-buffer';
type IGroupBufferEntry = {
@@ -69,7 +74,9 @@ export class GroupBuffer extends BaseBuffer {
id: string
): Promise<IGroupCacheEntry | null> {
const raw = await this.redis.get(this.getCacheKey(projectId, id));
if (!raw) return null;
if (!raw) {
return null;
}
return getSafeJson<IGroupCacheEntry>(raw);
}
@@ -157,7 +164,11 @@ export class GroupBuffer extends BaseBuffer {
async processBuffer(): Promise<void> {
try {
this.logger.debug('Starting group buffer processing');
const items = await this.redis.lrange(this.redisKey, 0, this.batchSize - 1);
const items = await this.redis.lrange(
this.redisKey,
0,
this.batchSize - 1
);
if (items.length === 0) {
this.logger.debug('No groups to process');

View File

@@ -22,10 +22,7 @@ export function isPartialMatch(source: any, partial: any): boolean {
// Check each property in partial
for (const key in partial) {
if (
Object.prototype.hasOwnProperty.call(partial, key) &&
partial[key] !== undefined
) {
if (Object.hasOwn(partial, key) && partial[key] !== undefined) {
// If property doesn't exist in source, no match
if (!(key in source)) {
return false;

View File

@@ -1,7 +1,7 @@
import { getSafeJson } from '@openpanel/json';
import { type Redis, getRedisCache } from '@openpanel/redis';
import { getRedisCache, type Redis } from '@openpanel/redis';
import sqlstring from 'sqlstring';
import { TABLE_NAMES, ch, getReplicatedTableName } from '../clickhouse/client';
import { ch, getReplicatedTableName, TABLE_NAMES } from '../clickhouse/client';
import { BaseBuffer } from './base-buffer';
export interface ProfileBackfillEntry {
@@ -48,7 +48,9 @@ export class ProfileBackfillBuffer extends BaseBuffer {
try {
const raw = await this.redis.lrange(this.redisKey, 0, this.batchSize - 1);
if (raw.length === 0) return;
if (raw.length === 0) {
return;
}
// Deduplicate by sessionId — last write wins (most recent profileId)
const seen = new Map<string, ProfileBackfillEntry>();
@@ -67,10 +69,16 @@ export class ProfileBackfillBuffer extends BaseBuffer {
for (const chunk of chunks) {
const caseClause = chunk
.map(({ sessionId, profileId }) => `WHEN ${sqlstring.escape(sessionId)} THEN ${sqlstring.escape(profileId)}`)
.map(
({ sessionId, profileId }) =>
`WHEN ${sqlstring.escape(sessionId)} THEN ${sqlstring.escape(profileId)}`
)
.join('\n');
const tupleList = chunk
.map(({ projectId, sessionId }) => `(${sqlstring.escape(projectId)}, ${sqlstring.escape(sessionId)})`)
.map(
({ projectId, sessionId }) =>
`(${sqlstring.escape(projectId)}, ${sqlstring.escape(sessionId)})`
)
.join(',');
const query = `
@@ -85,7 +93,7 @@ export class ProfileBackfillBuffer extends BaseBuffer {
query,
clickhouse_settings: {
mutations_sync: '0',
allow_experimental_lightweight_update: '1'
allow_experimental_lightweight_update: '1',
},
});

View File

@@ -13,12 +13,14 @@ vi.mock('../clickhouse/client', () => ({
},
}));
import { ProfileBuffer } from './profile-buffer';
import { chQuery } from '../clickhouse/client';
import { ProfileBuffer } from './profile-buffer';
const redis = getRedisCache();
function makeProfile(overrides: Partial<IClickhouseProfile>): IClickhouseProfile {
function makeProfile(
overrides: Partial<IClickhouseProfile>
): IClickhouseProfile {
return {
id: 'profile-1',
project_id: 'project-1',
@@ -36,10 +38,12 @@ function makeProfile(overrides: Partial<IClickhouseProfile>): IClickhouseProfile
beforeEach(async () => {
const keys = [
...await redis.keys('profile*'),
...await redis.keys('lock:profile'),
...(await redis.keys('profile*')),
...(await redis.keys('lock:profile')),
];
if (keys.length > 0) await redis.del(...keys);
if (keys.length > 0) {
await redis.del(...keys);
}
vi.mocked(chQuery).mockResolvedValue([]);
});
@@ -57,7 +61,10 @@ describe('ProfileBuffer', () => {
});
it('adds a profile to the buffer', async () => {
const profile = makeProfile({ first_name: 'John', email: 'john@example.com' });
const profile = makeProfile({
first_name: 'John',
email: 'john@example.com',
});
const sizeBefore = await profileBuffer.getBufferSize();
await profileBuffer.add(profile);
@@ -185,7 +192,9 @@ describe('ProfileBuffer', () => {
});
it('proceeds with insert when ClickHouse fetch fails (treats profiles as new)', async () => {
vi.mocked(chQuery).mockRejectedValueOnce(new Error('ClickHouse unavailable'));
vi.mocked(chQuery).mockRejectedValueOnce(
new Error('ClickHouse unavailable')
);
const { ch } = await import('../clickhouse/client');
const insertSpy = vi

View File

@@ -1,6 +1,6 @@
import { getSafeJson } from '@openpanel/json';
import { getRedisCache } from '@openpanel/redis';
import { TABLE_NAMES, ch } from '../clickhouse/client';
import { ch, TABLE_NAMES } from '../clickhouse/client';
import { BaseBuffer } from './base-buffer';
export interface IClickhouseSessionReplayChunk {

View File

@@ -11,8 +11,8 @@ vi.mock('../clickhouse/client', () => ({
},
}));
import { SessionBuffer } from './session-buffer';
import type { IClickhouseEvent } from '../services/event.service';
import { SessionBuffer } from './session-buffer';
const redis = getRedisCache();
@@ -39,10 +39,12 @@ function makeEvent(overrides: Partial<IClickhouseEvent>): IClickhouseEvent {
beforeEach(async () => {
const keys = [
...await redis.keys('session*'),
...await redis.keys('lock:session'),
...(await redis.keys('session*')),
...(await redis.keys('lock:session')),
];
if (keys.length > 0) await redis.del(...keys);
if (keys.length > 0) {
await redis.del(...keys);
}
vi.mocked(ch.insert).mockResolvedValue(undefined as any);
});
@@ -78,11 +80,15 @@ describe('SessionBuffer', () => {
it('updates existing session on subsequent events', async () => {
const t0 = Date.now();
await sessionBuffer.add(makeEvent({ created_at: new Date(t0).toISOString() }));
await sessionBuffer.add(
makeEvent({ created_at: new Date(t0).toISOString() })
);
// Second event updates the same session — emits old (sign=-1) + new (sign=1)
const sizeBefore = await sessionBuffer.getBufferSize();
await sessionBuffer.add(makeEvent({ created_at: new Date(t0 + 5000).toISOString() }));
await sessionBuffer.add(
makeEvent({ created_at: new Date(t0 + 5000).toISOString() })
);
const sizeAfter = await sessionBuffer.getBufferSize();
expect(sizeAfter).toBe(sizeBefore + 2);

View File

@@ -1,6 +1,20 @@
import crypto from 'node:crypto';
import { createClient } from './client';
import { formatClickhouseDate } from './client';
import { createClient, formatClickhouseDate } from './client';
// Create a client without database for database creation
// This is needed because the main client connects to a specific database
// which doesn't exist yet during the database creation step
function createNoDbClient() {
const url = process.env.CLICKHOUSE_URL || '';
// Remove database path from URL (e.g., http://user:pass@host:port/dbname -> http://user:pass@host:port)
const urlWithoutDb = url.replace(/\/[^/]*$/, '');
return createClient({
url: urlWithoutDb,
request_timeout: 3_600_000,
keep_alive: { enabled: true },
compression: { request: true, response: true },
});
}
interface CreateTableOptions {
name: string;
@@ -36,7 +50,7 @@ const replicated = (tableName: string) => `${tableName}_replicated`;
export const chMigrationClient = createClient({
url: process.env.CLICKHOUSE_URL,
request_timeout: 3600000, // 1 hour in milliseconds
request_timeout: 3_600_000, // 1 hour in milliseconds
keep_alive: {
enabled: true,
},
@@ -137,7 +151,7 @@ export const modifyTTL = ({
export function addColumns(
tableName: string,
columns: string[],
isClustered: boolean,
isClustered: boolean
): string[] {
if (isClustered) {
return columns.flatMap((col) => [
@@ -147,7 +161,7 @@ export function addColumns(
}
return columns.map(
(col) => `ALTER TABLE ${tableName} ADD COLUMN IF NOT EXISTS ${col}`,
(col) => `ALTER TABLE ${tableName} ADD COLUMN IF NOT EXISTS ${col}`
);
}
@@ -157,7 +171,7 @@ export function addColumns(
export function dropColumns(
tableName: string,
columnNames: string[],
isClustered: boolean,
isClustered: boolean
): string[] {
if (isClustered) {
return columnNames.flatMap((colName) => [
@@ -167,7 +181,7 @@ export function dropColumns(
}
return columnNames.map(
(colName) => `ALTER TABLE ${tableName} DROP COLUMN IF EXISTS ${colName}`,
(colName) => `ALTER TABLE ${tableName} DROP COLUMN IF EXISTS ${colName}`
);
}
@@ -266,7 +280,7 @@ export function moveDataBetweenTables({
const shouldContinue = (
current: Date,
start: Date,
intervalType: string,
intervalType: string
): boolean => {
if (intervalType === 'month') {
// For months, compare by year and month
@@ -383,7 +397,7 @@ export function createMaterializedView({
// Transform query to use replicated table names in clustered mode
const transformedQuery = query.replace(/\{(\w+)\}/g, (_, tableName) =>
isClustered ? replicated(tableName) : tableName,
isClustered ? replicated(tableName) : tableName
);
if (!isClustered) {
@@ -420,10 +434,11 @@ export function countRows(tableName: string) {
export async function runClickhouseMigrationCommands(sqls: string[]) {
let abort: AbortController | undefined;
let activeQueryId: string | undefined;
let noDbClient: ReturnType<typeof createNoDbClient> | undefined;
const handleTermination = async (signal: string) => {
console.warn(
`Received ${signal}. Cleaning up active queries before exit...`,
`Received ${signal}. Cleaning up active queries before exit...`
);
if (abort) {
@@ -452,9 +467,19 @@ export async function runClickhouseMigrationCommands(sqls: string[]) {
console.log(sql);
console.log('----------------------------------------');
// Use no-db client for CREATE DATABASE commands
// This is needed because the database doesn't exist yet
const isCreateDatabase = sql.match(
/^CREATE\s+DATABASE\s+IF\s+NOT\s+EXISTS/i
);
if (isCreateDatabase && !noDbClient) {
noDbClient = createNoDbClient();
}
const client = isCreateDatabase ? noDbClient! : chMigrationClient;
try {
const res = await Promise.race([
chMigrationClient.command({
client.command({
query: sql,
query_id: activeQueryId,
abort_signal: abort?.signal,
@@ -464,7 +489,9 @@ export async function runClickhouseMigrationCommands(sqls: string[]) {
let checking = false; // Add flag to prevent multiple concurrent checks
async function check() {
if (checking) return; // Skip if already checking
if (checking) {
return; // Skip if already checking
}
checking = true;
try {
@@ -501,7 +528,7 @@ export async function runClickhouseMigrationCommands(sqls: string[]) {
const { elapsed, read_rows, written_rows, memory_usage } =
res[0] as any;
console.log(
`Progress: ${elapsed.toFixed(2)}s | Memory: ${formatMemory(memory_usage)} | Read: ${formatNumber(read_rows)} rows | Written: ${formatNumber(written_rows)} rows`,
`Progress: ${elapsed.toFixed(2)}s | Memory: ${formatMemory(memory_usage)} | Read: ${formatNumber(read_rows)} rows | Written: ${formatNumber(written_rows)} rows`
);
}
} finally {

View File

@@ -341,7 +341,9 @@ export class Query<T = any> {
}
rawJoin(sql: string): this {
if (this._skipNext) return this;
if (this._skipNext) {
return this;
}
this._rawJoins.push(sql);
return this;
}

View File

@@ -14,7 +14,7 @@ export function compute(
type: 'event' | 'formula';
id?: string;
formula?: string;
}>,
}>
): ConcreteSeries[] {
const results: ConcreteSeries[] = [...fetchedSeries];
@@ -69,7 +69,7 @@ export function compute(
});
const sortedDates = Array.from(allDates).sort(
(a, b) => new Date(a).getTime() - new Date(b).getTime(),
(a, b) => new Date(a).getTime() - new Date(b).getTime()
);
// Calculate total_count for the formula using the same formula applied to input series' total_count values
@@ -86,7 +86,7 @@ export function compute(
if (depSeries) {
// Get total_count from any data point (it's the same for all dates)
const totalCount = depSeries.data.find(
(d) => d.total_count != null,
(d) => d.total_count != null
)?.total_count;
totalCountScope[readableId] = totalCount ?? 0;
} else {
@@ -96,11 +96,11 @@ export function compute(
s.definitionIndex === depIndex &&
'type' in s.definition &&
s.definition.type === 'formula' &&
s.name.slice(1).join(':::') === breakdownSignature,
s.name.slice(1).join(':::') === breakdownSignature
);
if (formulaSerie) {
const totalCount = formulaSerie.data.find(
(d) => d.total_count != null,
(d) => d.total_count != null
)?.total_count;
totalCountScope[readableId] = totalCount ?? 0;
} else {
@@ -148,7 +148,7 @@ export function compute(
s.definitionIndex === depIndex &&
'type' in s.definition &&
s.definition.type === 'formula' &&
s.name.slice(1).join(':::') === breakdownSignature,
s.name.slice(1).join(':::') === breakdownSignature
);
if (formulaSerie) {
const dataPoint = formulaSerie.data.find((d) => d.date === date);

View File

@@ -26,7 +26,7 @@ export async function fetch(plan: Plan): Promise<ConcreteSeries[]> {
// Find the corresponding concrete series placeholder
const placeholder = plan.concreteSeries.find(
(cs) => cs.definitionId === definition.id,
(cs) => cs.definitionId === definition.id
);
if (!placeholder) {
@@ -60,7 +60,7 @@ export async function fetch(plan: Plan): Promise<ConcreteSeries[]> {
getChartSql({ ...queryInput, timezone: plan.timezone }),
{
session_timezone: plan.timezone,
},
}
);
// Fallback: if no results with breakdowns, try without breakdowns
@@ -73,7 +73,7 @@ export async function fetch(plan: Plan): Promise<ConcreteSeries[]> {
}),
{
session_timezone: plan.timezone,
},
}
);
}

View File

@@ -1,7 +1,6 @@
import { alphabetIds } from '@openpanel/constants';
import type {
IChartEvent,
IChartEventItem,
IReportInput,
IReportInputWithDates,
} from '@openpanel/validation';
@@ -15,7 +14,7 @@ export type NormalizedInput = Awaited<ReturnType<typeof normalize>>;
* Normalize a chart input into a clean structure with dates and normalized series
*/
export async function normalize(
input: IReportInput,
input: IReportInput
): Promise<IReportInputWithDates & { series: SeriesDefinition[] }> {
const { timezone } = await getSettingsForProject(input.projectId);
const { startDate, endDate } = getChartStartEndDate(
@@ -24,7 +23,7 @@ export async function normalize(
startDate: input.startDate ?? undefined,
endDate: input.endDate ?? undefined,
},
timezone,
timezone
);
// Get series from input (handles both 'series' and 'events' fields)
@@ -53,7 +52,7 @@ export async function normalize(
displayName: event.displayName,
property: event.property,
} as SeriesDefinition;
},
}
);
return {
@@ -63,4 +62,3 @@ export async function normalize(
endDate,
};
}

View File

@@ -1,10 +1,6 @@
import type {
IChartBreakdown,
IChartEvent,
IChartEventFilter,
IChartEventItem,
IChartFormula,
IReportInput,
IReportInputWithDates,
} from '@openpanel/validation';
@@ -23,7 +19,7 @@ export type ConcreteSeries = {
definitionId: string; // ID of the SeriesDefinition this came from
definitionIndex: number; // Index in the original series array (for A, B, C references)
name: string[]; // Display name parts: ["Session Start", "Chrome"] or ["Formula 1"]
// Context for Drill-down / Profiles
// This contains everything needed to query 'who are these users?'
context: {
@@ -82,4 +78,3 @@ export type ChartResponse = {
max: number;
};
};

View File

@@ -1,7 +1,7 @@
import { createLogger } from '@openpanel/logger';
import { cacheable } from '@openpanel/redis';
import { originalCh } from './clickhouse/client';
import { decrypt, encrypt } from './encryption';
import { createLogger } from '@openpanel/logger';
import { db } from './prisma-client';
const logger = createLogger({ name: 'db:gsc' });
@@ -14,7 +14,7 @@ export interface GscSite {
async function refreshGscToken(
refreshToken: string
): Promise<{ accessToken: string; expiresAt: Date }> {
if (!process.env.GOOGLE_CLIENT_ID || !process.env.GOOGLE_CLIENT_SECRET) {
if (!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET)) {
throw new Error(
'GOOGLE_CLIENT_ID or GOOGLE_CLIENT_SECRET is not set in this environment'
);
@@ -74,14 +74,20 @@ export async function getGscAccessToken(projectId: string): Promise<string> {
);
await db.gscConnection.update({
where: { projectId },
data: { accessToken: encrypt(accessToken), accessTokenExpiresAt: expiresAt },
data: {
accessToken: encrypt(accessToken),
accessTokenExpiresAt: expiresAt,
},
});
logger.info('GSC token refreshed successfully', { projectId, expiresAt });
return accessToken;
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Failed to refresh token';
logger.error('GSC token refresh failed', { projectId, error: errorMessage });
logger.error('GSC token refresh failed', {
projectId,
error: errorMessage,
});
await db.gscConnection.update({
where: { projectId },
data: {
@@ -143,7 +149,7 @@ async function queryGscSearchAnalytics(
const allRows: GscApiRow[] = [];
let startRow = 0;
const rowLimit = 25000;
const rowLimit = 25_000;
while (true) {
const res = await fetch(url, {
@@ -165,14 +171,18 @@ async function queryGscSearchAnalytics(
if (!res.ok) {
const text = await res.text();
throw new Error(`GSC query failed for dimensions [${dimensions.join(',')}]: ${text}`);
throw new Error(
`GSC query failed for dimensions [${dimensions.join(',')}]: ${text}`
);
}
const data = (await res.json()) as { rows?: GscApiRow[] };
const rows = data.rows ?? [];
allRows.push(...rows);
if (rows.length < rowLimit) break;
if (rows.length < rowLimit) {
break;
}
startRow += rowLimit;
}
@@ -430,7 +440,9 @@ export const getGscCannibalization = cacheable(
const totalImpressions = existing.impressions + row.impressions;
if (totalImpressions > 0) {
existing.position =
(existing.position * existing.impressions + row.position * row.impressions) / totalImpressions;
(existing.position * existing.impressions +
row.position * row.impressions) /
totalImpressions;
}
existing.clicks += row.clicks;
existing.impressions += row.impressions;
@@ -472,16 +484,46 @@ export async function getGscPageDetails(
startDate: string,
endDate: string
): Promise<{
timeseries: Array<{ date: string; clicks: number; impressions: number; ctr: number; position: number }>;
queries: Array<{ query: string; clicks: number; impressions: number; ctr: number; position: number }>;
timeseries: Array<{
date: string;
clicks: number;
impressions: number;
ctr: number;
position: number;
}>;
queries: Array<{
query: string;
clicks: number;
impressions: number;
ctr: number;
position: number;
}>;
}> {
const conn = await db.gscConnection.findUniqueOrThrow({ where: { projectId } });
const conn = await db.gscConnection.findUniqueOrThrow({
where: { projectId },
});
const accessToken = await getGscAccessToken(projectId);
const filterGroups: GscFilterGroup[] = [{ filters: [{ dimension: 'page', operator: 'equals', expression: page }] }];
const filterGroups: GscFilterGroup[] = [
{ filters: [{ dimension: 'page', operator: 'equals', expression: page }] },
];
const [timeseriesRows, queryRows] = await Promise.all([
queryGscSearchAnalytics(accessToken, conn.siteUrl, startDate, endDate, ['date'], filterGroups),
queryGscSearchAnalytics(accessToken, conn.siteUrl, startDate, endDate, ['query'], filterGroups),
queryGscSearchAnalytics(
accessToken,
conn.siteUrl,
startDate,
endDate,
['date'],
filterGroups
),
queryGscSearchAnalytics(
accessToken,
conn.siteUrl,
startDate,
endDate,
['query'],
filterGroups
),
]);
return {
@@ -508,16 +550,48 @@ export async function getGscQueryDetails(
startDate: string,
endDate: string
): Promise<{
timeseries: Array<{ date: string; clicks: number; impressions: number; ctr: number; position: number }>;
pages: Array<{ page: string; clicks: number; impressions: number; ctr: number; position: number }>;
timeseries: Array<{
date: string;
clicks: number;
impressions: number;
ctr: number;
position: number;
}>;
pages: Array<{
page: string;
clicks: number;
impressions: number;
ctr: number;
position: number;
}>;
}> {
const conn = await db.gscConnection.findUniqueOrThrow({ where: { projectId } });
const conn = await db.gscConnection.findUniqueOrThrow({
where: { projectId },
});
const accessToken = await getGscAccessToken(projectId);
const filterGroups: GscFilterGroup[] = [{ filters: [{ dimension: 'query', operator: 'equals', expression: query }] }];
const filterGroups: GscFilterGroup[] = [
{
filters: [{ dimension: 'query', operator: 'equals', expression: query }],
},
];
const [timeseriesRows, pageRows] = await Promise.all([
queryGscSearchAnalytics(accessToken, conn.siteUrl, startDate, endDate, ['date'], filterGroups),
queryGscSearchAnalytics(accessToken, conn.siteUrl, startDate, endDate, ['page'], filterGroups),
queryGscSearchAnalytics(
accessToken,
conn.siteUrl,
startDate,
endDate,
['date'],
filterGroups
),
queryGscSearchAnalytics(
accessToken,
conn.siteUrl,
startDate,
endDate,
['page'],
filterGroups
),
]);
return {

View File

@@ -1,11 +1,5 @@
import { createLogger } from '@openpanel/logger';
import { readReplicas } from '@prisma/extension-read-replicas';
import {
type Organization,
Prisma,
PrismaClient,
} from './generated/prisma/client';
import { logger } from './logger';
import { type Organization, PrismaClient } from './generated/prisma/client';
import { sessionConsistency } from './session-consistency';
export * from './generated/prisma/client';
@@ -14,7 +8,7 @@ const isWillBeCanceled = (
organization: Pick<
Organization,
'subscriptionStatus' | 'subscriptionCanceledAt' | 'subscriptionEndsAt'
>,
>
) =>
organization.subscriptionStatus === 'active' &&
organization.subscriptionCanceledAt &&
@@ -24,7 +18,7 @@ const isCanceled = (
organization: Pick<
Organization,
'subscriptionStatus' | 'subscriptionCanceledAt'
>,
>
) =>
organization.subscriptionStatus === 'canceled' &&
organization.subscriptionCanceledAt &&
@@ -231,8 +225,8 @@ const getPrismaClient = () => {
0,
0,
0,
0,
),
0
)
);
}
@@ -265,8 +259,8 @@ const getPrismaClient = () => {
0,
0,
0,
0,
),
0
)
);
}
@@ -279,7 +273,7 @@ const getPrismaClient = () => {
.$extends(
readReplicas({
url: process.env.DATABASE_URL_REPLICA ?? process.env.DATABASE_URL!,
}),
})
);
return prisma;

View File

@@ -4,13 +4,7 @@ import { getProjectById } from './project.service';
export const getProjectAccess = cacheable(
'getProjectAccess',
async ({
userId,
projectId,
}: {
userId: string;
projectId: string;
}) => {
async ({ userId, projectId }: { userId: string; projectId: string }) => {
try {
// Check if user has access to the project
const project = await getProjectById(projectId);
@@ -42,7 +36,7 @@ export const getProjectAccess = cacheable(
return false;
}
},
60 * 5,
60 * 5
);
export const getOrganizationAccess = cacheable(
@@ -61,7 +55,7 @@ export const getOrganizationAccess = cacheable(
},
});
},
60 * 5,
60 * 5
);
export async function getClientAccess({

View File

@@ -24,7 +24,7 @@ export async function getClientsByOrganizationId(organizationId: string) {
}
export async function getClientById(
id: string,
id: string
): Promise<IServiceClientWithProject | null> {
return db.client.findUnique({
where: { id },

View File

@@ -2,7 +2,7 @@ import { NOT_SET_VALUE } from '@openpanel/constants';
import type { IReportInput } from '@openpanel/validation';
import { omit } from 'ramda';
import sqlstring from 'sqlstring';
import { TABLE_NAMES, ch } from '../clickhouse/client';
import { ch, TABLE_NAMES } from '../clickhouse/client';
import { clix } from '../clickhouse/query-builder';
import {
getEventFiltersWhereClause,
@@ -30,17 +30,17 @@ export class ConversionService {
const funnelGroup = funnelOptions?.funnelGroup;
const funnelWindow = funnelOptions?.funnelWindow ?? 24;
const group = funnelGroup === 'profile_id' ? 'profile_id' : 'session_id';
const breakdownExpressions = breakdowns.map(
(b) => getSelectPropertyKey(b.name, projectId),
const breakdownExpressions = breakdowns.map((b) =>
getSelectPropertyKey(b.name, projectId)
);
const breakdownSelects = breakdownExpressions.map(
(expr, index) => `${expr} as b_${index}`,
(expr, index) => `${expr} as b_${index}`
);
const breakdownGroupBy = breakdowns.map((_, index) => `b_${index}`);
// Check if any breakdown or filter uses profile fields
const profileBreakdowns = breakdowns.filter((b) =>
b.name.startsWith('profile.'),
b.name.startsWith('profile.')
);
const needsProfileJoin = profileBreakdowns.length > 0;
@@ -73,10 +73,10 @@ export class ConversionService {
// Check if any breakdown or filter uses group fields
const anyBreakdownOnGroup = breakdowns.some((b) =>
b.name.startsWith('group.'),
b.name.startsWith('group.')
);
const anyFilterOnGroup = events.some((e) =>
e.filters?.some((f) => f.name.startsWith('group.')),
e.filters?.some((f) => f.name.startsWith('group.'))
);
const needsGroupArrayJoin = anyBreakdownOnGroup || anyFilterOnGroup;
@@ -84,17 +84,17 @@ export class ConversionService {
throw new Error('events must be an array of two events');
}
if (!startDate || !endDate) {
if (!(startDate && endDate)) {
throw new Error('startDate and endDate are required');
}
const eventA = events[0]!;
const eventB = events[1]!;
const whereA = Object.values(
getEventFiltersWhereClause(eventA.filters, projectId),
getEventFiltersWhereClause(eventA.filters, projectId)
).join(' AND ');
const whereB = Object.values(
getEventFiltersWhereClause(eventB.filters, projectId),
getEventFiltersWhereClause(eventB.filters, projectId)
).join(' AND ');
const funnelWindowSeconds = funnelWindow * 3600;
@@ -144,7 +144,7 @@ export class ConversionService {
AND events.name IN ('${eventA.name}', '${eventB.name}')
AND created_at BETWEEN toDateTime('${startDate}') AND toDateTime('${endDate}')
GROUP BY ${group}${breakdownExpressions.length ? `, ${breakdownExpressions.join(', ')}` : ''})
`),
`)
)
.where('steps', '>', 0)
.groupBy(['event_day', ...breakdownGroupBy]);
@@ -166,7 +166,7 @@ export class ConversionService {
serie: omit(['data'], serie),
})),
};
},
}
);
}
@@ -179,7 +179,7 @@ export class ConversionService {
[key: string]: string | number;
}[],
breakdowns: { name: string }[] = [],
limit: number | undefined = undefined,
limit: number | undefined = undefined
) {
if (!breakdowns.length) {
return [
@@ -210,7 +210,7 @@ export class ConversionService {
acc[key] = {
id: key,
breakdowns: breakdowns.map(
(b, index) => (d[`b_${index}`] || NOT_SET_VALUE) as string,
(b, index) => (d[`b_${index}`] || NOT_SET_VALUE) as string
),
data: [],
};
@@ -235,7 +235,7 @@ export class ConversionService {
rate: number;
}[];
}
>,
>
);
return Object.values(series).map((serie, serieIndex) => ({

View File

@@ -1,9 +1,8 @@
import { TABLE_NAMES, ch, getReplicatedTableName } from '../clickhouse/client';
import sqlstring from 'sqlstring';
import { ch, getReplicatedTableName, TABLE_NAMES } from '../clickhouse/client';
import { logger } from '../logger';
import { db } from '../prisma-client';
import sqlstring from 'sqlstring';
export async function deleteOrganization(organizationId: string) {
return await db.organization.delete({
where: {

View File

@@ -1,9 +1,8 @@
import { ifNaN } from '@openpanel/common';
import type { IChartEvent, IReportInput } from '@openpanel/validation';
import { last, reverse, uniq } from 'ramda';
import { last, reverse } from 'ramda';
import sqlstring from 'sqlstring';
import { ch } from '../clickhouse/client';
import { TABLE_NAMES } from '../clickhouse/client';
import { ch, TABLE_NAMES } from '../clickhouse/client';
import { clix } from '../clickhouse/query-builder';
import { createSqlBuilder } from '../sql-builder';
import {
@@ -34,7 +33,10 @@ export class FunnelService {
return group === 'profile_id' ? 'profile_id' : 'session_id';
}
getFunnelConditions(events: IChartEvent[] = [], projectId?: string): string[] {
getFunnelConditions(
events: IChartEvent[] = [],
projectId?: string
): string[] {
return events.map((event) => {
const { sb, getWhere } = createSqlBuilder();
sb.where = getEventFiltersWhereClause(event.filters, projectId);
@@ -92,7 +94,7 @@ export class FunnelService {
.where(
'events.name',
'IN',
eventSeries.map((e) => e.name),
eventSeries.map((e) => e.name)
)
.groupBy([primaryKey, ...additionalGroupBy]);
}
@@ -120,7 +122,7 @@ export class FunnelService {
private fillFunnel(
funnel: { level: number; count: number }[],
steps: number,
steps: number
) {
const filled = Array.from({ length: steps }, (_, index) => {
const level = index + 1;
@@ -146,7 +148,7 @@ export class FunnelService {
toSeries(
funnel: { level: number; count: number; [key: string]: any }[],
breakdowns: { name: string }[] = [],
limit: number | undefined = undefined,
limit: number | undefined = undefined
) {
if (!breakdowns.length) {
return [
@@ -175,7 +177,7 @@ export class FunnelService {
acc[key]!.push({
id: key,
breakdowns: breakdowns.map((b, index) =>
normalizeBreakdownValue(f[`b_${index}`]),
normalizeBreakdownValue(f[`b_${index}`])
),
level: f.level,
count: f.count,
@@ -190,7 +192,7 @@ export class FunnelService {
level: number;
count: number;
}[]
>,
>
);
return Object.values(series);
@@ -200,7 +202,7 @@ export class FunnelService {
return events.flatMap((e) =>
e.filters
?.filter((f) => f.name.startsWith('profile.'))
.map((f) => f.name.replace('profile.', '')),
.map((f) => f.name.replace('profile.', ''))
);
}
@@ -214,7 +216,7 @@ export class FunnelService {
limit,
timezone = 'UTC',
}: IReportInput & { timezone: string; events?: IChartEvent[] }) {
if (!startDate || !endDate) {
if (!(startDate && endDate)) {
throw new Error('startDate and endDate are required');
}
@@ -234,20 +236,20 @@ export class FunnelService {
const profileFilters = this.getProfileFilters(eventSeries);
const anyFilterOnProfile = profileFilters.length > 0;
const anyBreakdownOnProfile = breakdowns.some((b) =>
b.name.startsWith('profile.'),
b.name.startsWith('profile.')
);
const anyFilterOnGroup = eventSeries.some((e) =>
e.filters?.some((f) => f.name.startsWith('group.')),
e.filters?.some((f) => f.name.startsWith('group.'))
);
const anyBreakdownOnGroup = breakdowns.some((b) =>
b.name.startsWith('group.'),
b.name.startsWith('group.')
);
const needsGroupArrayJoin =
anyFilterOnGroup || anyBreakdownOnGroup || funnelGroup === 'group';
// Create the funnel CTE (session-level)
const breakdownSelects = breakdowns.map(
(b, index) => `${getSelectPropertyKey(b.name, projectId)} as b_${index}`,
(b, index) => `${getSelectPropertyKey(b.name, projectId)} as b_${index}`
);
const breakdownGroupBy = breakdowns.map((b, index) => `b_${index}`);
@@ -281,7 +283,7 @@ export class FunnelService {
funnelCte.leftJoin(
`(SELECT ${profileSelectColumns} FROM ${TABLE_NAMES.profiles} FINAL
WHERE project_id = ${sqlstring.escape(projectId)}) as profile`,
'profile.id = events.profile_id',
'profile.id = events.profile_id'
);
}
@@ -296,7 +298,7 @@ export class FunnelService {
if (needsGroupArrayJoin) {
funnelQuery.with(
'_g',
`SELECT id, name, type, properties FROM ${TABLE_NAMES.groups} FINAL WHERE project_id = ${sqlstring.escape(projectId)}`,
`SELECT id, name, type, properties FROM ${TABLE_NAMES.groups} FINAL WHERE project_id = ${sqlstring.escape(projectId)}`
);
}
@@ -304,10 +306,7 @@ export class FunnelService {
// windowFunnel is computed per the primary key (profile_id or session_id),
// so we just filter out level=0 rows — no re-aggregation needed.
funnelQuery.with(
'funnel',
'SELECT * FROM session_funnel WHERE level != 0',
);
funnelQuery.with('funnel', 'SELECT * FROM session_funnel WHERE level != 0');
funnelQuery
.select<{
@@ -331,7 +330,7 @@ export class FunnelService {
const maxLevel = eventSeries.length;
const filledFunnelRes = this.fillFunnel(
data.map((d) => ({ level: d.level, count: d.count })),
maxLevel,
maxLevel
);
const totalSessions = last(filledFunnelRes)?.count ?? 0;
@@ -367,7 +366,7 @@ export class FunnelService {
dropoffPercent: number | null;
previousCount: number;
nextCount: number | null;
}[],
}[]
)
.map((step, index, list) => {
return {
@@ -376,13 +375,15 @@ export class FunnelService {
dropoffPercent: ifNaN(step.dropoffPercent, 0),
isHighestDropoff: (() => {
// Skip if current step has no dropoff
if (!step?.dropoffCount) return false;
if (!step?.dropoffCount) {
return false;
}
// Get maximum dropoff count, excluding 0s
const maxDropoff = Math.max(
...list
.map((s) => s.dropoffCount || 0)
.filter((count) => count > 0),
.filter((count) => count > 0)
);
// Check if this is the first step with the highest dropoff

View File

@@ -1,10 +1,9 @@
import { slug } from '@openpanel/common';
import { db } from '../prisma-client';
export async function getId(
tableName: 'project' | 'dashboard' | 'organization',
name: string,
name: string
) {
const newId = slug(name);
if (!db[tableName]) {

View File

@@ -84,7 +84,7 @@ export class InsightsService {
.where(
'created_at',
'>=',
new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
)
.where('project_id', '=', projectId)
.groupBy(['referrer_name', 'date'])
@@ -110,7 +110,7 @@ export class InsightsService {
.where(
'created_at',
'>=',
new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
)
.where('project_id', '=', projectId)
.groupBy(['date'])
@@ -136,7 +136,7 @@ export class InsightsService {
.where(
'created_at',
'>=',
new Date(Date.now() - 60 * 24 * 60 * 60 * 1000),
new Date(Date.now() - 60 * 24 * 60 * 60 * 1000)
)
.where('project_id', '=', projectId)
.where('is_new', '=', true)
@@ -153,7 +153,7 @@ export class InsightsService {
}
private async getReferralSourceHighlights(
projectId: string,
projectId: string
): Promise<Insight[]> {
const query = clix(this.client)
.select([
@@ -177,7 +177,7 @@ export class InsightsService {
}
private async getSessionDurationChanges(
projectId: string,
projectId: string
): Promise<Insight[]> {
const query = clix(this.client)
.select([
@@ -189,7 +189,7 @@ export class InsightsService {
.where(
'created_at',
'>=',
new Date(Date.now() - 14 * 24 * 60 * 60 * 1000),
new Date(Date.now() - 14 * 24 * 60 * 60 * 1000)
)
.where('project_id', '=', projectId)
.groupBy(['week'])
@@ -215,7 +215,7 @@ export class InsightsService {
.where(
'created_at',
'>=',
new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
)
.where('project_id', '=', projectId)
.groupBy(['path'])
@@ -231,7 +231,7 @@ export class InsightsService {
}
private async getBounceRateImprovements(
projectId: string,
projectId: string
): Promise<Insight[]> {
const query = clix(this.client)
.select([
@@ -243,7 +243,7 @@ export class InsightsService {
.where(
'created_at',
'>=',
new Date(Date.now() - 60 * 24 * 60 * 60 * 1000),
new Date(Date.now() - 60 * 24 * 60 * 60 * 1000)
)
.where('project_id', '=', projectId)
.groupBy(['month'])
@@ -259,7 +259,7 @@ export class InsightsService {
}
private async getReturningVisitorTrends(
projectId: string,
projectId: string
): Promise<Insight[]> {
const query = clix(this.client)
.select([
@@ -271,7 +271,7 @@ export class InsightsService {
.where(
'created_at',
'>=',
new Date(Date.now() - 180 * 24 * 60 * 60 * 1000),
new Date(Date.now() - 180 * 24 * 60 * 60 * 1000)
)
.where('project_id', '=', projectId)
.where('is_returning', '=', true)
@@ -288,7 +288,7 @@ export class InsightsService {
}
private async getGeographicInterestShifts(
projectId: string,
projectId: string
): Promise<Insight[]> {
const query = clix(this.client)
.select([
@@ -300,7 +300,7 @@ export class InsightsService {
.where(
'created_at',
'>=',
new Date(Date.now() - 14 * 24 * 60 * 60 * 1000),
new Date(Date.now() - 14 * 24 * 60 * 60 * 1000)
)
.where('project_id', '=', projectId)
.groupBy(['country', 'toWeek(created_at)'])
@@ -316,7 +316,7 @@ export class InsightsService {
}
private async getEventCompletionChanges(
projectId: string,
projectId: string
): Promise<Insight[]> {
const query = clix(this.client)
.select([
@@ -329,7 +329,7 @@ export class InsightsService {
.where(
'created_at',
'>=',
new Date(Date.now() - 60 * 24 * 60 * 60 * 1000),
new Date(Date.now() - 60 * 24 * 60 * 60 * 1000)
)
.where('project_id', '=', projectId)
.where('status', '=', 'completed')
@@ -384,10 +384,10 @@ export class InsightsService {
].sort((a, b) => {
// Sort by most recent data first
const dateA = new Date(
a.data.date || a.data.month || a.data.week || a.data.quarter,
a.data.date || a.data.month || a.data.week || a.data.quarter
);
const dateB = new Date(
b.data.date || b.data.month || b.data.week || b.data.quarter,
b.data.date || b.data.month || b.data.week || b.data.quarter
);
return dateB.getTime() - dateA.getTime();
});

View File

@@ -1,8 +1,8 @@
import crypto from 'node:crypto';
import type { ClickHouseClient } from '@clickhouse/client';
import {
type Query,
clix as originalClix,
type Query,
} from '../../clickhouse/query-builder';
/**
@@ -17,7 +17,7 @@ import {
export function createCachedClix(
client: ClickHouseClient,
cache?: Map<string, any>,
timezone?: string,
timezone?: string
) {
function clixCached(): Query {
const query = originalClix(client, timezone);

View File

@@ -42,7 +42,7 @@ export interface EngineConfig {
function passesThresholds(
r: ComputeResult,
mod: InsightModule,
cfg: EngineConfig,
cfg: EngineConfig
): boolean {
const t = mod.thresholds ?? {};
const minTotal = t.minTotal ?? cfg.globalThresholds.minTotal;
@@ -53,16 +53,26 @@ function passesThresholds(
const total = cur + cmp;
const absDelta = Math.abs(cur - cmp);
const pct = Math.abs(r.changePct ?? 0);
if (total < minTotal) return false;
if (absDelta < minAbsDelta) return false;
if (pct < minPct) return false;
if (total < minTotal) {
return false;
}
if (absDelta < minAbsDelta) {
return false;
}
if (pct < minPct) {
return false;
}
return true;
}
function chunk<T>(arr: T[], size: number): T[][] {
if (size <= 0) return [arr];
if (size <= 0) {
return [arr];
}
const out: T[][] = [];
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
for (let i = 0; i < arr.length; i += size) {
out.push(arr.slice(i, i + size));
}
return out;
}
@@ -78,9 +88,11 @@ export function createEngine(args: {
function isProjectOldEnoughForWindow(
projectCreatedAt: Date | null | undefined,
baselineStart: Date,
baselineStart: Date
): boolean {
if (!projectCreatedAt) return true; // best-effort; don't block if unknown
if (!projectCreatedAt) {
return true; // best-effort; don't block if unknown
}
return projectCreatedAt.getTime() <= baselineStart.getTime();
}
@@ -147,7 +159,9 @@ export function createEngine(args: {
continue;
}
const maxDims = mod.thresholds?.maxDims ?? 25;
if (dims.length > maxDims) dims = dims.slice(0, maxDims);
if (dims.length > maxDims) {
dims = dims.slice(0, maxDims);
}
if (dims.length === 0) {
// Still do lifecycle close / suppression based on "nothing emitted"
@@ -189,14 +203,20 @@ export function createEngine(args: {
}
for (const r of results) {
if (!r?.ok) continue;
if (!r.dimensionKey) continue;
if (!r?.ok) {
continue;
}
if (!r.dimensionKey) {
continue;
}
// Sanitize dimensionKey to remove null bytes that PostgreSQL can't handle
r.dimensionKey = sanitizeForPostgres(r.dimensionKey);
// 3) gate noise
if (!passesThresholds(r, mod, config)) continue;
if (!passesThresholds(r, mod, config)) {
continue;
}
// 4) score
const impact = mod.score

View File

@@ -1,8 +1,8 @@
export * from './types';
export * from './windows';
export * from './scoring';
export * from './material';
export * from './engine';
export * from './store';
export * from './utils';
export * from './material';
export * from './modules';
export * from './scoring';
export * from './store';
export * from './types';
export * from './utils';
export * from './windows';

View File

@@ -6,7 +6,7 @@ export function materialDecision(
next: {
changePct?: number;
direction?: 'up' | 'down' | 'flat';
},
}
): MaterialDecision {
const nextBand = band(next.changePct);
if (!prev) {

View File

@@ -1,4 +1,4 @@
import { TABLE_NAMES, formatClickhouseDate } from '../../../clickhouse/client';
import { formatClickhouseDate, TABLE_NAMES } from '../../../clickhouse/client';
import type {
ComputeContext,
ComputeResult,
@@ -55,7 +55,7 @@ async function fetchDeviceAggregates(ctx: ComputeContext): Promise<{
.clix()
.select<{ cur_total: number }>([
ctx.clix.exp(
`countIf(created_at BETWEEN '${formatClickhouseDate(ctx.window.start)}' AND '${formatClickhouseDate(getEndOfDay(ctx.window.end))}') as cur_total`,
`countIf(created_at BETWEEN '${formatClickhouseDate(ctx.window.start)}' AND '${formatClickhouseDate(getEndOfDay(ctx.window.end))}') as cur_total`
),
])
.from(TABLE_NAMES.sessions)
@@ -117,10 +117,10 @@ async function fetchDeviceAggregates(ctx: ComputeContext): Promise<{
.select<{ device: string; cur: number; base: number }>([
'device',
ctx.clix.exp(
`countIf(created_at BETWEEN '${curStart}' AND '${curEnd}') as cur`,
`countIf(created_at BETWEEN '${curStart}' AND '${curEnd}') as cur`
),
ctx.clix.exp(
`countIf(created_at BETWEEN '${baseStart}' AND '${baseEnd}') as base`,
`countIf(created_at BETWEEN '${baseStart}' AND '${baseEnd}') as base`
),
])
.from(TABLE_NAMES.sessions)
@@ -136,10 +136,10 @@ async function fetchDeviceAggregates(ctx: ComputeContext): Promise<{
.clix()
.select<{ cur_total: number; base_total: number }>([
ctx.clix.exp(
`countIf(created_at BETWEEN '${curStart}' AND '${curEnd}') as cur_total`,
`countIf(created_at BETWEEN '${curStart}' AND '${curEnd}') as cur_total`
),
ctx.clix.exp(
`countIf(created_at BETWEEN '${baseStart}' AND '${baseEnd}') as base_total`,
`countIf(created_at BETWEEN '${baseStart}' AND '${baseEnd}') as base_total`
),
])
.from(TABLE_NAMES.sessions)
@@ -155,13 +155,13 @@ async function fetchDeviceAggregates(ctx: ComputeContext): Promise<{
const currentMap = buildLookupMap(
results,
(r) => r.device,
(r) => Number(r.cur ?? 0),
(r) => Number(r.cur ?? 0)
);
const baselineMap = buildLookupMap(
results,
(r) => r.device,
(r) => Number(r.base ?? 0),
(r) => Number(r.base ?? 0)
);
const totalCurrent = totals[0]?.cur_total ?? 0;
@@ -180,7 +180,7 @@ export const devicesModule: InsightModule = {
const topDims = selectTopDimensions(
currentMap,
baselineMap,
this.thresholds?.maxDims ?? 5,
this.thresholds?.maxDims ?? 5
);
return topDims.map((dim) => `device:${dim}`);
},
@@ -191,7 +191,9 @@ export const devicesModule: InsightModule = {
const results: ComputeResult[] = [];
for (const dimKey of dimensionKeys) {
if (!dimKey.startsWith('device:')) continue;
if (!dimKey.startsWith('device:')) {
continue;
}
const deviceType = dimKey.replace('device:', '');
const currentValue = currentMap.get(deviceType) ?? 0;

View File

@@ -1,4 +1,4 @@
import { TABLE_NAMES, formatClickhouseDate } from '../../../clickhouse/client';
import { formatClickhouseDate, TABLE_NAMES } from '../../../clickhouse/client';
import type {
ComputeContext,
ComputeResult,
@@ -67,7 +67,7 @@ async function fetchEntryPageAggregates(ctx: ComputeContext): Promise<{
.clix()
.select<{ cur_total: number }>([
ctx.clix.exp(
`countIf(created_at BETWEEN '${formatClickhouseDate(ctx.window.start)}' AND '${formatClickhouseDate(getEndOfDay(ctx.window.end))}') as cur_total`,
`countIf(created_at BETWEEN '${formatClickhouseDate(ctx.window.start)}' AND '${formatClickhouseDate(getEndOfDay(ctx.window.end))}') as cur_total`
),
])
.from(TABLE_NAMES.sessions)
@@ -82,20 +82,20 @@ async function fetchEntryPageAggregates(ctx: ComputeContext): Promise<{
const currentMap = buildLookupMap(
currentResults,
(r) => `${r.entry_origin || ''}${DELIMITER}${r.entry_path || '/'}`,
(r) => `${r.entry_origin || ''}${DELIMITER}${r.entry_path || '/'}`
);
const targetWeekday = getWeekday(ctx.window.start);
const baselineMap = computeWeekdayMedians(
baselineResults,
targetWeekday,
(r) => `${r.entry_origin || ''}${DELIMITER}${r.entry_path || '/'}`,
(r) => `${r.entry_origin || ''}${DELIMITER}${r.entry_path || '/'}`
);
const totalCurrent = totals[0]?.cur_total ?? 0;
const totalBaseline = Array.from(baselineMap.values()).reduce(
(sum, val) => sum + val,
0,
0
);
return { currentMap, baselineMap, totalCurrent, totalBaseline };
@@ -118,10 +118,10 @@ async function fetchEntryPageAggregates(ctx: ComputeContext): Promise<{
'entry_origin',
'entry_path',
ctx.clix.exp(
`countIf(created_at BETWEEN '${curStart}' AND '${curEnd}') as cur`,
`countIf(created_at BETWEEN '${curStart}' AND '${curEnd}') as cur`
),
ctx.clix.exp(
`countIf(created_at BETWEEN '${baseStart}' AND '${baseEnd}') as base`,
`countIf(created_at BETWEEN '${baseStart}' AND '${baseEnd}') as base`
),
])
.from(TABLE_NAMES.sessions)
@@ -137,10 +137,10 @@ async function fetchEntryPageAggregates(ctx: ComputeContext): Promise<{
.clix()
.select<{ cur_total: number; base_total: number }>([
ctx.clix.exp(
`countIf(created_at BETWEEN '${curStart}' AND '${curEnd}') as cur_total`,
`countIf(created_at BETWEEN '${curStart}' AND '${curEnd}') as cur_total`
),
ctx.clix.exp(
`countIf(created_at BETWEEN '${baseStart}' AND '${baseEnd}') as base_total`,
`countIf(created_at BETWEEN '${baseStart}' AND '${baseEnd}') as base_total`
),
])
.from(TABLE_NAMES.sessions)
@@ -156,13 +156,13 @@ async function fetchEntryPageAggregates(ctx: ComputeContext): Promise<{
const currentMap = buildLookupMap(
results,
(r) => `${r.entry_origin || ''}${DELIMITER}${r.entry_path || '/'}`,
(r) => Number(r.cur ?? 0),
(r) => Number(r.cur ?? 0)
);
const baselineMap = buildLookupMap(
results,
(r) => `${r.entry_origin || ''}${DELIMITER}${r.entry_path || '/'}`,
(r) => Number(r.base ?? 0),
(r) => Number(r.base ?? 0)
);
const totalCurrent = totals[0]?.cur_total ?? 0;
@@ -181,7 +181,7 @@ export const entryPagesModule: InsightModule = {
const topDims = selectTopDimensions(
currentMap,
baselineMap,
this.thresholds?.maxDims ?? 100,
this.thresholds?.maxDims ?? 100
);
return topDims.map((dim) => `entry:${dim}`);
},
@@ -192,7 +192,9 @@ export const entryPagesModule: InsightModule = {
const results: ComputeResult[] = [];
for (const dimKey of dimensionKeys) {
if (!dimKey.startsWith('entry:')) continue;
if (!dimKey.startsWith('entry:')) {
continue;
}
const originPath = dimKey.replace('entry:', '');
const currentValue = currentMap.get(originPath) ?? 0;

View File

@@ -1,5 +1,5 @@
import { getCountry } from '@openpanel/constants';
import { TABLE_NAMES, formatClickhouseDate } from '../../../clickhouse/client';
import { formatClickhouseDate, TABLE_NAMES } from '../../../clickhouse/client';
import type {
ComputeContext,
ComputeResult,
@@ -59,7 +59,7 @@ async function fetchGeoAggregates(ctx: ComputeContext): Promise<{
.clix()
.select<{ cur_total: number }>([
ctx.clix.exp(
`countIf(created_at BETWEEN '${formatClickhouseDate(ctx.window.start)}' AND '${formatClickhouseDate(getEndOfDay(ctx.window.end))}') as cur_total`,
`countIf(created_at BETWEEN '${formatClickhouseDate(ctx.window.start)}' AND '${formatClickhouseDate(getEndOfDay(ctx.window.end))}') as cur_total`
),
])
.from(TABLE_NAMES.sessions)
@@ -74,20 +74,20 @@ async function fetchGeoAggregates(ctx: ComputeContext): Promise<{
const currentMap = buildLookupMap(
currentResults,
(r) => r.country || 'unknown',
(r) => r.country || 'unknown'
);
const targetWeekday = getWeekday(ctx.window.start);
const baselineMap = computeWeekdayMedians(
baselineResults,
targetWeekday,
(r) => r.country || 'unknown',
(r) => r.country || 'unknown'
);
const totalCurrent = totals[0]?.cur_total ?? 0;
const totalBaseline = Array.from(baselineMap.values()).reduce(
(sum, val) => sum + val,
0,
0
);
return { currentMap, baselineMap, totalCurrent, totalBaseline };
@@ -104,10 +104,10 @@ async function fetchGeoAggregates(ctx: ComputeContext): Promise<{
.select<{ country: string; cur: number; base: number }>([
'country',
ctx.clix.exp(
`countIf(created_at BETWEEN '${curStart}' AND '${curEnd}') as cur`,
`countIf(created_at BETWEEN '${curStart}' AND '${curEnd}') as cur`
),
ctx.clix.exp(
`countIf(created_at BETWEEN '${baseStart}' AND '${baseEnd}') as base`,
`countIf(created_at BETWEEN '${baseStart}' AND '${baseEnd}') as base`
),
])
.from(TABLE_NAMES.sessions)
@@ -123,10 +123,10 @@ async function fetchGeoAggregates(ctx: ComputeContext): Promise<{
.clix()
.select<{ cur_total: number; base_total: number }>([
ctx.clix.exp(
`countIf(created_at BETWEEN '${curStart}' AND '${curEnd}') as cur_total`,
`countIf(created_at BETWEEN '${curStart}' AND '${curEnd}') as cur_total`
),
ctx.clix.exp(
`countIf(created_at BETWEEN '${baseStart}' AND '${baseEnd}') as base_total`,
`countIf(created_at BETWEEN '${baseStart}' AND '${baseEnd}') as base_total`
),
])
.from(TABLE_NAMES.sessions)
@@ -142,13 +142,13 @@ async function fetchGeoAggregates(ctx: ComputeContext): Promise<{
const currentMap = buildLookupMap(
results,
(r) => r.country || 'unknown',
(r) => Number(r.cur ?? 0),
(r) => Number(r.cur ?? 0)
);
const baselineMap = buildLookupMap(
results,
(r) => r.country || 'unknown',
(r) => Number(r.base ?? 0),
(r) => Number(r.base ?? 0)
);
const totalCurrent = totals[0]?.cur_total ?? 0;
@@ -167,7 +167,7 @@ export const geoModule: InsightModule = {
const topDims = selectTopDimensions(
currentMap,
baselineMap,
this.thresholds?.maxDims ?? 30,
this.thresholds?.maxDims ?? 30
);
return topDims.map((dim) => `country:${dim}`);
},
@@ -178,7 +178,9 @@ export const geoModule: InsightModule = {
const results: ComputeResult[] = [];
for (const dimKey of dimensionKeys) {
if (!dimKey.startsWith('country:')) continue;
if (!dimKey.startsWith('country:')) {
continue;
}
const country = dimKey.replace('country:', '');
const currentValue = currentMap.get(country) ?? 0;
@@ -232,9 +234,7 @@ export const geoModule: InsightModule = {
displayName,
payload: {
kind: 'insight_v1',
dimensions: [
{ key: 'country', value: country, displayName: displayName },
],
dimensions: [{ key: 'country', value: country, displayName }],
primaryMetric: 'sessions',
metrics: {
sessions: {

View File

@@ -1,5 +1,5 @@
export { referrersModule } from './referrers.module';
export { entryPagesModule } from './entry-pages.module';
export { pageTrendsModule } from './page-trends.module';
export { geoModule } from './geo.module';
export { devicesModule } from './devices.module';
export { entryPagesModule } from './entry-pages.module';
export { geoModule } from './geo.module';
export { pageTrendsModule } from './page-trends.module';
export { referrersModule } from './referrers.module';

View File

@@ -1,4 +1,4 @@
import { TABLE_NAMES, formatClickhouseDate } from '../../../clickhouse/client';
import { formatClickhouseDate, TABLE_NAMES } from '../../../clickhouse/client';
import type {
ComputeContext,
ComputeResult,
@@ -62,7 +62,7 @@ async function fetchPageTrendAggregates(ctx: ComputeContext): Promise<{
.clix()
.select<{ cur_total: number }>([
ctx.clix.exp(
`countIf(created_at BETWEEN '${formatClickhouseDate(ctx.window.start)}' AND '${formatClickhouseDate(getEndOfDay(ctx.window.end))}') as cur_total`,
`countIf(created_at BETWEEN '${formatClickhouseDate(ctx.window.start)}' AND '${formatClickhouseDate(getEndOfDay(ctx.window.end))}') as cur_total`
),
])
.from(TABLE_NAMES.events)
@@ -77,20 +77,20 @@ async function fetchPageTrendAggregates(ctx: ComputeContext): Promise<{
const currentMap = buildLookupMap(
currentResults,
(r) => `${r.origin || ''}${DELIMITER}${r.path || '/'}`,
(r) => `${r.origin || ''}${DELIMITER}${r.path || '/'}`
);
const targetWeekday = getWeekday(ctx.window.start);
const baselineMap = computeWeekdayMedians(
baselineResults,
targetWeekday,
(r) => `${r.origin || ''}${DELIMITER}${r.path || '/'}`,
(r) => `${r.origin || ''}${DELIMITER}${r.path || '/'}`
);
const totalCurrent = totals[0]?.cur_total ?? 0;
const totalBaseline = Array.from(baselineMap.values()).reduce(
(sum, val) => sum + val,
0,
0
);
return { currentMap, baselineMap, totalCurrent, totalBaseline };
@@ -108,10 +108,10 @@ async function fetchPageTrendAggregates(ctx: ComputeContext): Promise<{
'origin',
'path',
ctx.clix.exp(
`countIf(created_at BETWEEN '${curStart}' AND '${curEnd}') as cur`,
`countIf(created_at BETWEEN '${curStart}' AND '${curEnd}') as cur`
),
ctx.clix.exp(
`countIf(created_at BETWEEN '${baseStart}' AND '${baseEnd}') as base`,
`countIf(created_at BETWEEN '${baseStart}' AND '${baseEnd}') as base`
),
])
.from(TABLE_NAMES.events)
@@ -127,10 +127,10 @@ async function fetchPageTrendAggregates(ctx: ComputeContext): Promise<{
.clix()
.select<{ cur_total: number; base_total: number }>([
ctx.clix.exp(
`countIf(created_at BETWEEN '${curStart}' AND '${curEnd}') as cur_total`,
`countIf(created_at BETWEEN '${curStart}' AND '${curEnd}') as cur_total`
),
ctx.clix.exp(
`countIf(created_at BETWEEN '${baseStart}' AND '${baseEnd}') as base_total`,
`countIf(created_at BETWEEN '${baseStart}' AND '${baseEnd}') as base_total`
),
])
.from(TABLE_NAMES.events)
@@ -146,13 +146,13 @@ async function fetchPageTrendAggregates(ctx: ComputeContext): Promise<{
const currentMap = buildLookupMap(
results,
(r) => `${r.origin || ''}${DELIMITER}${r.path || '/'}`,
(r) => Number(r.cur ?? 0),
(r) => Number(r.cur ?? 0)
);
const baselineMap = buildLookupMap(
results,
(r) => `${r.origin || ''}${DELIMITER}${r.path || '/'}`,
(r) => Number(r.base ?? 0),
(r) => Number(r.base ?? 0)
);
const totalCurrent = totals[0]?.cur_total ?? 0;
@@ -175,7 +175,7 @@ export const pageTrendsModule: InsightModule = {
const topDims = selectTopDimensions(
currentMap,
baselineMap,
this.thresholds?.maxDims ?? 100,
this.thresholds?.maxDims ?? 100
);
return topDims.map((dim) => `page:${dim}`);
},
@@ -186,7 +186,9 @@ export const pageTrendsModule: InsightModule = {
const results: ComputeResult[] = [];
for (const dimKey of dimensionKeys) {
if (!dimKey.startsWith('page:')) continue;
if (!dimKey.startsWith('page:')) {
continue;
}
const originPath = dimKey.replace('page:', '');
const pageviewsCurrent = currentMap.get(originPath) ?? 0;
@@ -199,8 +201,8 @@ export const pageTrendsModule: InsightModule = {
// Use share values in basis points (100 = 1%) for thresholding
// This makes thresholds intuitive: minAbsDelta=50 means 0.5pp shift
const currentShareBp = currentShare * 10000;
const compareShareBp = compareShare * 10000;
const currentShareBp = currentShare * 10_000;
const compareShareBp = compareShare * 10_000;
const shareShiftPp = (currentShare - compareShare) * 100;
// changePct is relative change in share, not absolute pageviews

View File

@@ -1,4 +1,4 @@
import { TABLE_NAMES, formatClickhouseDate } from '../../../clickhouse/client';
import { formatClickhouseDate, TABLE_NAMES } from '../../../clickhouse/client';
import type {
ComputeContext,
ComputeResult,
@@ -58,7 +58,7 @@ async function fetchReferrerAggregates(ctx: ComputeContext): Promise<{
.clix()
.select<{ cur_total: number }>([
ctx.clix.exp(
`countIf(created_at BETWEEN '${formatClickhouseDate(ctx.window.start)}' AND '${formatClickhouseDate(getEndOfDay(ctx.window.end))}') as cur_total`,
`countIf(created_at BETWEEN '${formatClickhouseDate(ctx.window.start)}' AND '${formatClickhouseDate(getEndOfDay(ctx.window.end))}') as cur_total`
),
])
.from(TABLE_NAMES.sessions)
@@ -73,20 +73,20 @@ async function fetchReferrerAggregates(ctx: ComputeContext): Promise<{
const currentMap = buildLookupMap(
currentResults,
(r) => r.referrer_name || 'direct',
(r) => r.referrer_name || 'direct'
);
const targetWeekday = getWeekday(ctx.window.start);
const baselineMap = computeWeekdayMedians(
baselineResults,
targetWeekday,
(r) => r.referrer_name || 'direct',
(r) => r.referrer_name || 'direct'
);
const totalCurrent = totals[0]?.cur_total ?? 0;
const totalBaseline = Array.from(baselineMap.values()).reduce(
(sum, val) => sum + val,
0,
0
);
return { currentMap, baselineMap, totalCurrent, totalBaseline };
@@ -103,10 +103,10 @@ async function fetchReferrerAggregates(ctx: ComputeContext): Promise<{
.select<{ referrer_name: string; cur: number; base: number }>([
'referrer_name',
ctx.clix.exp(
`countIf(created_at BETWEEN '${curStart}' AND '${curEnd}') as cur`,
`countIf(created_at BETWEEN '${curStart}' AND '${curEnd}') as cur`
),
ctx.clix.exp(
`countIf(created_at BETWEEN '${baseStart}' AND '${baseEnd}') as base`,
`countIf(created_at BETWEEN '${baseStart}' AND '${baseEnd}') as base`
),
])
.from(TABLE_NAMES.sessions)
@@ -122,10 +122,10 @@ async function fetchReferrerAggregates(ctx: ComputeContext): Promise<{
.clix()
.select<{ cur_total: number; base_total: number }>([
ctx.clix.exp(
`countIf(created_at BETWEEN '${curStart}' AND '${curEnd}') as cur_total`,
`countIf(created_at BETWEEN '${curStart}' AND '${curEnd}') as cur_total`
),
ctx.clix.exp(
`countIf(created_at BETWEEN '${baseStart}' AND '${baseEnd}') as base_total`,
`countIf(created_at BETWEEN '${baseStart}' AND '${baseEnd}') as base_total`
),
])
.from(TABLE_NAMES.sessions)
@@ -141,13 +141,13 @@ async function fetchReferrerAggregates(ctx: ComputeContext): Promise<{
const currentMap = buildLookupMap(
results,
(r) => r.referrer_name || 'direct',
(r) => Number(r.cur ?? 0),
(r) => Number(r.cur ?? 0)
);
const baselineMap = buildLookupMap(
results,
(r) => r.referrer_name || 'direct',
(r) => Number(r.base ?? 0),
(r) => Number(r.base ?? 0)
);
const totalCurrent = totals[0]?.cur_total ?? 0;
@@ -166,7 +166,7 @@ export const referrersModule: InsightModule = {
const topDims = selectTopDimensions(
currentMap,
baselineMap,
this.thresholds?.maxDims ?? 50,
this.thresholds?.maxDims ?? 50
);
return topDims.map((dim) => `referrer:${dim}`);
},
@@ -177,7 +177,9 @@ export const referrersModule: InsightModule = {
const results: ComputeResult[] = [];
for (const dimKey of dimensionKeys) {
if (!dimKey.startsWith('referrer:')) continue;
if (!dimKey.startsWith('referrer:')) {
continue;
}
const referrerName = dimKey.replace('referrer:', '');
const currentValue = currentMap.get(referrerName) ?? 0;

View File

@@ -8,11 +8,17 @@ export function defaultImpactScore(r: ComputeResult): number {
}
export function severityBand(
changePct?: number | null,
changePct?: number | null
): 'low' | 'moderate' | 'severe' | null {
const p = Math.abs(changePct ?? 0);
if (p < 0.1) return null;
if (p < 0.5) return 'low';
if (p < 1) return 'moderate';
if (p < 0.1) {
return null;
}
if (p < 0.5) {
return 'low';
}
if (p < 1) {
return 'moderate';
}
return 'severe';
}

View File

@@ -1,11 +1,9 @@
import { Prisma, db } from '../../prisma-client';
import { db, Prisma } from '../../prisma-client';
import type {
Cadence,
InsightStore,
PersistedInsight,
RenderedCard,
WindowKind,
WindowRange,
} from './types';
export const insightStore: InsightStore = {
@@ -48,7 +46,9 @@ export const insightStore: InsightStore = {
},
});
if (!insight) return null;
if (!insight) {
return null;
}
return {
id: insight.id,
@@ -276,12 +276,14 @@ export const insightStore: InsightStore = {
// Filter to only non-stale insights for top-N logic
const freshInsights = insights.filter((insight) => {
if (!insight.windowEnd) return false;
if (!insight.windowEnd) {
return false;
}
const windowEndTime = new Date(insight.windowEnd).setUTCHours(
0,
0,
0,
0,
0
);
return windowEndTime === yesterdayTime;
});

View File

@@ -1,9 +1,4 @@
import type {
InsightDimension,
InsightMetricEntry,
InsightMetricKey,
InsightPayload,
} from '@openpanel/validation';
import type { InsightPayload } from '@openpanel/validation';
export type Cadence = 'daily';
@@ -78,7 +73,7 @@ export interface InsightModule {
/** Preferred path: batch compute many dimensions in one go. */
computeMany(
ctx: ComputeContext,
dimensionKeys: string[],
dimensionKeys: string[]
): Promise<ComputeResult[]>;
/** Must not do DB reads; just format output. */
render(result: ComputeResult, ctx: ComputeContext): RenderedCard;
@@ -87,7 +82,7 @@ export interface InsightModule {
/** Optional: compute "drivers" for AI explain step */
drivers?(
result: ComputeResult,
ctx: ComputeContext,
ctx: ComputeContext
): Promise<Record<string, unknown>>;
}

View File

@@ -13,7 +13,9 @@ export function getWeekday(date: Date): number {
* Compute median of a sorted array of numbers
*/
export function computeMedian(sortedValues: number[]): number {
if (sortedValues.length === 0) return 0;
if (sortedValues.length === 0) {
return 0;
}
const mid = Math.floor(sortedValues.length / 2);
return sortedValues.length % 2 === 0
? ((sortedValues[mid - 1] ?? 0) + (sortedValues[mid] ?? 0)) / 2
@@ -32,14 +34,16 @@ export function computeMedian(sortedValues: number[]): number {
export function computeWeekdayMedians<T>(
data: T[],
targetWeekday: number,
getDimension: (row: T) => string,
getDimension: (row: T) => string
): Map<string, number> {
// Group by dimension, filtered to target weekday
const byDimension = new Map<string, number[]>();
for (const row of data) {
const rowWeekday = getWeekday(new Date((row as any).date));
if (rowWeekday !== targetWeekday) continue;
if (rowWeekday !== targetWeekday) {
continue;
}
const dim = getDimension(row);
const values = byDimension.get(dim) ?? [];
@@ -62,7 +66,7 @@ export function computeWeekdayMedians<T>(
*/
export function computeChangePct(
currentValue: number,
compareValue: number,
compareValue: number
): number {
return compareValue > 0
? (currentValue - compareValue) / compareValue
@@ -76,7 +80,7 @@ export function computeChangePct(
*/
export function computeDirection(
changePct: number,
threshold = 0.05,
threshold = 0.05
): 'up' | 'down' | 'flat' {
return changePct > threshold
? 'up'
@@ -107,7 +111,7 @@ export function getEndOfDay(date: Date): Date {
export function buildLookupMap<T>(
results: T[],
getKey: (row: T) => string,
getCount: (row: T) => number = (row) => Number((row as any).cnt ?? 0),
getCount: (row: T) => number = (row) => Number((row as any).cnt ?? 0)
): Map<string, number> {
const map = new Map<string, number>();
for (const row of results) {
@@ -130,12 +134,16 @@ export function buildLookupMap<T>(
export function selectTopDimensions(
currentMap: Map<string, number>,
baselineMap: Map<string, number>,
maxDims: number,
maxDims: number
): string[] {
// Merge all dimensions from both maps
const allDims = new Set<string>();
for (const dim of currentMap.keys()) allDims.add(dim);
for (const dim of baselineMap.keys()) allDims.add(dim);
for (const dim of currentMap.keys()) {
allDims.add(dim);
}
for (const dim of baselineMap.keys()) {
allDims.add(dim);
}
// Rank by greatest(current, baseline)
const ranked = Array.from(allDims)

View File

@@ -90,7 +90,7 @@ export const getNotificationRulesByProjectId = cacheable(
},
});
},
60 * 24,
60 * 24
);
function getIntegration(integrationId: string | null) {

View File

@@ -6,7 +6,7 @@ import type { Invite, Prisma, ProjectAccess, User } from '../prisma-client';
import { db } from '../prisma-client';
import { createSqlBuilder } from '../sql-builder';
import { getOrganizationAccess, getProjectAccess } from './access.service';
import { type IServiceProject, getProjectById } from './project.service';
import type { IServiceProject } from './project.service';
export type IServiceOrganization = Awaited<
ReturnType<typeof db.organization.findUniqueOrThrow>
>;
@@ -17,7 +17,9 @@ export type IServiceMember = Prisma.MemberGetPayload<{
export type IServiceProjectAccess = ProjectAccess;
export async function getOrganizations(userId: string | null) {
if (!userId) return [];
if (!userId) {
return [];
}
const organizations = await db.organization.findMany({
where: {
@@ -62,7 +64,7 @@ export async function getOrganizationByProjectId(projectId: string) {
export const getOrganizationByProjectIdCached = cacheable(
getOrganizationByProjectId,
60 * 5,
60 * 5
);
export async function getInvites(organizationId: string) {
@@ -202,13 +204,15 @@ export async function connectUserToOrganization({
* current subscription period for an organization
*/
export async function getOrganizationBillingEventsCount(
organization: IServiceOrganization & { projects: IServiceProject[] },
organization: IServiceOrganization & { projects: IServiceProject[] }
) {
// Dont count events if the organization has no subscription
// Since we only use this for billing purposes
if (
!organization.subscriptionCurrentPeriodStart ||
!organization.subscriptionCurrentPeriodEnd
!(
organization.subscriptionCurrentPeriodStart &&
organization.subscriptionCurrentPeriodEnd
)
) {
return 0;
}
@@ -232,7 +236,7 @@ export async function getOrganizationBillingEventsCountSerie(
}: {
startDate: Date;
endDate: Date;
},
}
) {
const interval = 'day';
const { sb, getSql } = createSqlBuilder();
@@ -251,12 +255,12 @@ export async function getOrganizationBillingEventsCountSerie(
export const getOrganizationBillingEventsCountSerieCached = cacheable(
getOrganizationBillingEventsCountSerie,
60 * 10,
60 * 10
);
export async function getOrganizationSubscriptionChartEndDate(
projectId: string,
endDate: string,
endDate: string
) {
const organization = await getOrganizationByProjectIdCached(projectId);
if (!organization) {

View File

@@ -1,4 +1,4 @@
import type { Prisma, Reference } from '../prisma-client';
import type { Reference } from '../prisma-client';
import { db } from '../prisma-client';
export type IServiceReference = Reference;

View File

@@ -12,21 +12,20 @@ import type {
IReport,
IReportOptions,
} from '@openpanel/validation';
import type { Report as DbReport, ReportLayout } from '../prisma-client';
import { db } from '../prisma-client';
export type IServiceReport = Awaited<ReturnType<typeof getReportById>>;
export const onlyReportEvents = (
series: NonNullable<IServiceReport>['series'],
series: NonNullable<IServiceReport>['series']
) => {
return series.filter((item) => item.type === 'event');
};
export function transformFilter(
filter: Partial<IChartEventFilter>,
index: number,
index: number
): IChartEventFilter {
return {
id: filter.id ?? alphabetIds[index] ?? 'A',
@@ -39,7 +38,7 @@ export function transformFilter(
export function transformReportEventItem(
item: IChartEventItem,
index: number,
index: number
): IChartEventItem {
if (item.type === 'formula') {
// Transform formula
@@ -64,7 +63,7 @@ export function transformReportEventItem(
}
export function transformReport(
report: DbReport & { layout?: ReportLayout | null },
report: DbReport & { layout?: ReportLayout | null }
): IReport & {
id: string;
layout?: ReportLayout | null;

View File

@@ -1,6 +1,5 @@
import sqlstring from 'sqlstring';
import { TABLE_NAMES, chQuery } from '../clickhouse/client';
import { chQuery, TABLE_NAMES } from '../clickhouse/client';
type IGetWeekRetentionInput = {
projectId: string;

View File

@@ -1,5 +1,4 @@
import { generateSalt } from '@openpanel/common/server';
import { cacheable } from '@openpanel/redis';
import { db } from '../prisma-client';
@@ -24,7 +23,7 @@ export const getSalts = cacheable(
return salts;
},
60 * 5,
60 * 5
);
export async function createInitialSalts() {
@@ -52,7 +51,7 @@ export async function createInitialSalts() {
if (retryCount < MAX_RETRIES) {
const delay = BASE_DELAY * 2 ** retryCount;
console.log(
`Retrying in ${delay}ms... (Attempt ${retryCount + 1}/${MAX_RETRIES})`,
`Retrying in ${delay}ms... (Attempt ${retryCount + 1}/${MAX_RETRIES})`
);
await new Promise((resolve) => setTimeout(resolve, delay));
return createSaltsWithRetry(retryCount + 1);

View File

@@ -1,7 +1,7 @@
import { chartColors } from '@openpanel/constants';
import { type IChartEventFilter, zChartEvent } from '@openpanel/validation';
import { z } from 'zod';
import { TABLE_NAMES, ch } from '../clickhouse/client';
import { ch, TABLE_NAMES } from '../clickhouse/client';
import { clix } from '../clickhouse/query-builder';
import { getEventFiltersWhereClause } from './chart.service';
@@ -43,7 +43,7 @@ export class SankeyService {
return item;
}
return item;
}),
})
);
return Object.values(where).join(' AND ');
@@ -53,7 +53,7 @@ export class SankeyService {
include: string[] | undefined,
exclude: string[],
startEventName: string | undefined,
endEventName: string | undefined,
endEventName: string | undefined
) {
if (include && include.length > 0) {
const eventNames = [...include, startEventName, endEventName]
@@ -76,7 +76,7 @@ export class SankeyService {
projectId: string,
startDate: string,
endDate: string,
timezone: string,
timezone: string
): ReturnType<typeof clix> {
return clix(this.client, timezone)
.select<{ session_id: string }>(['session_id'])
@@ -97,7 +97,7 @@ export class SankeyService {
endEvent: z.infer<typeof zChartEvent> | undefined,
hasStartEventCTE: boolean,
hasEndEventCTE: boolean,
steps: number,
steps: number
): { sessionFilter: string; eventsSliceExpr: string } {
const defaultSliceExpr = `arraySlice(events_deduped, 1, ${steps})`;
@@ -149,7 +149,7 @@ export class SankeyService {
endEvent: z.infer<typeof zChartEvent>,
steps: number,
COLORS: string[],
timezone: string,
timezone: string
): Promise<{
nodes: Array<{
id: string;
@@ -226,7 +226,7 @@ export class SankeyService {
'arraySlice(events, start_index, end_index - start_index + 1) as events',
])
.from('between_sessions')
.having('events[1]', 'IN', topEntryEvents),
.having('events[1]', 'IN', topEntryEvents)
)
.select<{
source: string;
@@ -241,8 +241,8 @@ export class SankeyService {
])
.from(
clix.exp(
'(SELECT arrayJoin(arrayMap(i -> (events[i], events[i + 1], i), range(1, length(events)))) as pair FROM session_paths WHERE length(events) >= 2)',
),
'(SELECT arrayJoin(arrayMap(i -> (events[i], events[i + 1], i), range(1, length(events)))) as pair FROM session_paths WHERE length(events) >= 2)'
)
)
.groupBy(['source', 'target', 'step'])
.orderBy('step', 'ASC')
@@ -255,7 +255,7 @@ export class SankeyService {
topEntries,
totalSessions,
steps,
COLORS,
COLORS
);
}
@@ -263,7 +263,7 @@ export class SankeyService {
sessionPathsQuery: ReturnType<typeof clix>,
steps: number,
COLORS: string[],
timezone: string,
timezone: string
): Promise<{
nodes: Array<{
id: string;
@@ -304,7 +304,7 @@ export class SankeyService {
clix(this.client, timezone)
.select(['session_id', 'events'])
.from('session_paths_base')
.having('events[1]', 'IN', topEntryEvents),
.having('events[1]', 'IN', topEntryEvents)
)
.select<{
source: string;
@@ -319,8 +319,8 @@ export class SankeyService {
])
.from(
clix.exp(
'(SELECT arrayJoin(arrayMap(i -> (events[i], events[i + 1], i), range(1, length(events)))) as pair FROM session_paths WHERE length(events) >= 2)',
),
'(SELECT arrayJoin(arrayMap(i -> (events[i], events[i + 1], i), range(1, length(events)))) as pair FROM session_paths WHERE length(events) >= 2)'
)
)
.groupBy(['source', 'target', 'step'])
.orderBy('step', 'ASC')
@@ -333,7 +333,7 @@ export class SankeyService {
topEntries,
totalSessions,
steps,
COLORS,
COLORS
);
}
@@ -366,7 +366,7 @@ export class SankeyService {
include,
exclude,
startEvent?.name,
endEvent?.name,
endEvent?.name
);
// 2. Build ordered events query
@@ -402,7 +402,7 @@ export class SankeyService {
projectId,
startDate,
endDate,
timezone,
timezone
)
: null;
const endEventCTE =
@@ -412,7 +412,7 @@ export class SankeyService {
projectId,
startDate,
endDate,
timezone,
timezone
)
: null;
@@ -440,7 +440,7 @@ export class SankeyService {
endEvent,
startEventCTE !== null,
endEventCTE !== null,
steps,
steps
);
// 6. Build truncate expression (for 'after' mode)
@@ -469,7 +469,7 @@ export class SankeyService {
const sessionPathsQuery = eventCTEs
.reduce(
(builder, cte) => builder.with(cte.name, cte.query),
clix(this.client, timezone),
clix(this.client, timezone)
)
.with('events_deduped_cte', eventsDedupedCTE)
.with(
@@ -480,7 +480,7 @@ export class SankeyService {
events_sliced: string[];
}>(['session_id', `${eventsSliceExpr} as events_sliced`])
.from('events_deduped_cte')
.rawHaving(sessionFilter || '1 = 1'),
.rawHaving(sessionFilter || '1 = 1')
)
.select<{
session_id: string;
@@ -498,7 +498,7 @@ export class SankeyService {
endEvent,
steps,
COLORS,
timezone,
timezone
);
}
@@ -515,7 +515,7 @@ export class SankeyService {
topEntries: Array<{ entry_event: string; count: number }>,
totalSessions: number,
steps: number,
COLORS: string[],
COLORS: string[]
) {
if (transitions.length === 0) {
return { nodes: [], links: [] };
@@ -570,7 +570,9 @@ export class SankeyService {
for (const t of fromSource) {
// Skip self-loops
if (t.source === t.target) continue;
if (t.source === t.target) {
continue;
}
const targetNodeId = getNodeId(t.target, step + 1);
@@ -607,7 +609,9 @@ export class SankeyService {
}
// Stop if no more nodes to process
if (activeNodes.size === 0) break;
if (activeNodes.size === 0) {
break;
}
}
// Filter links by threshold (0.25% of total sessions)
@@ -657,21 +661,23 @@ export class SankeyService {
};
})
.sort((a, b) => {
if (a.step !== b.step) return a.step - b.step;
if (a.step !== b.step) {
return a.step - b.step;
}
return b.value - a.value;
});
// Sanity check: Ensure all link endpoints exist in nodes
const nodeIds = new Set(finalNodes.map((n) => n.id));
const validLinks = filteredLinks.filter(
(link) => nodeIds.has(link.source) && nodeIds.has(link.target),
(link) => nodeIds.has(link.source) && nodeIds.has(link.target)
);
// Combine final nodes with the same event name
// A final node is one that has no outgoing links
const nodesWithOutgoing = new Set(validLinks.map((l) => l.source));
const finalNodeIds = new Set(
finalNodes.filter((n) => !nodesWithOutgoing.has(n.id)).map((n) => n.id),
finalNodes.filter((n) => !nodesWithOutgoing.has(n.id)).map((n) => n.id)
);
// Group final nodes by event name
@@ -695,7 +701,7 @@ export class SankeyService {
const maxStep = Math.max(...nodesToMerge.map((n) => n.step || 0));
const totalValue = nodesToMerge.reduce(
(sum, n) => sum + (n.value || 0),
0,
0
);
const mergedNodeId = `${eventName}::final`;
const firstNode = nodesToMerge[0]!;
@@ -735,12 +741,14 @@ export class SankeyService {
// Remove old final nodes that were merged
const mergedOldNodeIds = new Set(nodeIdRemap.keys());
const remainingNodes = nonFinalNodes.filter(
(n) => !mergedOldNodeIds.has(n.id),
(n) => !mergedOldNodeIds.has(n.id)
);
// Combine all nodes and sort
const allNodes = [...remainingNodes, ...finalNodesList].sort((a, b) => {
if (a.step !== b.step) return a.step! - b.step!;
if (a.step !== b.step) {
return a.step! - b.step!;
}
return b.value! - a.value!;
});
@@ -754,12 +762,14 @@ export class SankeyService {
const aggregatedLinks = Array.from(linkMap.entries())
.map(([key, value]) => {
const parts = key.split('->');
if (parts.length !== 2) return null;
if (parts.length !== 2) {
return null;
}
return { source: parts[0]!, target: parts[1]!, value };
})
.filter(
(link): link is { source: string; target: string; value: number } =>
link !== null,
link !== null
);
// Final sanity check: Ensure all link endpoints exist in nodes
@@ -770,7 +780,7 @@ export class SankeyService {
value: number;
}> = aggregatedLinks.filter(
(link) =>
finalNodeIdsSet.has(link.source) && finalNodeIdsSet.has(link.target),
finalNodeIdsSet.has(link.source) && finalNodeIdsSet.has(link.target)
);
return {

View File

@@ -72,7 +72,7 @@ export function getShareReportByReportId(reportId: string) {
export async function validateReportAccess(
reportId: string,
shareId: string,
shareType: 'dashboard' | 'report',
shareType: 'dashboard' | 'report'
) {
if (shareType === 'dashboard') {
const share = await db.shareDashboard.findUnique({
@@ -88,7 +88,7 @@ export async function validateReportAccess(
},
});
if (!share || !share.public) {
if (!(share && share.public)) {
throw new Error('Share not found or not public');
}
@@ -106,7 +106,7 @@ export async function validateReportAccess(
},
});
if (!share || !share.public) {
if (!(share && share.public)) {
throw new Error('Share not found or not public');
}
@@ -124,7 +124,7 @@ export async function validateShareAccess(
ctx: {
cookies: Record<string, string | undefined>;
session?: { userId?: string | null };
},
}
): Promise<{ projectId: string; isValid: boolean }> {
// Check ShareDashboard first
const dashboardShare = await db.shareDashboard.findUnique({
@@ -221,7 +221,7 @@ export async function validateOverviewShareAccess(
ctx: {
cookies: Record<string, string | undefined>;
session?: { userId?: string | null };
},
}
): Promise<{ isValid: boolean }> {
// If shareId is provided, validate share access
if (shareId) {
@@ -229,7 +229,7 @@ export async function validateOverviewShareAccess(
where: { id: shareId },
});
if (!share || !share.public) {
if (!(share && share.public)) {
throw new Error('Share not found or not public');
}

View File

@@ -14,7 +14,11 @@ export async function getUserAccount({
email,
provider,
providerId,
}: { email: string; provider: string; providerId?: string }) {
}: {
email: string;
provider: string;
providerId?: string;
}) {
const res = await db.user.findFirst({
where: {
email: {

View File

@@ -1,6 +1,6 @@
import { getRedisCache } from '@openpanel/redis';
import type { Operation } from '@prisma/client/runtime/client';
import { Prisma, type PrismaClient } from './generated/prisma/client';
import { Prisma } from './generated/prisma/client';
import { logger } from './logger';
import { getAlsSessionId } from './session-context';
@@ -43,7 +43,7 @@ const isReadOperation = (operation: string) =>
READ_OPERATIONS.includes(operation as Operation);
async function getCurrentWalLsn(
prismaClient: BarePrismaClient,
prismaClient: BarePrismaClient
): Promise<string | null> {
try {
const result = await prismaClient.$queryRaw<[{ lsn: string }]>`
@@ -58,7 +58,7 @@ async function getCurrentWalLsn(
async function cacheWalLsnForSession(
sessionId: string,
lsn: string,
lsn: string
): Promise<void> {
try {
const redis = getRedisCache();
@@ -85,8 +85,12 @@ function compareWalLsn(lsn1: string, lsn2: string): number {
const v1 = ((x1 ?? 0n) << 32n) + (y1 ?? 0n);
const v2 = ((x2 ?? 0n) << 32n) + (y2 ?? 0n);
if (v1 < v2) return -1;
if (v1 > v2) return 1;
if (v1 < v2) {
return -1;
}
if (v1 > v2) {
return 1;
}
return 0;
}
@@ -98,7 +102,7 @@ async function sleep(ms: number): Promise<void> {
// Need a way to check LSN on the actual replica that will be used for the read.
async function waitForReplicaCatchup(
prismaClient: BarePrismaClient,
sessionId: string,
sessionId: string
): Promise<boolean> {
const expectedLsn = await getCachedWalLsn(sessionId);
@@ -142,7 +146,7 @@ async function waitForReplicaCatchup(
{
sessionId,
expectedLsn,
},
}
);
return false;
}
@@ -235,6 +239,6 @@ export function sessionConsistency() {
return query(args);
},
},
}),
})
);
}

View File

@@ -6,7 +6,7 @@ export const als = new AsyncLocalStorage<Ctx>();
export const runWithAlsSession = <T>(
sid: string | null | undefined,
fn: () => Promise<T>,
fn: () => Promise<T>
) => als.run({ sessionId: sid || null }, fn);
export const getAlsSessionId = () => als.getStore()?.sessionId ?? null;

View File

@@ -50,9 +50,11 @@ export function createSqlBuilder() {
const getFill = () => (sb.fill ? `WITH FILL ${sb.fill}` : '');
const getWith = () => {
const cteEntries = Object.entries(sb.ctes);
if (cteEntries.length === 0) return '';
if (cteEntries.length === 0) {
return '';
}
const cteClauses = cteEntries.map(
([name, query]) => `${name} AS (${query})`,
([name, query]) => `${name} AS (${query})`
);
return `WITH ${cteClauses.join(', ')} `;
};

View File

@@ -2,9 +2,9 @@ import type {
IImportConfig,
IIntegrationConfig,
INotificationRuleConfig,
InsightPayload,
IProjectFilters,
IWidgetOptions,
InsightPayload,
} from '@openpanel/validation';
import type {
IClickhouseBotEvent,

View File

@@ -6,7 +6,11 @@ export function Button({
href,
children,
style,
}: { href: string; children: React.ReactNode; style?: React.CSSProperties }) {
}: {
href: string;
children: React.ReactNode;
style?: React.CSSProperties;
}) {
return (
<EmailButton
href={href}

View File

@@ -7,7 +7,6 @@ import {
Section,
Text,
} from '@react-email/components';
import React from 'react';
const baseUrl = 'https://openpanel.dev';
@@ -16,7 +15,7 @@ export function Footer({ unsubscribeUrl }: { unsubscribeUrl?: string }) {
<>
<Hr />
<Section className="w-full p-6">
<Text className="text-[21px] font-regular" style={{ margin: 0 }}>
<Text className="font-regular text-[21px]" style={{ margin: 0 }}>
An open-source alternative to Mixpanel
</Text>
@@ -26,40 +25,40 @@ export function Footer({ unsubscribeUrl }: { unsubscribeUrl?: string }) {
<Column className="w-8">
<Link href="https://git.new/openpanel">
<Img
alt="OpenPanel on Github"
height="22"
src={`${baseUrl}/icons/github.png`}
width="22"
height="22"
alt="OpenPanel on Github"
/>
</Link>
</Column>
<Column className="w-8">
<Link href="https://x.com/openpaneldev">
<Img
alt="OpenPanel on X"
height="22"
src={`${baseUrl}/icons/x.png`}
width="22"
height="22"
alt="OpenPanel on X"
/>
</Link>
</Column>
<Column className="w-8">
<Link href="https://go.openpanel.dev/discord">
<Img
alt="OpenPanel on Discord"
height="22"
src={`${baseUrl}/icons/discord.png`}
width="22"
height="22"
alt="OpenPanel on Discord"
/>
</Link>
</Column>
<Column className="w-auto">
<Link href="mailto:hello@openpanel.dev">
<Img
alt="Contact OpenPanel with email"
height="22"
src={`${baseUrl}/icons/email.png`}
width="22"
height="22"
alt="Contact OpenPanel with email"
/>
</Link>
</Column>

View File

@@ -22,39 +22,39 @@ export function Layout({ children, unsubscribeUrl }: Props) {
<Tailwind>
<head>
<Font
fontFamily="Geist"
fallbackFontFamily="Helvetica"
fontFamily="Geist"
fontStyle="normal"
fontWeight={400}
webFont={{
url: 'https://cdn.jsdelivr.net/npm/@fontsource/geist-sans@5.0.1/files/geist-sans-latin-400-normal.woff2',
format: 'woff2',
}}
fontWeight={400}
fontStyle="normal"
/>
<Font
fontFamily="Geist"
fallbackFontFamily="Helvetica"
fontFamily="Geist"
fontStyle="normal"
fontWeight={500}
webFont={{
url: 'https://cdn.jsdelivr.net/npm/@fontsource/geist-sans@5.0.1/files/geist-sans-latin-500-normal.woff2',
format: 'woff2',
}}
fontWeight={500}
fontStyle="normal"
/>
</head>
<Body className="bg-[#fff] my-auto mx-auto font-sans">
<Body className="mx-auto my-auto bg-[#fff] font-sans">
<Container
className="border-transparent md:border-[#E8E7E1] my-[40px] mx-auto max-w-[600px]"
className="mx-auto my-[40px] max-w-[600px] border-transparent md:border-[#E8E7E1]"
style={{ borderStyle: 'solid', borderWidth: 1 }}
>
<Section className="p-6">
<Img
src={'https://openpanel.dev/logo.png'}
width="80"
height="80"
alt="OpenPanel Logo"
height="80"
src={'https://openpanel.dev/logo.png'}
style={{ borderRadius: 4 }}
width="80"
/>
</Section>
<Section className="p-6">{children}</Section>

View File

@@ -1,5 +1,4 @@
import { Button, Link, Text } from '@react-email/components';
import React from 'react';
import { z } from 'zod';
import { Layout } from '../components/layout';

View File

@@ -1,5 +1,4 @@
import { Button, Link, Text } from '@react-email/components';
import React from 'react';
import { z } from 'zod';
import { Layout } from '../components/layout';

View File

@@ -1,5 +1,4 @@
import { Link, Text } from '@react-email/components';
import React from 'react';
import { Text } from '@react-email/components';
import { z } from 'zod';
import { Layout } from '../components/layout';
import { List } from '../components/list';
@@ -51,8 +50,8 @@ export function OnboardingDashboards({
</Text>
<span style={{ margin: '0 -20px', display: 'block' }}>
<img
src="https://openpanel.dev/_next/image?url=%2Fscreenshots%2Fdashboard-dark.webp&w=3840&q=75"
alt="Dashboard"
src="https://openpanel.dev/_next/image?url=%2Fscreenshots%2Fdashboard-dark.webp&w=3840&q=75"
style={{
width: '100%',
height: 'auto',

View File

@@ -1,5 +1,4 @@
import { Link, Text } from '@react-email/components';
import React from 'react';
import { z } from 'zod';
import { Layout } from '../components/layout';

View File

@@ -1,5 +1,4 @@
import { Text } from '@react-email/components';
import React from 'react';
import { z } from 'zod';
import { Button } from '../components/button';
import { Layout } from '../components/layout';

View File

@@ -1,5 +1,4 @@
import { Text } from '@react-email/components';
import React from 'react';
import { z } from 'zod';
import { Button } from '../components/button';
import { Layout } from '../components/layout';

View File

@@ -1,5 +1,4 @@
import { Heading, Link, Text } from '@react-email/components';
import React from 'react';
import { Link, Text } from '@react-email/components';
import { z } from 'zod';
import { Layout } from '../components/layout';
import { List } from '../components/list';
@@ -32,14 +31,14 @@ export function OnboardingWelcome({
<List
items={[
<Link
key=""
href={'https://openpanel.dev/docs/get-started/install-openpanel'}
key=""
>
Install tracking script
</Link>,
<Link
key=""
href={'https://openpanel.dev/docs/get-started/track-events'}
key=""
>
Start tracking your events
</Link>,

View File

@@ -1,5 +1,4 @@
import { Text } from '@react-email/components';
import React from 'react';
import { z } from 'zod';
import { Layout } from '../components/layout';
import { List } from '../components/list';

View File

@@ -1,5 +1,4 @@
import { Button, Hr, Link, Text } from '@react-email/components';
import React from 'react';
import { z } from 'zod';
import { Layout } from '../components/layout';

View File

@@ -1,8 +1,6 @@
import React from 'react';
import { db } from '@openpanel/db';
import { Resend } from 'resend';
import type { z } from 'zod';
import { db } from '@openpanel/db';
import { type TemplateKey, type Templates, templates } from './emails';
import { getUnsubscribeUrl } from './unsubscribe';
@@ -18,7 +16,7 @@ export async function sendEmail<T extends TemplateKey>(
options: {
to: string;
data: z.infer<Templates[T]['schema']>;
},
}
) {
const { to, data } = options;
const template = templates[templateKey];
@@ -41,7 +39,7 @@ export async function sendEmail<T extends TemplateKey>(
if (unsubscribed) {
console.log(
`Skipping email to ${to} - unsubscribed from ${template.category}`,
`Skipping email to ${to} - unsubscribed from ${template.category}`
);
return null;
}

Some files were not shown because too many files have changed in this diff Show More