Files
stats/packages/db/src/services/profile.service.ts
Carl-Gerhard Lindesvärd 64d3a59722 improve event list a bit
2024-08-12 22:34:44 +02:00

249 lines
6.3 KiB
TypeScript

import { uniq } from 'ramda';
import { escape } from 'sqlstring';
import { toObject } from '@openpanel/common';
import { cacheable } from '@openpanel/redis';
import type { IChartEventFilter } from '@openpanel/validation';
import { profileBuffer } from '../buffers';
import {
ch,
chQuery,
formatClickhouseDate,
TABLE_NAMES,
} from '../clickhouse-client';
import { createSqlBuilder } from '../sql-builder';
export type IProfileMetrics = {
lastSeen: string;
firstSeen: string;
screenViews: number;
sessions: number;
durationAvg: number;
durationP90: number;
};
export function getProfileMetrics(profileId: string, projectId: string) {
return chQuery<IProfileMetrics>(`
WITH lastSeen AS (
SELECT max(created_at) as lastSeen FROM ${TABLE_NAMES.events} WHERE profile_id = ${escape(profileId)} AND project_id = ${escape(projectId)}
),
firstSeen AS (
SELECT min(created_at) as firstSeen FROM ${TABLE_NAMES.events} WHERE profile_id = ${escape(profileId)} AND project_id = ${escape(projectId)}
),
screenViews AS (
SELECT count(*) as screenViews FROM ${TABLE_NAMES.events} WHERE name = 'screen_view' AND profile_id = ${escape(profileId)} AND project_id = ${escape(projectId)}
),
sessions AS (
SELECT count(*) as sessions FROM ${TABLE_NAMES.events} WHERE name = 'session_start' AND profile_id = ${escape(profileId)} AND project_id = ${escape(projectId)}
),
duration AS (
SELECT avg(duration) as durationAvg, quantilesExactInclusive(0.9)(duration)[1] as durationP90 FROM ${TABLE_NAMES.events} WHERE name = 'session_end' AND duration != 0 AND profile_id = ${escape(profileId)} AND project_id = ${escape(projectId)}
)
SELECT lastSeen, firstSeen, screenViews, sessions, durationAvg, durationP90 FROM lastSeen, firstSeen, screenViews,sessions, duration
`).then((data) => data[0]!);
}
export async function getProfileById(id: string, projectId: string) {
if (id === '' || projectId === '') {
return null;
}
const [profile] = await chQuery<IClickhouseProfile>(
`SELECT * FROM profiles WHERE id = ${escape(String(id))} AND project_id = ${escape(projectId)} ORDER BY created_at DESC LIMIT 1`
);
if (!profile) {
return null;
}
return transformProfile(profile);
}
interface GetProfileListOptions {
projectId: string;
take: number;
cursor?: number;
filters?: IChartEventFilter[];
}
export async function getProfiles(ids: string[]) {
const filteredIds = uniq(ids.filter((id) => id !== ''));
if (filteredIds.length === 0) {
return [];
}
const data = await chQuery<IClickhouseProfile>(
`SELECT *
FROM profiles FINAL
WHERE id IN (${filteredIds.map((id) => escape(id)).join(',')})
`
);
return data.map(transformProfile);
}
export async function getProfileList({
take,
cursor,
projectId,
filters,
}: GetProfileListOptions) {
const { sb, getSql } = createSqlBuilder();
sb.from = 'profiles FINAL';
sb.select.all = '*';
sb.where.project_id = `project_id = ${escape(projectId)}`;
sb.limit = take;
sb.offset = Math.max(0, (cursor ?? 0) * take);
sb.orderBy.created_at = 'created_at DESC';
const data = await chQuery<IClickhouseProfile>(getSql());
return data.map(transformProfile);
}
export async function getProfileListCount({
projectId,
filters,
}: Omit<GetProfileListOptions, 'cursor' | 'take'>) {
const { sb, getSql } = createSqlBuilder();
sb.from = 'profiles FINAL';
sb.select.count = 'count(id) as count';
sb.where.project_id = `project_id = ${escape(projectId)}`;
sb.groupBy.project_id = 'project_id';
const data = await chQuery<{ count: number }>(getSql());
return data[0]?.count ?? 0;
}
export type IServiceProfile = Omit<
IClickhouseProfile,
'created_at' | 'properties' | 'first_name' | 'last_name' | 'is_external'
> & {
firstName: string;
lastName: string;
createdAt: Date;
isExternal: boolean;
properties: Record<string, unknown> & {
country?: string;
city?: string;
os?: string;
os_version?: string;
browser?: string;
browser_version?: string;
referrer_name?: string;
referrer_type?: string;
};
};
export interface IClickhouseProfile {
id: string;
first_name: string;
last_name: string;
email: string;
avatar: string;
properties: Record<string, string | undefined>;
project_id: string;
is_external: boolean;
created_at: string;
}
export interface IServiceUpsertProfile {
projectId: string;
id: string;
firstName?: string;
lastName?: string;
email?: string;
avatar?: string;
properties?: Record<string, unknown>;
isExternal: boolean;
}
export function transformProfile({
created_at,
first_name,
last_name,
...profile
}: IClickhouseProfile): IServiceProfile {
return {
...profile,
firstName: first_name,
lastName: last_name,
isExternal: profile.is_external,
properties: toObject(profile.properties),
createdAt: new Date(created_at),
};
}
export async function createProfileAlias({
projectId,
alias,
profileId,
}: {
projectId: string;
alias: string;
profileId: string;
}) {
await getProfileIdCached.clear({ profileId, projectId });
await ch.insert({
table: TABLE_NAMES.alias,
format: 'JSONEachRow',
values: [
{
projectId,
profile_id: profileId,
alias,
created_at: new Date(),
},
],
});
}
export async function upsertProfile({
id,
firstName,
lastName,
email,
avatar,
properties,
projectId,
isExternal,
}: IServiceUpsertProfile) {
return profileBuffer.insert({
id,
first_name: firstName!,
last_name: lastName!,
email: email!,
avatar: avatar!,
properties: properties as Record<string, string | undefined>,
project_id: projectId,
created_at: formatClickhouseDate(new Date()),
is_external: isExternal,
});
}
export async function getProfileId({
profileId,
projectId,
}: {
profileId: number | string | undefined;
projectId: string;
}) {
if (!profileId) {
return '';
}
const res = await chQuery<{
alias: string;
profile_id: string;
project_id: string;
}>(
`SELECT * FROM ${TABLE_NAMES.alias} WHERE project_id = '${projectId}' AND (alias = '${profileId}' OR profile_id = '${profileId}')`
);
if (res[0]) {
return res[0].profile_id;
}
return String(profileId);
}
export const getProfileIdCached = cacheable(getProfileId, 60 * 30);