feat: revenue tracking
* wip * wip * wip * wip * show revenue better on overview * align realtime and overview counters * update revenue docs * always return device id * add project settings, improve projects charts, * fix: comments * fixes * fix migration * ignore sql files * fix comments
This commit is contained in:
committed by
GitHub
parent
d61cbf6f2c
commit
790801b728
@@ -1,167 +0,0 @@
|
||||
CREATE DATABASE IF NOT EXISTS openpanel;
|
||||
|
||||
---
|
||||
|
||||
CREATE TABLE IF NOT EXISTS self_hosting (
|
||||
`created_at` Date,
|
||||
`domain` String,
|
||||
`count` UInt64
|
||||
)
|
||||
ENGINE = MergeTree()
|
||||
PARTITION BY toYYYYMM(created_at)
|
||||
ORDER BY (domain, created_at);
|
||||
|
||||
---
|
||||
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
`id` UUID DEFAULT generateUUIDv4(),
|
||||
`name` LowCardinality(String),
|
||||
`sdk_name` LowCardinality(String),
|
||||
`sdk_version` LowCardinality(String),
|
||||
`device_id` String CODEC(ZSTD(3)),
|
||||
`profile_id` String CODEC(ZSTD(3)),
|
||||
`project_id` String CODEC(ZSTD(3)),
|
||||
`session_id` String CODEC(LZ4),
|
||||
`path` String CODEC(ZSTD(3)),
|
||||
`origin` String CODEC(ZSTD(3)),
|
||||
`referrer` String CODEC(ZSTD(3)),
|
||||
`referrer_name` String CODEC(ZSTD(3)),
|
||||
`referrer_type` LowCardinality(String),
|
||||
`duration` UInt64 CODEC(Delta(4), LZ4),
|
||||
`properties` Map(String, String) CODEC(ZSTD(3)),
|
||||
`created_at` DateTime64(3) CODEC(DoubleDelta, ZSTD(3)),
|
||||
`country` LowCardinality(FixedString(2)),
|
||||
`city` String,
|
||||
`region` LowCardinality(String),
|
||||
`longitude` Nullable(Float32) CODEC(Gorilla, LZ4),
|
||||
`latitude` Nullable(Float32) CODEC(Gorilla, LZ4),
|
||||
`os` LowCardinality(String),
|
||||
`os_version` LowCardinality(String),
|
||||
`browser` LowCardinality(String),
|
||||
`browser_version` LowCardinality(String),
|
||||
`device` LowCardinality(String),
|
||||
`brand` LowCardinality(String),
|
||||
`model` LowCardinality(String),
|
||||
`imported_at` Nullable(DateTime) CODEC(Delta(4), LZ4),
|
||||
INDEX idx_name name TYPE bloom_filter GRANULARITY 1,
|
||||
INDEX idx_properties_bounce properties['__bounce'] TYPE set(3) GRANULARITY 1,
|
||||
INDEX idx_origin origin TYPE bloom_filter(0.05) GRANULARITY 1,
|
||||
INDEX idx_path path TYPE bloom_filter(0.01) GRANULARITY 1
|
||||
)
|
||||
ENGINE = MergeTree()
|
||||
PARTITION BY toYYYYMM(created_at)
|
||||
ORDER BY (project_id, toDate(created_at), profile_id, name)
|
||||
SETTINGS index_granularity = 8192;
|
||||
|
||||
---
|
||||
|
||||
CREATE TABLE IF NOT EXISTS events_bots (
|
||||
`id` UUID DEFAULT generateUUIDv4(),
|
||||
`project_id` String,
|
||||
`name` String,
|
||||
`type` String,
|
||||
`path` String,
|
||||
`created_at` DateTime64(3)
|
||||
)
|
||||
ENGINE = MergeTree()
|
||||
ORDER BY (project_id, created_at)
|
||||
SETTINGS index_granularity = 8192;
|
||||
|
||||
---
|
||||
|
||||
CREATE TABLE IF NOT EXISTS profiles (
|
||||
`id` String CODEC(ZSTD(3)),
|
||||
`is_external` Bool,
|
||||
`first_name` String CODEC(ZSTD(3)),
|
||||
`last_name` String CODEC(ZSTD(3)),
|
||||
`email` String CODEC(ZSTD(3)),
|
||||
`avatar` String CODEC(ZSTD(3)),
|
||||
`properties` Map(String, String) CODEC(ZSTD(3)),
|
||||
`project_id` String CODEC(ZSTD(3)),
|
||||
`created_at` DateTime64(3) CODEC(Delta(4), LZ4),
|
||||
INDEX idx_first_name first_name TYPE bloom_filter GRANULARITY 1,
|
||||
INDEX idx_last_name last_name TYPE bloom_filter GRANULARITY 1,
|
||||
INDEX idx_email email TYPE bloom_filter GRANULARITY 1
|
||||
)
|
||||
ENGINE = ReplacingMergeTree(created_at)
|
||||
PARTITION BY toYYYYMM(created_at)
|
||||
ORDER BY (project_id, id)
|
||||
SETTINGS index_granularity = 8192;
|
||||
|
||||
---
|
||||
|
||||
CREATE TABLE IF NOT EXISTS profile_aliases (
|
||||
`project_id` String,
|
||||
`profile_id` String,
|
||||
`alias` String,
|
||||
`created_at` DateTime
|
||||
)
|
||||
ENGINE = MergeTree()
|
||||
ORDER BY (project_id, profile_id, alias, created_at)
|
||||
SETTINGS index_granularity = 8192;
|
||||
|
||||
---
|
||||
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS dau_mv
|
||||
ENGINE = AggregatingMergeTree()
|
||||
PARTITION BY toYYYYMMDD(date)
|
||||
ORDER BY (project_id, date)
|
||||
AS SELECT
|
||||
toDate(created_at) as date,
|
||||
uniqState(profile_id) as profile_id,
|
||||
project_id
|
||||
FROM events
|
||||
GROUP BY date, project_id;
|
||||
|
||||
---
|
||||
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS cohort_events_mv
|
||||
ENGINE = AggregatingMergeTree()
|
||||
ORDER BY (project_id, name, created_at, profile_id)
|
||||
AS SELECT
|
||||
project_id,
|
||||
name,
|
||||
toDate(created_at) AS created_at,
|
||||
profile_id,
|
||||
COUNT() AS event_count
|
||||
FROM events
|
||||
WHERE profile_id != device_id
|
||||
GROUP BY project_id, name, created_at, profile_id;
|
||||
|
||||
---
|
||||
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS distinct_event_names_mv
|
||||
ENGINE = AggregatingMergeTree()
|
||||
ORDER BY (project_id, name, created_at)
|
||||
AS SELECT
|
||||
project_id,
|
||||
name,
|
||||
max(created_at) AS created_at,
|
||||
count() AS event_count
|
||||
FROM events
|
||||
GROUP BY project_id, name;
|
||||
|
||||
---
|
||||
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS event_property_values_mv
|
||||
ENGINE = AggregatingMergeTree()
|
||||
ORDER BY (project_id, name, property_key, property_value)
|
||||
AS SELECT
|
||||
project_id,
|
||||
name,
|
||||
key_value.keys as property_key,
|
||||
key_value.values as property_value,
|
||||
created_at
|
||||
FROM (
|
||||
SELECT
|
||||
project_id,
|
||||
name,
|
||||
untuple(arrayJoin(properties)) as key_value,
|
||||
max(created_at) as created_at
|
||||
FROM events
|
||||
GROUP BY project_id, name, key_value
|
||||
)
|
||||
WHERE property_value != ''
|
||||
AND property_key != ''
|
||||
AND property_key NOT IN ('__duration_from', '__properties_from')
|
||||
GROUP BY project_id, name, property_key, property_value, created_at;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,47 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS events_imports_replicated ON CLUSTER '{cluster}' (
|
||||
`id` UUID DEFAULT generateUUIDv4(),
|
||||
`name` LowCardinality(String),
|
||||
`sdk_name` LowCardinality(String),
|
||||
`sdk_version` LowCardinality(String),
|
||||
`device_id` String CODEC(ZSTD(3)),
|
||||
`profile_id` String CODEC(ZSTD(3)),
|
||||
`project_id` String CODEC(ZSTD(3)),
|
||||
`session_id` String CODEC(LZ4),
|
||||
`path` String CODEC(ZSTD(3)),
|
||||
`origin` String CODEC(ZSTD(3)),
|
||||
`referrer` String CODEC(ZSTD(3)),
|
||||
`referrer_name` String CODEC(ZSTD(3)),
|
||||
`referrer_type` LowCardinality(String),
|
||||
`duration` UInt64 CODEC(Delta(4), LZ4),
|
||||
`properties` Map(String, String) CODEC(ZSTD(3)),
|
||||
`created_at` DateTime64(3) CODEC(DoubleDelta, ZSTD(3)),
|
||||
`country` LowCardinality(FixedString(2)),
|
||||
`city` String,
|
||||
`region` LowCardinality(String),
|
||||
`longitude` Nullable(Float32) CODEC(Gorilla, LZ4),
|
||||
`latitude` Nullable(Float32) CODEC(Gorilla, LZ4),
|
||||
`os` LowCardinality(String),
|
||||
`os_version` LowCardinality(String),
|
||||
`browser` LowCardinality(String),
|
||||
`browser_version` LowCardinality(String),
|
||||
`device` LowCardinality(String),
|
||||
`brand` LowCardinality(String),
|
||||
`model` LowCardinality(String),
|
||||
`imported_at` Nullable(DateTime) CODEC(Delta(4), LZ4),
|
||||
`import_id` String CODEC(ZSTD(3)),
|
||||
`import_status` LowCardinality(String) DEFAULT 'pending',
|
||||
`imported_at_meta` DateTime DEFAULT now()
|
||||
)
|
||||
ENGINE = ReplicatedMergeTree('/clickhouse/{installation}/{cluster}/tables/{shard}/openpanel/v1/{table}', '{replica}')
|
||||
PARTITION BY toYYYYMM(imported_at_meta)
|
||||
ORDER BY (import_id, created_at)
|
||||
SETTINGS index_granularity = 8192;
|
||||
|
||||
---
|
||||
|
||||
CREATE TABLE IF NOT EXISTS events_imports ON CLUSTER '{cluster}' AS events_imports_replicated
|
||||
ENGINE = Distributed('{cluster}', currentDatabase(), events_imports_replicated, cityHash64(import_id));
|
||||
|
||||
---
|
||||
|
||||
ALTER TABLE events_imports_replicated ON CLUSTER '{cluster}' MODIFY TTL imported_at_meta + INTERVAL 7 DAY;
|
||||
36
packages/db/code-migrations/6-add-revenue-column.ts
Normal file
36
packages/db/code-migrations/6-add-revenue-column.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
addColumns,
|
||||
runClickhouseMigrationCommands,
|
||||
} from '../src/clickhouse/migration';
|
||||
import { getIsCluster } from './helpers';
|
||||
|
||||
export async function up() {
|
||||
const isClustered = getIsCluster();
|
||||
|
||||
const sqls: string[] = [
|
||||
...addColumns(
|
||||
'events',
|
||||
['`revenue` UInt64 AFTER `referrer_type`'],
|
||||
isClustered,
|
||||
),
|
||||
];
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(__filename.replace('.ts', '.sql')),
|
||||
sqls
|
||||
.map((sql) =>
|
||||
sql
|
||||
.trim()
|
||||
.replace(/;$/, '')
|
||||
.replace(/\n{2,}/g, '\n')
|
||||
.concat(';'),
|
||||
)
|
||||
.join('\n\n---\n\n'),
|
||||
);
|
||||
|
||||
if (!process.argv.includes('--dry')) {
|
||||
await runClickhouseMigrationCommands(sqls);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."projects" ADD COLUMN "allowUnsafeRevenueTracking" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -172,17 +172,18 @@ model Invite {
|
||||
}
|
||||
|
||||
model Project {
|
||||
id String @id @default(dbgenerated("gen_random_uuid()"))
|
||||
name String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
organizationId String
|
||||
eventsCount Int @default(0)
|
||||
types ProjectType[] @default([])
|
||||
domain String?
|
||||
cors String[] @default([])
|
||||
crossDomain Boolean @default(false)
|
||||
id String @id @default(dbgenerated("gen_random_uuid()"))
|
||||
name String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
organizationId String
|
||||
eventsCount Int @default(0)
|
||||
types ProjectType[] @default([])
|
||||
domain String?
|
||||
cors String[] @default([])
|
||||
crossDomain Boolean @default(false)
|
||||
allowUnsafeRevenueTracking Boolean @default(false)
|
||||
/// [IPrismaProjectFilters]
|
||||
filters Json @default("[]")
|
||||
filters Json @default("[]")
|
||||
|
||||
clients Client[]
|
||||
reports Report[]
|
||||
|
||||
@@ -28,8 +28,24 @@ export class SessionBuffer extends BaseBuffer {
|
||||
this.redis = getRedisCache();
|
||||
}
|
||||
|
||||
public async getExistingSession(sessionId: string) {
|
||||
const hit = await this.redis.get(`session:${sessionId}`);
|
||||
public async getExistingSession(
|
||||
options:
|
||||
| {
|
||||
sessionId: string;
|
||||
}
|
||||
| {
|
||||
projectId: string;
|
||||
profileId: string;
|
||||
},
|
||||
) {
|
||||
let hit: string | null = null;
|
||||
if ('sessionId' in options) {
|
||||
hit = await this.redis.get(`session:${options.sessionId}`);
|
||||
} else {
|
||||
hit = await this.redis.get(
|
||||
`session:${options.projectId}:${options.profileId}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (hit) {
|
||||
return getSafeJson<IClickhouseSession>(hit);
|
||||
@@ -41,7 +57,9 @@ export class SessionBuffer extends BaseBuffer {
|
||||
async getSession(
|
||||
event: IClickhouseEvent,
|
||||
): Promise<[IClickhouseSession] | [IClickhouseSession, IClickhouseSession]> {
|
||||
const existingSession = await this.getExistingSession(event.session_id);
|
||||
const existingSession = await this.getExistingSession({
|
||||
sessionId: event.session_id,
|
||||
});
|
||||
|
||||
if (existingSession) {
|
||||
const oldSession = assocPath(['sign'], -1, clone(existingSession));
|
||||
@@ -77,7 +95,9 @@ export class SessionBuffer extends BaseBuffer {
|
||||
...(event.properties || {}),
|
||||
...(newSession.properties || {}),
|
||||
});
|
||||
// newSession.revenue += event.properties?.__revenue ?? 0;
|
||||
|
||||
const addedRevenue = event.name === 'revenue' ? (event.revenue ?? 0) : 0;
|
||||
newSession.revenue = (newSession.revenue ?? 0) + addedRevenue;
|
||||
|
||||
if (event.name === 'screen_view' && event.path) {
|
||||
newSession.screen_views.push(event.path);
|
||||
@@ -114,7 +134,7 @@ export class SessionBuffer extends BaseBuffer {
|
||||
entry_origin: event.origin,
|
||||
exit_path: event.path,
|
||||
exit_origin: event.origin,
|
||||
revenue: 0,
|
||||
revenue: event.name === 'revenue' ? (event.revenue ?? 0) : 0,
|
||||
referrer: event.referrer,
|
||||
referrer_name: event.referrer_name,
|
||||
referrer_type: event.referrer_type,
|
||||
@@ -174,6 +194,14 @@ export class SessionBuffer extends BaseBuffer {
|
||||
'EX',
|
||||
60 * 60,
|
||||
);
|
||||
if (newSession.profile_id) {
|
||||
multi.set(
|
||||
`session:${newSession.project_id}:${newSession.profile_id}`,
|
||||
JSON.stringify(newSession),
|
||||
'EX',
|
||||
60 * 60,
|
||||
);
|
||||
}
|
||||
for (const session of sessions) {
|
||||
multi.rpush(this.redisKey, JSON.stringify(session));
|
||||
}
|
||||
|
||||
@@ -140,10 +140,10 @@ export function addColumns(
|
||||
isClustered: boolean,
|
||||
): string[] {
|
||||
if (isClustered) {
|
||||
return columns.map(
|
||||
(col) =>
|
||||
`ALTER TABLE ${replicated(tableName)} ON CLUSTER '{cluster}' ADD COLUMN IF NOT EXISTS ${col}`,
|
||||
);
|
||||
return columns.flatMap((col) => [
|
||||
`ALTER TABLE ${replicated(tableName)} ON CLUSTER '{cluster}' ADD COLUMN IF NOT EXISTS ${col}`,
|
||||
`ALTER TABLE ${tableName} ON CLUSTER '{cluster}' ADD COLUMN IF NOT EXISTS ${col}`,
|
||||
]);
|
||||
}
|
||||
|
||||
return columns.map(
|
||||
@@ -160,10 +160,10 @@ export function dropColumns(
|
||||
isClustered: boolean,
|
||||
): string[] {
|
||||
if (isClustered) {
|
||||
return columnNames.map(
|
||||
(colName) =>
|
||||
`ALTER TABLE ${replicated(tableName)} ON CLUSTER '{cluster}' DROP COLUMN IF EXISTS ${colName}`,
|
||||
);
|
||||
return columnNames.flatMap((colName) => [
|
||||
`ALTER TABLE ${replicated(tableName)} ON CLUSTER '{cluster}' DROP COLUMN IF EXISTS ${colName}`,
|
||||
`ALTER TABLE ${tableName} ON CLUSTER '{cluster}' DROP COLUMN IF EXISTS ${colName}`,
|
||||
]);
|
||||
}
|
||||
|
||||
return columnNames.map(
|
||||
|
||||
@@ -174,23 +174,43 @@ export function getChartSql({
|
||||
}
|
||||
|
||||
if (event.segment === 'property_sum' && event.property) {
|
||||
sb.select.count = `sum(toFloat64(${getSelectPropertyKey(event.property)})) as count`;
|
||||
sb.where.property = `${getSelectPropertyKey(event.property)} IS NOT NULL AND notEmpty(${getSelectPropertyKey(event.property)})`;
|
||||
if (event.property === 'revenue') {
|
||||
sb.select.count = `sum(revenue) as count`;
|
||||
sb.where.property = `revenue > 0`;
|
||||
} else {
|
||||
sb.select.count = `sum(toFloat64(${getSelectPropertyKey(event.property)})) as count`;
|
||||
sb.where.property = `${getSelectPropertyKey(event.property)} IS NOT NULL AND notEmpty(${getSelectPropertyKey(event.property)})`;
|
||||
}
|
||||
}
|
||||
|
||||
if (event.segment === 'property_average' && event.property) {
|
||||
sb.select.count = `avg(toFloat64(${getSelectPropertyKey(event.property)})) as count`;
|
||||
sb.where.property = `${getSelectPropertyKey(event.property)} IS NOT NULL AND notEmpty(${getSelectPropertyKey(event.property)})`;
|
||||
if (event.property === 'revenue') {
|
||||
sb.select.count = `avg(revenue) as count`;
|
||||
sb.where.property = `revenue > 0`;
|
||||
} else {
|
||||
sb.select.count = `avg(toFloat64(${getSelectPropertyKey(event.property)})) as count`;
|
||||
sb.where.property = `${getSelectPropertyKey(event.property)} IS NOT NULL AND notEmpty(${getSelectPropertyKey(event.property)})`;
|
||||
}
|
||||
}
|
||||
|
||||
if (event.segment === 'property_max' && event.property) {
|
||||
sb.select.count = `max(toFloat64(${getSelectPropertyKey(event.property)})) as count`;
|
||||
sb.where.property = `${getSelectPropertyKey(event.property)} IS NOT NULL AND notEmpty(${getSelectPropertyKey(event.property)})`;
|
||||
if (event.property === 'revenue') {
|
||||
sb.select.count = `max(revenue) as count`;
|
||||
sb.where.property = `revenue > 0`;
|
||||
} else {
|
||||
sb.select.count = `max(toFloat64(${getSelectPropertyKey(event.property)})) as count`;
|
||||
sb.where.property = `${getSelectPropertyKey(event.property)} IS NOT NULL AND notEmpty(${getSelectPropertyKey(event.property)})`;
|
||||
}
|
||||
}
|
||||
|
||||
if (event.segment === 'property_min' && event.property) {
|
||||
sb.select.count = `min(toFloat64(${getSelectPropertyKey(event.property)})) as count`;
|
||||
sb.where.property = `${getSelectPropertyKey(event.property)} IS NOT NULL AND notEmpty(${getSelectPropertyKey(event.property)})`;
|
||||
if (event.property === 'revenue') {
|
||||
sb.select.count = `min(revenue) as count`;
|
||||
sb.where.property = `revenue > 0`;
|
||||
} else {
|
||||
sb.select.count = `min(toFloat64(${getSelectPropertyKey(event.property)})) as count`;
|
||||
sb.where.property = `${getSelectPropertyKey(event.property)} IS NOT NULL AND notEmpty(${getSelectPropertyKey(event.property)})`;
|
||||
}
|
||||
}
|
||||
|
||||
if (event.segment === 'one_event_per_user') {
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
getProfilesCached,
|
||||
upsertProfile,
|
||||
} from './profile.service';
|
||||
import type { IClickhouseSession } from './session.service';
|
||||
|
||||
export type IImportedEvent = Omit<
|
||||
IClickhouseEvent,
|
||||
@@ -92,12 +93,62 @@ export interface IClickhouseEvent {
|
||||
imported_at: string | null;
|
||||
sdk_name: string;
|
||||
sdk_version: string;
|
||||
revenue?: number;
|
||||
|
||||
// They do not exist here. Just make ts happy for now
|
||||
profile?: IServiceProfile;
|
||||
meta?: EventMeta;
|
||||
}
|
||||
|
||||
export function transformSessionToEvent(
|
||||
session: IClickhouseSession,
|
||||
): IServiceEvent {
|
||||
return {
|
||||
id: '', // Not used
|
||||
name: 'screen_view',
|
||||
sessionId: session.id,
|
||||
profileId: session.profile_id,
|
||||
path: session.exit_path,
|
||||
origin: session.exit_origin,
|
||||
createdAt: convertClickhouseDateToJs(session.ended_at),
|
||||
referrer: session.referrer,
|
||||
referrerName: session.referrer_name,
|
||||
referrerType: session.referrer_type,
|
||||
os: session.os,
|
||||
osVersion: session.os_version,
|
||||
browser: session.browser,
|
||||
browserVersion: session.browser_version,
|
||||
device: session.device,
|
||||
brand: session.brand,
|
||||
model: session.model,
|
||||
country: session.country,
|
||||
region: session.region,
|
||||
city: session.city,
|
||||
longitude: session.longitude,
|
||||
latitude: session.latitude,
|
||||
projectId: session.project_id,
|
||||
deviceId: session.device_id,
|
||||
duration: 0,
|
||||
revenue: session.revenue,
|
||||
properties: {
|
||||
...session.properties,
|
||||
is_bounce: session.is_bounce,
|
||||
__query: {
|
||||
utm_medium: session.utm_medium,
|
||||
utm_source: session.utm_source,
|
||||
utm_campaign: session.utm_campaign,
|
||||
utm_content: session.utm_content,
|
||||
utm_term: session.utm_term,
|
||||
},
|
||||
},
|
||||
profile: undefined,
|
||||
meta: undefined,
|
||||
importedAt: undefined,
|
||||
sdkName: undefined,
|
||||
sdkVersion: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function transformEvent(event: IClickhouseEvent): IServiceEvent {
|
||||
return {
|
||||
id: event.id,
|
||||
@@ -131,6 +182,7 @@ export function transformEvent(event: IClickhouseEvent): IServiceEvent {
|
||||
sdkName: event.sdk_name,
|
||||
sdkVersion: event.sdk_version,
|
||||
profile: event.profile,
|
||||
revenue: event.revenue,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -178,6 +230,7 @@ export interface IServiceEvent {
|
||||
meta: EventMeta | undefined;
|
||||
sdkName: string | undefined;
|
||||
sdkVersion: string | undefined;
|
||||
revenue?: number;
|
||||
}
|
||||
|
||||
type SelectHelper<T> = {
|
||||
@@ -336,6 +389,7 @@ export async function createEvent(payload: IServiceCreateEventPayload) {
|
||||
imported_at: null,
|
||||
sdk_name: payload.sdkName ?? '',
|
||||
sdk_version: payload.sdkVersion ?? '',
|
||||
revenue: payload.revenue,
|
||||
};
|
||||
|
||||
const promises = [sessionBuffer.add(event), eventBuffer.add(event)];
|
||||
|
||||
@@ -104,6 +104,7 @@ export class OverviewService {
|
||||
avg_session_duration: number;
|
||||
total_screen_views: number;
|
||||
views_per_session: number;
|
||||
total_revenue: number;
|
||||
};
|
||||
series: {
|
||||
date: string;
|
||||
@@ -113,6 +114,7 @@ export class OverviewService {
|
||||
avg_session_duration: number;
|
||||
total_screen_views: number;
|
||||
views_per_session: number;
|
||||
total_revenue: number;
|
||||
}[];
|
||||
}> {
|
||||
const where = this.getRawWhereClause('sessions', filters);
|
||||
@@ -122,6 +124,7 @@ export class OverviewService {
|
||||
.select([
|
||||
`${clix.toStartOf('created_at', interval, timezone)} AS date`,
|
||||
'round((countIf(is_bounce = 1 AND sign = 1) * 100.) / countIf(sign = 1), 2) AS bounce_rate',
|
||||
'sum(revenue * sign) AS total_revenue',
|
||||
])
|
||||
.from(TABLE_NAMES.sessions, true)
|
||||
.where('sign', '=', 1)
|
||||
@@ -165,10 +168,17 @@ export class OverviewService {
|
||||
.from('session_agg')
|
||||
.where('date', '=', rollupDate),
|
||||
)
|
||||
.with(
|
||||
'overall_total_revenue',
|
||||
clix(this.client, timezone)
|
||||
.select(['total_revenue'])
|
||||
.from('session_agg')
|
||||
.where('date', '=', rollupDate),
|
||||
)
|
||||
.with(
|
||||
'daily_stats',
|
||||
clix(this.client, timezone)
|
||||
.select(['date', 'bounce_rate'])
|
||||
.select(['date', 'bounce_rate', 'total_revenue'])
|
||||
.from('session_agg')
|
||||
.where('date', '!=', rollupDate),
|
||||
)
|
||||
@@ -181,9 +191,11 @@ export class OverviewService {
|
||||
avg_session_duration: number;
|
||||
total_screen_views: number;
|
||||
views_per_session: number;
|
||||
total_revenue: number;
|
||||
overall_unique_visitors: number;
|
||||
overall_total_sessions: number;
|
||||
overall_bounce_rate: number;
|
||||
overall_total_revenue: number;
|
||||
}>([
|
||||
`${clix.toStartOf('e.created_at', interval)} AS date`,
|
||||
'ds.bounce_rate as bounce_rate',
|
||||
@@ -193,9 +205,11 @@ export class OverviewService {
|
||||
'if(isNaN(_avg_session_duration), 0, _avg_session_duration) AS avg_session_duration',
|
||||
'count(*) AS total_screen_views',
|
||||
'round((count(*) * 1.) / uniq(e.session_id), 2) AS views_per_session',
|
||||
'ds.total_revenue AS total_revenue',
|
||||
'(SELECT unique_visitors FROM overall_unique_visitors) AS overall_unique_visitors',
|
||||
'(SELECT total_sessions FROM overall_unique_visitors) AS overall_total_sessions',
|
||||
'(SELECT bounce_rate FROM overall_bounce_rate) AS overall_bounce_rate',
|
||||
'(SELECT total_revenue FROM overall_total_revenue) AS overall_total_revenue',
|
||||
])
|
||||
.from(`${TABLE_NAMES.events} AS e`)
|
||||
.leftJoin(
|
||||
@@ -209,7 +223,7 @@ export class OverviewService {
|
||||
clix.datetime(endDate, 'toDateTime'),
|
||||
])
|
||||
.rawWhere(this.getRawWhereClause('events', filters))
|
||||
.groupBy(['date', 'ds.bounce_rate'])
|
||||
.groupBy(['date', 'ds.bounce_rate', 'ds.total_revenue'])
|
||||
.orderBy('date', 'ASC')
|
||||
.fill(
|
||||
clix.toStartOf(
|
||||
@@ -234,7 +248,8 @@ export class OverviewService {
|
||||
(item) =>
|
||||
item.overall_bounce_rate !== null ||
|
||||
item.overall_total_sessions !== null ||
|
||||
item.overall_unique_visitors !== null,
|
||||
item.overall_unique_visitors !== null ||
|
||||
item.overall_total_revenue !== null,
|
||||
);
|
||||
return {
|
||||
metrics: {
|
||||
@@ -250,12 +265,14 @@ export class OverviewService {
|
||||
views_per_session: average(
|
||||
res.map((item) => item.views_per_session),
|
||||
),
|
||||
total_revenue: anyRowWithData?.overall_total_revenue ?? 0,
|
||||
},
|
||||
series: res.map(
|
||||
omit([
|
||||
'overall_bounce_rate',
|
||||
'overall_unique_visitors',
|
||||
'overall_total_sessions',
|
||||
'overall_total_revenue',
|
||||
]),
|
||||
),
|
||||
};
|
||||
@@ -271,6 +288,7 @@ export class OverviewService {
|
||||
avg_session_duration: number;
|
||||
total_screen_views: number;
|
||||
views_per_session: number;
|
||||
total_revenue: number;
|
||||
}>([
|
||||
`${clix.toStartOf('created_at', interval, timezone)} AS date`,
|
||||
'round(sum(sign * is_bounce) * 100.0 / sum(sign), 2) as bounce_rate',
|
||||
@@ -280,6 +298,7 @@ export class OverviewService {
|
||||
'if(isNaN(_avg_session_duration), 0, _avg_session_duration) AS avg_session_duration',
|
||||
'sum(sign * screen_view_count) AS total_screen_views',
|
||||
'round(sum(sign * screen_view_count) * 1.0 / sum(sign), 2) AS views_per_session',
|
||||
'sum(revenue * sign) AS total_revenue',
|
||||
])
|
||||
.from('sessions')
|
||||
.where('created_at', 'BETWEEN', [
|
||||
@@ -320,6 +339,7 @@ export class OverviewService {
|
||||
avg_session_duration: res[0]?.avg_session_duration ?? 0,
|
||||
total_screen_views: res[0]?.total_screen_views ?? 0,
|
||||
views_per_session: res[0]?.views_per_session ?? 0,
|
||||
total_revenue: res[0]?.total_revenue ?? 0,
|
||||
},
|
||||
series: res
|
||||
.slice(1)
|
||||
@@ -394,6 +414,7 @@ export class OverviewService {
|
||||
'entry_path',
|
||||
'entry_origin',
|
||||
'coalesce(round(countIf(is_bounce = 1 AND sign = 1) * 100.0 / countIf(sign = 1), 2), 0) as bounce_rate',
|
||||
'sum(revenue * sign) as revenue',
|
||||
])
|
||||
.from(TABLE_NAMES.sessions, true)
|
||||
.where('sign', '=', 1)
|
||||
@@ -417,6 +438,7 @@ export class OverviewService {
|
||||
avg_duration: number;
|
||||
bounce_rate: number;
|
||||
sessions: number;
|
||||
revenue: number;
|
||||
}>([
|
||||
'p.title',
|
||||
'p.origin',
|
||||
@@ -424,6 +446,7 @@ export class OverviewService {
|
||||
'p.avg_duration',
|
||||
'p.count as sessions',
|
||||
'b.bounce_rate',
|
||||
'coalesce(b.revenue, 0) as revenue',
|
||||
])
|
||||
.from('page_stats p', false)
|
||||
.leftJoin(
|
||||
@@ -465,12 +488,14 @@ export class OverviewService {
|
||||
avg_duration: number;
|
||||
bounce_rate: number;
|
||||
sessions: number;
|
||||
revenue: number;
|
||||
}>([
|
||||
`${mode}_origin AS origin`,
|
||||
`${mode}_path AS path`,
|
||||
'round(avg(duration * sign)/1000, 2) as avg_duration',
|
||||
'round(sum(sign * is_bounce) * 100.0 / sum(sign), 2) as bounce_rate',
|
||||
'sum(sign) as sessions',
|
||||
'sum(revenue * sign) as revenue',
|
||||
])
|
||||
.from(TABLE_NAMES.sessions, true)
|
||||
.where('project_id', '=', projectId)
|
||||
@@ -566,12 +591,14 @@ export class OverviewService {
|
||||
sessions: number;
|
||||
bounce_rate: number;
|
||||
avg_session_duration: number;
|
||||
revenue: number;
|
||||
}>([
|
||||
prefixColumn && `${prefixColumn} as prefix`,
|
||||
`nullIf(${column}, '') as name`,
|
||||
'sum(sign) as sessions',
|
||||
'round(sum(sign * is_bounce) * 100.0 / sum(sign), 2) AS bounce_rate',
|
||||
'round(avgIf(duration, duration > 0 AND sign > 0), 2)/1000 AS avg_session_duration',
|
||||
'sum(revenue * sign) as revenue',
|
||||
])
|
||||
.from(TABLE_NAMES.sessions, true)
|
||||
.where('project_id', '=', projectId)
|
||||
|
||||
@@ -28,6 +28,7 @@ export type IProfileMetrics = {
|
||||
avgEventsPerSession: number;
|
||||
conversionEvents: number;
|
||||
avgTimeBetweenSessions: number;
|
||||
revenue: number;
|
||||
};
|
||||
export function getProfileMetrics(profileId: string, projectId: string) {
|
||||
return chQuery<
|
||||
@@ -76,6 +77,9 @@ export function getProfileMetrics(profileId: string, projectId: string) {
|
||||
WHEN (SELECT sessions FROM sessions) <= 1 THEN 0
|
||||
ELSE round(dateDiff('second', (SELECT firstSeen FROM firstSeen), (SELECT lastSeen FROM lastSeen)) / nullIf((SELECT sessions FROM sessions) - 1, 0), 1)
|
||||
END as avgTimeBetweenSessions
|
||||
),
|
||||
revenue AS (
|
||||
SELECT sum(revenue) as revenue FROM ${TABLE_NAMES.events} WHERE name = 'revenue' AND profile_id = ${sqlstring.escape(profileId)} AND project_id = ${sqlstring.escape(projectId)}
|
||||
)
|
||||
SELECT
|
||||
(SELECT lastSeen FROM lastSeen) as lastSeen,
|
||||
@@ -89,7 +93,8 @@ export function getProfileMetrics(profileId: string, projectId: string) {
|
||||
(SELECT bounceRate FROM bounceRate) as bounceRate,
|
||||
(SELECT avgEventsPerSession FROM avgEventsPerSession) as avgEventsPerSession,
|
||||
(SELECT conversionEvents FROM conversionEvents) as conversionEvents,
|
||||
(SELECT avgTimeBetweenSessions FROM avgTimeBetweenSessions) as avgTimeBetweenSessions
|
||||
(SELECT avgTimeBetweenSessions FROM avgTimeBetweenSessions) as avgTimeBetweenSessions,
|
||||
(SELECT revenue FROM revenue) as revenue
|
||||
`)
|
||||
.then((data) => data[0]!)
|
||||
.then((data) => {
|
||||
|
||||
@@ -209,12 +209,27 @@ export function sessionConsistency() {
|
||||
// Since the check probably goes to the primary anyways it will always be true,
|
||||
// Not sure how to check LSN on the actual replica that will be used for the read.
|
||||
if (
|
||||
model !== 'Session' &&
|
||||
isReadOperation(operation) &&
|
||||
sessionId &&
|
||||
(await getCachedWalLsn(sessionId))
|
||||
) {
|
||||
// This will force readReplicas extension to use primary
|
||||
__internalParams.transaction = true;
|
||||
const MAX_RETRIES = 3;
|
||||
const INITIAL_RETRY_DELAY_MS = 50;
|
||||
|
||||
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
||||
const result = await query(args);
|
||||
|
||||
if (result !== null) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// If not the last attempt, wait with exponential backoff before retrying
|
||||
if (attempt < MAX_RETRIES - 1) {
|
||||
const delayMs = INITIAL_RETRY_DELAY_MS * 2 ** attempt;
|
||||
await sleep(delayMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return query(args);
|
||||
|
||||
@@ -183,6 +183,28 @@ export class OpenPanel {
|
||||
});
|
||||
}
|
||||
|
||||
async revenue(
|
||||
amount: number,
|
||||
properties?: TrackProperties & { deviceId?: string },
|
||||
) {
|
||||
const deviceId = properties?.deviceId;
|
||||
delete properties?.deviceId;
|
||||
return this.track('revenue', {
|
||||
...(properties ?? {}),
|
||||
...(deviceId ? { __deviceId: deviceId } : {}),
|
||||
__revenue: amount,
|
||||
});
|
||||
}
|
||||
|
||||
async fetchDeviceId(): Promise<string> {
|
||||
const result = await this.api.fetch<undefined, { deviceId: string }>(
|
||||
'/track/device-id',
|
||||
undefined,
|
||||
{ method: 'GET', keepalive: false },
|
||||
);
|
||||
return result?.deviceId ?? '';
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.profileId = undefined;
|
||||
// should we force a session end here?
|
||||
|
||||
@@ -20,9 +20,15 @@ function toCamelCase(str: string) {
|
||||
);
|
||||
}
|
||||
|
||||
type PendingRevenue = {
|
||||
amount: number;
|
||||
properties?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export class OpenPanel extends OpenPanelBase {
|
||||
private lastPath = '';
|
||||
private debounceTimer: any;
|
||||
private pendingRevenues: PendingRevenue[] = [];
|
||||
|
||||
constructor(public options: OpenPanelOptions) {
|
||||
super({
|
||||
@@ -34,6 +40,18 @@ export class OpenPanel extends OpenPanelBase {
|
||||
if (!this.isServer()) {
|
||||
console.log('OpenPanel.dev - Initialized', this.options);
|
||||
|
||||
try {
|
||||
const pending = sessionStorage.getItem('openpanel-pending-revenues');
|
||||
if (pending) {
|
||||
const parsed = JSON.parse(pending);
|
||||
if (Array.isArray(parsed)) {
|
||||
this.pendingRevenues = parsed;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
this.pendingRevenues = [];
|
||||
}
|
||||
|
||||
this.setGlobalProperties({
|
||||
__referrer: document.referrer,
|
||||
});
|
||||
@@ -191,4 +209,33 @@ export class OpenPanel extends OpenPanelBase {
|
||||
__title: document.title,
|
||||
});
|
||||
}
|
||||
|
||||
async flushRevenue() {
|
||||
const promises = this.pendingRevenues.map((pending) =>
|
||||
super.revenue(pending.amount, pending.properties),
|
||||
);
|
||||
await Promise.all(promises);
|
||||
this.clearRevenue();
|
||||
}
|
||||
|
||||
clearRevenue() {
|
||||
this.pendingRevenues = [];
|
||||
if (!this.isServer()) {
|
||||
try {
|
||||
sessionStorage.removeItem('openpanel-pending-revenues');
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
pendingRevenue(amount: number, properties?: Record<string, unknown>) {
|
||||
this.pendingRevenues.push({ amount, properties });
|
||||
if (!this.isServer()) {
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
'openpanel-pending-revenues',
|
||||
JSON.stringify(this.pendingRevenues),
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
6
packages/sdks/web/src/types.d.ts
vendored
6
packages/sdks/web/src/types.d.ts
vendored
@@ -8,7 +8,11 @@ type ExposedMethodsNames =
|
||||
| 'alias'
|
||||
| 'increment'
|
||||
| 'decrement'
|
||||
| 'clear';
|
||||
| 'clear'
|
||||
| 'revenue'
|
||||
| 'flushRevenue'
|
||||
| 'clearRevenue'
|
||||
| 'pendingRevenue';
|
||||
|
||||
export type ExposedMethods = {
|
||||
[K in ExposedMethodsNames]: OpenPanel[K] extends (...args: any[]) => any
|
||||
|
||||
@@ -60,13 +60,18 @@ export const chartRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.query(async ({ input: { projectId } }) => {
|
||||
const chartPromise = chQuery<{ value: number; date: Date }>(
|
||||
const { timezone } = await getSettingsForProject(projectId);
|
||||
const chartPromise = chQuery<{
|
||||
value: number;
|
||||
date: Date;
|
||||
revenue: number;
|
||||
}>(
|
||||
`SELECT
|
||||
uniqHLL12(profile_id) as value,
|
||||
toStartOfDay(created_at) as date
|
||||
toStartOfDay(created_at) as date,
|
||||
sum(revenue * sign) as revenue
|
||||
FROM ${TABLE_NAMES.sessions}
|
||||
WHERE
|
||||
sign = 1 AND
|
||||
project_id = ${sqlstring.escape(projectId)} AND
|
||||
created_at >= now() - interval '3 month'
|
||||
GROUP BY date
|
||||
@@ -74,22 +79,25 @@ export const chartRouter = createTRPCRouter({
|
||||
WITH FILL FROM toStartOfDay(now() - interval '1 month')
|
||||
TO toStartOfDay(now())
|
||||
STEP INTERVAL 1 day
|
||||
SETTINGS session_timezone = '${timezone}'
|
||||
`,
|
||||
);
|
||||
|
||||
const metricsPromise = clix(ch)
|
||||
const metricsPromise = clix(ch, timezone)
|
||||
.select<{
|
||||
months_3: number;
|
||||
months_3_prev: number;
|
||||
month: number;
|
||||
day: number;
|
||||
day_prev: number;
|
||||
revenue: number;
|
||||
}>([
|
||||
'uniqHLL12(if(created_at >= (now() - toIntervalMonth(3)), profile_id, null)) AS months_3',
|
||||
'uniqHLL12(if(created_at >= (now() - toIntervalMonth(6)) AND created_at < (now() - toIntervalMonth(3)), profile_id, null)) AS months_3_prev',
|
||||
'uniqHLL12(if(created_at >= (now() - toIntervalMonth(1)), profile_id, null)) AS month',
|
||||
'uniqHLL12(if(created_at >= (now() - toIntervalDay(1)), profile_id, null)) AS day',
|
||||
'uniqHLL12(if(created_at >= (now() - toIntervalDay(2)) AND created_at < (now() - toIntervalDay(1)), profile_id, null)) AS day_prev',
|
||||
'sum(revenue * sign) as revenue',
|
||||
])
|
||||
.from(TABLE_NAMES.sessions)
|
||||
.where('project_id', '=', projectId)
|
||||
@@ -207,6 +215,7 @@ export const chartRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
properties.push(
|
||||
'revenue',
|
||||
'has_profile',
|
||||
'path',
|
||||
'origin',
|
||||
|
||||
@@ -103,7 +103,6 @@ export const overviewRouter = createTRPCRouter({
|
||||
])
|
||||
.from(TABLE_NAMES.events)
|
||||
.where('project_id', '=', input.projectId)
|
||||
.where('name', '=', 'session_start')
|
||||
.where('created_at', '>=', clix.exp('now() - INTERVAL 30 MINUTE'));
|
||||
|
||||
// Get counts per minute for the last 30 minutes
|
||||
@@ -119,7 +118,6 @@ export const overviewRouter = createTRPCRouter({
|
||||
])
|
||||
.from(TABLE_NAMES.events)
|
||||
.where('project_id', '=', input.projectId)
|
||||
.where('name', 'IN', ['session_start', 'screen_view'])
|
||||
.where('created_at', '>=', clix.exp('now() - INTERVAL 30 MINUTE'))
|
||||
.groupBy(['minute'])
|
||||
.orderBy('minute', 'ASC')
|
||||
@@ -138,11 +136,10 @@ export const overviewRouter = createTRPCRouter({
|
||||
}>([
|
||||
`${clix.toStartOf('created_at', 'minute')} as minute`,
|
||||
'referrer_name',
|
||||
'count(*) as count',
|
||||
'uniq(session_id) as count',
|
||||
])
|
||||
.from(TABLE_NAMES.events)
|
||||
.where('project_id', '=', input.projectId)
|
||||
.where('name', '=', 'session_start')
|
||||
.where('created_at', '>=', clix.exp('now() - INTERVAL 30 MINUTE'))
|
||||
.where('referrer_name', '!=', '')
|
||||
.where('referrer_name', 'IS NOT NULL')
|
||||
@@ -154,11 +151,10 @@ export const overviewRouter = createTRPCRouter({
|
||||
const referrersQuery = clix(ch, timezone)
|
||||
.select<{ referrer: string; count: number }>([
|
||||
'referrer_name as referrer',
|
||||
'count(*) as count',
|
||||
'uniq(session_id) as count',
|
||||
])
|
||||
.from(TABLE_NAMES.events)
|
||||
.where('project_id', '=', input.projectId)
|
||||
.where('name', '=', 'session_start')
|
||||
.where('created_at', '>=', clix.exp('now() - INTERVAL 30 MINUTE'))
|
||||
.where('referrer_name', '!=', '')
|
||||
.where('referrer_name', 'IS NOT NULL')
|
||||
@@ -234,6 +230,7 @@ export const overviewRouter = createTRPCRouter({
|
||||
previous?.metrics.avg_session_duration || null,
|
||||
prev_views_per_session: previous?.metrics.views_per_session || null,
|
||||
prev_total_sessions: previous?.metrics.total_sessions || null,
|
||||
prev_total_revenue: previous?.metrics.total_revenue || null,
|
||||
},
|
||||
series: current.series.map((item, index) => {
|
||||
const prev = previous?.series[index];
|
||||
@@ -246,6 +243,7 @@ export const overviewRouter = createTRPCRouter({
|
||||
prev_avg_session_duration: prev?.avg_session_duration,
|
||||
prev_views_per_session: prev?.views_per_session,
|
||||
prev_total_sessions: prev?.total_sessions,
|
||||
prev_total_revenue: prev?.total_revenue,
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -84,6 +84,7 @@ export const projectRouter = createTRPCRouter({
|
||||
input.cors === undefined
|
||||
? undefined
|
||||
: input.cors.map((c) => stripTrailingSlash(c)) || [],
|
||||
allowUnsafeRevenueTracking: input.allowUnsafeRevenueTracking,
|
||||
},
|
||||
include: {
|
||||
clients: {
|
||||
@@ -123,6 +124,7 @@ export const projectRouter = createTRPCRouter({
|
||||
domain: input.domain,
|
||||
cors: input.cors,
|
||||
crossDomain: false,
|
||||
allowUnsafeRevenueTracking: false,
|
||||
filters: [],
|
||||
clients: {
|
||||
create: data,
|
||||
|
||||
@@ -191,7 +191,7 @@ export const cacheMiddleware = (
|
||||
key += JSON.stringify(rawInput).replace(/\"/g, "'");
|
||||
}
|
||||
const cache = await getRedisCache().getJson(key);
|
||||
if (cache) {
|
||||
if (cache && process.env.NODE_ENV === 'production') {
|
||||
return {
|
||||
ok: true,
|
||||
data: cache,
|
||||
|
||||
@@ -379,6 +379,7 @@ export const zProject = z.object({
|
||||
domain: z.string().url().or(z.literal('').or(z.null())),
|
||||
cors: z.array(z.string()).default([]),
|
||||
crossDomain: z.boolean().default(false),
|
||||
allowUnsafeRevenueTracking: z.boolean().default(false),
|
||||
});
|
||||
export type IProjectEdit = z.infer<typeof zProject>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user