chore:little fixes and formating and linting and patches
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export * from './src';
|
||||
export * from './constants';
|
||||
export * from './src';
|
||||
|
||||
@@ -24,4 +24,4 @@
|
||||
"peerDependencies": {
|
||||
"react": "catalog:"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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))
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -107,7 +107,7 @@ describe('getReferrerWithQuery', () => {
|
||||
utm_source: 'google',
|
||||
ref: 'facebook',
|
||||
utm_referrer: 'twitter',
|
||||
}),
|
||||
})
|
||||
).toEqual({
|
||||
name: 'Google',
|
||||
type: 'search',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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}',
|
||||
};
|
||||
|
||||
@@ -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]) => {
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
import { getSharedVitestConfig } from '../../vitest.shared';
|
||||
|
||||
export default getSharedVitestConfig({ __dirname });
|
||||
export default getSharedVitestConfig({ __dirname: import.meta.dirname });
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
)}"`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -6,7 +6,7 @@ export function materialDecision(
|
||||
next: {
|
||||
changePct?: number;
|
||||
direction?: 'up' | 'down' | 'flat';
|
||||
},
|
||||
}
|
||||
): MaterialDecision {
|
||||
const nextBand = band(next.changePct);
|
||||
if (!prev) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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>>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -90,7 +90,7 @@ export const getNotificationRulesByProjectId = cacheable(
|
||||
},
|
||||
});
|
||||
},
|
||||
60 * 24,
|
||||
60 * 24
|
||||
);
|
||||
|
||||
function getIntegration(integrationId: string | null) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
}),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(', ')} `;
|
||||
};
|
||||
|
||||
@@ -2,9 +2,9 @@ import type {
|
||||
IImportConfig,
|
||||
IIntegrationConfig,
|
||||
INotificationRuleConfig,
|
||||
InsightPayload,
|
||||
IProjectFilters,
|
||||
IWidgetOptions,
|
||||
InsightPayload,
|
||||
} from '@openpanel/validation';
|
||||
import type {
|
||||
IClickhouseBotEvent,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Link, Text } from '@react-email/components';
|
||||
import React from 'react';
|
||||
import { z } from 'zod';
|
||||
import { Layout } from '../components/layout';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user