import { toDots, toObject } from '@openpanel/common'; import type { IChartEventFilter } from '@openpanel/validation'; import { ch, chQuery } from '../clickhouse-client'; import { createSqlBuilder } from '../sql-builder'; import { getEventFiltersWhereClause } from './chart.service'; export async function getProfileById(id: string) { if (id === '') { return null; } const [profile] = await chQuery( `SELECT *, created_at as max_created_at FROM profiles WHERE id = '${id}' ORDER BY created_at DESC LIMIT 1` ); if (!profile) { return null; } return transformProfile(profile); } interface GetProfileListOptions { projectId: string; take: number; cursor?: number; filters?: IChartEventFilter[]; } function getProfileSelectFields() { return [ 'id', 'argMax(first_name, created_at) as first_name', 'argMax(last_name, created_at) as last_name', 'argMax(email, created_at) as email', 'argMax(avatar, created_at) as avatar', 'argMax(properties, created_at) as properties', 'argMax(project_id, created_at) as project_id', 'max(created_at) as max_created_at', ].join(', '); } interface GetProfilesOptions { ids: string[]; } export async function getProfiles({ ids }: GetProfilesOptions) { if (ids.length === 0) { return []; } const data = await chQuery( `SELECT ${getProfileSelectFields()} FROM profiles WHERE id IN (${ids.map((id) => `'${id}'`).join(',')}) GROUP BY id ` ); return data.map(transformProfile); } function getProfileInnerSelect(projectId: string) { return `(SELECT ${getProfileSelectFields()} FROM profiles GROUP BY id HAVING project_id = '${projectId}')`; } export async function getProfileList({ take, cursor, projectId, filters, }: GetProfileListOptions) { const { sb, getSql } = createSqlBuilder(); sb.from = getProfileInnerSelect(projectId); if (filters) { sb.where = { ...sb.where, ...getEventFiltersWhereClause(filters), }; } sb.limit = take; sb.offset = (cursor ?? 0) * take; sb.orderBy.created_at = 'max_created_at DESC'; const data = await chQuery(getSql()); return data.map(transformProfile); } export async function getProfileListCount({ projectId, filters, }: Omit) { const { sb, getSql } = createSqlBuilder(); sb.select.count = 'count(id) as count'; sb.from = getProfileInnerSelect(projectId); if (filters) { sb.where = { ...sb.where, ...getEventFiltersWhereClause(filters), }; } const [data] = await chQuery<{ count: number }>(getSql()); return data?.count ?? 0; } export async function getProfilesByExternalId( externalId: string | null, projectId: string ) { if (externalId === null) { return []; } const data = await chQuery( `SELECT ${getProfileSelectFields()} FROM profiles GROUP BY id HAVING project_id = '${projectId}' AND external_id = '${externalId}' ` ); return data.map(transformProfile); } export type IServiceProfile = Omit< IClickhouseProfile, 'max_created_at' | 'properties' | 'first_name' | 'last_name' > & { firstName: string; lastName: string; createdAt: Date; properties: Record & { 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; project_id: string; max_created_at: string; } export interface IServiceUpsertProfile { projectId: string; id: string; firstName?: string; lastName?: string; email?: string; avatar?: string; properties?: Record; } function transformProfile({ max_created_at, first_name, last_name, ...profile }: IClickhouseProfile): IServiceProfile { return { ...profile, firstName: first_name, lastName: last_name, properties: toObject(profile.properties), createdAt: new Date(max_created_at), }; } export async function upsertProfile({ id, firstName, lastName, email, avatar, properties, projectId, }: IServiceUpsertProfile) { const [profile] = await chQuery( `SELECT * FROM profiles WHERE id = '${id}' AND project_id = '${projectId}' ORDER BY created_at DESC LIMIT 1` ); await ch.insert({ table: 'profiles', format: 'JSONEachRow', clickhouse_settings: { date_time_input_format: 'best_effort', }, values: [ { id, first_name: firstName ?? profile?.first_name ?? '', last_name: lastName ?? profile?.last_name ?? '', email: email ?? profile?.email ?? '', avatar: avatar ?? profile?.avatar ?? '', properties: toDots({ ...(profile?.properties ?? {}), ...(properties ?? {}), }), project_id: projectId ?? profile?.project_id ?? '', created_at: new Date(), }, ], }); } export function getProfileName(profile: IServiceProfile | undefined | null) { if (!profile) return 'No name'; return [profile.firstName, profile.lastName].filter(Boolean).join(' '); }