move trpc to api

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-04-23 08:21:15 +02:00
parent 8207f15a83
commit ec8bf02fb9
37 changed files with 497 additions and 156 deletions

View File

@@ -1,25 +0,0 @@
import { appRouter } from '@/trpc/api/root';
import { getAuth } from '@clerk/nextjs/server';
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext({ req }) {
const session = getAuth(req as any);
return {
session,
};
},
onError(opts) {
const { error, type, path, input, ctx, req } = opts;
console.error('---- TRPC ERROR');
console.error('Error:', error);
console.error('Context:', ctx);
console.error();
},
});
export { handler as GET, handler as POST };

View File

@@ -35,7 +35,15 @@ function AllProviders({ children }: { children: React.ReactNode }) {
transformer: superjson,
links: [
httpLink({
url: `${process.env.NEXT_PUBLIC_DASHBOARD_URL}/api/trpc`,
url: `${process.env.NEXT_PUBLIC_API_URL}/trpc`,
fetch(url, options) {
// Send cookies
return fetch(url, {
...options,
credentials: 'include',
mode: 'cors',
});
},
}),
],
})

View File

@@ -9,8 +9,8 @@ import {
useMemo,
useState,
} from 'react';
import type { IChartSerie } from '@/trpc/api/routers/chart';
import type { IChartSerie } from '@openpanel/trpc/src/routers/chart';
import type { IChartInput } from '@openpanel/validation';
import { ChartLoading } from './ChartLoading';

View File

@@ -1,3 +1,5 @@
// @ts-nocheck
'use client';
import {

View File

@@ -1,7 +1,10 @@
/* eslint-disable */
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
if (
process.env.NODE_ENV === 'production' &&
process.env.NEXT_RUNTIME === 'nodejs'
) {
const { BaselimeSDK, VercelPlugin, BetterHttpInstrumentation } =
// @ts-expect-error
await import('@baselime/node-opentelemetry');

View File

@@ -1,37 +0,0 @@
import { createTRPCRouter } from '@/trpc/api/trpc';
import { chartRouter } from './routers/chart';
import { clientRouter } from './routers/client';
import { dashboardRouter } from './routers/dashboard';
import { eventRouter } from './routers/event';
import { onboardingRouter } from './routers/onboarding';
import { organizationRouter } from './routers/organization';
import { profileRouter } from './routers/profile';
import { projectRouter } from './routers/project';
import { referenceRouter } from './routers/reference';
import { reportRouter } from './routers/report';
import { shareRouter } from './routers/share';
import { userRouter } from './routers/user';
/**
* This is the primary router for your server.
*
* All routers added in /api/routers should be manually added here.
*/
export const appRouter = createTRPCRouter({
chart: chartRouter,
report: reportRouter,
dashboard: dashboardRouter,
organization: organizationRouter,
user: userRouter,
project: projectRouter,
client: clientRouter,
event: eventRouter,
profile: profileRouter,
share: shareRouter,
onboarding: onboardingRouter,
reference: referenceRouter,
});
// export type definition of API
export type AppRouter = typeof appRouter;

View File

@@ -1,620 +0,0 @@
import { round } from '@/utils/math';
import { subDays } from 'date-fns';
import * as mathjs from 'mathjs';
import { repeat, reverse, sort } from 'ramda';
import { escape } from 'sqlstring';
import { alphabetIds, NOT_SET_VALUE } from '@openpanel/constants';
import {
chQuery,
convertClickhouseDateToJs,
createSqlBuilder,
formatClickhouseDate,
getChartSql,
getEventFiltersWhereClause,
getProfiles,
transformProfile,
} from '@openpanel/db';
import type {
IChartEvent,
IChartInput,
IChartRange,
IGetChartDataInput,
IInterval,
} from '@openpanel/validation';
export type GetChartDataResult = Awaited<ReturnType<typeof getChartData>>;
export interface ResultItem {
label: string | null;
count: number | null;
date: string;
}
function getEventLegend(event: IChartEvent) {
return event.displayName ?? `${event.name} (${event.id})`;
}
function fillEmptySpotsInTimeline(
items: ResultItem[],
interval: IInterval,
startDate: string,
endDate: string
) {
const result = [];
const clonedStartDate = new Date(startDate);
const clonedEndDate = new Date(endDate);
const today = new Date();
if (interval === 'minute') {
clonedStartDate.setUTCSeconds(0, 0);
clonedEndDate.setUTCMinutes(clonedEndDate.getUTCMinutes() + 1, 0, 0);
} else if (interval === 'hour') {
clonedStartDate.setUTCMinutes(0, 0, 0);
clonedEndDate.setUTCMinutes(0, 0, 0);
} else {
clonedStartDate.setUTCHours(0, 0, 0, 0);
clonedEndDate.setUTCHours(0, 0, 0, 0);
}
if (interval === 'month') {
clonedStartDate.setUTCDate(1);
clonedEndDate.setUTCDate(1);
}
// Force if interval is month and the start date is the same month as today
const shouldForce = () =>
interval === 'month' &&
clonedStartDate.getUTCFullYear() === today.getUTCFullYear() &&
clonedStartDate.getUTCMonth() === today.getUTCMonth();
let prev = undefined;
while (
shouldForce() ||
clonedStartDate.getTime() <= clonedEndDate.getTime()
) {
if (prev === clonedStartDate.getTime()) {
break;
}
prev = clonedStartDate.getTime();
const getYear = (date: Date) => date.getUTCFullYear();
const getMonth = (date: Date) => date.getUTCMonth();
const getDay = (date: Date) => date.getUTCDate();
const getHour = (date: Date) => date.getUTCHours();
const getMinute = (date: Date) => date.getUTCMinutes();
const item = items.find((item) => {
const date = convertClickhouseDateToJs(item.date);
if (interval === 'month') {
return (
getYear(date) === getYear(clonedStartDate) &&
getMonth(date) === getMonth(clonedStartDate)
);
}
if (interval === 'day') {
return (
getYear(date) === getYear(clonedStartDate) &&
getMonth(date) === getMonth(clonedStartDate) &&
getDay(date) === getDay(clonedStartDate)
);
}
if (interval === 'hour') {
return (
getYear(date) === getYear(clonedStartDate) &&
getMonth(date) === getMonth(clonedStartDate) &&
getDay(date) === getDay(clonedStartDate) &&
getHour(date) === getHour(clonedStartDate)
);
}
if (interval === 'minute') {
return (
getYear(date) === getYear(clonedStartDate) &&
getMonth(date) === getMonth(clonedStartDate) &&
getDay(date) === getDay(clonedStartDate) &&
getHour(date) === getHour(clonedStartDate) &&
getMinute(date) === getMinute(clonedStartDate)
);
}
return false;
});
if (item) {
result.push({
...item,
date: clonedStartDate.toISOString(),
});
} else {
result.push({
date: clonedStartDate.toISOString(),
count: 0,
label: null,
});
}
switch (interval) {
case 'day': {
clonedStartDate.setUTCDate(clonedStartDate.getUTCDate() + 1);
break;
}
case 'hour': {
clonedStartDate.setUTCHours(clonedStartDate.getUTCHours() + 1);
break;
}
case 'minute': {
clonedStartDate.setUTCMinutes(clonedStartDate.getUTCMinutes() + 1);
break;
}
case 'month': {
clonedStartDate.setUTCMonth(clonedStartDate.getUTCMonth() + 1);
break;
}
}
}
return sort(function (a, b) {
return new Date(a.date).getTime() - new Date(b.date).getTime();
}, result);
}
export function withFormula(
{ formula, events }: IChartInput,
series: GetChartDataResult
) {
if (!formula) {
return series;
}
if (!series) {
return series;
}
if (!series[0]) {
return series;
}
if (!series[0].data) {
return series;
}
if (events.length === 1) {
return series.map((serie) => {
return {
...serie,
data: serie.data.map((item) => {
serie.event.id;
const scope = {
[serie.event.id]: item?.count ?? 0,
};
const count = mathjs
.parse(formula)
.compile()
.evaluate(scope) as number;
return {
...item,
count:
Number.isNaN(count) || !Number.isFinite(count)
? null
: round(count, 2),
};
}),
};
});
}
return [
{
...series[0],
data: series[0].data.map((item, dIndex) => {
const scope = series.reduce((acc, item) => {
return {
...acc,
[item.event.id]: item.data[dIndex]?.count ?? 0,
};
}, {});
const count = mathjs.parse(formula).compile().evaluate(scope) as number;
return {
...item,
count:
Number.isNaN(count) || !Number.isFinite(count)
? null
: round(count, 2),
};
}),
},
];
}
export async function getChartData(payload: IGetChartDataInput) {
let result = await chQuery<ResultItem>(getChartSql(payload));
if (result.length === 0 && payload.breakdowns.length > 0) {
result = await chQuery<ResultItem>(
getChartSql({
...payload,
breakdowns: [],
})
);
}
// group by sql label
const series = result.reduce(
(acc, item) => {
// item.label can be null when using breakdowns on a property
// that doesn't exist on all events
const label = item.label?.trim() || NOT_SET_VALUE;
if (label) {
if (acc[label]) {
acc[label]?.push(item);
} else {
acc[label] = [item];
}
}
return {
...acc,
};
},
{} as Record<string, ResultItem[]>
);
return Object.keys(series).map((key) => {
// If we have breakdowns, we want to use the breakdown key as the legend
// But only if it successfully broke it down, otherwise we use the getEventLabel
const isBreakdown =
payload.breakdowns.length && !alphabetIds.includes(key as 'A');
const serieName = isBreakdown ? key : getEventLegend(payload.event);
const data =
payload.chartType === 'area' ||
payload.chartType === 'linear' ||
payload.chartType === 'histogram' ||
payload.chartType === 'metric' ||
payload.chartType === 'pie' ||
payload.chartType === 'bar'
? fillEmptySpotsInTimeline(
series[key] ?? [],
payload.interval,
payload.startDate,
payload.endDate
).map((item) => {
return {
label: serieName,
count: item.count ? round(item.count) : null,
date: new Date(item.date).toISOString(),
};
})
: (series[key] ?? []).map((item) => ({
label: item.label,
count: item.count ? round(item.count) : null,
date: new Date(item.date).toISOString(),
}));
return {
name: serieName,
event: payload.event,
data,
};
});
}
export function getDatesFromRange(range: IChartRange) {
if (range === 'today') {
const startDate = new Date();
const endDate = new Date();
startDate.setUTCHours(0, 0, 0, 0);
endDate.setUTCHours(23, 59, 59, 999);
return {
startDate: startDate.toUTCString(),
endDate: endDate.toUTCString(),
};
}
if (range === '30min' || range === '1h') {
const startDate = new Date(
Date.now() - 1000 * 60 * (range === '30min' ? 30 : 60)
).toUTCString();
const endDate = new Date().toUTCString();
return {
startDate,
endDate,
};
}
let days = 1;
if (range === '24h') {
const startDate = subDays(new Date(), days);
const endDate = new Date();
return {
startDate: startDate.toUTCString(),
endDate: endDate.toUTCString(),
};
} else if (range === '7d') {
days = 7;
} else if (range === '14d') {
days = 14;
} else if (range === '1m') {
days = 30;
} else if (range === '3m') {
days = 90;
} else if (range === '6m') {
days = 180;
} else if (range === '1y') {
days = 365;
}
const startDate = subDays(new Date(), days);
startDate.setUTCHours(0, 0, 0, 0);
const endDate = new Date();
endDate.setUTCHours(23, 59, 59, 999);
return {
startDate: startDate.toUTCString(),
endDate: endDate.toUTCString(),
};
}
export function getChartStartEndDate({
startDate,
endDate,
range,
}: Pick<IChartInput, 'endDate' | 'startDate' | 'range'>) {
return startDate && endDate
? { startDate: startDate, endDate: endDate }
: getDatesFromRange(range);
}
export function getChartPrevStartEndDate({
startDate,
endDate,
range,
}: {
startDate: string;
endDate: string;
range: IChartRange;
}) {
let diff = 0;
switch (range) {
case '30min': {
diff = 1000 * 60 * 30;
break;
}
case '1h': {
diff = 1000 * 60 * 60;
break;
}
case '24h':
case 'today': {
diff = 1000 * 60 * 60 * 24;
break;
}
case '7d': {
diff = 1000 * 60 * 60 * 24 * 7;
break;
}
case '14d': {
diff = 1000 * 60 * 60 * 24 * 14;
break;
}
case '1m': {
diff = 1000 * 60 * 60 * 24 * 30;
break;
}
case '3m': {
diff = 1000 * 60 * 60 * 24 * 90;
break;
}
case '6m': {
diff = 1000 * 60 * 60 * 24 * 180;
break;
}
}
return {
startDate: new Date(new Date(startDate).getTime() - diff).toISOString(),
endDate: new Date(new Date(endDate).getTime() - diff).toISOString(),
};
}
const ONE_DAY_IN_SECONDS = 60 * 60 * 24;
export async function getFunnelData({
projectId,
startDate,
endDate,
...payload
}: IChartInput) {
if (!startDate || !endDate) {
throw new Error('startDate and endDate are required');
}
if (payload.events.length === 0) {
return {
totalSessions: 0,
steps: [],
};
}
const funnels = payload.events.map((event) => {
const { sb, getWhere } = createSqlBuilder();
sb.where = getEventFiltersWhereClause(event.filters);
sb.where.name = `name = ${escape(event.name)}`;
return getWhere().replace('WHERE ', '');
});
const innerSql = `SELECT
session_id,
windowFunnel(${ONE_DAY_IN_SECONDS})(toUnixTimestamp(created_at), ${funnels.join(', ')}) AS level
FROM events
WHERE
project_id = ${escape(projectId)} AND
created_at >= '${formatClickhouseDate(startDate)}' AND
created_at <= '${formatClickhouseDate(endDate)}' AND
name IN (${payload.events.map((event) => escape(event.name)).join(', ')})
GROUP BY session_id`;
const sql = `SELECT level, count() AS count FROM (${innerSql}) GROUP BY level ORDER BY level DESC`;
const [funnelRes, sessionRes] = await Promise.all([
chQuery<{ level: number; count: number }>(sql),
chQuery<{ count: number }>(
`SELECT count(name) as count FROM events WHERE project_id = ${escape(projectId)} AND name = 'session_start' AND (created_at >= '${formatClickhouseDate(startDate)}') AND (created_at <= '${formatClickhouseDate(endDate)}')`
),
]);
if (funnelRes[0]?.level !== payload.events.length) {
funnelRes.unshift({
level: payload.events.length,
count: 0,
});
}
const totalSessions = sessionRes[0]?.count ?? 0;
const filledFunnelRes = funnelRes.reduce(
(acc, item, index) => {
const diff =
index !== 0 ? (acc[acc.length - 1]?.level ?? 0) - item.level : 1;
if (diff > 1) {
acc.push(
...reverse(
repeat({}, diff - 1).map((_, index) => ({
count: acc[acc.length - 1]?.count ?? 0,
level: item.level + index + 1,
}))
)
);
}
return [
...acc,
{
count: item.count + (acc[acc.length - 1]?.count ?? 0),
level: item.level,
},
];
},
[] as typeof funnelRes
);
const steps = reverse(filledFunnelRes)
.filter((item) => item.level !== 0)
.reduce(
(acc, item, index, list) => {
const prev = list[index - 1] ?? { count: totalSessions };
const event = payload.events[item.level - 1]!;
return [
...acc,
{
event: {
...event,
displayName: event.displayName ?? event.name,
},
count: item.count,
percent: (item.count / totalSessions) * 100,
dropoffCount: prev.count - item.count,
dropoffPercent: 100 - (item.count / prev.count) * 100,
previousCount: prev.count,
},
];
},
[] as {
event: IChartEvent & { displayName: string };
count: number;
percent: number;
dropoffCount: number;
dropoffPercent: number;
previousCount: number;
}[]
);
return {
totalSessions,
steps,
};
}
export async function getFunnelStep({
projectId,
startDate,
endDate,
step,
...payload
}: IChartInput & {
step: number;
}) {
if (!startDate || !endDate) {
throw new Error('startDate and endDate are required');
}
if (payload.events.length === 0) {
throw new Error('no events selected');
}
const funnels = payload.events.map((event) => {
const { sb, getWhere } = createSqlBuilder();
sb.where = getEventFiltersWhereClause(event.filters);
sb.where.name = `name = ${escape(event.name)}`;
return getWhere().replace('WHERE ', '');
});
const innerSql = `SELECT
session_id,
windowFunnel(${ONE_DAY_IN_SECONDS})(toUnixTimestamp(created_at), ${funnels.join(', ')}) AS level
FROM events
WHERE
project_id = ${escape(projectId)} AND
created_at >= '${formatClickhouseDate(startDate)}' AND
created_at <= '${formatClickhouseDate(endDate)}' AND
name IN (${payload.events.map((event) => escape(event.name)).join(', ')})
GROUP BY session_id`;
const profileIdsQuery = `WITH sessions AS (${innerSql})
SELECT
DISTINCT e.profile_id as id
FROM sessions s
JOIN events e ON s.session_id = e.session_id
WHERE
s.level = ${step} AND
e.project_id = ${escape(projectId)} AND
e.created_at >= '${formatClickhouseDate(startDate)}' AND
e.created_at <= '${formatClickhouseDate(endDate)}' AND
name IN (${payload.events.map((event) => escape(event.name)).join(', ')})
ORDER BY e.created_at DESC
LIMIT 500
`;
const res = await chQuery<{
id: string;
}>(profileIdsQuery);
return getProfiles({ ids: res.map((r) => r.id) });
}
export async function getSeriesFromEvents(input: IChartInput) {
const { startDate, endDate } =
input.startDate && input.endDate
? {
startDate: input.startDate,
endDate: input.endDate,
}
: getDatesFromRange(input.range);
const series = (
await Promise.all(
input.events.map(async (event) =>
getChartData({
...input,
startDate,
endDate,
event,
})
)
)
).flat();
return withFormula(input, series);
}

View File

@@ -1,361 +0,0 @@
import {
createTRPCRouter,
protectedProcedure,
publicProcedure,
} from '@/trpc/api/trpc';
import { average, max, min, round, sum } from '@/utils/math';
import { flatten, map, pipe, prop, sort, uniq } from 'ramda';
import { escape } from 'sqlstring';
import { z } from 'zod';
import { chQuery, createSqlBuilder } from '@openpanel/db';
import { zChartInput } from '@openpanel/validation';
import type { IChartEvent, IChartInput } from '@openpanel/validation';
import {
getChartPrevStartEndDate,
getChartStartEndDate,
getFunnelData,
getFunnelStep,
getSeriesFromEvents,
} from './chart.helpers';
type PreviousValue = {
value: number;
diff: number | null;
state: 'positive' | 'negative' | 'neutral';
} | null;
interface Metrics {
sum: number;
average: number;
min: number;
max: number;
previous: {
sum: PreviousValue;
average: PreviousValue;
min: PreviousValue;
max: PreviousValue;
};
}
export interface IChartSerie {
name: string;
event: IChartEvent;
metrics: Metrics;
data: {
date: string;
count: number;
label: string | null;
previous: PreviousValue;
}[];
}
export interface FinalChart {
events: IChartInput['events'];
series: IChartSerie[];
metrics: Metrics;
}
export const chartRouter = createTRPCRouter({
events: protectedProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ input: { projectId } }) => {
const events = await chQuery<{ name: string }>(
`SELECT DISTINCT name FROM events WHERE project_id = ${escape(projectId)}`
);
return [
{
name: '*',
},
...events,
];
}),
properties: protectedProcedure
.input(z.object({ event: z.string().optional(), projectId: z.string() }))
.query(async ({ input: { projectId, event } }) => {
const events = await chQuery<{ keys: string[] }>(
`SELECT distinct mapKeys(properties) as keys from events where ${
event && event !== '*' ? `name = ${escape(event)} AND ` : ''
} project_id = ${escape(projectId)};`
);
const properties = events
.flatMap((event) => event.keys)
.map((item) => item.replace(/\.([0-9]+)\./g, '.*.'))
.map((item) => item.replace(/\.([0-9]+)/g, '[*]'))
.map((item) => `properties.${item}`);
properties.push(
'name',
'path',
'referrer',
'referrer_name',
'duration',
'created_at',
'country',
'city',
'region',
'os',
'os_version',
'browser',
'browser_version',
'device',
'brand',
'model'
);
return pipe(
sort<string>((a, b) => a.length - b.length),
uniq
)(properties);
}),
// TODO: Make this private
values: publicProcedure
.input(
z.object({
event: z.string(),
property: z.string(),
projectId: z.string(),
})
)
.query(async ({ input: { event, property, projectId } }) => {
const { sb, getSql } = createSqlBuilder();
sb.where.project_id = `project_id = ${escape(projectId)}`;
if (event !== '*') {
sb.where.event = `name = ${escape(event)}`;
}
if (property.startsWith('properties.')) {
sb.select.values = `distinct mapValues(mapExtractKeyLike(properties, ${escape(
property.replace(/^properties\./, '').replace('.*.', '.%.')
)})) as values`;
} else {
sb.select.values = `distinct ${property} as values`;
}
const events = await chQuery<{ values: string[] }>(getSql());
const values = pipe(
(data: typeof events) => map(prop('values'), data),
flatten,
uniq,
sort((a, b) => a.length - b.length)
)(events);
return {
values,
};
}),
funnel: publicProcedure.input(zChartInput).query(async ({ input }) => {
const currentPeriod = getChartStartEndDate(input);
const previousPeriod = getChartPrevStartEndDate({
range: input.range,
...currentPeriod,
});
const [current, previous] = await Promise.all([
getFunnelData({ ...input, ...currentPeriod }),
getFunnelData({ ...input, ...previousPeriod }),
]);
return {
current,
previous,
};
}),
funnelStep: publicProcedure
.input(
zChartInput.extend({
step: z.number(),
})
)
.query(async ({ input }) => {
const currentPeriod = getChartStartEndDate(input);
return getFunnelStep({ ...input, ...currentPeriod });
}),
// TODO: Make this private
chart: publicProcedure.input(zChartInput).query(async ({ input }) => {
const currentPeriod = getChartStartEndDate(input);
const previousPeriod = getChartPrevStartEndDate({
range: input.range,
...currentPeriod,
});
const promises = [getSeriesFromEvents({ ...input, ...currentPeriod })];
if (input.previous) {
promises.push(
getSeriesFromEvents({
...input,
...previousPeriod,
})
);
}
const result = await Promise.all(promises);
const series = result[0]!;
const previousSeries = result[1];
const final: FinalChart = {
events: input.events,
series: series.map((serie, index) => {
const previousSerie = previousSeries?.[index];
const metrics = {
sum: sum(serie.data.map((item) => item.count)),
average: round(average(serie.data.map((item) => item.count)), 2),
min: min(serie.data.map((item) => item.count)),
max: max(serie.data.map((item) => item.count)),
};
return {
name: serie.name,
event: {
...serie.event,
displayName: serie.event.displayName ?? serie.event.name,
},
metrics: {
...metrics,
previous: {
sum: getPreviousMetric(
metrics.sum,
previousSerie
? sum(previousSerie?.data.map((item) => item.count))
: null
),
average: getPreviousMetric(
metrics.average,
previousSerie
? round(
average(previousSerie?.data.map((item) => item.count)),
2
)
: null
),
min: getPreviousMetric(
metrics.sum,
previousSerie
? min(previousSerie?.data.map((item) => item.count))
: null
),
max: getPreviousMetric(
metrics.sum,
previousSerie
? max(previousSerie?.data.map((item) => item.count))
: null
),
},
},
data: serie.data.map((item, index) => ({
date: item.date,
count: item.count ?? 0,
label: item.label,
previous: previousSerie?.data[index]
? getPreviousMetric(
item.count ?? 0,
previousSerie?.data[index]?.count ?? null
)
: null,
})),
};
}),
metrics: {
sum: 0,
average: 0,
min: 0,
max: 0,
previous: {
sum: null,
average: null,
min: null,
max: null,
},
},
};
final.metrics.sum = sum(final.series.map((item) => item.metrics.sum));
final.metrics.average = round(
average(final.series.map((item) => item.metrics.average)),
2
);
final.metrics.min = min(final.series.map((item) => item.metrics.min));
final.metrics.max = max(final.series.map((item) => item.metrics.max));
final.metrics.previous = {
sum: getPreviousMetric(
final.metrics.sum,
sum(final.series.map((item) => item.metrics.previous.sum?.value ?? 0))
),
average: getPreviousMetric(
final.metrics.average,
round(
average(
final.series.map(
(item) => item.metrics.previous.average?.value ?? 0
)
),
2
)
),
min: getPreviousMetric(
final.metrics.min,
min(final.series.map((item) => item.metrics.previous.min?.value ?? 0))
),
max: getPreviousMetric(
final.metrics.max,
max(final.series.map((item) => item.metrics.previous.max?.value ?? 0))
),
};
// Sort by sum
final.series = final.series.sort((a, b) => {
if (input.chartType === 'linear') {
const sumA = a.data.reduce((acc, item) => acc + (item.count ?? 0), 0);
const sumB = b.data.reduce((acc, item) => acc + (item.count ?? 0), 0);
return sumB - sumA;
} else {
return b.metrics[input.metric] - a.metrics[input.metric];
}
});
return final;
}),
});
export function getPreviousMetric(
current: number,
previous: number | null
): PreviousValue {
if (previous === null) {
return null;
}
const diff = round(
((current > previous
? current / previous
: current < previous
? previous / current
: 0) -
1) *
100,
1
);
return {
diff:
Number.isNaN(diff) || !Number.isFinite(diff) || current === previous
? null
: diff,
state:
current > previous
? 'positive'
: current < previous
? 'negative'
: 'neutral',
value: previous,
};
}

View File

@@ -1,71 +0,0 @@
import { randomUUID } from 'crypto';
import { createTRPCRouter, protectedProcedure } from '@/trpc/api/trpc';
import { z } from 'zod';
import { hashPassword, stripTrailingSlash } from '@openpanel/common';
import type { Prisma } from '@openpanel/db';
import { db } from '@openpanel/db';
export const clientRouter = createTRPCRouter({
update: protectedProcedure
.input(
z.object({
id: z.string(),
name: z.string(),
cors: z.string().nullable(),
})
)
.mutation(({ input }) => {
return db.client.update({
where: {
id: input.id,
},
data: {
name: input.name,
cors: input.cors ?? null,
},
});
}),
create: protectedProcedure
.input(
z.object({
name: z.string(),
projectId: z.string(),
organizationSlug: z.string(),
cors: z.string().nullable(),
type: z.enum(['read', 'write', 'root']).optional(),
})
)
.mutation(async ({ input }) => {
const secret = randomUUID();
const data: Prisma.ClientCreateArgs['data'] = {
organizationSlug: input.organizationSlug,
projectId: input.projectId,
name: input.name,
type: input.type ?? 'write',
cors: input.cors ? stripTrailingSlash(input.cors) : null,
secret: await hashPassword(secret),
};
const client = await db.client.create({ data });
return {
...client,
secret,
};
}),
remove: protectedProcedure
.input(
z.object({
id: z.string(),
})
)
.mutation(async ({ input }) => {
await db.client.delete({
where: {
id: input.id,
},
});
return true;
}),
});

View File

@@ -1,91 +0,0 @@
import { createTRPCRouter, protectedProcedure } from '@/trpc/api/trpc';
import { getId } from '@/utils/getDbId';
import { PrismaError } from 'prisma-error-enum';
import { z } from 'zod';
import { db, getDashboardsByProjectId } from '@openpanel/db';
import type { Prisma } from '@openpanel/db';
export const dashboardRouter = createTRPCRouter({
list: protectedProcedure
.input(
z.object({
projectId: z.string(),
})
)
.query(async ({ input }) => {
return getDashboardsByProjectId(input.projectId);
}),
create: protectedProcedure
.input(
z.object({
name: z.string(),
projectId: z.string(),
organizationSlug: z.string(),
})
)
.mutation(async ({ input: { organizationSlug, projectId, name } }) => {
return db.dashboard.create({
data: {
id: await getId('dashboard', name),
projectId: projectId,
organizationSlug: organizationSlug,
name,
},
});
}),
update: protectedProcedure
.input(
z.object({
id: z.string(),
name: z.string(),
})
)
.mutation(({ input }) => {
return db.dashboard.update({
where: {
id: input.id,
},
data: {
name: input.name,
},
});
}),
delete: protectedProcedure
.input(
z.object({
id: z.string(),
forceDelete: z.boolean().optional(),
})
)
.mutation(async ({ input: { id, forceDelete } }) => {
try {
if (forceDelete) {
await db.report.deleteMany({
where: {
dashboardId: id,
},
});
}
await db.dashboard.delete({
where: {
id,
},
});
} catch (e) {
// Below does not work...
// error instanceof Prisma.PrismaClientKnownRequestError
if (typeof e === 'object' && e && 'code' in e) {
const error = e as Prisma.PrismaClientKnownRequestError;
switch (error.code) {
case PrismaError.ForeignConstraintViolation:
throw new Error(
'Cannot delete dashboard with associated reports'
);
default:
throw new Error('Unknown error deleting dashboard');
}
}
}
}),
});

View File

@@ -1,70 +0,0 @@
import {
createTRPCRouter,
protectedProcedure,
publicProcedure,
} from '@/trpc/api/trpc';
import { escape } from 'sqlstring';
import { z } from 'zod';
import { chQuery, convertClickhouseDateToJs, db } from '@openpanel/db';
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(({ input: { projectId, name, icon, color, conversion } }) => {
return db.eventMeta.upsert({
where: {
name_projectId: {
name,
projectId,
},
},
create: { projectId, name, icon, color, conversion },
update: { icon, color, conversion },
});
}),
bots: publicProcedure
.input(
z.object({
projectId: z.string(),
cursor: z.number().optional(),
limit: z.number().default(8),
})
)
.query(async ({ input: { projectId, cursor, limit } }) => {
const [events, counts] = await Promise.all([
chQuery<{
id: string;
project_id: string;
name: string;
type: string;
path: string;
created_at: string;
}>(
`SELECT * FROM events_bots WHERE project_id = ${escape(projectId)} ORDER BY created_at DESC LIMIT ${limit} OFFSET ${(cursor ?? 0) * limit}`
),
chQuery<{
count: number;
}>(
`SELECT count(*) as count FROM events_bots WHERE project_id = ${escape(projectId)}`
),
]);
return {
data: events.map((item) => ({
...item,
createdAt: convertClickhouseDateToJs(item.created_at),
})),
count: counts[0]?.count ?? 0,
};
}),
});

View File

@@ -1,63 +0,0 @@
import { randomUUID } from 'crypto';
import { createTRPCRouter, protectedProcedure } from '@/trpc/api/trpc';
import { getId } from '@/utils/getDbId';
import { slug } from '@/utils/slug';
import { clerkClient } from '@clerk/nextjs';
import { cookies } from 'next/headers';
import { hashPassword, stripTrailingSlash } from '@openpanel/common';
import type { ProjectType } from '@openpanel/db';
import { db } from '@openpanel/db';
import { zOnboardingProject } from '@openpanel/validation';
export const onboardingRouter = createTRPCRouter({
project: protectedProcedure
.input(zOnboardingProject)
.mutation(async ({ input, ctx }) => {
const types: ProjectType[] = [];
if (input.website) types.push('website');
if (input.app) types.push('app');
if (input.backend) types.push('backend');
const organization = await clerkClient.organizations.createOrganization({
name: input.organization,
slug: slug(input.organization),
createdBy: ctx.session.userId,
});
if (!organization.slug) {
throw new Error('Organization slug is missing');
}
const project = await db.project.create({
data: {
id: await getId('project', input.project),
name: input.project,
organizationSlug: organization.slug,
types,
},
});
const secret = randomUUID();
const client = await db.client.create({
data: {
name: `${project.name} Client`,
organizationSlug: organization.slug,
projectId: project.id,
type: 'write',
cors: input.domain ? stripTrailingSlash(input.domain) : null,
secret: await hashPassword(secret),
},
});
cookies().set('onboarding_client_secret', secret, {
maxAge: 60 * 60, // 1 hour
path: '/',
});
return {
...client,
secret,
};
}),
});

View File

@@ -1,94 +0,0 @@
import { createTRPCRouter, protectedProcedure } from '@/trpc/api/trpc';
import { clerkClient } from '@clerk/nextjs';
import { z } from 'zod';
import { db, getOrganizationBySlug } from '@openpanel/db';
import { zInviteUser } from '@openpanel/validation';
export const organizationRouter = createTRPCRouter({
list: protectedProcedure.query(() => {
return clerkClient.organizations.getOrganizationList();
}),
get: protectedProcedure
.input(
z.object({
id: z.string(),
})
)
.query(({ input }) => {
return getOrganizationBySlug(input.id);
}),
update: protectedProcedure
.input(
z.object({
id: z.string(),
name: z.string(),
})
)
.mutation(({ input }) => {
return clerkClient.organizations.updateOrganization(input.id, {
name: input.name,
});
}),
inviteUser: protectedProcedure
.input(zInviteUser)
.mutation(async ({ input, ctx }) => {
const organization = await getOrganizationBySlug(input.organizationSlug);
if (!organization) {
throw new Error('Organization not found');
}
return clerkClient.organizations.createOrganizationInvitation({
organizationId: organization.id,
emailAddress: input.email,
role: input.role,
inviterUserId: ctx.session.userId,
publicMetadata: {
access: input.access,
},
});
}),
revokeInvite: protectedProcedure
.input(
z.object({
organizationId: z.string(),
invitationId: z.string(),
})
)
.mutation(async ({ input, ctx }) => {
return clerkClient.organizations.revokeOrganizationInvitation({
organizationId: input.organizationId,
invitationId: input.invitationId,
requestingUserId: ctx.session.userId,
});
}),
updateMemberAccess: protectedProcedure
.input(
z.object({
userId: z.string(),
organizationSlug: z.string(),
access: z.array(z.string()),
})
)
.mutation(async ({ input }) => {
return db.$transaction([
db.projectAccess.deleteMany({
where: {
userId: input.userId,
organizationSlug: input.organizationSlug,
},
}),
db.projectAccess.createMany({
data: input.access.map((projectId) => ({
userId: input.userId,
organizationSlug: input.organizationSlug,
projectId: projectId,
level: 'read',
})),
}),
]);
}),
});

View File

@@ -1,66 +0,0 @@
import {
createTRPCRouter,
protectedProcedure,
publicProcedure,
} from '@/trpc/api/trpc';
import { flatten, map, pipe, prop, sort, uniq } from 'ramda';
import { escape } from 'sqlstring';
import { z } from 'zod';
import { chQuery, createSqlBuilder } from '@openpanel/db';
export const profileRouter = createTRPCRouter({
properties: protectedProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ input: { projectId } }) => {
const events = await chQuery<{ keys: string[] }>(
`SELECT distinct mapKeys(properties) as keys from profiles where project_id = ${escape(projectId)};`
);
const properties = events
.flatMap((event) => event.keys)
.map((item) => item.replace(/\.([0-9]+)\./g, '.*.'))
.map((item) => item.replace(/\.([0-9]+)/g, '[*]'))
.map((item) => `properties.${item}`);
properties.push('external_id', 'first_name', 'last_name', 'email');
return pipe(
sort<string>((a, b) => a.length - b.length),
uniq
)(properties);
}),
values: publicProcedure
.input(
z.object({
property: z.string(),
projectId: z.string(),
})
)
.query(async ({ input: { property, projectId } }) => {
const { sb, getSql } = createSqlBuilder();
sb.from = 'profiles';
sb.where.project_id = `project_id = ${escape(projectId)}`;
if (property.startsWith('properties.')) {
sb.select.values = `distinct mapValues(mapExtractKeyLike(properties, ${escape(
property.replace(/^properties\./, '').replace('.*.', '.%.')
)})) as values`;
} else {
sb.select.values = `${property} as values`;
}
const profiles = await chQuery<{ values: string[] }>(getSql());
const values = pipe(
(data: typeof profiles) => map(prop('values'), data),
flatten,
uniq,
sort((a, b) => a.length - b.length)
)(profiles);
return {
values,
};
}),
});

View File

@@ -1,66 +0,0 @@
import { createTRPCRouter, protectedProcedure } from '@/trpc/api/trpc';
import { getId } from '@/utils/getDbId';
import { z } from 'zod';
import { db, getProjectsByOrganizationSlug } from '@openpanel/db';
export const projectRouter = createTRPCRouter({
list: protectedProcedure
.input(
z.object({
organizationSlug: z.string().nullable(),
})
)
.query(async ({ input: { organizationSlug } }) => {
if (organizationSlug === null) return [];
return getProjectsByOrganizationSlug(organizationSlug);
}),
update: protectedProcedure
.input(
z.object({
id: z.string(),
name: z.string(),
})
)
.mutation(({ input }) => {
return db.project.update({
where: {
id: input.id,
},
data: {
name: input.name,
},
});
}),
create: protectedProcedure
.input(
z.object({
name: z.string().min(1),
organizationSlug: z.string(),
})
)
.mutation(async ({ input: { name, organizationSlug } }) => {
return db.project.create({
data: {
id: await getId('project', name),
organizationSlug: organizationSlug,
name: name,
},
});
}),
remove: protectedProcedure
.input(
z.object({
id: z.string(),
})
)
.mutation(async ({ input }) => {
await db.project.delete({
where: {
id: input.id,
},
});
return true;
}),
});

View File

@@ -1,58 +0,0 @@
import {
createTRPCRouter,
protectedProcedure,
publicProcedure,
} from '@/trpc/api/trpc';
import { z } from 'zod';
import { db, getReferences } from '@openpanel/db';
import { zCreateReference, zRange } from '@openpanel/validation';
import { getChartStartEndDate } from './chart.helpers';
export const referenceRouter = createTRPCRouter({
create: protectedProcedure
.input(zCreateReference)
.mutation(
async ({ input: { title, description, datetime, projectId } }) => {
return db.reference.create({
data: {
title,
description,
projectId,
date: new Date(datetime),
},
});
}
),
delete: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ input: { id } }) => {
return db.reference.delete({
where: {
id,
},
});
}),
getChartReferences: publicProcedure
.input(
z.object({
projectId: z.string(),
startDate: z.string().nullish(),
endDate: z.string().nullish(),
range: zRange,
})
)
.query(({ input: { projectId, ...input } }) => {
const { startDate, endDate } = getChartStartEndDate(input);
return getReferences({
where: {
projectId,
date: {
gte: new Date(startDate),
lte: new Date(endDate),
},
},
});
}),
});

View File

@@ -1,73 +0,0 @@
import { createTRPCRouter, protectedProcedure } from '@/trpc/api/trpc';
import { z } from 'zod';
import { db } from '@openpanel/db';
import { zChartInput } from '@openpanel/validation';
export const reportRouter = createTRPCRouter({
create: protectedProcedure
.input(
z.object({
report: zChartInput.omit({ projectId: true }),
dashboardId: z.string(),
})
)
.mutation(async ({ input: { report, dashboardId } }) => {
const dashboard = await db.dashboard.findUniqueOrThrow({
where: {
id: dashboardId,
},
});
return db.report.create({
data: {
projectId: dashboard.projectId,
dashboardId,
name: report.name,
events: report.events,
interval: report.interval,
breakdowns: report.breakdowns,
chartType: report.chartType,
lineType: report.lineType,
range: report.range,
formula: report.formula,
},
});
}),
update: protectedProcedure
.input(
z.object({
reportId: z.string(),
report: zChartInput.omit({ projectId: true }),
})
)
.mutation(({ input: { report, reportId } }) => {
return db.report.update({
where: {
id: reportId,
},
data: {
name: report.name,
events: report.events,
interval: report.interval,
breakdowns: report.breakdowns,
chartType: report.chartType,
lineType: report.lineType,
range: report.range,
formula: report.formula,
},
});
}),
delete: protectedProcedure
.input(
z.object({
reportId: z.string(),
})
)
.mutation(({ input: { reportId } }) => {
return db.report.delete({
where: {
id: reportId,
},
});
}),
});

View File

@@ -1,30 +0,0 @@
import { createTRPCRouter, protectedProcedure } from '@/trpc/api/trpc';
import ShortUniqueId from 'short-unique-id';
import { db } from '@openpanel/db';
import { zShareOverview } from '@openpanel/validation';
const uid = new ShortUniqueId({ length: 6 });
export const shareRouter = createTRPCRouter({
shareOverview: protectedProcedure
.input(zShareOverview)
.mutation(({ input }) => {
return db.shareOverview.upsert({
where: {
projectId: input.projectId,
},
create: {
id: uid.rnd(),
organizationSlug: input.organizationSlug,
projectId: input.projectId,
public: input.public,
password: input.password || null,
},
update: {
public: input.public,
password: input.password,
},
});
}),
});

View File

@@ -1,23 +0,0 @@
import { createTRPCRouter, protectedProcedure } from '@/trpc/api/trpc';
import { clerkClient } from '@clerk/nextjs';
import { z } from 'zod';
import { transformUser } from '@openpanel/db';
export const userRouter = createTRPCRouter({
update: protectedProcedure
.input(
z.object({
firstName: z.string(),
lastName: z.string(),
})
)
.mutation(({ input, ctx }) => {
return clerkClient.users
.updateUser(ctx.session.userId, {
firstName: input.firstName,
lastName: input.lastName,
})
.then(transformUser);
}),
});

View File

@@ -1,47 +0,0 @@
import type { getAuth } from '@clerk/nextjs/server';
import { initTRPC, TRPCError } from '@trpc/server';
import superjson from 'superjson';
import { ZodError } from 'zod';
interface CreateContextOptions {
session: ReturnType<typeof getAuth> | null;
}
const t = initTRPC.context<CreateContextOptions>().create({
transformer: superjson,
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.cause instanceof ZodError ? error.cause.flatten() : null,
},
};
},
});
export const createTRPCRouter = t.router;
export const publicProcedure = t.procedure;
const enforceUserIsAuthed = t.middleware(async ({ ctx, next }) => {
if (!ctx.session?.userId) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Not authenticated' });
}
try {
return next({
ctx: {
session: { ...ctx.session },
},
});
} catch (error) {
console.error('Failes to get user', error);
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'Failed to get user',
});
}
});
export const protectedProcedure = t.procedure.use(enforceUserIsAuthed);

View File

@@ -1,10 +1,11 @@
import type { AppRouter } from '@/trpc/api/root';
import type { TRPCClientErrorBase } from '@trpc/react-query';
import { createTRPCReact } from '@trpc/react-query';
import type { inferRouterInputs, inferRouterOutputs } from '@trpc/server';
import type { ExternalToast } from 'sonner';
import { toast } from 'sonner';
import type { AppRouter } from '@openpanel/trpc';
export const api = createTRPCReact<AppRouter>({});
/**

View File

@@ -1,26 +1 @@
import { isNumber } from 'mathjs';
export const round = (num: number, decimals = 2) => {
const factor = Math.pow(10, decimals);
return Math.round((num + Number.EPSILON) * factor) / factor;
};
export const average = (arr: (number | null)[]) => {
const filtered = arr.filter(
(n): n is number =>
isNumber(n) && !Number.isNaN(n) && Number.isFinite(n) && n !== 0
);
const avg = filtered.reduce((p, c) => p + c, 0) / filtered.length;
return Number.isNaN(avg) ? 0 : avg;
};
export const sum = (arr: (number | null)[]): number =>
round(arr.filter(isNumber).reduce((acc, item) => acc + item, 0));
export const min = (arr: (number | null)[]): number =>
Math.min(...arr.filter(isNumber));
export const max = (arr: (number | null)[]): number =>
Math.max(...arr.filter(isNumber));
export const isFloat = (n: number) => n % 1 !== 0;
export * from '@openpanel/common/src/math'

View File

@@ -1,18 +1 @@
import _slugify from 'slugify';
const slugify = (str: string) => {
return _slugify(
str
.replace('å', 'a')
.replace('ä', 'a')
.replace('ö', 'o')
.replace('Å', 'A')
.replace('Ä', 'A')
.replace('Ö', 'O'),
{ lower: true, strict: true, trim: true }
);
};
export function slug(str: string): string {
return slugify(str);
}
export * from '@openpanel/common/src/slug';