aboutsummaryrefslogtreecommitdiff
path: root/src/lib
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-01-24 13:09:50 +0000
committerFuwn <[email protected]>2026-01-24 13:09:50 +0000
commit396acf3bbbe00a192cb0ea0a9ccf91b1d8d2850b (patch)
treeb9df4ca6a70db45cfffbae6fdd7252e20fb8e93c /src/lib
downloadumami-396acf3bbbe00a192cb0ea0a9ccf91b1d8d2850b.tar.xz
umami-396acf3bbbe00a192cb0ea0a9ccf91b1d8d2850b.zip
Initial commitHEADmain
Created from https://vercel.com/new
Diffstat (limited to 'src/lib')
-rw-r--r--src/lib/__tests__/charts.test.ts39
-rw-r--r--src/lib/__tests__/detect.test.ts22
-rw-r--r--src/lib/__tests__/format.test.ts38
-rw-r--r--src/lib/auth.ts80
-rw-r--r--src/lib/charts.ts27
-rw-r--r--src/lib/clickhouse.ts273
-rw-r--r--src/lib/client.ts14
-rw-r--r--src/lib/colors.ts91
-rw-r--r--src/lib/constants.ts682
-rw-r--r--src/lib/crypto.ts65
-rw-r--r--src/lib/data.ts94
-rw-r--r--src/lib/date.ts375
-rw-r--r--src/lib/db.ts40
-rw-r--r--src/lib/detect.ts154
-rw-r--r--src/lib/fetch.ts58
-rw-r--r--src/lib/filters.ts31
-rw-r--r--src/lib/format.ts118
-rw-r--r--src/lib/generate.ts20
-rw-r--r--src/lib/ip.ts60
-rw-r--r--src/lib/jwt.ts36
-rw-r--r--src/lib/kafka.ts112
-rw-r--r--src/lib/lang.ts111
-rw-r--r--src/lib/load.ts40
-rw-r--r--src/lib/params.ts62
-rw-r--r--src/lib/password.ts11
-rw-r--r--src/lib/prisma.ts368
-rw-r--r--src/lib/react.ts77
-rw-r--r--src/lib/redis.ts18
-rw-r--r--src/lib/request.ts145
-rw-r--r--src/lib/response.ts58
-rw-r--r--src/lib/schema.ts232
-rw-r--r--src/lib/sql.ts0
-rw-r--r--src/lib/storage.ts25
-rw-r--r--src/lib/types.ts143
-rw-r--r--src/lib/url.ts49
-rw-r--r--src/lib/utils.ts46
36 files changed, 3814 insertions, 0 deletions
diff --git a/src/lib/__tests__/charts.test.ts b/src/lib/__tests__/charts.test.ts
new file mode 100644
index 0000000..e81be16
--- /dev/null
+++ b/src/lib/__tests__/charts.test.ts
@@ -0,0 +1,39 @@
+import { renderNumberLabels } from '../charts';
+
+// test for renderNumberLabels
+
+describe('renderNumberLabels', () => {
+ test.each([
+ ['1000000', '1.0m'],
+ ['2500000', '2.5m'],
+ ])("formats numbers ≥ 1 million as 'Xm' (%s → %s)", (input, expected) => {
+ expect(renderNumberLabels(input)).toBe(expected);
+ });
+
+ test.each([['150000', '150k']])("formats numbers ≥ 100K as 'Xk' (%s → %s)", (input, expected) => {
+ expect(renderNumberLabels(input)).toBe(expected);
+ });
+
+ test.each([
+ ['12500', '12.5k'],
+ ])("formats numbers ≥ 10K as 'X.Xk' (%s → %s)", (input, expected) => {
+ expect(renderNumberLabels(input)).toBe(expected);
+ });
+
+ test.each([['1500', '1.50k']])("formats numbers ≥ 1K as 'X.XXk' (%s → %s)", (input, expected) => {
+ expect(renderNumberLabels(input)).toBe(expected);
+ });
+
+ test.each([
+ ['999', '999'],
+ ])('calls formatNumber for values < 1000 (%s → %s)', (input, expected) => {
+ expect(renderNumberLabels(input)).toBe(expected);
+ });
+
+ test.each([
+ ['0', '0'],
+ ['-5000', '-5000'],
+ ])('handles edge cases correctly (%s → %s)', (input, expected) => {
+ expect(renderNumberLabels(input)).toBe(expected);
+ });
+});
diff --git a/src/lib/__tests__/detect.test.ts b/src/lib/__tests__/detect.test.ts
new file mode 100644
index 0000000..0395aef
--- /dev/null
+++ b/src/lib/__tests__/detect.test.ts
@@ -0,0 +1,22 @@
+import { getIpAddress } from '../ip';
+
+const IP = '127.0.0.1';
+const BAD_IP = '127.127.127.127';
+
+test('getIpAddress: Custom header', () => {
+ process.env.CLIENT_IP_HEADER = 'x-custom-ip-header';
+
+ expect(getIpAddress(new Headers({ 'x-custom-ip-header': IP }))).toEqual(IP);
+});
+
+test('getIpAddress: CloudFlare header', () => {
+ expect(getIpAddress(new Headers({ 'cf-connecting-ip': IP }))).toEqual(IP);
+});
+
+test('getIpAddress: Standard header', () => {
+ expect(getIpAddress(new Headers({ 'x-forwarded-for': IP }))).toEqual(IP);
+});
+
+test('getIpAddress: No header', () => {
+ expect(getIpAddress(new Headers())).toEqual(null);
+});
diff --git a/src/lib/__tests__/format.test.ts b/src/lib/__tests__/format.test.ts
new file mode 100644
index 0000000..6e1b319
--- /dev/null
+++ b/src/lib/__tests__/format.test.ts
@@ -0,0 +1,38 @@
+import * as format from '../format';
+
+test('parseTime', () => {
+ expect(format.parseTime(86400 + 3600 + 60 + 1)).toEqual({
+ days: 1,
+ hours: 1,
+ minutes: 1,
+ seconds: 1,
+ ms: 0,
+ });
+});
+
+test('formatTime', () => {
+ expect(format.formatTime(3600 + 60 + 1)).toBe('1:01:01');
+});
+
+test('formatShortTime', () => {
+ expect(format.formatShortTime(3600 + 60 + 1)).toBe('1m1s');
+
+ expect(format.formatShortTime(3600 + 60 + 1, ['h', 'm', 's'])).toBe('1h1m1s');
+});
+
+test('formatNumber', () => {
+ expect(format.formatNumber('10.2')).toBe('10');
+ expect(format.formatNumber('10.5')).toBe('11');
+});
+
+test('formatLongNumber', () => {
+ expect(format.formatLongNumber(1200000)).toBe('1.2m');
+ expect(format.formatLongNumber(575000)).toBe('575k');
+ expect(format.formatLongNumber(10500)).toBe('10.5k');
+ expect(format.formatLongNumber(1200)).toBe('1.20k');
+});
+
+test('stringToColor', () => {
+ expect(format.stringToColor('hello')).toBe('#d218e9');
+ expect(format.stringToColor('goodbye')).toBe('#11e956');
+});
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
new file mode 100644
index 0000000..ba6d8b0
--- /dev/null
+++ b/src/lib/auth.ts
@@ -0,0 +1,80 @@
+import debug from 'debug';
+import { ROLE_PERMISSIONS, ROLES, SHARE_TOKEN_HEADER } from '@/lib/constants';
+import { secret } from '@/lib/crypto';
+import { getRandomChars } from '@/lib/generate';
+import { createSecureToken, parseSecureToken, parseToken } from '@/lib/jwt';
+import redis from '@/lib/redis';
+import { ensureArray } from '@/lib/utils';
+import { getUser } from '@/queries/prisma/user';
+
+const log = debug('umami:auth');
+
+export function getBearerToken(request: Request) {
+ const auth = request.headers.get('authorization');
+
+ return auth?.split(' ')[1];
+}
+
+export async function checkAuth(request: Request) {
+ const token = getBearerToken(request);
+ const payload = parseSecureToken(token, secret());
+ const shareToken = await parseShareToken(request);
+
+ let user = null;
+ const { userId, authKey } = payload || {};
+
+ if (userId) {
+ user = await getUser(userId);
+ } else if (redis.enabled && authKey) {
+ const key = await redis.client.get(authKey);
+
+ if (key?.userId) {
+ user = await getUser(key.userId);
+ }
+ }
+
+ log({ token, payload, authKey, shareToken, user });
+
+ if (!user?.id && !shareToken) {
+ log('User not authorized');
+ return null;
+ }
+
+ if (user) {
+ user.isAdmin = user.role === ROLES.admin;
+ }
+
+ return {
+ token,
+ authKey,
+ shareToken,
+ user,
+ };
+}
+
+export async function saveAuth(data: any, expire = 0) {
+ const authKey = `auth:${getRandomChars(32)}`;
+
+ if (redis.enabled) {
+ await redis.client.set(authKey, data);
+
+ if (expire) {
+ await redis.client.expire(authKey, expire);
+ }
+ }
+
+ return createSecureToken({ authKey }, secret());
+}
+
+export async function hasPermission(role: string, permission: string | string[]) {
+ return ensureArray(permission).some(e => ROLE_PERMISSIONS[role]?.includes(e));
+}
+
+export function parseShareToken(request: Request) {
+ try {
+ return parseToken(request.headers.get(SHARE_TOKEN_HEADER), secret());
+ } catch (e) {
+ log(e);
+ return null;
+ }
+}
diff --git a/src/lib/charts.ts b/src/lib/charts.ts
new file mode 100644
index 0000000..7d4208e
--- /dev/null
+++ b/src/lib/charts.ts
@@ -0,0 +1,27 @@
+import { formatDate } from '@/lib/date';
+import { formatLongNumber } from '@/lib/format';
+
+export function renderNumberLabels(label: string) {
+ return +label > 1000 ? formatLongNumber(+label) : label;
+}
+
+export function renderDateLabels(unit: string, locale: string) {
+ return (label: string, index: number, values: any[]) => {
+ const d = new Date(values[index].value);
+
+ switch (unit) {
+ case 'minute':
+ return formatDate(d, 'h:mm', locale);
+ case 'hour':
+ return formatDate(d, 'p', locale);
+ case 'day':
+ return formatDate(d, 'PP', locale).replace(/\W*20\d{2}\W*/, ''); // Remove year
+ case 'month':
+ return formatDate(d, 'MMM', locale);
+ case 'year':
+ return formatDate(d, 'yyyy', locale);
+ default:
+ return label;
+ }
+ };
+}
diff --git a/src/lib/clickhouse.ts b/src/lib/clickhouse.ts
new file mode 100644
index 0000000..f2ebbb7
--- /dev/null
+++ b/src/lib/clickhouse.ts
@@ -0,0 +1,273 @@
+import { type ClickHouseClient, createClient } from '@clickhouse/client';
+import { formatInTimeZone } from 'date-fns-tz';
+import debug from 'debug';
+import { CLICKHOUSE } from '@/lib/db';
+import { DEFAULT_PAGE_SIZE, FILTER_COLUMNS, OPERATORS } from './constants';
+import { filtersObjectToArray } from './params';
+import type { QueryFilters, QueryOptions } from './types';
+
+export const CLICKHOUSE_DATE_FORMATS = {
+ utc: '%Y-%m-%dT%H:%i:%SZ',
+ second: '%Y-%m-%d %H:%i:%S',
+ minute: '%Y-%m-%d %H:%i:00',
+ hour: '%Y-%m-%d %H:00:00',
+ day: '%Y-%m-%d',
+ month: '%Y-%m-01',
+ year: '%Y-01-01',
+};
+
+const log = debug('umami:clickhouse');
+
+let clickhouse: ClickHouseClient;
+const enabled = Boolean(process.env.CLICKHOUSE_URL);
+
+function getClient() {
+ const {
+ hostname,
+ port,
+ pathname,
+ protocol,
+ username = 'default',
+ password,
+ } = new URL(process.env.CLICKHOUSE_URL);
+
+ const client = createClient({
+ url: `${protocol}//${hostname}:${port}`,
+ database: pathname.replace('/', ''),
+ username: username,
+ password,
+ });
+
+ if (process.env.NODE_ENV !== 'production') {
+ globalThis[CLICKHOUSE] = client;
+ }
+
+ log('Clickhouse initialized');
+
+ return client;
+}
+
+function getUTCString(date?: Date | string | number) {
+ return formatInTimeZone(date || new Date(), 'UTC', 'yyyy-MM-dd HH:mm:ss');
+}
+
+function getDateStringSQL(data: any, unit: string = 'utc', timezone?: string) {
+ if (timezone) {
+ return `formatDateTime(${data}, '${CLICKHOUSE_DATE_FORMATS[unit]}', '${timezone}')`;
+ }
+
+ return `formatDateTime(${data}, '${CLICKHOUSE_DATE_FORMATS[unit]}')`;
+}
+
+function getDateSQL(field: string, unit: string, timezone?: string) {
+ if (timezone) {
+ return `toDateTime(date_trunc('${unit}', ${field}, '${timezone}'))`;
+ }
+ return `toDateTime(date_trunc('${unit}', ${field}))`;
+}
+
+function getSearchSQL(column: string, param: string = 'search'): string {
+ return `and positionCaseInsensitive(${column}, {${param}:String}) > 0`;
+}
+
+function mapFilter(column: string, operator: string, name: string, type: string = 'String') {
+ const value = `{${name}:${type}}`;
+
+ switch (operator) {
+ case OPERATORS.equals:
+ return `${column} = ${value}`;
+ case OPERATORS.notEquals:
+ return `${column} != ${value}`;
+ case OPERATORS.contains:
+ return `positionCaseInsensitive(${column}, ${value}) > 0`;
+ case OPERATORS.doesNotContain:
+ return `positionCaseInsensitive(${column}, ${value}) = 0`;
+ default:
+ return '';
+ }
+}
+
+function getFilterQuery(filters: Record<string, any>, options: QueryOptions = {}) {
+ const query = filtersObjectToArray(filters, options).reduce((arr, { name, column, operator }) => {
+ const isCohort = options?.isCohort;
+
+ if (isCohort) {
+ column = FILTER_COLUMNS[name.slice('cohort_'.length)];
+ }
+
+ if (column) {
+ if (name === 'eventType') {
+ arr.push(`and ${mapFilter(column, operator, name, 'UInt32')}`);
+ } else {
+ arr.push(`and ${mapFilter(column, operator, name)}`);
+ }
+
+ if (name === 'referrer') {
+ arr.push(`and referrer_domain != hostname`);
+ }
+ }
+
+ return arr;
+ }, []);
+
+ return query.join('\n');
+}
+
+function getCohortQuery(filters: Record<string, any>) {
+ if (!filters || Object.keys(filters).length === 0) {
+ return '';
+ }
+
+ const filterQuery = getFilterQuery(filters, { isCohort: true });
+
+ return `join (
+ select distinct session_id
+ from website_event
+ where website_id = {websiteId:UUID}
+ and created_at between {cohort_startDate:DateTime64} and {cohort_endDate:DateTime64}
+ ${filterQuery}
+ ) as cohort
+ on cohort.session_id = website_event.session_id
+ `;
+}
+
+function getDateQuery(filters: Record<string, any>) {
+ const { startDate, endDate, timezone } = filters;
+
+ if (startDate) {
+ if (endDate) {
+ if (timezone) {
+ return `and created_at between toTimezone({startDate:DateTime64},{timezone:String}) and toTimezone({endDate:DateTime64},{timezone:String})`;
+ }
+ return `and created_at between {startDate:DateTime64} and {endDate:DateTime64}`;
+ } else {
+ if (timezone) {
+ return `and created_at >= toTimezone({startDate:DateTime64},{timezone:String})`;
+ }
+ return `and created_at >= {startDate:DateTime64}`;
+ }
+ }
+
+ return '';
+}
+
+function getQueryParams(filters: Record<string, any>) {
+ return {
+ ...filters,
+ ...filtersObjectToArray(filters).reduce((obj, { name, value }) => {
+ if (name && value !== undefined) {
+ obj[name] = value;
+ }
+
+ return obj;
+ }, {}),
+ };
+}
+
+function parseFilters(filters: Record<string, any>, options?: QueryOptions) {
+ const cohortFilters = Object.fromEntries(
+ Object.entries(filters).filter(([key]) => key.startsWith('cohort_')),
+ );
+
+ return {
+ filterQuery: getFilterQuery(filters, options),
+ dateQuery: getDateQuery(filters),
+ queryParams: getQueryParams(filters),
+ cohortQuery: getCohortQuery(cohortFilters),
+ };
+}
+
+async function pagedRawQuery(
+ query: string,
+ queryParams: Record<string, any>,
+ filters: QueryFilters,
+ name?: string,
+) {
+ const { page = 1, pageSize, orderBy, sortDescending = false, search } = filters;
+ const size = +pageSize || DEFAULT_PAGE_SIZE;
+ const offset = +size * (+page - 1);
+ const direction = sortDescending ? 'desc' : 'asc';
+
+ const statements = [
+ orderBy && `order by ${orderBy} ${direction}`,
+ +size > 0 && `limit ${+size} offset ${+offset}`,
+ ]
+ .filter(n => n)
+ .join('\n');
+
+ const count = await rawQuery(`select count(*) as num from (${query}) t`, queryParams).then(
+ res => res[0].num,
+ );
+
+ const data = await rawQuery(`${query}${statements}`, queryParams, name);
+
+ return { data, count, page: +page, pageSize: size, orderBy, search };
+}
+
+async function rawQuery<T = unknown>(
+ query: string,
+ params: Record<string, unknown> = {},
+ name?: string,
+): Promise<T> {
+ if (process.env.LOG_QUERY) {
+ log({ query, params, name });
+ }
+
+ await connect();
+
+ const resultSet = await clickhouse.query({
+ query: query,
+ query_params: params,
+ format: 'JSONEachRow',
+ clickhouse_settings: {
+ date_time_output_format: 'iso',
+ output_format_json_quote_64bit_integers: 0,
+ },
+ });
+
+ return (await resultSet.json()) as T;
+}
+
+async function insert(table: string, values: any[]) {
+ await connect();
+
+ return clickhouse.insert({ table, values, format: 'JSONEachRow' });
+}
+
+async function findUnique(data: any[]) {
+ if (data.length > 1) {
+ throw `${data.length} records found when expecting 1.`;
+ }
+
+ return findFirst(data);
+}
+
+async function findFirst(data: any[]) {
+ return data[0] ?? null;
+}
+
+async function connect() {
+ if (enabled && !clickhouse) {
+ clickhouse = process.env.CLICKHOUSE_URL && (globalThis[CLICKHOUSE] || getClient());
+ }
+
+ return clickhouse;
+}
+
+export default {
+ enabled,
+ client: clickhouse,
+ log,
+ connect,
+ getDateStringSQL,
+ getDateSQL,
+ getSearchSQL,
+ getFilterQuery,
+ getUTCString,
+ parseFilters,
+ pagedRawQuery,
+ findUnique,
+ findFirst,
+ rawQuery,
+ insert,
+};
diff --git a/src/lib/client.ts b/src/lib/client.ts
new file mode 100644
index 0000000..e176215
--- /dev/null
+++ b/src/lib/client.ts
@@ -0,0 +1,14 @@
+import { getItem, removeItem, setItem } from '@/lib/storage';
+import { AUTH_TOKEN } from './constants';
+
+export function getClientAuthToken() {
+ return getItem(AUTH_TOKEN);
+}
+
+export function setClientAuthToken(token: string) {
+ setItem(AUTH_TOKEN, token);
+}
+
+export function removeClientAuthToken() {
+ removeItem(AUTH_TOKEN);
+}
diff --git a/src/lib/colors.ts b/src/lib/colors.ts
new file mode 100644
index 0000000..2ae9bda
--- /dev/null
+++ b/src/lib/colors.ts
@@ -0,0 +1,91 @@
+import { colord } from 'colord';
+import { THEME_COLORS } from '@/lib/constants';
+
+export function hex6(str: string) {
+ let h = 0x811c9dc5; // FNV-1a 32-bit offset
+ for (let i = 0; i < str.length; i++) {
+ h ^= str.charCodeAt(i);
+ h = (h >>> 0) * 0x01000193; // FNV prime
+ }
+ // use lower 24 bits; pad to 6 hex chars
+ return ((h >>> 0) & 0xffffff).toString(16).padStart(6, '0');
+}
+
+export const pick = (num: number, arr: any[]) => {
+ return arr[num % arr.length];
+};
+
+export function clamp(num: number, min: number, max: number) {
+ return num < min ? min : num > max ? max : num;
+}
+
+export function hex2RGB(color: string, min: number = 0, max: number = 255) {
+ const c = color.replace(/^#/, '');
+ const diff = max - min;
+
+ const normalize = (num: number) => {
+ return Math.floor((num / 255) * diff + min);
+ };
+
+ const r = normalize(parseInt(c.substring(0, 2), 16));
+ const g = normalize(parseInt(c.substring(2, 4), 16));
+ const b = normalize(parseInt(c.substring(4, 6), 16));
+
+ return { r, g, b };
+}
+
+export function rgb2Hex(r: number, g: number, b: number, prefix = '') {
+ return `${prefix}${r.toString(16)}${g.toString(16)}${b.toString(16)}`;
+}
+
+export function getPastel(color: string, factor: number = 0.5, prefix = '') {
+ let { r, g, b } = hex2RGB(color);
+
+ r = Math.floor((r + 255 * factor) / (1 + factor));
+ g = Math.floor((g + 255 * factor) / (1 + factor));
+ b = Math.floor((b + 255 * factor) / (1 + factor));
+
+ return rgb2Hex(r, g, b, prefix);
+}
+
+export function getColor(seed: string, min: number = 0, max: number = 255) {
+ const color = hex6(seed);
+ const { r, g, b } = hex2RGB(color, min, max);
+
+ return rgb2Hex(r, g, b);
+}
+
+export function getThemeColors(theme: string) {
+ const { primary, text, line, fill } = THEME_COLORS[theme];
+ const primaryColor = colord(THEME_COLORS[theme].primary);
+
+ return {
+ colors: {
+ theme: {
+ ...THEME_COLORS[theme],
+ },
+ chart: {
+ text,
+ line,
+ views: {
+ hoverBackgroundColor: primaryColor.alpha(0.7).toRgbString(),
+ backgroundColor: primaryColor.alpha(0.4).toRgbString(),
+ borderColor: primaryColor.alpha(0.7).toRgbString(),
+ hoverBorderColor: primaryColor.toRgbString(),
+ },
+ visitors: {
+ hoverBackgroundColor: primaryColor.alpha(0.9).toRgbString(),
+ backgroundColor: primaryColor.alpha(0.6).toRgbString(),
+ borderColor: primaryColor.alpha(0.9).toRgbString(),
+ hoverBorderColor: primaryColor.toRgbString(),
+ },
+ },
+ map: {
+ baseColor: primary,
+ fillColor: fill,
+ strokeColor: primary,
+ hoverColor: primary,
+ },
+ },
+ };
+}
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
new file mode 100644
index 0000000..e5090c3
--- /dev/null
+++ b/src/lib/constants.ts
@@ -0,0 +1,682 @@
+export const CURRENT_VERSION = process.env.currentVersion;
+export const AUTH_TOKEN = 'umami.auth';
+export const LOCALE_CONFIG = 'umami.locale';
+export const TIMEZONE_CONFIG = 'umami.timezone';
+export const DATE_RANGE_CONFIG = 'umami.date-range';
+export const THEME_CONFIG = 'umami.theme';
+export const DASHBOARD_CONFIG = 'umami.dashboard';
+export const LAST_TEAM_CONFIG = 'umami.last-team';
+export const VERSION_CHECK = 'umami.version-check';
+export const SHARE_TOKEN_HEADER = 'x-umami-share-token';
+export const HOMEPAGE_URL = 'https://umami.is';
+export const DOCS_URL = 'https://umami.is/docs';
+export const REPO_URL = 'https://github.com/umami-software/umami';
+export const UPDATES_URL = 'https://api.umami.is/v1/updates';
+export const TELEMETRY_PIXEL = 'https://i.umami.is/a.png';
+export const FAVICON_URL = 'https://icons.duckduckgo.com/ip3/{{domain}}.ico';
+export const LINKS_URL = `${globalThis?.location?.origin}/q`;
+export const PIXELS_URL = `${globalThis?.location?.origin}/p`;
+
+export const DEFAULT_LOCALE = 'en-US';
+export const DEFAULT_THEME = 'light';
+export const DEFAULT_ANIMATION_DURATION = 300;
+export const DEFAULT_DATE_RANGE_VALUE = '24hour';
+export const DEFAULT_WEBSITE_LIMIT = 10;
+export const DEFAULT_RESET_DATE = '2000-01-01';
+export const DEFAULT_PAGE_SIZE = 20;
+export const DEFAULT_DATE_COMPARE = 'prev';
+
+export const REALTIME_RANGE = 30;
+export const REALTIME_INTERVAL = 10000;
+
+export const UNIT_TYPES = ['year', 'month', 'hour', 'day', 'minute'];
+
+export const EVENT_COLUMNS = [
+ 'path',
+ 'entry',
+ 'exit',
+ 'referrer',
+ 'domain',
+ 'title',
+ 'query',
+ 'event',
+ 'tag',
+ 'hostname',
+];
+
+export const SESSION_COLUMNS = [
+ 'browser',
+ 'os',
+ 'device',
+ 'screen',
+ 'language',
+ 'country',
+ 'city',
+ 'region',
+];
+
+export const SEGMENT_TYPES = {
+ segment: 'segment',
+ cohort: 'cohort',
+};
+
+export const FILTER_COLUMNS = {
+ path: 'url_path',
+ entry: 'url_path',
+ exit: 'url_path',
+ referrer: 'referrer_domain',
+ domain: 'referrer_domain',
+ hostname: 'hostname',
+ title: 'page_title',
+ query: 'url_query',
+ os: 'os',
+ browser: 'browser',
+ device: 'device',
+ country: 'country',
+ region: 'region',
+ city: 'city',
+ language: 'language',
+ event: 'event_name',
+ tag: 'tag',
+ eventType: 'event_type',
+};
+
+export const COLLECTION_TYPE = {
+ event: 'event',
+ identify: 'identify',
+} as const;
+
+export const EVENT_TYPE = {
+ pageView: 1,
+ customEvent: 2,
+ linkEvent: 3,
+ pixelEvent: 4,
+} as const;
+
+export const DATA_TYPE = {
+ string: 1,
+ number: 2,
+ boolean: 3,
+ date: 4,
+ array: 5,
+} as const;
+
+export const OPERATORS = {
+ equals: 'eq',
+ notEquals: 'neq',
+ set: 's',
+ notSet: 'ns',
+ contains: 'c',
+ doesNotContain: 'dnc',
+ true: 't',
+ false: 'f',
+ greaterThan: 'gt',
+ lessThan: 'lt',
+ greaterThanEquals: 'gte',
+ lessThanEquals: 'lte',
+ before: 'bf',
+ after: 'af',
+} as const;
+
+export const DATA_TYPES = {
+ [DATA_TYPE.string]: 'string',
+ [DATA_TYPE.number]: 'number',
+ [DATA_TYPE.boolean]: 'boolean',
+ [DATA_TYPE.date]: 'date',
+ [DATA_TYPE.array]: 'array',
+} as const;
+
+export const ROLES = {
+ admin: 'admin',
+ user: 'user',
+ viewOnly: 'view-only',
+ teamOwner: 'team-owner',
+ teamManager: 'team-manager',
+ teamMember: 'team-member',
+ teamViewOnly: 'team-view-only',
+} as const;
+
+export const PERMISSIONS = {
+ all: 'all',
+ websiteCreate: 'website:create',
+ websiteUpdate: 'website:update',
+ websiteDelete: 'website:delete',
+ websiteTransferToTeam: 'website:transfer-to-team',
+ websiteTransferToUser: 'website:transfer-to-user',
+ teamCreate: 'team:create',
+ teamUpdate: 'team:update',
+ teamDelete: 'team:delete',
+} as const;
+
+export const ROLE_PERMISSIONS = {
+ [ROLES.admin]: [PERMISSIONS.all],
+ [ROLES.user]: [
+ PERMISSIONS.websiteCreate,
+ PERMISSIONS.websiteUpdate,
+ PERMISSIONS.websiteDelete,
+ PERMISSIONS.teamCreate,
+ ],
+ [ROLES.viewOnly]: [],
+ [ROLES.teamOwner]: [
+ PERMISSIONS.teamUpdate,
+ PERMISSIONS.teamDelete,
+ PERMISSIONS.websiteCreate,
+ PERMISSIONS.websiteUpdate,
+ PERMISSIONS.websiteDelete,
+ PERMISSIONS.websiteTransferToTeam,
+ PERMISSIONS.websiteTransferToUser,
+ ],
+ [ROLES.teamManager]: [
+ PERMISSIONS.teamUpdate,
+ PERMISSIONS.websiteCreate,
+ PERMISSIONS.websiteUpdate,
+ PERMISSIONS.websiteDelete,
+ PERMISSIONS.websiteTransferToTeam,
+ ],
+ [ROLES.teamMember]: [
+ PERMISSIONS.websiteCreate,
+ PERMISSIONS.websiteUpdate,
+ PERMISSIONS.websiteDelete,
+ ],
+ [ROLES.teamViewOnly]: [],
+} as const;
+
+export const THEME_COLORS = {
+ light: {
+ primary: '#2680eb',
+ text: '#838383',
+ line: '#d9d9d9',
+ fill: '#f9f9f9',
+ },
+ dark: {
+ primary: '#2680eb',
+ text: '#7b7b7b',
+ line: '#3a3a3a',
+ fill: '#191919',
+ },
+} as const;
+
+export const CHART_COLORS = [
+ '#2680eb',
+ '#9256d9',
+ '#44b556',
+ '#e68619',
+ '#e34850',
+ '#f7bd12',
+ '#01bad7',
+ '#6734bc',
+ '#89c541',
+ '#ffc301',
+ '#ec1562',
+ '#ffec16',
+];
+
+export const DOMAIN_REGEX =
+ /^(localhost(:[1-9]\d{0,4})?|((?=[a-z0-9-_]{1,63}\.)(xn--)?[a-z0-9-_]+(-[a-z0-9-_]+)*\.)+(xn--)?[a-z0-9-_]{2,63})$/;
+export const SHARE_ID_REGEX = /^[a-zA-Z0-9]{8,50}$/;
+export const DATETIME_REGEX =
+ /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{3}(Z|\+[0-9]{2}:[0-9]{2})?)?$/;
+
+export const URL_LENGTH = 500;
+export const PAGE_TITLE_LENGTH = 500;
+export const EVENT_NAME_LENGTH = 50;
+
+export const UTM_PARAMS = ['utm_campaign', 'utm_content', 'utm_medium', 'utm_source', 'utm_term'];
+
+export const OS_NAMES = {
+ 'Android OS': 'Android',
+ 'Chrome OS': 'ChromeOS',
+ 'Mac OS': 'macOS',
+ 'Sun OS': 'SunOS',
+ 'Windows 10': 'Windows 10/11',
+} as const;
+
+export const BROWSERS = {
+ android: 'Android',
+ aol: 'AOL',
+ bb10: 'BlackBerry 10',
+ beaker: 'Beaker',
+ chrome: 'Chrome',
+ 'chromium-webview': 'Chrome (webview)',
+ crios: 'Chrome (iOS)',
+ curl: 'Curl',
+ edge: 'Edge',
+ 'edge-chromium': 'Edge (Chromium)',
+ 'edge-ios': 'Edge (iOS)',
+ facebook: 'Facebook',
+ firefox: 'Firefox',
+ fxios: 'Firefox (iOS)',
+ ie: 'IE',
+ instagram: 'Instagram',
+ ios: 'iOS',
+ 'ios-webview': 'iOS (webview)',
+ kakaotalk: 'KakaoTalk',
+ miui: 'MIUI',
+ opera: 'Opera',
+ 'opera-mini': 'Opera Mini',
+ phantomjs: 'PhantomJS',
+ safari: 'Safari',
+ samsung: 'Samsung',
+ searchbot: 'Searchbot',
+ silk: 'Silk',
+ yandexbrowser: 'Yandex',
+} as const;
+
+export const SOCIAL_DOMAINS = [
+ 'bsky.app',
+ 'facebook.com',
+ 'fb.com',
+ 'ig.com',
+ 'instagram.com',
+ 'linkedin.',
+ 'news.ycombinator.com',
+ 'pinterest.',
+ 'reddit.',
+ 'snapchat.',
+ 't.co',
+ 'threads.net',
+ 'tiktok.',
+ 'twitter.com',
+ 'x.com',
+];
+
+export const SEARCH_DOMAINS = [
+ 'baidu.com',
+ 'bing.com',
+ 'chatgpt.com',
+ 'duckduckgo.com',
+ 'ecosia.org',
+ 'google.',
+ 'msn.com',
+ 'perplexity.ai',
+ 'search.brave.com',
+ 'yandex.',
+];
+
+export const SHOPPING_DOMAINS = [
+ 'alibaba.com',
+ 'aliexpress.com',
+ 'amazon.',
+ 'bestbuy.com',
+ 'ebay.com',
+ 'etsy.com',
+ 'newegg.com',
+ 'target.com',
+ 'walmart.com',
+];
+
+export const EMAIL_DOMAINS = [
+ 'gmail.',
+ 'hotmail.',
+ 'mail.yahoo.',
+ 'outlook.',
+ 'proton.me',
+ 'protonmail.',
+];
+
+export const VIDEO_DOMAINS = ['twitch.', 'youtube.'];
+
+export const PAID_AD_PARAMS = [
+ 'ad_id=',
+ 'aid=',
+ 'dclid=',
+ 'epik=',
+ 'fbclid=',
+ 'gclid=',
+ 'li_fat_id=',
+ 'msclkid=',
+ 'ob_click_id=',
+ 'pc_id=',
+ 'rdt_cid=',
+ 'scid=',
+ 'ttclid=',
+ 'twclid=',
+ 'utm_medium=cpc',
+ 'utm_medium=paid',
+ 'utm_medium=paid_social',
+ 'utm_source=google',
+];
+
+export const GROUPED_DOMAINS = [
+ { name: 'Baidu', domain: 'baidu.com', match: 'baidu.' },
+ { name: 'Bing', domain: 'bing.com', match: 'bing.' },
+ { name: 'Brave', domain: 'brave.com', match: 'brave.' },
+ { name: 'ChatGPT', domain: 'chatgpt.com', match: 'chatgpt.' },
+ { name: 'DuckDuckGo', domain: 'duckduckgo.com', match: 'duckduckgo.' },
+ { name: 'Facebook', domain: 'facebook.com', match: 'facebook.' },
+ { name: 'GitHub', domain: 'github.com', match: 'github.' },
+ { name: 'Google', domain: 'google.com', match: 'google.' },
+ { name: 'Hacker News', domain: 'news.ycombinator.com', match: 'news.ycombinator.com' },
+ { name: 'Instagram', domain: 'instagram.com', match: ['instagram.', 'ig.com'] },
+ { name: 'LinkedIn', domain: 'linkedin.com', match: 'linkedin.' },
+ { name: 'Pinterest', domain: 'pinterest.com', match: 'pinterest.' },
+ { name: 'Reddit', domain: 'reddit.com', match: 'reddit.' },
+ { name: 'Snapchat', domain: 'snapchat.com', match: 'snapchat.' },
+ { name: 'Twitter', domain: 'twitter.com', match: ['twitter.', 't.co', 'x.com'] },
+ { name: 'Yahoo', domain: 'yahoo.com', match: 'yahoo.' },
+ { name: 'Yandex', domain: 'yandex.ru', match: 'yandex.' },
+];
+
+export const MAP_FILE = '/datamaps.world.json';
+
+export const ISO_COUNTRIES = {
+ ABW: 'AW',
+ AFG: 'AF',
+ AGO: 'AO',
+ AIA: 'AI',
+ ALA: 'AX',
+ ALB: 'AL',
+ AND: 'AD',
+ ANT: 'AN',
+ ARE: 'AE',
+ ARG: 'AR',
+ ARM: 'AM',
+ ASM: 'AS',
+ ATF: 'TF',
+ ATG: 'AG',
+ AUS: 'AU',
+ AUT: 'AT',
+ AZE: 'AZ',
+ BDI: 'BI',
+ BEL: 'BE',
+ BEN: 'BJ',
+ BFA: 'BF',
+ BGD: 'BD',
+ BGR: 'BG',
+ BHR: 'BH',
+ BHS: 'BS',
+ BIH: 'BA',
+ BLR: 'BY',
+ BLZ: 'BZ',
+ BLM: 'BL',
+ BMU: 'BM',
+ BOL: 'BO',
+ BRA: 'BR',
+ BRB: 'BB',
+ BRN: 'BN',
+ BTN: 'BT',
+ BVT: 'BV',
+ BWA: 'BW',
+ CAF: 'CF',
+ CAN: 'CA',
+ CCK: 'CC',
+ CHE: 'CH',
+ CHL: 'CL',
+ CHN: 'CN',
+ CIV: 'CI',
+ CMR: 'CM',
+ COD: 'CD',
+ COG: 'CG',
+ COK: 'CK',
+ COL: 'CO',
+ COM: 'KM',
+ CPV: 'CV',
+ CRI: 'CR',
+ CUB: 'CU',
+ CXR: 'CX',
+ CYM: 'KY',
+ CYP: 'CY',
+ CZE: 'CZ',
+ DEU: 'DE',
+ DJI: 'DJ',
+ DMA: 'DM',
+ DNK: 'DK',
+ DOM: 'DO',
+ DZA: 'DZ',
+ ECU: 'EC',
+ EGY: 'EG',
+ ERI: 'ER',
+ ESH: 'EH',
+ ESP: 'ES',
+ EST: 'EE',
+ ETH: 'ET',
+ FIN: 'FI',
+ FJI: 'FJ',
+ FLK: 'FK',
+ FRA: 'FR',
+ FRO: 'FO',
+ FSM: 'FM',
+ GAB: 'GA',
+ GBR: 'GB',
+ GEO: 'GE',
+ GGY: 'GG',
+ GHA: 'GH',
+ GIB: 'GI',
+ GIN: 'GN',
+ GLP: 'GP',
+ GMB: 'GM',
+ GNB: 'GW',
+ GNQ: 'GQ',
+ GRC: 'GR',
+ GRD: 'GD',
+ GRL: 'GL',
+ GTM: 'GT',
+ GUF: 'GF',
+ GUM: 'GU',
+ GUY: 'GY',
+ HKG: 'HK',
+ HMD: 'HM',
+ HND: 'HN',
+ HRV: 'HR',
+ HTI: 'HT',
+ HUN: 'HU',
+ IDN: 'ID',
+ IMN: 'IM',
+ IND: 'IN',
+ IOT: 'IO',
+ IRL: 'IE',
+ IRN: 'IR',
+ IRQ: 'IQ',
+ ISL: 'IS',
+ ISR: 'IL',
+ ITA: 'IT',
+ JAM: 'JM',
+ JEY: 'JE',
+ JOR: 'JO',
+ JPN: 'JP',
+ KAZ: 'KZ',
+ KEN: 'KE',
+ KGZ: 'KG',
+ KHM: 'KH',
+ KIR: 'KI',
+ KNA: 'KN',
+ KOR: 'KR',
+ KWT: 'KW',
+ LAO: 'LA',
+ LBN: 'LB',
+ LBR: 'LR',
+ LBY: 'LY',
+ LCA: 'LC',
+ LIE: 'LI',
+ LKA: 'LK',
+ LSO: 'LS',
+ LTU: 'LT',
+ LUX: 'LU',
+ LVA: 'LV',
+ MAF: 'MF',
+ MAR: 'MA',
+ MCO: 'MC',
+ MDA: 'MD',
+ MDG: 'MG',
+ MDV: 'MV',
+ MEX: 'MX',
+ MHL: 'MH',
+ MKD: 'MK',
+ MLI: 'ML',
+ MLT: 'MT',
+ MMR: 'MM',
+ MNE: 'ME',
+ MNG: 'MN',
+ MNP: 'MP',
+ MOZ: 'MZ',
+ MRT: 'MR',
+ MSR: 'MS',
+ MTQ: 'MQ',
+ MUS: 'MU',
+ MWI: 'MW',
+ MYS: 'MY',
+ MYT: 'YT',
+ NAM: 'NA',
+ NCL: 'NC',
+ NER: 'NE',
+ NFK: 'NF',
+ NGA: 'NG',
+ NIC: 'NI',
+ NIU: 'NU',
+ NLD: 'NL',
+ NOR: 'NO',
+ NPL: 'NP',
+ NRU: 'NR',
+ NZL: 'NZ',
+ OMN: 'OM',
+ PAK: 'PK',
+ PAN: 'PA',
+ PCN: 'PN',
+ PER: 'PE',
+ PHL: 'PH',
+ PLW: 'PW',
+ PNG: 'PG',
+ POL: 'PL',
+ PRI: 'PR',
+ PRK: 'KP',
+ PRT: 'PT',
+ PRY: 'PY',
+ PSE: 'PS',
+ PYF: 'PF',
+ QAT: 'QA',
+ REU: 'RE',
+ ROU: 'RO',
+ RUS: 'RU',
+ RWA: 'RW',
+ SAU: 'SA',
+ SDN: 'SD',
+ SEN: 'SN',
+ SGP: 'SG',
+ SGS: 'GS',
+ SHN: 'SH',
+ SJM: 'SJ',
+ SLB: 'SB',
+ SLE: 'SL',
+ SLV: 'SV',
+ SMR: 'SM',
+ SOM: 'SO',
+ SPM: 'PM',
+ SRB: 'RS',
+ SUR: 'SR',
+ STP: 'ST',
+ SVK: 'SK',
+ SVN: 'SI',
+ SWE: 'SE',
+ SWZ: 'SZ',
+ SYC: 'SC',
+ SYR: 'SY',
+ TCA: 'TC',
+ TCD: 'TD',
+ TGO: 'TG',
+ THA: 'TH',
+ TJK: 'TJ',
+ TKL: 'TK',
+ TKM: 'TM',
+ TLS: 'TL',
+ TON: 'TO',
+ TTO: 'TT',
+ TUN: 'TN',
+ TUR: 'TR',
+ TUV: 'TV',
+ TWN: 'TW',
+ TZA: 'TZ',
+ UGA: 'UG',
+ UKR: 'UA',
+ UMI: 'UM',
+ URY: 'UY',
+ USA: 'US',
+ UZB: 'UZ',
+ VAT: 'VA',
+ VCT: 'VC',
+ VEN: 'VE',
+ VGB: 'VG',
+ VIR: 'VI',
+ VNM: 'VN',
+ VUT: 'VU',
+ WLF: 'WF',
+ WSM: 'WS',
+ XKX: 'XK',
+ YEM: 'YE',
+ ZAF: 'ZA',
+ ZMB: 'ZM',
+ ZWE: 'ZW',
+};
+
+export const CURRENCIES = [
+ { id: 'USD', name: 'US Dollar' },
+ { id: 'EUR', name: 'Euro' },
+ { id: 'GBP', name: 'British Pound' },
+ { id: 'JPY', name: 'Japanese Yen' },
+ { id: 'CNY', name: 'Chinese Renminbi (Yuan)' },
+ { id: 'CAD', name: 'Canadian Dollar' },
+ { id: 'HKD', name: 'Hong Kong Dollar' },
+ { id: 'AUD', name: 'Australian Dollar' },
+ { id: 'SGD', name: 'Singapore Dollar' },
+ { id: 'CHF', name: 'Swiss Franc' },
+ { id: 'SEK', name: 'Swedish Krona' },
+ { id: 'PLN', name: 'Polish Złoty' },
+ { id: 'NOK', name: 'Norwegian Krone' },
+ { id: 'DKK', name: 'Danish Krone' },
+ { id: 'NZD', name: 'New Zealand Dollar' },
+ { id: 'ZAR', name: 'South African Rand' },
+ { id: 'MXN', name: 'Mexican Peso' },
+ { id: 'THB', name: 'Thai Baht' },
+ { id: 'HUF', name: 'Hungarian Forint' },
+ { id: 'MYR', name: 'Malaysian Ringgit' },
+ { id: 'INR', name: 'Indian Rupee' },
+ { id: 'KRW', name: 'South Korean Won' },
+ { id: 'BRL', name: 'Brazilian Real' },
+ { id: 'TRY', name: 'Turkish Lira' },
+ { id: 'CZK', name: 'Czech Koruna' },
+ { id: 'ILS', name: 'Israeli New Shekel' },
+ { id: 'RUB', name: 'Russian Ruble' },
+ { id: 'AED', name: 'United Arab Emirates Dirham' },
+ { id: 'IDR', name: 'Indonesian Rupiah' },
+ { id: 'PHP', name: 'Philippine Peso' },
+ { id: 'RON', name: 'Romanian Leu' },
+ { id: 'COP', name: 'Colombian Peso' },
+ { id: 'SAR', name: 'Saudi Riyal' },
+ { id: 'ARS', name: 'Argentine Peso' },
+ { id: 'VND', name: 'Vietnamese Dong' },
+ { id: 'CLP', name: 'Chilean Peso' },
+ { id: 'EGP', name: 'Egyptian Pound' },
+ { id: 'KWD', name: 'Kuwaiti Dinar' },
+ { id: 'PKR', name: 'Pakistani Rupee' },
+ { id: 'QAR', name: 'Qatari Riyal' },
+ { id: 'BHD', name: 'Bahraini Dinar' },
+ { id: 'UAH', name: 'Ukrainian Hryvnia' },
+ { id: 'PEN', name: 'Peruvian Sol' },
+ { id: 'BDT', name: 'Bangladeshi Taka' },
+ { id: 'MAD', name: 'Moroccan Dirham' },
+ { id: 'KES', name: 'Kenyan Shilling' },
+ { id: 'NGN', name: 'Nigerian Naira' },
+ { id: 'TND', name: 'Tunisian Dinar' },
+ { id: 'OMR', name: 'Omani Rial' },
+ { id: 'GHS', name: 'Ghanaian Cedi' },
+];
+
+export const TIMEZONE_LEGACY: Record<string, string> = {
+ 'Asia/Batavia': 'Asia/Jakarta',
+ 'Asia/Calcutta': 'Asia/Kolkata',
+ 'Asia/Chongqing': 'Asia/Shanghai',
+ 'Asia/Harbin': 'Asia/Shanghai',
+ 'Asia/Jayapura': 'Asia/Pontianak',
+ 'Asia/Katmandu': 'Asia/Kathmandu',
+ 'Asia/Macao': 'Asia/Macau',
+ 'Asia/Rangoon': 'Asia/Yangon',
+ 'Asia/Saigon': 'Asia/Ho_Chi_Minh',
+ 'Europe/Kiev': 'Europe/Kyiv',
+ 'Europe/Zaporozhye': 'Europe/Kyiv',
+ 'Etc/UTC': 'UTC',
+ 'US/Arizona': 'America/Phoenix',
+ 'US/Central': 'America/Chicago',
+ 'US/Eastern': 'America/New_York',
+ 'US/Mountain': 'America/Denver',
+ 'US/Pacific': 'America/Los_Angeles',
+ 'US/Samoa': 'Pacific/Pago_Pago',
+};
diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts
new file mode 100644
index 0000000..a6d912b
--- /dev/null
+++ b/src/lib/crypto.ts
@@ -0,0 +1,65 @@
+import crypto from 'node:crypto';
+import { v4, v5, v7 } from 'uuid';
+
+const ALGORITHM = 'aes-256-gcm';
+const IV_LENGTH = 16;
+const SALT_LENGTH = 64;
+const TAG_LENGTH = 16;
+const TAG_POSITION = SALT_LENGTH + IV_LENGTH;
+const ENC_POSITION = TAG_POSITION + TAG_LENGTH;
+
+const HASH_ALGO = 'sha512';
+const HASH_ENCODING = 'hex';
+
+const getKey = (password: string, salt: Buffer) =>
+ crypto.pbkdf2Sync(password, salt, 10000, 32, 'sha512');
+
+export function encrypt(value: any, secret: any) {
+ const iv = crypto.randomBytes(IV_LENGTH);
+ const salt = crypto.randomBytes(SALT_LENGTH);
+ const key = getKey(secret, salt);
+
+ const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
+
+ const encrypted = Buffer.concat([cipher.update(String(value), 'utf8'), cipher.final()]);
+
+ const tag = cipher.getAuthTag();
+
+ return Buffer.concat([salt, iv, tag, encrypted]).toString('base64');
+}
+
+export function decrypt(value: any, secret: any) {
+ const str = Buffer.from(String(value), 'base64');
+ const salt = str.subarray(0, SALT_LENGTH);
+ const iv = str.subarray(SALT_LENGTH, TAG_POSITION);
+ const tag = str.subarray(TAG_POSITION, ENC_POSITION);
+ const encrypted = str.subarray(ENC_POSITION);
+
+ const key = getKey(secret, salt);
+
+ const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
+
+ decipher.setAuthTag(tag);
+
+ return decipher.update(encrypted) + decipher.final('utf8');
+}
+
+export function hash(...args: string[]) {
+ return crypto.createHash(HASH_ALGO).update(args.join('')).digest(HASH_ENCODING);
+}
+
+export function md5(...args: string[]) {
+ return crypto.createHash('md5').update(args.join('')).digest('hex');
+}
+
+export function secret() {
+ return hash(process.env.APP_SECRET || process.env.DATABASE_URL);
+}
+
+export function uuid(...args: any) {
+ if (args.length) {
+ return v5(hash(...args, secret()), v5.DNS);
+ }
+
+ return process.env.USE_UUIDV7 ? v7() : v4();
+}
diff --git a/src/lib/data.ts b/src/lib/data.ts
new file mode 100644
index 0000000..fe69edf
--- /dev/null
+++ b/src/lib/data.ts
@@ -0,0 +1,94 @@
+import { DATA_TYPE, DATETIME_REGEX } from './constants';
+import type { DynamicDataType } from './types';
+
+export function flattenJSON(
+ eventData: Record<string, any>,
+ keyValues: { key: string; value: any; dataType: DynamicDataType }[] = [],
+ parentKey = '',
+): { key: string; value: any; dataType: DynamicDataType }[] {
+ return Object.keys(eventData).reduce(
+ (acc, key) => {
+ const value = eventData[key];
+ const type = typeof eventData[key];
+
+ // nested object
+ if (value && type === 'object' && !Array.isArray(value) && !isValidDateValue(value)) {
+ flattenJSON(value, acc.keyValues, getKeyName(key, parentKey));
+ } else {
+ createKey(getKeyName(key, parentKey), value, acc);
+ }
+
+ return acc;
+ },
+ { keyValues, parentKey },
+ ).keyValues;
+}
+
+export function isValidDateValue(value: string) {
+ return typeof value === 'string' && DATETIME_REGEX.test(value);
+}
+
+export function getDataType(value: any): string {
+ let type: string = typeof value;
+
+ if (isValidDateValue(value)) {
+ type = 'date';
+ }
+
+ return type;
+}
+
+export function getStringValue(value: string, dataType: number) {
+ if (dataType === DATA_TYPE.number) {
+ return parseFloat(value).toFixed(4);
+ }
+
+ if (dataType === DATA_TYPE.date) {
+ return new Date(value).toISOString();
+ }
+
+ return value;
+}
+
+function createKey(key: string, value: string, acc: { keyValues: any[]; parentKey: string }) {
+ const type = getDataType(value);
+
+ let dataType = null;
+
+ switch (type) {
+ case 'number':
+ dataType = DATA_TYPE.number;
+ break;
+ case 'string':
+ dataType = DATA_TYPE.string;
+ break;
+ case 'boolean':
+ dataType = DATA_TYPE.boolean;
+ value = value ? 'true' : 'false';
+ break;
+ case 'date':
+ dataType = DATA_TYPE.date;
+ break;
+ case 'object':
+ dataType = DATA_TYPE.array;
+ value = JSON.stringify(value);
+ break;
+ default:
+ dataType = DATA_TYPE.string;
+ break;
+ }
+
+ acc.keyValues.push({ key, value, dataType });
+}
+
+function getKeyName(key: string, parentKey: string) {
+ if (!parentKey) {
+ return key;
+ }
+
+ return `${parentKey}.${key}`;
+}
+
+export function objectToArray(obj: object) {
+ return Object.keys(obj).map(key => obj[key]);
+}
diff --git a/src/lib/date.ts b/src/lib/date.ts
new file mode 100644
index 0000000..3c1fd1b
--- /dev/null
+++ b/src/lib/date.ts
@@ -0,0 +1,375 @@
+import {
+ addDays,
+ addHours,
+ addMinutes,
+ addMonths,
+ addWeeks,
+ addYears,
+ differenceInCalendarDays,
+ differenceInCalendarMonths,
+ differenceInCalendarWeeks,
+ differenceInCalendarYears,
+ differenceInHours,
+ differenceInMinutes,
+ endOfDay,
+ endOfHour,
+ endOfMinute,
+ endOfMonth,
+ endOfWeek,
+ endOfYear,
+ format,
+ isBefore,
+ isDate,
+ isEqual,
+ isSameDay,
+ max,
+ min,
+ startOfDay,
+ startOfHour,
+ startOfMinute,
+ startOfMonth,
+ startOfWeek,
+ startOfYear,
+ subDays,
+ subHours,
+ subMinutes,
+ subMonths,
+ subWeeks,
+ subYears,
+} from 'date-fns';
+import { utcToZonedTime } from 'date-fns-tz';
+import { getDateLocale } from '@/lib/lang';
+import type { DateRange } from '@/lib/types';
+
+export const TIME_UNIT = {
+ minute: 'minute',
+ hour: 'hour',
+ day: 'day',
+ week: 'week',
+ month: 'month',
+ year: 'year',
+};
+
+export const DATE_FUNCTIONS = {
+ minute: {
+ diff: differenceInMinutes,
+ add: addMinutes,
+ sub: subMinutes,
+ start: startOfMinute,
+ end: endOfMinute,
+ },
+ hour: {
+ diff: differenceInHours,
+ add: addHours,
+ sub: subHours,
+ start: startOfHour,
+ end: endOfHour,
+ },
+ day: {
+ diff: differenceInCalendarDays,
+ add: addDays,
+ sub: subDays,
+ start: startOfDay,
+ end: endOfDay,
+ },
+ week: {
+ diff: differenceInCalendarWeeks,
+ add: addWeeks,
+ sub: subWeeks,
+ start: startOfWeek,
+ end: endOfWeek,
+ },
+ month: {
+ diff: differenceInCalendarMonths,
+ add: addMonths,
+ sub: subMonths,
+ start: startOfMonth,
+ end: endOfMonth,
+ },
+ year: {
+ diff: differenceInCalendarYears,
+ add: addYears,
+ sub: subYears,
+ start: startOfYear,
+ end: endOfYear,
+ },
+};
+
+export const DATE_FORMATS = {
+ minute: 'yyyy-MM-dd HH:mm',
+ hour: 'yyyy-MM-dd HH',
+ day: 'yyyy-MM-dd',
+ week: "yyyy-'W'II",
+ month: 'yyyy-MM',
+ year: 'yyyy',
+};
+
+const TIMEZONE_MAPPINGS: Record<string, string> = {
+ 'Asia/Calcutta': 'Asia/Kolkata',
+};
+
+export function normalizeTimezone(timezone: string): string {
+ return TIMEZONE_MAPPINGS[timezone] || timezone;
+}
+
+export function isValidTimezone(timezone: string) {
+ try {
+ const normalizedTimezone = normalizeTimezone(timezone);
+ Intl.DateTimeFormat(undefined, { timeZone: normalizedTimezone });
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+export function getTimezone() {
+ return Intl.DateTimeFormat().resolvedOptions().timeZone;
+}
+
+export function parseDateValue(value: string) {
+ const match = value.match?.(/^(?<num>[0-9-]+)(?<unit>hour|day|week|month|year)$/);
+
+ if (!match) return null;
+
+ const { num, unit } = match.groups;
+
+ return { num: +num, unit };
+}
+
+export function parseDateRange(value: string, locale = 'en-US', timezone?: string): DateRange {
+ if (typeof value !== 'string') {
+ return null;
+ }
+
+ if (value.startsWith('range')) {
+ const [, startTime, endTime] = value.split(':');
+
+ const startDate = new Date(+startTime);
+ const endDate = new Date(+endTime);
+ const unit = getMinimumUnit(startDate, endDate);
+
+ return {
+ startDate,
+ endDate,
+ value,
+ ...parseDateValue(value),
+ unit,
+ };
+ }
+
+ const date = new Date();
+ const now = timezone ? utcToZonedTime(date, timezone) : date;
+ const dateLocale = getDateLocale(locale);
+ const { num = 1, unit } = parseDateValue(value);
+
+ switch (unit) {
+ case 'hour':
+ return {
+ startDate: num ? subHours(startOfHour(now), num) : startOfHour(now),
+ endDate: endOfHour(now),
+ offset: 0,
+ num: num || 1,
+ unit,
+ value,
+ };
+ case 'day':
+ return {
+ startDate: num ? subDays(startOfDay(now), num) : startOfDay(now),
+ endDate: endOfDay(now),
+ unit: num ? 'day' : 'hour',
+ offset: 0,
+ num: num || 1,
+ value,
+ };
+ case 'week':
+ return {
+ startDate: num
+ ? subWeeks(startOfWeek(now, { locale: dateLocale }), num)
+ : startOfWeek(now, { locale: dateLocale }),
+ endDate: endOfWeek(now, { locale: dateLocale }),
+ unit: 'day',
+ offset: 0,
+ num: num || 1,
+ value,
+ };
+ case 'month':
+ return {
+ startDate: num ? subMonths(startOfMonth(now), num) : startOfMonth(now),
+ endDate: endOfMonth(now),
+ unit: num ? 'month' : 'day',
+ offset: 0,
+ num: num || 1,
+ value,
+ };
+ case 'year':
+ return {
+ startDate: num ? subYears(startOfYear(now), num) : startOfYear(now),
+ endDate: endOfYear(now),
+ unit: 'month',
+ offset: 0,
+ num: num || 1,
+ value,
+ };
+ }
+}
+
+export function getOffsetDateRange(dateRange: DateRange, offset: number) {
+ if (offset === 0) {
+ return dateRange;
+ }
+
+ const { startDate, endDate, unit, num, value } = dateRange;
+
+ const change = num * offset;
+ const { add } = DATE_FUNCTIONS[unit];
+ const { unit: originalUnit } = parseDateValue(value) || {};
+
+ switch (originalUnit) {
+ case 'day':
+ return {
+ ...dateRange,
+ offset,
+ startDate: addDays(startDate, change),
+ endDate: addDays(endDate, change),
+ };
+ case 'week':
+ return {
+ ...dateRange,
+ offset,
+ startDate: addWeeks(startDate, change),
+ endDate: addWeeks(endDate, change),
+ };
+ case 'month':
+ return {
+ ...dateRange,
+ offset,
+ startDate: addMonths(startDate, change),
+ endDate: addMonths(endDate, change),
+ };
+ case 'year':
+ return {
+ ...dateRange,
+ offset,
+ startDate: addYears(startDate, change),
+ endDate: addYears(endDate, change),
+ };
+ default:
+ return {
+ startDate: add(startDate, change),
+ endDate: add(endDate, change),
+ offset,
+ value,
+ unit,
+ num,
+ };
+ }
+}
+
+export function getAllowedUnits(startDate: Date, endDate: Date) {
+ const units = ['minute', 'hour', 'day', 'month', 'year'];
+ const minUnit = getMinimumUnit(startDate, endDate);
+ const index = units.indexOf(minUnit === 'year' ? 'month' : minUnit);
+
+ return index >= 0 ? units.splice(index) : [];
+}
+
+export function getMinimumUnit(startDate: number | Date, endDate: number | Date) {
+ if (differenceInMinutes(endDate, startDate) <= 60) {
+ return 'minute';
+ } else if (differenceInHours(endDate, startDate) <= 48) {
+ return 'hour';
+ } else if (differenceInCalendarMonths(endDate, startDate) <= 6) {
+ return 'day';
+ } else if (differenceInCalendarMonths(endDate, startDate) <= 24) {
+ return 'month';
+ }
+
+ return 'year';
+}
+
+export function maxDate(...args: Date[]) {
+ return max(args.filter(n => isDate(n)));
+}
+
+export function minDate(...args: any[]) {
+ return min(args.filter(n => isDate(n)));
+}
+
+export function getCompareDate(compare: string, startDate: Date, endDate: Date) {
+ if (compare === 'yoy') {
+ return { compare, startDate: subYears(startDate, 1), endDate: subYears(endDate, 1) };
+ }
+
+ if (compare === 'prev') {
+ const diff = differenceInMinutes(endDate, startDate);
+
+ return { compare, startDate: subMinutes(startDate, diff), endDate: subMinutes(endDate, diff) };
+ }
+
+ return {};
+}
+
+export function getDayOfWeekAsDate(dayOfWeek: number) {
+ const startOfWeekDay = startOfWeek(new Date());
+ const daysToAdd = [0, 1, 2, 3, 4, 5, 6].indexOf(dayOfWeek);
+ let currentDate = addDays(startOfWeekDay, daysToAdd);
+
+ // Ensure we're not returning a past date
+ if (isSameDay(currentDate, startOfWeekDay)) {
+ currentDate = addDays(currentDate, 7);
+ }
+
+ return currentDate;
+}
+
+export function formatDate(
+ date: string | number | Date,
+ dateFormat: string = 'PPpp',
+ locale = 'en-US',
+) {
+ return format(typeof date === 'string' ? new Date(date) : date, dateFormat, {
+ locale: getDateLocale(locale),
+ });
+}
+
+export function generateTimeSeries(
+ data: { x: string; y: number; d?: string }[],
+ minDate: Date,
+ maxDate: Date,
+ unit: string,
+ locale: string,
+) {
+ const add = DATE_FUNCTIONS[unit].add;
+ const start = DATE_FUNCTIONS[unit].start;
+ const fmt = DATE_FORMATS[unit];
+
+ let current = start(minDate);
+ const end = start(maxDate);
+
+ const timeseries: string[] = [];
+
+ while (isBefore(current, end) || isEqual(current, end)) {
+ timeseries.push(formatDate(current, fmt, locale));
+ current = add(current, 1);
+ }
+
+ const lookup = new Map(data.map(({ x, y, d }) => [formatDate(x, fmt, locale), { x, y, d }]));
+
+ return timeseries.map(t => {
+ const { x, y, d } = lookup.get(t) || {};
+
+ return { x: t, d: d ?? x, y: y ?? null };
+ });
+}
+
+export function getDateRangeValue(startDate: Date, endDate: Date) {
+ return `range:${startDate.getTime()}:${endDate.getTime()}`;
+}
+
+export function getMonthDateRangeValue(date: Date) {
+ return getDateRangeValue(startOfMonth(date), endOfMonth(date));
+}
+
+export function isInvalidDate(date: any) {
+ return date instanceof Date && Number.isNaN(date.getTime());
+}
diff --git a/src/lib/db.ts b/src/lib/db.ts
new file mode 100644
index 0000000..7b6e836
--- /dev/null
+++ b/src/lib/db.ts
@@ -0,0 +1,40 @@
+export const PRISMA = 'prisma';
+export const POSTGRESQL = 'postgresql';
+export const CLICKHOUSE = 'clickhouse';
+export const KAFKA = 'kafka';
+export const KAFKA_PRODUCER = 'kafka-producer';
+
+// Fixes issue with converting bigint values
+BigInt.prototype.toJSON = function () {
+ return Number(this);
+};
+
+export function getDatabaseType(url = process.env.DATABASE_URL) {
+ const type = url?.split(':')[0];
+
+ if (type === 'postgres') {
+ return POSTGRESQL;
+ }
+
+ return type;
+}
+
+export async function runQuery(queries: any) {
+ if (process.env.CLICKHOUSE_URL) {
+ if (queries[KAFKA]) {
+ return queries[KAFKA]();
+ }
+
+ return queries[CLICKHOUSE]();
+ }
+
+ const db = getDatabaseType();
+
+ if (db === POSTGRESQL) {
+ return queries[PRISMA]();
+ }
+}
+
+export function notImplemented() {
+ throw new Error('Not implemented.');
+}
diff --git a/src/lib/detect.ts b/src/lib/detect.ts
new file mode 100644
index 0000000..68cb667
--- /dev/null
+++ b/src/lib/detect.ts
@@ -0,0 +1,154 @@
+import path from 'node:path';
+import { browserName, detectOS } from 'detect-browser';
+import ipaddr from 'ipaddr.js';
+import isLocalhost from 'is-localhost-ip';
+import maxmind from 'maxmind';
+import { UAParser } from 'ua-parser-js';
+import { getIpAddress, stripPort } from '@/lib/ip';
+import { safeDecodeURIComponent } from '@/lib/url';
+
+const MAXMIND = 'maxmind';
+
+const PROVIDER_HEADERS = [
+ // Cloudflare headers
+ {
+ countryHeader: 'cf-ipcountry',
+ regionHeader: 'cf-region-code',
+ cityHeader: 'cf-ipcity',
+ },
+ // Vercel headers
+ {
+ countryHeader: 'x-vercel-ip-country',
+ regionHeader: 'x-vercel-ip-country-region',
+ cityHeader: 'x-vercel-ip-city',
+ },
+ // CloudFront headers
+ {
+ countryHeader: 'cloudfront-viewer-country',
+ regionHeader: 'cloudfront-viewer-country-region',
+ cityHeader: 'cloudfront-viewer-city',
+ },
+];
+
+export function getDevice(userAgent: string, screen: string = '') {
+ const { device } = UAParser(userAgent);
+
+ const [width] = screen.split('x');
+
+ const type = device?.type || 'desktop';
+
+ if (type === 'desktop' && screen && +width <= 1920) {
+ return 'laptop';
+ }
+
+ return type;
+}
+
+function getRegionCode(country: string, region: string) {
+ if (!country || !region) {
+ return undefined;
+ }
+
+ return region.includes('-') ? region : `${country}-${region}`;
+}
+
+function decodeHeader(s: string | undefined | null): string | undefined | null {
+ if (s === undefined || s === null) {
+ return s;
+ }
+
+ return Buffer.from(s, 'latin1').toString('utf-8');
+}
+
+export async function getLocation(ip: string = '', headers: Headers, hasPayloadIP: boolean) {
+ // Ignore local ips
+ if (!ip || (await isLocalhost(ip))) {
+ return null;
+ }
+
+ if (!hasPayloadIP && !process.env.SKIP_LOCATION_HEADERS) {
+ for (const provider of PROVIDER_HEADERS) {
+ const countryHeader = headers.get(provider.countryHeader);
+ if (countryHeader) {
+ const country = decodeHeader(countryHeader);
+ const region = decodeHeader(headers.get(provider.regionHeader));
+ const city = decodeHeader(headers.get(provider.cityHeader));
+
+ return {
+ country,
+ region: getRegionCode(country, region),
+ city,
+ };
+ }
+ }
+ }
+
+ // Database lookup
+ if (!globalThis[MAXMIND]) {
+ const dir = path.join(process.cwd(), 'geo');
+
+ globalThis[MAXMIND] = await maxmind.open(
+ process.env.GEOLITE_DB_PATH || path.resolve(dir, 'GeoLite2-City.mmdb'),
+ );
+ }
+
+ const result = globalThis[MAXMIND]?.get(stripPort(ip));
+
+ if (result) {
+ const country = result.country?.iso_code ?? result?.registered_country?.iso_code;
+ const region = result.subdivisions?.[0]?.iso_code;
+ const city = result.city?.names?.en;
+
+ return {
+ country,
+ region: getRegionCode(country, region),
+ city,
+ };
+ }
+}
+
+export async function getClientInfo(request: Request, payload: Record<string, any>) {
+ const userAgent = payload?.userAgent || request.headers.get('user-agent');
+ const ip = payload?.ip || getIpAddress(request.headers);
+ const location = await getLocation(ip, request.headers, !!payload?.ip);
+ const country = safeDecodeURIComponent(location?.country);
+ const region = safeDecodeURIComponent(location?.region);
+ const city = safeDecodeURIComponent(location?.city);
+ const browser = payload?.browser ?? browserName(userAgent);
+ const os = payload?.os ?? (detectOS(userAgent) as string);
+ const device = payload?.device ?? getDevice(userAgent, payload?.screen);
+
+ return { userAgent, browser, os, ip, country, region, city, device };
+}
+
+export function hasBlockedIp(clientIp: string) {
+ const ignoreIps = process.env.IGNORE_IP;
+
+ if (ignoreIps) {
+ const ips = [];
+
+ if (ignoreIps) {
+ ips.push(...ignoreIps.split(',').map(n => n.trim()));
+ }
+
+ return ips.find(ip => {
+ if (ip === clientIp) {
+ return true;
+ }
+
+ // CIDR notation
+ if (ip.indexOf('/') > 0) {
+ const addr = ipaddr.parse(clientIp);
+ const range = ipaddr.parseCIDR(ip);
+
+ if (addr.kind() === range[0].kind() && addr.match(range)) {
+ return true;
+ }
+ }
+
+ return false;
+ });
+ }
+
+ return false;
+}
diff --git a/src/lib/fetch.ts b/src/lib/fetch.ts
new file mode 100644
index 0000000..1086973
--- /dev/null
+++ b/src/lib/fetch.ts
@@ -0,0 +1,58 @@
+import { buildPath } from '@/lib/url';
+
+export interface ErrorResponse {
+ error: {
+ status: number;
+ message: string;
+ code?: string;
+ };
+}
+
+export interface FetchResponse {
+ ok: boolean;
+ status: number;
+ data?: any;
+ error?: ErrorResponse;
+}
+
+export async function request(
+ method: string,
+ url: string,
+ body?: string,
+ headers: object = {},
+): Promise<FetchResponse> {
+ return fetch(url, {
+ method,
+ cache: 'no-cache',
+ headers: {
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+ ...headers,
+ },
+ body,
+ }).then(async res => {
+ const data = await res.json();
+
+ return {
+ ok: res.ok,
+ status: res.status,
+ data,
+ };
+ });
+}
+
+export async function httpGet(path: string, params: object = {}, headers: object = {}) {
+ return request('GET', buildPath(path, params), undefined, headers);
+}
+
+export async function httpDelete(path: string, params: object = {}, headers: object = {}) {
+ return request('DELETE', buildPath(path, params), undefined, headers);
+}
+
+export async function httpPost(path: string, params: object = {}, headers: object = {}) {
+ return request('POST', path, JSON.stringify(params), headers);
+}
+
+export async function httpPut(path: string, params: object = {}, headers: object = {}) {
+ return request('PUT', path, JSON.stringify(params), headers);
+}
diff --git a/src/lib/filters.ts b/src/lib/filters.ts
new file mode 100644
index 0000000..3da268d
--- /dev/null
+++ b/src/lib/filters.ts
@@ -0,0 +1,31 @@
+export const percentFilter = (data: any[]) => {
+ if (!Array.isArray(data)) return [];
+ const total = data.reduce((n, { y }) => n + y, 0);
+ return data.map(({ x, y, ...props }) => ({ x, y, z: total ? (y / total) * 100 : 0, ...props }));
+};
+
+export const paramFilter = (data: any[]) => {
+ const map = data.reduce((obj, { x, y }) => {
+ try {
+ const searchParams = new URLSearchParams(x);
+
+ for (const [key, value] of searchParams) {
+ if (!obj[key]) {
+ obj[key] = { [value]: y };
+ } else if (!obj[key][value]) {
+ obj[key][value] = y;
+ } else {
+ obj[key][value] += y;
+ }
+ }
+ } catch {
+ // Ignore
+ }
+
+ return obj;
+ }, {});
+
+ return Object.keys(map).flatMap(key =>
+ Object.keys(map[key]).map(n => ({ x: `${key}=${n}`, p: key, v: n, y: map[key][n] })),
+ );
+};
diff --git a/src/lib/format.ts b/src/lib/format.ts
new file mode 100644
index 0000000..52fd304
--- /dev/null
+++ b/src/lib/format.ts
@@ -0,0 +1,118 @@
+export function parseTime(val: number) {
+ const days = ~~(val / 86400);
+ const hours = ~~(val / 3600) - days * 24;
+ const minutes = ~~(val / 60) - days * 1440 - hours * 60;
+ const seconds = ~~val - days * 86400 - hours * 3600 - minutes * 60;
+ const ms = (val - ~~val) * 1000;
+
+ return {
+ days,
+ hours,
+ minutes,
+ seconds,
+ ms,
+ };
+}
+
+export function formatTime(val: number) {
+ const { hours, minutes, seconds } = parseTime(val);
+ const h = hours > 0 ? `${hours}:` : '';
+ const m = hours > 0 ? minutes.toString().padStart(2, '0') : minutes;
+ const s = seconds.toString().padStart(2, '0');
+
+ return `${h}${m}:${s}`;
+}
+
+export function formatShortTime(val: number, formats = ['m', 's'], space = '') {
+ const { days, hours, minutes, seconds, ms } = parseTime(val);
+ let t = '';
+
+ if (days > 0 && formats.indexOf('d') !== -1) t += `${days}d${space}`;
+ if (hours > 0 && formats.indexOf('h') !== -1) t += `${hours}h${space}`;
+ if (minutes > 0 && formats.indexOf('m') !== -1) t += `${minutes}m${space}`;
+ if (seconds > 0 && formats.indexOf('s') !== -1) t += `${seconds}s${space}`;
+ if (ms > 0 && formats.indexOf('ms') !== -1) t += `${ms}ms`;
+
+ if (!t) {
+ return `0${formats[formats.length - 1]}`;
+ }
+
+ return t;
+}
+
+export function formatNumber(n: string | number) {
+ return Number(n).toFixed(0);
+}
+
+export function formatLongNumber(value: number) {
+ const n = Number(value);
+
+ if (n >= 1000000000) {
+ return `${(n / 1000000).toFixed(1)}b`;
+ }
+ if (n >= 1000000) {
+ return `${(n / 1000000).toFixed(1)}m`;
+ }
+ if (n >= 100000) {
+ return `${(n / 1000).toFixed(0)}k`;
+ }
+ if (n >= 10000) {
+ return `${(n / 1000).toFixed(1)}k`;
+ }
+ if (n >= 1000) {
+ return `${(n / 1000).toFixed(2)}k`;
+ }
+
+ return formatNumber(n);
+}
+
+export function stringToColor(str: string) {
+ if (!str) {
+ return '#ffffff';
+ }
+ let hash = 0;
+ for (let i = 0; i < str.length; i++) {
+ hash = str.charCodeAt(i) + ((hash << 5) - hash);
+ }
+ let color = '#';
+ for (let i = 0; i < 3; i++) {
+ const value = (hash >> (i * 8)) & 0xff;
+ color += `00${value.toString(16)}`.slice(-2);
+ }
+ return color;
+}
+
+export function formatCurrency(value: number, currency: string, locale = 'en-US') {
+ let formattedValue: Intl.NumberFormat;
+
+ try {
+ formattedValue = new Intl.NumberFormat(locale, {
+ style: 'currency',
+ currency: currency,
+ });
+ } catch {
+ // Fallback to default currency format if an error occurs
+ formattedValue = new Intl.NumberFormat(locale, {
+ style: 'currency',
+ currency: 'USD',
+ });
+ }
+
+ return formattedValue.format(value);
+}
+
+export function formatLongCurrency(value: number, currency: string, locale = 'en-US') {
+ const n = Number(value);
+
+ if (n >= 1000000000) {
+ return `${formatCurrency(n / 1000000000, currency, locale)}b`;
+ }
+ if (n >= 1000000) {
+ return `${formatCurrency(n / 1000000, currency, locale)}m`;
+ }
+ if (n >= 1000) {
+ return `${formatCurrency(n / 1000, currency, locale)}k`;
+ }
+
+ return formatCurrency(n, currency, locale);
+}
diff --git a/src/lib/generate.ts b/src/lib/generate.ts
new file mode 100644
index 0000000..8e25aa0
--- /dev/null
+++ b/src/lib/generate.ts
@@ -0,0 +1,20 @@
+import prand from 'pure-rand';
+
+const seed = Date.now() ^ (Math.random() * 0x100000000);
+const rng = prand.xoroshiro128plus(seed);
+
+export function random(min: number, max: number) {
+ return prand.unsafeUniformIntDistribution(min, max, rng);
+}
+
+export function getRandomChars(
+ n: number,
+ chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ',
+) {
+ const arr = chars.split('');
+ let s = '';
+ for (let i = 0; i < n; i++) {
+ s += arr[random(0, arr.length - 1)];
+ }
+ return s;
+}
diff --git a/src/lib/ip.ts b/src/lib/ip.ts
new file mode 100644
index 0000000..5cd7757
--- /dev/null
+++ b/src/lib/ip.ts
@@ -0,0 +1,60 @@
+export const IP_ADDRESS_HEADERS = [
+ 'true-client-ip', // CDN
+ 'cf-connecting-ip', // Cloudflare
+ 'fastly-client-ip', // Fastly
+ 'x-nf-client-connection-ip', // Netlify
+ 'do-connecting-ip', // Digital Ocean
+ 'x-real-ip', // Reverse proxy
+ 'x-appengine-user-ip', // Google App Engine
+ 'x-forwarded-for',
+ 'forwarded',
+ 'x-client-ip',
+ 'x-cluster-client-ip',
+ 'x-forwarded',
+];
+
+export function getIpAddress(headers: Headers) {
+ const customHeader = process.env.CLIENT_IP_HEADER;
+
+ if (customHeader && headers.get(customHeader)) {
+ return headers.get(customHeader);
+ }
+
+ const header = IP_ADDRESS_HEADERS.find(name => {
+ return headers.get(name);
+ });
+
+ const ip = headers.get(header);
+
+ if (header === 'x-forwarded-for') {
+ return ip?.split(',')?.[0]?.trim();
+ }
+
+ if (header === 'forwarded') {
+ const match = ip.match(/for=(\[?[0-9a-fA-F:.]+\]?)/);
+
+ if (match) {
+ return match[1];
+ }
+ }
+
+ return ip;
+}
+
+export function stripPort(ip: string) {
+ if (ip.startsWith('[')) {
+ const endBracket = ip.indexOf(']');
+ if (endBracket !== -1) {
+ return ip.slice(0, endBracket + 1);
+ }
+ }
+
+ const idx = ip.lastIndexOf(':');
+ if (idx !== -1) {
+ if (ip.includes('.') || /^[a-zA-Z0-9.-]+$/.test(ip.slice(0, idx))) {
+ return ip.slice(0, idx);
+ }
+ }
+
+ return ip;
+}
diff --git a/src/lib/jwt.ts b/src/lib/jwt.ts
new file mode 100644
index 0000000..470c48f
--- /dev/null
+++ b/src/lib/jwt.ts
@@ -0,0 +1,36 @@
+import jwt from 'jsonwebtoken';
+import { decrypt, encrypt } from '@/lib/crypto';
+
+export function createToken(payload: any, secret: any, options?: any) {
+ return jwt.sign(payload, secret, options);
+}
+
+export function parseToken(token: string, secret: any) {
+ try {
+ return jwt.verify(token, secret);
+ } catch {
+ return null;
+ }
+}
+
+export function createSecureToken(payload: any, secret: any, options?: any) {
+ return encrypt(createToken(payload, secret, options), secret);
+}
+
+export function parseSecureToken(token: string, secret: any) {
+ try {
+ return jwt.verify(decrypt(token, secret), secret);
+ } catch {
+ return null;
+ }
+}
+
+export async function parseAuthToken(req: Request, secret: string) {
+ try {
+ const token = req.headers.get('authorization')?.split(' ')?.[1];
+
+ return parseSecureToken(token as string, secret);
+ } catch {
+ return null;
+ }
+}
diff --git a/src/lib/kafka.ts b/src/lib/kafka.ts
new file mode 100644
index 0000000..1d60e1f
--- /dev/null
+++ b/src/lib/kafka.ts
@@ -0,0 +1,112 @@
+import type * as tls from 'node:tls';
+import debug from 'debug';
+import { Kafka, logLevel, type Producer, type RecordMetadata, type SASLOptions } from 'kafkajs';
+import { serializeError } from 'serialize-error';
+import { KAFKA, KAFKA_PRODUCER } from '@/lib/db';
+
+const log = debug('umami:kafka');
+const CONNECT_TIMEOUT = 5000;
+const SEND_TIMEOUT = 3000;
+const ACKS = 1;
+
+let kafka: Kafka;
+let producer: Producer;
+const enabled = Boolean(process.env.KAFKA_URL && process.env.KAFKA_BROKER);
+
+function getClient() {
+ const { username, password } = new URL(process.env.KAFKA_URL);
+ const brokers = process.env.KAFKA_BROKER.split(',');
+ const mechanism =
+ (process.env.KAFKA_SASL_MECHANISM as 'plain' | 'scram-sha-256' | 'scram-sha-512') || 'plain';
+
+ const ssl: { ssl?: tls.ConnectionOptions | boolean; sasl?: SASLOptions } =
+ username && password
+ ? {
+ ssl: {
+ rejectUnauthorized: false,
+ },
+ sasl: {
+ mechanism,
+ username,
+ password,
+ },
+ }
+ : {};
+
+ const client: Kafka = new Kafka({
+ clientId: 'umami',
+ brokers: brokers,
+ connectionTimeout: CONNECT_TIMEOUT,
+ logLevel: logLevel.ERROR,
+ ...ssl,
+ });
+
+ if (process.env.NODE_ENV !== 'production') {
+ globalThis[KAFKA] = client;
+ }
+
+ log('Kafka initialized');
+
+ return client;
+}
+
+async function getProducer(): Promise<Producer> {
+ const producer = kafka.producer();
+ await producer.connect();
+
+ if (process.env.NODE_ENV !== 'production') {
+ globalThis[KAFKA_PRODUCER] = producer;
+ }
+
+ log('Kafka producer initialized');
+
+ return producer;
+}
+
+async function sendMessage(
+ topic: string,
+ message: Record<string, string | number> | Record<string, string | number>[],
+): Promise<RecordMetadata[]> {
+ try {
+ await connect();
+
+ return producer.send({
+ topic,
+ messages: Array.isArray(message)
+ ? message.map(a => {
+ return { value: JSON.stringify(a) };
+ })
+ : [
+ {
+ value: JSON.stringify(message),
+ },
+ ],
+ timeout: SEND_TIMEOUT,
+ acks: ACKS,
+ });
+ } catch (e) {
+ // eslint-disable-next-line no-console
+ console.log('KAFKA ERROR:', serializeError(e));
+ }
+}
+
+async function connect(): Promise<Kafka> {
+ if (!kafka) {
+ kafka = process.env.KAFKA_URL && process.env.KAFKA_BROKER && (globalThis[KAFKA] || getClient());
+
+ if (kafka) {
+ producer = globalThis[KAFKA_PRODUCER] || (await getProducer());
+ }
+ }
+
+ return kafka;
+}
+
+export default {
+ enabled,
+ client: kafka,
+ producer,
+ log,
+ connect,
+ sendMessage,
+};
diff --git a/src/lib/lang.ts b/src/lib/lang.ts
new file mode 100644
index 0000000..f874640
--- /dev/null
+++ b/src/lib/lang.ts
@@ -0,0 +1,111 @@
+import {
+ arSA,
+ be,
+ bg,
+ bn,
+ bs,
+ ca,
+ cs,
+ da,
+ de,
+ el,
+ enGB,
+ enUS,
+ es,
+ faIR,
+ fi,
+ fr,
+ he,
+ hi,
+ hr,
+ hu,
+ id,
+ it,
+ ja,
+ km,
+ ko,
+ lt,
+ mn,
+ ms,
+ nb,
+ nl,
+ pl,
+ pt,
+ ptBR,
+ ro,
+ ru,
+ sk,
+ sl,
+ sv,
+ ta,
+ th,
+ tr,
+ uk,
+ uz,
+ vi,
+ zhCN,
+ zhTW,
+} from 'date-fns/locale';
+
+export const languages = {
+ 'ar-SA': { label: 'العربية', dateLocale: arSA, dir: 'rtl' },
+ 'be-BY': { label: 'Беларуская', dateLocale: be },
+ 'bg-BG': { label: 'български език', dateLocale: bg },
+ 'bn-BD': { label: 'বাংলা', dateLocale: bn },
+ 'bs-BA': { label: 'Bosanski', dateLocale: bs },
+ 'ca-ES': { label: 'Català', dateLocale: ca },
+ 'cs-CZ': { label: 'Čeština', dateLocale: cs },
+ 'da-DK': { label: 'Dansk', dateLocale: da },
+ 'de-CH': { label: 'Schwiizerdütsch', dateLocale: de },
+ 'de-DE': { label: 'Deutsch', dateLocale: de },
+ 'el-GR': { label: 'Ελληνικά', dateLocale: el },
+ 'en-GB': { label: 'English (UK)', dateLocale: enGB },
+ 'en-US': { label: 'English (US)', dateLocale: enUS },
+ 'es-ES': { label: 'Español', dateLocale: es },
+ 'fa-IR': { label: 'فارسی', dateLocale: faIR, dir: 'rtl' },
+ 'fi-FI': { label: 'Suomi', dateLocale: fi },
+ 'fo-FO': { label: 'Føroyskt' },
+ 'fr-FR': { label: 'Français', dateLocale: fr },
+ 'ga-ES': { label: 'Galacian (Spain)', dateLocale: es },
+ 'he-IL': { label: 'עברית', dateLocale: he },
+ 'hi-IN': { label: 'हिन्दी', dateLocale: hi },
+ 'hr-HR': { label: 'Hrvatski', dateLocale: hr },
+ 'hu-HU': { label: 'Hungarian', dateLocale: hu },
+ 'id-ID': { label: 'Bahasa Indonesia', dateLocale: id },
+ 'it-IT': { label: 'Italiano', dateLocale: it },
+ 'ja-JP': { label: '日本語', dateLocale: ja },
+ 'km-KH': { label: 'ភាសាខ្មែរ', dateLocale: km },
+ 'ko-KR': { label: '한국어', dateLocale: ko },
+ 'lt-LT': { label: 'Lietuvių', dateLocale: lt },
+ 'mn-MN': { label: 'Монгол', dateLocale: mn },
+ 'ms-MY': { label: 'Malay', dateLocale: ms },
+ 'my-MM': { label: 'မြန်မာဘာသာ', dateLocale: enUS },
+ 'nl-NL': { label: 'Nederlands', dateLocale: nl },
+ 'nb-NO': { label: 'Norsk Bokmål', dateLocale: nb },
+ 'pl-PL': { label: 'Polski', dateLocale: pl },
+ 'pt-BR': { label: 'Português do Brasil', dateLocale: ptBR },
+ 'pt-PT': { label: 'Português', dateLocale: pt },
+ 'ro-RO': { label: 'Română', dateLocale: ro },
+ 'ru-RU': { label: 'Русский', dateLocale: ru },
+ 'si-LK': { label: 'සිංහල', dateLocale: id },
+ 'sk-SK': { label: 'Slovenčina', dateLocale: sk },
+ 'sl-SI': { label: 'Slovenščina', dateLocale: sl },
+ 'sv-SE': { label: 'Svenska', dateLocale: sv },
+ 'ta-IN': { label: 'தமிழ்', dateLocale: ta },
+ 'th-TH': { label: 'ภาษาไทย', dateLocale: th },
+ 'tr-TR': { label: 'Türkçe', dateLocale: tr },
+ 'uk-UA': { label: 'українська', dateLocale: uk },
+ 'ur-PK': { label: 'Urdu (Pakistan)', dateLocale: uk, dir: 'rtl' },
+ 'uz-UZ': { label: 'O‘zbekcha', dateLocale: uz },
+ 'vi-VN': { label: 'Tiếng Việt', dateLocale: vi },
+ 'zh-CN': { label: '中文', dateLocale: zhCN },
+ 'zh-TW': { label: '中文(繁體)', dateLocale: zhTW },
+};
+
+export function getDateLocale(locale: string) {
+ return languages[locale]?.dateLocale || enUS;
+}
+
+export function getTextDirection(locale: string) {
+ return languages[locale]?.dir || 'ltr';
+}
diff --git a/src/lib/load.ts b/src/lib/load.ts
new file mode 100644
index 0000000..d4d6c3c
--- /dev/null
+++ b/src/lib/load.ts
@@ -0,0 +1,40 @@
+import type { Session, Website } from '@/generated/prisma/client';
+import redis from '@/lib/redis';
+import { getWebsite } from '@/queries/prisma';
+import { getWebsiteSession } from '@/queries/sql';
+
+export async function fetchWebsite(websiteId: string): Promise<Website> {
+ let website = null;
+
+ if (redis.enabled) {
+ website = await redis.client.fetch(`website:${websiteId}`, () => getWebsite(websiteId), 86400);
+ } else {
+ website = await getWebsite(websiteId);
+ }
+
+ if (!website || website.deletedAt) {
+ return null;
+ }
+
+ return website;
+}
+
+export async function fetchSession(websiteId: string, sessionId: string): Promise<Session> {
+ let session = null;
+
+ if (redis.enabled) {
+ session = await redis.client.fetch(
+ `session:${sessionId}`,
+ () => getWebsiteSession(websiteId, sessionId),
+ 86400,
+ );
+ } else {
+ session = await getWebsiteSession(websiteId, sessionId);
+ }
+
+ if (!session) {
+ return null;
+ }
+
+ return session;
+}
diff --git a/src/lib/params.ts b/src/lib/params.ts
new file mode 100644
index 0000000..ab2d586
--- /dev/null
+++ b/src/lib/params.ts
@@ -0,0 +1,62 @@
+import { FILTER_COLUMNS, OPERATORS } from '@/lib/constants';
+import type { Filter, QueryFilters, QueryOptions } from '@/lib/types';
+
+export function parseFilterValue(param: any) {
+ if (typeof param === 'string') {
+ const operatorValues = Object.values(OPERATORS).join('|');
+
+ const regex = new RegExp(`^(${operatorValues})\\.(.*)$`);
+
+ const [, operator, value] = param.match(regex) || [];
+
+ return { operator: operator || OPERATORS.equals, value: value || param };
+ }
+
+ return { operator: OPERATORS.equals, value: param };
+}
+
+export function isEqualsOperator(operator: any) {
+ return [OPERATORS.equals, OPERATORS.notEquals].includes(operator);
+}
+
+export function isSearchOperator(operator: any) {
+ return [OPERATORS.contains, OPERATORS.doesNotContain].includes(operator);
+}
+
+export function filtersObjectToArray(filters: QueryFilters, options: QueryOptions = {}): Filter[] {
+ if (!filters) {
+ return [];
+ }
+
+ return Object.keys(filters).reduce((arr, key) => {
+ const filter = filters[key];
+
+ if (filter === undefined || filter === null) {
+ return arr;
+ }
+
+ if (filter?.name && filter?.value !== undefined) {
+ return arr.concat({ ...filter, column: options?.columns?.[key] ?? FILTER_COLUMNS[key] });
+ }
+
+ const { operator, value } = parseFilterValue(filter);
+
+ return arr.concat({
+ name: key,
+ column: options?.columns?.[key] ?? FILTER_COLUMNS[key],
+ operator,
+ value,
+ prefix: options?.prefix,
+ });
+ }, []);
+}
+
+export function filtersArrayToObject(filters: Filter[]) {
+ return filters.reduce((obj, filter: Filter) => {
+ const { name, operator, value } = filter;
+
+ obj[name] = `${operator}.${value}`;
+
+ return obj;
+ }, {});
+}
diff --git a/src/lib/password.ts b/src/lib/password.ts
new file mode 100644
index 0000000..f5c450b
--- /dev/null
+++ b/src/lib/password.ts
@@ -0,0 +1,11 @@
+import bcrypt from 'bcryptjs';
+
+const SALT_ROUNDS = 10;
+
+export function hashPassword(password: string, rounds = SALT_ROUNDS) {
+ return bcrypt.hashSync(password, rounds);
+}
+
+export function checkPassword(password: string, passwordHash: string) {
+ return bcrypt.compareSync(password, passwordHash);
+}
diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts
new file mode 100644
index 0000000..64cb870
--- /dev/null
+++ b/src/lib/prisma.ts
@@ -0,0 +1,368 @@
+import { PrismaPg } from '@prisma/adapter-pg';
+import { readReplicas } from '@prisma/extension-read-replicas';
+import debug from 'debug';
+import { PrismaClient } from '@/generated/prisma/client';
+import { DEFAULT_PAGE_SIZE, FILTER_COLUMNS, OPERATORS, SESSION_COLUMNS } from './constants';
+import { filtersObjectToArray } from './params';
+import type { Operator, QueryFilters, QueryOptions } from './types';
+
+const log = debug('umami:prisma');
+
+const PRISMA = 'prisma';
+
+const PRISMA_LOG_OPTIONS = {
+ log: [
+ {
+ emit: 'event' as const,
+ level: 'query' as const,
+ },
+ ],
+};
+
+const DATE_FORMATS = {
+ minute: 'YYYY-MM-DD HH24:MI:00',
+ hour: 'YYYY-MM-DD HH24:00:00',
+ day: 'YYYY-MM-DD HH24:00:00',
+ month: 'YYYY-MM-01 HH24:00:00',
+ year: 'YYYY-01-01 HH24:00:00',
+};
+
+const DATE_FORMATS_UTC = {
+ minute: 'YYYY-MM-DD"T"HH24:MI:00"Z"',
+ hour: 'YYYY-MM-DD"T"HH24:00:00"Z"',
+ day: 'YYYY-MM-DD"T"HH24:00:00"Z"',
+ month: 'YYYY-MM-01"T"HH24:00:00"Z"',
+ year: 'YYYY-01-01"T"HH24:00:00"Z"',
+};
+
+function getAddIntervalQuery(field: string, interval: string): string {
+ return `${field} + interval '${interval}'`;
+}
+
+function getDayDiffQuery(field1: string, field2: string): string {
+ return `${field1}::date - ${field2}::date`;
+}
+
+function getCastColumnQuery(field: string, type: string): string {
+ return `${field}::${type}`;
+}
+
+function getDateSQL(field: string, unit: string, timezone?: string): string {
+ if (timezone && timezone !== 'utc') {
+ return `to_char(date_trunc('${unit}', ${field} at time zone '${timezone}'), '${DATE_FORMATS[unit]}')`;
+ }
+
+ return `to_char(date_trunc('${unit}', ${field}), '${DATE_FORMATS_UTC[unit]}')`;
+}
+
+function getDateWeeklySQL(field: string, timezone?: string) {
+ return `concat(extract(dow from (${field} at time zone '${timezone}')), ':', to_char((${field} at time zone '${timezone}'), 'HH24'))`;
+}
+
+export function getTimestampSQL(field: string) {
+ return `floor(extract(epoch from ${field}))`;
+}
+
+function getTimestampDiffSQL(field1: string, field2: string): string {
+ return `floor(extract(epoch from (${field2} - ${field1})))`;
+}
+
+function getSearchSQL(column: string, param: string = 'search'): string {
+ return `and ${column} ilike {{${param}}}`;
+}
+
+function mapFilter(column: string, operator: string, name: string, type: string = '') {
+ const value = `{{${name}${type ? `::${type}` : ''}}}`;
+
+ switch (operator) {
+ case OPERATORS.equals:
+ return `${column} = ${value}`;
+ case OPERATORS.notEquals:
+ return `${column} != ${value}`;
+ case OPERATORS.contains:
+ return `${column} ilike ${value}`;
+ case OPERATORS.doesNotContain:
+ return `${column} not ilike ${value}`;
+ default:
+ return '';
+ }
+}
+
+function getFilterQuery(filters: Record<string, any>, options: QueryOptions = {}): string {
+ const query = filtersObjectToArray(filters, options).reduce(
+ (arr, { name, column, operator, prefix = '' }) => {
+ const isCohort = options?.isCohort;
+
+ if (isCohort) {
+ column = FILTER_COLUMNS[name.slice('cohort_'.length)];
+ }
+
+ if (column) {
+ arr.push(`and ${mapFilter(`${prefix}${column}`, operator, name)}`);
+
+ if (name === 'referrer') {
+ arr.push(
+ `and (website_event.referrer_domain != website_event.hostname or website_event.referrer_domain is null)`,
+ );
+ }
+ }
+
+ return arr;
+ },
+ [],
+ );
+
+ return query.join('\n');
+}
+
+function getCohortQuery(filters: QueryFilters = {}) {
+ if (!filters || Object.keys(filters).length === 0) {
+ return '';
+ }
+
+ const filterQuery = getFilterQuery(filters, { isCohort: true });
+
+ return `join
+ (select distinct website_event.session_id
+ from website_event
+ join session on session.session_id = website_event.session_id
+ and session.website_id = website_event.website_id
+ where website_event.website_id = {{websiteId}}
+ and website_event.created_at between {{cohort_startDate}} and {{cohort_endDate}}
+ ${filterQuery}
+ ) cohort
+ on cohort.session_id = website_event.session_id
+ `;
+}
+
+function getDateQuery(filters: Record<string, any>) {
+ const { startDate, endDate } = filters;
+
+ if (startDate) {
+ if (endDate) {
+ return `and website_event.created_at between {{startDate}} and {{endDate}}`;
+ } else {
+ return `and website_event.created_at >= {{startDate}}`;
+ }
+ }
+
+ return '';
+}
+
+function getQueryParams(filters: Record<string, any>) {
+ return {
+ ...filters,
+ ...filtersObjectToArray(filters).reduce((obj, { name, operator, value }) => {
+ obj[name] = ([OPERATORS.contains, OPERATORS.doesNotContain] as Operator[]).includes(operator)
+ ? `%${value}%`
+ : value;
+
+ return obj;
+ }, {}),
+ };
+}
+
+function parseFilters(filters: Record<string, any>, options?: QueryOptions) {
+ const joinSession = Object.keys(filters).find(key =>
+ ['referrer', ...SESSION_COLUMNS].includes(key),
+ );
+
+ const cohortFilters = Object.fromEntries(
+ Object.entries(filters).filter(([key]) => key.startsWith('cohort_')),
+ );
+
+ return {
+ joinSessionQuery:
+ options?.joinSession || joinSession
+ ? `inner join session on website_event.session_id = session.session_id and website_event.website_id = session.website_id`
+ : '',
+ dateQuery: getDateQuery(filters),
+ filterQuery: getFilterQuery(filters, options),
+ queryParams: getQueryParams(filters),
+ cohortQuery: getCohortQuery(cohortFilters),
+ };
+}
+
+async function rawQuery(sql: string, data: Record<string, any>, name?: string): Promise<any> {
+ if (process.env.LOG_QUERY) {
+ log('QUERY:\n', sql);
+ log('PARAMETERS:\n', data);
+ log('NAME:\n', name);
+ }
+ const params = [];
+ const schema = getSchema();
+
+ if (schema) {
+ await client.$executeRawUnsafe(`SET search_path TO "${schema}";`);
+ }
+
+ const query = sql?.replaceAll(/\{\{\s*(\w+)(::\w+)?\s*}}/g, (...args) => {
+ const [, name, type] = args;
+
+ const value = data[name];
+
+ params.push(value);
+
+ return `$${params.length}${type ?? ''}`;
+ });
+
+ if (process.env.DATABASE_REPLICA_URL && '$replica' in client) {
+ return client.$replica().$queryRawUnsafe(query, ...params);
+ }
+
+ return client.$queryRawUnsafe(query, ...params);
+}
+
+async function pagedQuery<T>(model: string, criteria: T, filters?: QueryFilters) {
+ const { page = 1, pageSize, orderBy, sortDescending = false, search } = filters || {};
+ const size = +pageSize || DEFAULT_PAGE_SIZE;
+
+ const data = await client[model].findMany({
+ ...criteria,
+ ...{
+ ...(size > 0 && { take: +size, skip: +size * (+page - 1) }),
+ ...(orderBy && {
+ orderBy: [
+ {
+ [orderBy]: sortDescending ? 'desc' : 'asc',
+ },
+ ],
+ }),
+ },
+ });
+
+ const count = await client[model].count({ where: (criteria as any).where });
+
+ return { data, count, page: +page, pageSize: size, orderBy, search };
+}
+
+async function pagedRawQuery(
+ query: string,
+ queryParams: Record<string, any>,
+ filters: QueryFilters,
+ name?: string,
+) {
+ const { page = 1, pageSize, orderBy, sortDescending = false } = filters;
+ const size = +pageSize || DEFAULT_PAGE_SIZE;
+ const offset = +size * (+page - 1);
+ const direction = sortDescending ? 'desc' : 'asc';
+
+ const statements = [
+ orderBy && `order by ${orderBy} ${direction}`,
+ +size > 0 && `limit ${+size} offset ${offset}`,
+ ]
+ .filter(n => n)
+ .join('\n');
+
+ const count = await rawQuery(`select count(*) as num from (${query}) t`, queryParams).then(
+ res => res[0].num,
+ );
+
+ const data = await rawQuery(`${query}${statements}`, queryParams, name);
+
+ return { data, count, page: +page, pageSize: size, orderBy };
+}
+
+function getSearchParameters(query: string, filters: Record<string, any>[]) {
+ if (!query) return;
+
+ const parseFilter = (filter: Record<string, any>) => {
+ const [[key, value]] = Object.entries(filter);
+
+ return {
+ [key]:
+ typeof value === 'string'
+ ? {
+ [value]: query,
+ mode: 'insensitive',
+ }
+ : parseFilter(value),
+ };
+ };
+
+ const params = filters.map(filter => parseFilter(filter));
+
+ return {
+ AND: {
+ OR: params,
+ },
+ };
+}
+
+function transaction(input: any, options?: any) {
+ return client.$transaction(input, options);
+}
+
+function getSchema() {
+ const connectionUrl = new URL(process.env.DATABASE_URL);
+
+ return connectionUrl.searchParams.get('schema');
+}
+
+function getClient() {
+ const url = process.env.DATABASE_URL;
+ const replicaUrl = process.env.DATABASE_REPLICA_URL;
+ const logQuery = process.env.LOG_QUERY;
+ const schema = getSchema();
+
+ const baseAdapter = new PrismaPg({ connectionString: url }, { schema });
+
+ const baseClient = new PrismaClient({
+ adapter: baseAdapter,
+ errorFormat: 'pretty',
+ ...(logQuery ? PRISMA_LOG_OPTIONS : {}),
+ });
+
+ if (logQuery) {
+ baseClient.$on('query', log);
+ }
+
+ if (!replicaUrl) {
+ log('Prisma initialized');
+ globalThis[PRISMA] ??= baseClient;
+ return baseClient;
+ }
+
+ const replicaAdapter = new PrismaPg({ connectionString: replicaUrl }, { schema });
+
+ const replicaClient = new PrismaClient({
+ adapter: replicaAdapter,
+ errorFormat: 'pretty',
+ ...(logQuery ? PRISMA_LOG_OPTIONS : {}),
+ });
+
+ if (logQuery) {
+ replicaClient.$on('query', log);
+ }
+
+ const extended = baseClient.$extends(
+ readReplicas({
+ replicas: [replicaClient],
+ }),
+ );
+
+ log('Prisma initialized (with replica)');
+ globalThis[PRISMA] ??= extended;
+
+ return extended;
+}
+
+const client = (globalThis[PRISMA] || getClient()) as ReturnType<typeof getClient>;
+
+export default {
+ client,
+ transaction,
+ getAddIntervalQuery,
+ getCastColumnQuery,
+ getDayDiffQuery,
+ getDateSQL,
+ getDateWeeklySQL,
+ getFilterQuery,
+ getSearchParameters,
+ getTimestampDiffSQL,
+ getSearchSQL,
+ pagedQuery,
+ pagedRawQuery,
+ parseFilters,
+ rawQuery,
+};
diff --git a/src/lib/react.ts b/src/lib/react.ts
new file mode 100644
index 0000000..668cdf1
--- /dev/null
+++ b/src/lib/react.ts
@@ -0,0 +1,77 @@
+import {
+ Children,
+ cloneElement,
+ type FC,
+ Fragment,
+ isValidElement,
+ type ReactElement,
+ type ReactNode,
+} from 'react';
+
+export function getFragmentChildren(children: ReactNode) {
+ return (children as ReactElement)?.type === Fragment
+ ? (children as ReactElement).props.children
+ : children;
+}
+
+export function isValidChild(child: ReactElement, types: FC | FC[]) {
+ if (!isValidElement(child)) {
+ return false;
+ }
+ return (Array.isArray(types) ? types : [types]).find(type => type === child.type);
+}
+
+export function mapChildren(
+ children: ReactNode,
+ handler: (child: ReactElement, index: number) => any,
+) {
+ return Children.map(getFragmentChildren(children) as ReactElement[], (child, index) => {
+ if (!child?.props) {
+ return null;
+ }
+ return handler(child, index);
+ });
+}
+
+export function cloneChildren(
+ children: ReactNode,
+ handler: (child: ReactElement, index: number) => any,
+ options?: { validChildren?: any[]; onlyRenderValid?: boolean },
+): ReactNode {
+ if (!children) {
+ return null;
+ }
+
+ const { validChildren, onlyRenderValid = false } = options || {};
+
+ return mapChildren(children, (child, index) => {
+ const invalid = validChildren && !isValidChild(child as ReactElement, validChildren);
+
+ if (onlyRenderValid && invalid) {
+ return null;
+ }
+
+ if (!invalid && isValidElement(child)) {
+ return cloneElement(child, handler(child, index));
+ }
+
+ return child;
+ });
+}
+
+export function renderChildren(
+ children: ReactNode | ((item: any, index: number, array: any) => ReactNode),
+ items: any[],
+ handler: (child: ReactElement, index: number) => object | undefined,
+ options?: { validChildren?: any[]; onlyRenderValid?: boolean },
+): ReactNode {
+ if (typeof children === 'function' && items?.length > 0) {
+ return cloneChildren(items.map(children), handler, options);
+ }
+
+ return cloneChildren(getFragmentChildren(children as ReactNode), handler, options);
+}
+
+export function countChildren(children: ReactNode): number {
+ return Children.count(getFragmentChildren(children));
+}
diff --git a/src/lib/redis.ts b/src/lib/redis.ts
new file mode 100644
index 0000000..edde3d6
--- /dev/null
+++ b/src/lib/redis.ts
@@ -0,0 +1,18 @@
+import { UmamiRedisClient } from '@umami/redis-client';
+
+const REDIS = 'redis';
+const enabled = !!process.env.REDIS_URL;
+
+function getClient() {
+ const redis = new UmamiRedisClient({ url: process.env.REDIS_URL });
+
+ if (process.env.NODE_ENV !== 'production') {
+ globalThis[REDIS] = redis;
+ }
+
+ return redis;
+}
+
+const client = globalThis[REDIS] || getClient();
+
+export default { client, enabled };
diff --git a/src/lib/request.ts b/src/lib/request.ts
new file mode 100644
index 0000000..42c4490
--- /dev/null
+++ b/src/lib/request.ts
@@ -0,0 +1,145 @@
+import { z } from 'zod';
+import { checkAuth } from '@/lib/auth';
+import { DEFAULT_PAGE_SIZE, FILTER_COLUMNS } from '@/lib/constants';
+import { getAllowedUnits, getMinimumUnit, maxDate, parseDateRange } from '@/lib/date';
+import { fetchWebsite } from '@/lib/load';
+import { filtersArrayToObject } from '@/lib/params';
+import { badRequest, unauthorized } from '@/lib/response';
+import type { QueryFilters } from '@/lib/types';
+import { getWebsiteSegment } from '@/queries/prisma';
+
+export async function parseRequest(
+ request: Request,
+ schema?: any,
+ options?: { skipAuth: boolean },
+): Promise<any> {
+ const url = new URL(request.url);
+ let query = Object.fromEntries(url.searchParams);
+ let body = await getJsonBody(request);
+ let error: () => undefined | undefined;
+ let auth = null;
+
+ if (schema) {
+ const isGet = request.method === 'GET';
+ const result = schema.safeParse(isGet ? query : body);
+
+ if (!result.success) {
+ error = () => badRequest(z.treeifyError(result.error));
+ } else if (isGet) {
+ query = result.data;
+ } else {
+ body = result.data;
+ }
+ }
+
+ if (!options?.skipAuth && !error) {
+ auth = await checkAuth(request);
+
+ if (!auth) {
+ error = () => unauthorized();
+ }
+ }
+
+ return { url, query, body, auth, error };
+}
+
+export async function getJsonBody(request: Request) {
+ try {
+ return await request.clone().json();
+ } catch {
+ return undefined;
+ }
+}
+
+export function getRequestDateRange(query: Record<string, string>) {
+ const { startAt, endAt, unit, timezone } = query;
+
+ const startDate = new Date(+startAt);
+ const endDate = new Date(+endAt);
+
+ return {
+ startDate,
+ endDate,
+ timezone,
+ unit: getAllowedUnits(startDate, endDate).includes(unit)
+ ? unit
+ : getMinimumUnit(startDate, endDate),
+ };
+}
+
+export function getRequestFilters(query: Record<string, any>) {
+ const result: Record<string, any> = {};
+
+ for (const key of Object.keys(FILTER_COLUMNS)) {
+ const value = query[key];
+ if (value !== undefined) {
+ result[key] = value;
+ }
+ }
+
+ return result;
+}
+
+export async function setWebsiteDate(websiteId: string, data: Record<string, any>) {
+ const website = await fetchWebsite(websiteId);
+
+ if (website?.resetAt) {
+ data.startDate = maxDate(data.startDate, new Date(website?.resetAt));
+ }
+
+ return data;
+}
+
+export async function getQueryFilters(
+ params: Record<string, any>,
+ websiteId?: string,
+): Promise<QueryFilters> {
+ const dateRange = getRequestDateRange(params);
+ const filters = getRequestFilters(params);
+
+ if (websiteId) {
+ await setWebsiteDate(websiteId, dateRange);
+
+ if (params.segment) {
+ const segmentParams = (await getWebsiteSegment(websiteId, params.segment))
+ ?.parameters as Record<string, any>;
+
+ Object.assign(filters, filtersArrayToObject(segmentParams.filters));
+ }
+
+ if (params.cohort) {
+ const cohortParams = (await getWebsiteSegment(websiteId, params.cohort))
+ ?.parameters as Record<string, any>;
+
+ const { startDate, endDate } = parseDateRange(cohortParams.dateRange);
+
+ const cohortFilters = cohortParams.filters.map(({ name, ...props }) => ({
+ ...props,
+ name: `cohort_${name}`,
+ }));
+
+ cohortFilters.push({
+ name: `cohort_${cohortParams.action.type}`,
+ operator: 'eq',
+ value: cohortParams.action.value,
+ });
+
+ Object.assign(filters, {
+ ...filtersArrayToObject(cohortFilters),
+ cohort_startDate: startDate,
+ cohort_endDate: endDate,
+ });
+ }
+ }
+
+ return {
+ ...dateRange,
+ ...filters,
+ page: params?.page,
+ pageSize: params?.pageSize ? params?.pageSize || DEFAULT_PAGE_SIZE : undefined,
+ orderBy: params?.orderBy,
+ sortDescending: params?.sortDescending,
+ search: params?.search,
+ compare: params?.compare,
+ };
+}
diff --git a/src/lib/response.ts b/src/lib/response.ts
new file mode 100644
index 0000000..f1ad5c7
--- /dev/null
+++ b/src/lib/response.ts
@@ -0,0 +1,58 @@
+export function ok() {
+ return Response.json({ ok: true });
+}
+
+export function json(data: Record<string, any> = {}) {
+ return Response.json(data);
+}
+
+export function badRequest(error?: Record<string, any>) {
+ return Response.json(
+ {
+ error: { message: 'Bad request', code: 'bad-request', status: 400, ...error },
+ },
+ { status: 400 },
+ );
+}
+
+export function unauthorized(error?: Record<string, any>) {
+ return Response.json(
+ {
+ error: {
+ message: 'Unauthorized',
+ code: 'unauthorized',
+ status: 401,
+ ...error,
+ },
+ },
+ { status: 401 },
+ );
+}
+
+export function forbidden(error?: Record<string, any>) {
+ return Response.json(
+ { error: { message: 'Forbidden', code: 'forbidden', status: 403, ...error } },
+ { status: 403 },
+ );
+}
+
+export function notFound(error?: Record<string, any>) {
+ return Response.json(
+ { error: { message: 'Not found', code: 'not-found', status: 404, ...error } },
+ { status: 404 },
+ );
+}
+
+export function serverError(error?: Record<string, any>) {
+ return Response.json(
+ {
+ error: {
+ message: 'Server error',
+ code: 'server-error',
+ status: 500,
+ ...error,
+ },
+ },
+ { status: 500 },
+ );
+}
diff --git a/src/lib/schema.ts b/src/lib/schema.ts
new file mode 100644
index 0000000..38f7339
--- /dev/null
+++ b/src/lib/schema.ts
@@ -0,0 +1,232 @@
+import { z } from 'zod';
+import { isValidTimezone, normalizeTimezone } from '@/lib/date';
+import { UNIT_TYPES } from './constants';
+
+export const timezoneParam = z
+ .string()
+ .refine((value: string) => isValidTimezone(value), {
+ message: 'Invalid timezone',
+ })
+ .transform((value: string) => normalizeTimezone(value));
+
+export const unitParam = z.string().refine(value => UNIT_TYPES.includes(value), {
+ message: 'Invalid unit',
+});
+
+export const dateRangeParams = {
+ startAt: z.coerce.number().optional(),
+ endAt: z.coerce.number().optional(),
+ startDate: z.coerce.date().optional(),
+ endDate: z.coerce.date().optional(),
+ timezone: timezoneParam.optional(),
+ unit: unitParam.optional(),
+ compare: z.string().optional(),
+};
+
+export const filterParams = {
+ path: z.string().optional(),
+ referrer: z.string().optional(),
+ title: z.string().optional(),
+ query: z.string().optional(),
+ os: z.string().optional(),
+ browser: z.string().optional(),
+ device: z.string().optional(),
+ country: z.string().optional(),
+ region: z.string().optional(),
+ city: z.string().optional(),
+ tag: z.string().optional(),
+ hostname: z.string().optional(),
+ language: z.string().optional(),
+ event: z.string().optional(),
+ segment: z.uuid().optional(),
+ cohort: z.uuid().optional(),
+ eventType: z.coerce.number().int().positive().optional(),
+};
+
+export const searchParams = {
+ search: z.string().optional(),
+};
+
+export const pagingParams = {
+ page: z.coerce.number().int().positive().optional(),
+ pageSize: z.coerce.number().int().positive().optional(),
+};
+
+export const sortingParams = {
+ orderBy: z.string().optional(),
+};
+
+export const userRoleParam = z.enum(['admin', 'user', 'view-only']);
+
+export const teamRoleParam = z.enum(['team-member', 'team-view-only', 'team-manager']);
+
+export const anyObjectParam = z.record(z.string(), z.any());
+
+export const urlOrPathParam = z.string().refine(
+ value => {
+ try {
+ new URL(value, 'https://localhost');
+ return true;
+ } catch {
+ return false;
+ }
+ },
+ {
+ message: 'Invalid URL.',
+ },
+);
+
+export const fieldsParam = z.enum([
+ 'path',
+ 'referrer',
+ 'title',
+ 'query',
+ 'os',
+ 'browser',
+ 'device',
+ 'country',
+ 'region',
+ 'city',
+ 'tag',
+ 'hostname',
+ 'language',
+ 'event',
+]);
+
+export const reportTypeParam = z.enum([
+ 'attribution',
+ 'breakdown',
+ 'funnel',
+ 'goal',
+ 'journey',
+ 'retention',
+ 'revenue',
+ 'utm',
+]);
+
+export const goalReportSchema = z.object({
+ type: z.literal('goal'),
+ parameters: z
+ .object({
+ startDate: z.coerce.date(),
+ endDate: z.coerce.date(),
+ type: z.string(),
+ value: z.string(),
+ operator: z.enum(['count', 'sum', 'average']).optional(),
+ property: z.string().optional(),
+ })
+ .refine(data => {
+ if (data.type === 'event' && data.property) {
+ return data.operator && data.property;
+ }
+ return true;
+ }),
+});
+
+export const funnelReportSchema = z.object({
+ type: z.literal('funnel'),
+ parameters: z.object({
+ startDate: z.coerce.date(),
+ endDate: z.coerce.date(),
+ window: z.coerce.number().positive(),
+ steps: z
+ .array(
+ z.object({
+ type: z.enum(['path', 'event']),
+ value: z.string(),
+ }),
+ )
+ .min(2)
+ .max(8),
+ }),
+});
+
+export const journeyReportSchema = z.object({
+ type: z.literal('journey'),
+ parameters: z.object({
+ startDate: z.coerce.date(),
+ endDate: z.coerce.date(),
+ steps: z.coerce.number().min(2).max(7),
+ startStep: z.string().optional(),
+ endStep: z.string().optional(),
+ }),
+});
+
+export const retentionReportSchema = z.object({
+ type: z.literal('retention'),
+ parameters: z.object({
+ startDate: z.coerce.date(),
+ endDate: z.coerce.date(),
+ timezone: z.string().optional(),
+ }),
+});
+
+export const utmReportSchema = z.object({
+ type: z.literal('utm'),
+ parameters: z.object({
+ startDate: z.coerce.date(),
+ endDate: z.coerce.date(),
+ }),
+});
+
+export const revenueReportSchema = z.object({
+ type: z.literal('revenue'),
+ parameters: z.object({
+ startDate: z.coerce.date(),
+ endDate: z.coerce.date(),
+ timezone: z.string().optional(),
+ currency: z.string(),
+ }),
+});
+
+export const attributionReportSchema = z.object({
+ type: z.literal('attribution'),
+ parameters: z.object({
+ startDate: z.coerce.date(),
+ endDate: z.coerce.date(),
+ model: z.enum(['first-click', 'last-click']),
+ type: z.enum(['path', 'event']),
+ step: z.string(),
+ currency: z.string().optional(),
+ }),
+});
+
+export const breakdownReportSchema = z.object({
+ type: z.literal('breakdown'),
+ parameters: z.object({
+ startDate: z.coerce.date(),
+ endDate: z.coerce.date(),
+ fields: z.array(fieldsParam),
+ }),
+});
+
+export const reportBaseSchema = z.object({
+ websiteId: z.uuid(),
+ type: reportTypeParam,
+ name: z.string().max(200),
+ description: z.string().max(500).optional(),
+ parameters: anyObjectParam,
+});
+
+export const reportTypeSchema = z.discriminatedUnion('type', [
+ goalReportSchema,
+ funnelReportSchema,
+ journeyReportSchema,
+ retentionReportSchema,
+ utmReportSchema,
+ revenueReportSchema,
+ attributionReportSchema,
+ breakdownReportSchema,
+]);
+
+export const reportSchema = reportBaseSchema;
+
+export const reportResultSchema = z.intersection(
+ z.object({
+ websiteId: z.uuid(),
+ filters: z.object({ ...filterParams }),
+ }),
+ reportTypeSchema,
+);
+
+export const segmentTypeParam = z.enum(['segment', 'cohort']);
diff --git a/src/lib/sql.ts b/src/lib/sql.ts
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/lib/sql.ts
diff --git a/src/lib/storage.ts b/src/lib/storage.ts
new file mode 100644
index 0000000..19681a2
--- /dev/null
+++ b/src/lib/storage.ts
@@ -0,0 +1,25 @@
+export function setItem(key: string, data: any, session?: boolean) {
+ if (typeof window !== 'undefined' && data) {
+ return (session ? sessionStorage : localStorage).setItem(key, JSON.stringify(data));
+ }
+}
+
+export function getItem(key: string, session?: boolean): any {
+ if (typeof window !== 'undefined') {
+ const value = (session ? sessionStorage : localStorage).getItem(key);
+
+ if (value !== 'undefined' && value !== null) {
+ try {
+ return JSON.parse(value);
+ } catch {
+ return null;
+ }
+ }
+ }
+}
+
+export function removeItem(key: string, session?: boolean) {
+ if (typeof window !== 'undefined') {
+ return (session ? sessionStorage : localStorage).removeItem(key);
+ }
+}
diff --git a/src/lib/types.ts b/src/lib/types.ts
new file mode 100644
index 0000000..9c06197
--- /dev/null
+++ b/src/lib/types.ts
@@ -0,0 +1,143 @@
+import type { UseQueryOptions } from '@tanstack/react-query';
+import type { DATA_TYPE, OPERATORS, ROLES } from './constants';
+import type { TIME_UNIT } from './date';
+
+export type ObjectValues<T> = T[keyof T];
+
+export type ReactQueryOptions<T = any> = Omit<UseQueryOptions<T, Error, T>, 'queryKey' | 'queryFn'>;
+
+export type TimeUnit = ObjectValues<typeof TIME_UNIT>;
+export type Role = ObjectValues<typeof ROLES>;
+export type DynamicDataType = ObjectValues<typeof DATA_TYPE>;
+export type Operator = (typeof OPERATORS)[keyof typeof OPERATORS];
+
+export interface Auth {
+ user?: {
+ id: string;
+ username: string;
+ role: string;
+ isAdmin: boolean;
+ };
+ shareToken?: {
+ websiteId: string;
+ };
+}
+
+export interface Filter {
+ name: string;
+ operator: Operator;
+ value: string;
+ type?: string;
+ column?: string;
+ prefix?: string;
+}
+
+export interface DateRange {
+ startDate: Date;
+ endDate: Date;
+ value?: string;
+ unit?: TimeUnit;
+ num?: number;
+ offset?: number;
+}
+
+export interface DynamicData {
+ [key: string]: number | string | number[] | string[];
+}
+
+export interface QueryOptions {
+ joinSession?: boolean;
+ columns?: Record<string, string>;
+ limit?: number;
+ prefix?: string;
+ isCohort?: boolean;
+}
+
+export interface QueryFilters
+ extends DateParams,
+ FilterParams,
+ SortParams,
+ PageParams,
+ SegmentParams {
+ cohortFilters?: QueryFilters;
+}
+
+export interface DateParams {
+ startDate?: Date;
+ endDate?: Date;
+ unit?: string;
+ timezone?: string;
+ compareDate?: Date;
+}
+
+export interface FilterParams {
+ path?: string;
+ referrer?: string;
+ title?: string;
+ query?: string;
+ host?: string;
+ os?: string;
+ browser?: string;
+ device?: string;
+ country?: string;
+ region?: string;
+ city?: string;
+ language?: string;
+ event?: string;
+ search?: string;
+ tag?: string;
+ eventType?: number;
+ segment?: string;
+ cohort?: string;
+ compare?: string;
+}
+
+export interface SortParams {
+ orderBy?: string;
+ sortDescending?: boolean;
+}
+
+export interface PageParams {
+ page?: number;
+ pageSize?: number;
+}
+
+export interface SegmentParams {
+ segment?: string;
+ cohort?: string;
+}
+
+export interface PageResult<T> {
+ data: T;
+ count: number;
+ page: number;
+ pageSize: number;
+ orderBy?: string;
+ sortDescending?: boolean;
+ search?: string;
+}
+
+export interface RealtimeData {
+ countries: Record<string, number>;
+ events: any[];
+ pageviews: any[];
+ referrers: Record<string, number>;
+ timestamp: number;
+ series: {
+ views: any[];
+ visitors: any[];
+ };
+ totals: {
+ views: number;
+ visitors: number;
+ events: number;
+ countries: number;
+ };
+ urls: Record<string, number>;
+ visitors: any[];
+}
+
+export interface ApiError extends Error {
+ code?: string;
+ message: string;
+}
diff --git a/src/lib/url.ts b/src/lib/url.ts
new file mode 100644
index 0000000..f6772fe
--- /dev/null
+++ b/src/lib/url.ts
@@ -0,0 +1,49 @@
+export function getQueryString(params: object = {}): string {
+ const searchParams = new URLSearchParams();
+
+ Object.entries(params).forEach(([key, value]) => {
+ if (value !== undefined) {
+ searchParams.append(key, value);
+ }
+ });
+
+ return searchParams.toString();
+}
+
+export function buildPath(path: string, params: object = {}): string {
+ const queryString = getQueryString(params);
+ return queryString ? `${path}?${queryString}` : path;
+}
+
+export function safeDecodeURI(s: string | undefined | null): string | undefined | null {
+ if (s === undefined || s === null) {
+ return s;
+ }
+
+ try {
+ return decodeURI(s);
+ } catch {
+ return s;
+ }
+}
+
+export function safeDecodeURIComponent(s: string | undefined | null): string | undefined | null {
+ if (s === undefined || s === null) {
+ return s;
+ }
+
+ try {
+ return decodeURIComponent(s);
+ } catch {
+ return s;
+ }
+}
+
+export function isValidUrl(url: string) {
+ try {
+ new URL(url);
+ return true;
+ } catch {
+ return false;
+ }
+}
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
new file mode 100644
index 0000000..2b0d9ff
--- /dev/null
+++ b/src/lib/utils.ts
@@ -0,0 +1,46 @@
+export function hook(
+ _this: { [x: string]: any },
+ method: string | number,
+ callback: (arg0: any) => void,
+) {
+ const orig = _this[method];
+
+ return (...args: any) => {
+ callback.apply(_this, args);
+
+ return orig.apply(_this, args);
+ };
+}
+
+export function sleep(ms: number | undefined) {
+ return new Promise(resolve => setTimeout(resolve, ms));
+}
+
+export function shuffleArray(a) {
+ const arr = a.slice();
+ for (let i = arr.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ const temp = arr[i];
+ arr[i] = arr[j];
+ arr[j] = temp;
+ }
+ return arr;
+}
+
+export function chunkArray(arr: any[], size: number) {
+ const chunks: any[] = [];
+
+ let index = 0;
+ while (index < arr.length) {
+ chunks.push(arr.slice(index, size + index));
+ index += size;
+ }
+
+ return chunks;
+}
+
+export function ensureArray(arr?: any) {
+ if (arr === undefined || arr === null) return [];
+ if (Array.isArray(arr)) return arr;
+ return [arr];
+}