move trpc to api
This commit is contained in:
@@ -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 };
|
||||
@@ -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',
|
||||
});
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// @ts-nocheck
|
||||
|
||||
'use client';
|
||||
|
||||
import {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}),
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -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',
|
||||
})),
|
||||
}),
|
||||
]);
|
||||
}),
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -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;
|
||||
}),
|
||||
});
|
||||
@@ -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),
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -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);
|
||||
}),
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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>({});
|
||||
|
||||
/**
|
||||
|
||||
@@ -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'
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user