369 lines
9.7 KiB
TypeScript
369 lines
9.7 KiB
TypeScript
import { TRPCError } from '@trpc/server';
|
|
import sqlstring from 'sqlstring';
|
|
import { z } from 'zod';
|
|
|
|
import {
|
|
type IServiceProfile,
|
|
type IServiceSession,
|
|
TABLE_NAMES,
|
|
chQuery,
|
|
convertClickhouseDateToJs,
|
|
db,
|
|
eventService,
|
|
formatClickhouseDate,
|
|
getChartStartEndDate,
|
|
getConversionEventNames,
|
|
getEventList,
|
|
getEventMetasCached,
|
|
getEvents,
|
|
getSettingsForProject,
|
|
overviewService,
|
|
sessionService,
|
|
} from '@openpanel/db';
|
|
import {
|
|
zChartEventFilter,
|
|
zRange,
|
|
zTimeInterval,
|
|
} from '@openpanel/validation';
|
|
|
|
import { clone } from 'ramda';
|
|
import { getProjectAccessCached } from '../access';
|
|
import { TRPCAccessError } from '../errors';
|
|
import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc';
|
|
|
|
export const eventRouter = createTRPCRouter({
|
|
updateEventMeta: protectedProcedure
|
|
.input(
|
|
z.object({
|
|
projectId: z.string(),
|
|
name: z.string(),
|
|
icon: z.string().optional(),
|
|
color: z.string().optional(),
|
|
conversion: z.boolean().optional(),
|
|
}),
|
|
)
|
|
.mutation(
|
|
async ({ input: { projectId, name, icon, color, conversion } }) => {
|
|
await getEventMetasCached.clear(projectId);
|
|
return db.eventMeta.upsert({
|
|
where: {
|
|
name_projectId: {
|
|
name,
|
|
projectId,
|
|
},
|
|
},
|
|
create: { projectId, name, icon, color, conversion },
|
|
update: { icon, color, conversion },
|
|
});
|
|
},
|
|
),
|
|
|
|
byId: protectedProcedure
|
|
.input(
|
|
z.object({
|
|
id: z.string(),
|
|
projectId: z.string(),
|
|
createdAt: z.date().optional(),
|
|
}),
|
|
)
|
|
.query(async ({ input: { id, projectId, createdAt } }) => {
|
|
const res = await eventService.getById({
|
|
projectId,
|
|
id,
|
|
createdAt,
|
|
});
|
|
|
|
if (!res) {
|
|
throw new TRPCError({
|
|
code: 'NOT_FOUND',
|
|
message: 'Event not found',
|
|
});
|
|
}
|
|
|
|
return res;
|
|
}),
|
|
|
|
details: protectedProcedure
|
|
.input(
|
|
z.object({
|
|
id: z.string(),
|
|
projectId: z.string(),
|
|
createdAt: z.date().optional(),
|
|
}),
|
|
)
|
|
.query(async ({ input: { id, projectId, createdAt } }) => {
|
|
const res = await eventService.getById({
|
|
projectId,
|
|
id,
|
|
createdAt,
|
|
});
|
|
|
|
if (!res) {
|
|
throw new TRPCError({
|
|
code: 'NOT_FOUND',
|
|
message: 'Event not found',
|
|
});
|
|
}
|
|
|
|
let session: IServiceSession | undefined;
|
|
if (res?.sessionId) {
|
|
session = await sessionService.byId(res?.sessionId, projectId);
|
|
}
|
|
|
|
return {
|
|
event: res,
|
|
session,
|
|
};
|
|
}),
|
|
|
|
events: protectedProcedure
|
|
.input(
|
|
z.object({
|
|
projectId: z.string(),
|
|
profileId: z.string().optional(),
|
|
sessionId: z.string().optional(),
|
|
cursor: z.string().optional(),
|
|
filters: z.array(zChartEventFilter).default([]),
|
|
startDate: z.date().optional(),
|
|
endDate: z.date().optional(),
|
|
events: z.array(z.string()).optional(),
|
|
}),
|
|
)
|
|
.query(async ({ input }) => {
|
|
const items = await getEventList({
|
|
...input,
|
|
take: 50,
|
|
cursor: input.cursor ? new Date(input.cursor) : undefined,
|
|
select: {
|
|
profile: true,
|
|
properties: true,
|
|
sessionId: true,
|
|
deviceId: true,
|
|
profileId: true,
|
|
referrerName: true,
|
|
referrerType: true,
|
|
referrer: true,
|
|
origin: true,
|
|
},
|
|
});
|
|
|
|
// Hacky join to get profile for entire session
|
|
// TODO: Replace this with a join on the session table
|
|
const map = new Map<string, IServiceProfile>(); // sessionId -> profileId
|
|
for (const item of items) {
|
|
if (item.sessionId && item.profile?.isExternal === true) {
|
|
map.set(item.sessionId, item.profile);
|
|
}
|
|
}
|
|
|
|
for (const item of items) {
|
|
const profile = map.get(item.sessionId);
|
|
if (profile && (item.profile?.isExternal === false || !item.profile)) {
|
|
item.profile = clone(profile);
|
|
if (item?.profile?.firstName) {
|
|
item.profile.firstName = `* ${item.profile.firstName}`;
|
|
}
|
|
}
|
|
}
|
|
|
|
const lastItem = items[items.length - 1];
|
|
|
|
return {
|
|
data: items,
|
|
meta: {
|
|
next:
|
|
items.length === 50 && lastItem
|
|
? lastItem.createdAt.toISOString()
|
|
: null,
|
|
},
|
|
};
|
|
}),
|
|
conversionNames: protectedProcedure
|
|
.input(z.object({ projectId: z.string() }))
|
|
.query(async ({ input: { projectId } }) => {
|
|
return getConversionEventNames(projectId);
|
|
}),
|
|
conversions: protectedProcedure
|
|
.input(
|
|
z.object({
|
|
projectId: z.string(),
|
|
cursor: z.string().optional(),
|
|
startDate: z.date().optional(),
|
|
endDate: z.date().optional(),
|
|
}),
|
|
)
|
|
.query(async ({ input }) => {
|
|
const conversions = await getConversionEventNames(input.projectId);
|
|
|
|
if (conversions.length === 0) {
|
|
return {
|
|
data: [],
|
|
meta: {
|
|
next: null,
|
|
},
|
|
};
|
|
}
|
|
|
|
const items = await getEventList({
|
|
...input,
|
|
take: 50,
|
|
cursor: input.cursor ? new Date(input.cursor) : undefined,
|
|
select: {
|
|
profile: true,
|
|
properties: true,
|
|
sessionId: true,
|
|
deviceId: true,
|
|
profileId: true,
|
|
referrerName: true,
|
|
referrerType: true,
|
|
referrer: true,
|
|
origin: true,
|
|
},
|
|
custom: (sb) => {
|
|
sb.where.name = `name IN (${conversions.map((event) => sqlstring.escape(event.name)).join(',')})`;
|
|
},
|
|
});
|
|
|
|
// Hacky join to get profile for entire session
|
|
// TODO: Replace this with a join on the session table
|
|
const map = new Map<string, IServiceProfile>(); // sessionId -> profileId
|
|
for (const item of items) {
|
|
if (item.sessionId && item.profile?.isExternal === true) {
|
|
map.set(item.sessionId, item.profile);
|
|
}
|
|
}
|
|
|
|
for (const item of items) {
|
|
const profile = map.get(item.sessionId);
|
|
if (profile && (item.profile?.isExternal === false || !item.profile)) {
|
|
item.profile = clone(profile);
|
|
if (item?.profile?.firstName) {
|
|
item.profile.firstName = `* ${item.profile.firstName}`;
|
|
}
|
|
}
|
|
}
|
|
|
|
const lastItem = items[items.length - 1];
|
|
|
|
return {
|
|
data: items,
|
|
meta: {
|
|
next:
|
|
items.length === 50 && lastItem
|
|
? lastItem.createdAt.toISOString()
|
|
: null,
|
|
},
|
|
};
|
|
}),
|
|
|
|
bots: publicProcedure
|
|
.input(
|
|
z.object({
|
|
projectId: z.string(),
|
|
cursor: z.number().optional(),
|
|
limit: z.number().default(8),
|
|
}),
|
|
)
|
|
.query(async ({ input: { projectId, cursor, limit }, ctx }) => {
|
|
if (ctx.session.userId) {
|
|
const access = await getProjectAccessCached({
|
|
projectId,
|
|
userId: ctx.session.userId,
|
|
});
|
|
if (!access) {
|
|
throw TRPCAccessError('You do not have access to this project');
|
|
}
|
|
} else {
|
|
const share = await db.shareOverview.findFirst({
|
|
where: {
|
|
projectId,
|
|
},
|
|
});
|
|
|
|
if (!share) {
|
|
throw TRPCAccessError('You do not have access to this project');
|
|
}
|
|
}
|
|
|
|
const [events, counts] = await Promise.all([
|
|
chQuery<{
|
|
id: string;
|
|
project_id: string;
|
|
name: string;
|
|
type: string;
|
|
path: string;
|
|
created_at: string;
|
|
}>(
|
|
`SELECT * FROM ${TABLE_NAMES.events_bots} WHERE project_id = ${sqlstring.escape(projectId)} ORDER BY created_at DESC LIMIT ${limit} OFFSET ${(cursor ?? 0) * limit}`,
|
|
),
|
|
chQuery<{
|
|
count: number;
|
|
}>(
|
|
`SELECT count(*) as count FROM ${TABLE_NAMES.events_bots} WHERE project_id = ${sqlstring.escape(projectId)}`,
|
|
),
|
|
]);
|
|
|
|
return {
|
|
data: events.map((item) => ({
|
|
...item,
|
|
createdAt: convertClickhouseDateToJs(item.created_at),
|
|
})),
|
|
count: counts[0]?.count ?? 0,
|
|
};
|
|
}),
|
|
|
|
pages: protectedProcedure
|
|
.input(
|
|
z.object({
|
|
projectId: z.string(),
|
|
cursor: z.number().optional(),
|
|
take: z.number().default(20),
|
|
search: z.string().optional(),
|
|
range: zRange,
|
|
interval: zTimeInterval,
|
|
filters: z.array(zChartEventFilter).default([]),
|
|
}),
|
|
)
|
|
.query(async ({ input }) => {
|
|
const { timezone } = await getSettingsForProject(input.projectId);
|
|
const { startDate, endDate } = getChartStartEndDate(input, timezone);
|
|
if (input.search) {
|
|
input.filters.push({
|
|
id: 'path',
|
|
name: 'path',
|
|
value: [input.search],
|
|
operator: 'contains',
|
|
});
|
|
}
|
|
return overviewService.getTopPages({
|
|
projectId: input.projectId,
|
|
filters: input.filters,
|
|
startDate,
|
|
endDate,
|
|
cursor: input.cursor || 1,
|
|
limit: input.take,
|
|
timezone,
|
|
});
|
|
}),
|
|
|
|
origin: protectedProcedure
|
|
.input(
|
|
z.object({
|
|
projectId: z.string(),
|
|
}),
|
|
)
|
|
.query(async ({ input }) => {
|
|
const res = await chQuery<{ origin: string }>(
|
|
`SELECT DISTINCT origin FROM ${TABLE_NAMES.events} WHERE project_id = ${sqlstring.escape(
|
|
input.projectId,
|
|
)} AND origin IS NOT NULL AND origin != '' AND toDate(created_at) > now() - INTERVAL 30 DAY ORDER BY origin ASC`,
|
|
);
|
|
|
|
return res.sort((a, b) =>
|
|
a.origin
|
|
.replace(/https?:\/\//, '')
|
|
.localeCompare(b.origin.replace(/https?:\/\//, '')),
|
|
);
|
|
}),
|
|
});
|